13 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
44 changed files with 4159 additions and 698 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 PhotoPrism's `PHOTOPRISM_READONLY` is controlled separately by
`PP_READONLY` and gates its own backwrite / import paths. `PP_READONLY` and gates its own backwrite / import paths.
## Mobile & third-party apps (per-user)
PhotoPrism CE does **not** enforce `auth_users.base_path` on API reads —
any authenticated user can search the whole library. The sidecar
therefore ships a scoping proxy at `/api/v1/*` (see
[`sidecar/handlers_ppproxy.go`](sidecar/handlers_ppproxy.go)) and the
reverse proxy routes the public `/api/v1` there instead of straight to
PhotoPrism. Result: any PhotoPrism-compatible app pointed at the site
sees only the logged-in user's photos.
- **Server URL for apps**: the site itself (e.g.
`https://photos.hubris.network`). Known-good client:
[Gallery for PhotoPrism](https://github.com/Radiokot/photoprism-android-client)
(Android/F-Droid).
- **Login**: the user's normal username/password. For OIDC accounts (no
password), mint an app password:
`docker exec pp-app photoprism auth add -n "gallery" -s "*" <username>`
and use it as the password in the app.
- **What's scoped**: photo/geo searches, per-photo reads and edits,
batch operations, downloads by UID. Hash-addressed media (thumbnails,
video streams, file downloads) is token-guarded and passes through.
- **What's shared** (CE has no per-user variants of these): album
*names*, labels, and people — the photos inside them stay scoped.
Album zip downloads are generated by PhotoPrism and are not scoped.
- **Uploads**: the reconciler mirrors `base_path` into `upload_path`,
so WebDAV/app uploads land inside the user's own subtree.
- Sessions with the `admin` role bypass the proxy scoping entirely (the
web client's settings/users/index dialogs need the raw API).
## Dev iteration loop ## Dev iteration loop
For fast iteration on the sidecar without rebuilding its image on every For fast iteration on the sidecar without rebuilding its image on every

View File

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

@@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
) )
// sanitizeFilename trims a user-supplied filename and rejects anything // sanitizeFilename trims a user-supplied filename and rejects anything
@@ -168,6 +169,7 @@ type fileEntry struct {
RelPath string RelPath string
AbsPath string AbsPath string
Size int64 Size int64
ModTime time.Time
} }
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism // supportedExts mirrors the Node prototype's whitelist. PhotoPrism
@@ -222,6 +224,7 @@ func walkFiles(root string) ([]fileEntry, error) {
RelPath: rel, RelPath: rel,
AbsPath: p, AbsPath: p,
Size: info.Size(), Size: info.Size(),
ModTime: info.ModTime(),
}) })
return nil return nil
}) })

View File

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

View File

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

View File

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

View File

@@ -90,6 +90,9 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return return
} }
if !requireUserScope(c, cfg, targetAbs, false) {
return
}
// Pull the heap's membership via the q=album:UID query (count=1000 // 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 // 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 // search's Files array is trimmed and drops videos, so we re-resolve
@@ -124,7 +127,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
return 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -154,6 +157,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"moved": moved, "moved": moved,
"copied": copied, "copied": copied,
"movedFiles": movedPairs,
"errors": errs, "errors": errs,
"heap_deleted": heapDeleted, "heap_deleted": heapDeleted,
}) })
@@ -167,17 +171,26 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
// and handlePhotosMove (UID-list scoped); both resolve `photos` differently // and handlePhotosMove (UID-list scoped); both resolve `photos` differently
// but move them identically. Returns per-photo errors in `errs`; the returned // but move them identically. Returns per-photo errors in `errs`; the returned
// top-level error is only for a fatal precondition (subfolder mkdir failed). // 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 destAbs := targetAbs
if subfolder != "" { if subfolder != "" {
destAbs = filepath.Join(targetAbs, subfolder) destAbs = filepath.Join(targetAbs, subfolder)
if e := os.MkdirAll(destAbs, 0o755); e != nil { if e := os.MkdirAll(destAbs, 0o755); e != nil {
return 0, 0, nil, e return 0, 0, nil, nil, e
} }
} }
sourceParents := map[string]struct{}{} sourceParents := map[string]struct{}{}
errs = []heapErr{} errs = []heapErr{}
movedPairs = []dupMoved{}
for _, photo := range photos { for _, photo := range photos {
// Gather *every* originals-rooted file of the photo, not just the // Gather *every* originals-rooted file of the photo, not just the
@@ -234,8 +247,8 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
for _, f := range group { for _, f := range group {
srcRel := f.Name srcRel := f.Name
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel) srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) { if !sameOrUnder(srcAbs, scopeAbs) {
failure = "path escapes originals" failure = "path outside your library"
break break
} }
st, statErr := os.Stat(srcAbs) st, statErr := os.Stat(srcAbs)
@@ -276,6 +289,9 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
break break
} }
} }
if dstRel, relErr := filepath.Rel(cfg.OriginalsRoot, dstAbs); relErr == nil {
movedPairs = append(movedPairs, dupMoved{From: srcRel, To: dstRel})
}
} else { } else {
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil { if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
failure = cpErr.Error() failure = cpErr.Error()
@@ -323,7 +339,7 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
fireReindex(cfg, pp, token, reindex) fireReindex(cfg, pp, token, reindex)
} }
return moved, copied, errs, nil return moved, copied, movedPairs, errs, nil
} }
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"." // 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 // PpCounts mirrors PhotoPrism's session config.count block that drives
// the sidebar badges (review, archive, all, etc.). // the sidebar badges (review, archive, all, etc.).
type PpCounts struct { type PpCounts struct {
All int `json:"all"` All int `json:"all"`
Photos int `json:"photos"` Photos int `json:"photos"`
Media int `json:"media"` Media int `json:"media"`
Videos int `json:"videos"` Videos int `json:"videos"`
Review int `json:"review"` Review int `json:"review"`
Archived int `json:"archived"` Archived int `json:"archived"`
Hidden int `json:"hidden"` Hidden int `json:"hidden"`
Private int `json:"private"` Private int `json:"private"`
Favorites int `json:"favorites"` Favorites int `json:"favorites"`
} }
@@ -168,4 +168,4 @@ func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc {
c.JSON(http.StatusOK, counts) c.JSON(http.StatusOK, counts)
} }
} }

View File

@@ -56,6 +56,9 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
return return
} }
if !requireUserScope(c, cfg, targetAbs, false) {
return
}
// Resolve each photo's FULL file list via the single-photo endpoint // Resolve each photo's FULL file list via the single-photo endpoint
// rather than the /photos search (see resolvePhotosFull) — the search // rather than the /photos search (see resolvePhotosFull) — the search
@@ -67,7 +70,7 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
return 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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -84,9 +87,10 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
"errors", len(errs), "errors", len(errs),
) )
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"moved": moved, "moved": moved,
"copied": copied, "copied": copied,
"errors": errs, "movedFiles": movedPairs,
"errors": errs,
}) })
} }
} }
@@ -148,6 +152,9 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
return return
} }
if !requireUserScope(c, cfg, oldAbs, true) {
return
}
st, err := os.Stat(oldAbs) st, err := os.Stat(oldAbs)
if err != nil || !st.IsDir() { if err != nil || !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"}) c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
@@ -158,6 +165,9 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"})
return return
} }
if !requireUserScope(c, cfg, targetParentAbs, false) {
return
}
// Can't move a folder into itself or one of its own descendants. // Can't move a folder into itself or one of its own descendants.
if sameOrUnder(targetParentAbs, oldAbs) { if sameOrUnder(targetParentAbs, oldAbs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot move a folder into itself"}) c.JSON(http.StatusBadRequest, gin.H{"error": "cannot move a folder into itself"})
@@ -194,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")
}
}

View File

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

View File

@@ -79,49 +79,59 @@ func main() {
// Every other endpoint runs behind the session gate. Mounting them // Every other endpoint runs behind the session gate. Mounting them
// under one group keeps the middleware wiring obvious. // under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp)) auth := r.Group("/api/sidecar", requireSession(pp))
{ {
auth.GET("/prefs", handlePrefsGet(db)) auth.GET("/prefs", handlePrefsGet(db))
auth.PUT("/prefs", handlePrefsPut(cfg, db)) auth.PUT("/prefs", handlePrefsPut(cfg, db))
auth.GET("/photos/marks", handleMarksAll(db)) auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db)) auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db)) auth.PUT("/photos/:uid/marks", handleMarkPut(db))
auth.POST("/photos/marks/bulk", handleMarkBulk(db)) auth.POST("/photos/marks/bulk", handleMarkBulk(db))
auth.POST("/files/:uid/rename", handleRename(cfg, pp)) auth.POST("/files/:uid/rename", handleRename(cfg, pp))
auth.POST("/folders", handleFolderCreate(cfg, pp)) auth.POST("/folders", handleFolderCreate(cfg, pp))
auth.POST("/folders/counts", handleFolderCounts(pp)) auth.POST("/folders/counts", handleFolderCounts(pp))
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp)) auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp)) auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp)) auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp)) auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp)) auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.POST("/files/restore-moves", handleRestoreMoves(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db)) auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db)) auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
auth.POST("/duplicates/restore", handleDupRestore(cfg, pp, db))
// User-scoped proxies — require PpDSN connection. // User-scoped proxies — require PpDSN connection.
if ppDb != nil { if ppDb != nil {
auth.GET("/labels", handleLabels(pp, ppDb)) auth.GET("/labels", handleLabels(pp, ppDb))
auth.GET("/counts", handleScopedCounts(ppDb)) auth.GET("/counts", handleScopedCounts(ppDb))
auth.GET("/countries", handleCountries(ppDb)) auth.GET("/countries", handleCountries(ppDb))
} auth.GET("/subjects", handleSubjects(pp, ppDb))
auth.GET("/faces/unnamed", handleUnnamedFaces(ppDb))
}
// User-scoped photos — post-filters by BasePath so review/archive // User-scoped photos — post-filters by BasePath so review/archive
// tabs only show photos the user owns. // tabs only show photos the user owns.
auth.GET("/timeline", handlePhotos(pp)) auth.GET("/timeline", handlePhotos(pp))
// Photos carrying a Note (Caption) — pages PhotoPrism fully so // Photos carrying a Note (Caption) — pages PhotoPrism fully so
// the /notes view isn't capped to the newest slice. // the /notes view isn't capped to the newest slice.
auth.GET("/notes", handleNotes(pp)) auth.GET("/notes", handleNotes(pp))
// User-scoped folders — post-filters the folder tree by BasePath // User-scoped folders — post-filters the folder tree by BasePath
// so the sidebar shows only folders under the user's library root. // so the sidebar shows only folders under the user's library root.
auth.GET("/folders", handleFoldersProxy(pp)) auth.GET("/folders", handleFoldersProxy(pp))
} }
// PhotoPrism-compatible scoped proxy — the public /api/v1 surface for
// both the web client and third-party PhotoPrism apps (Caddy routes
// /api/v1 here instead of straight to PhotoPrism, which does not
// enforce base_path in CE). See handlers_ppproxy.go for the rules.
r.Any("/api/v1/*rest", handlePPProxy(cfg, ppDb))
addr := cfg.ListenAddr + ":" + itoa(cfg.Port) addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
srv := &http.Server{ srv := &http.Server{
@@ -154,4 +164,3 @@ func main() {
} }
<-idleClosed <-idleClosed
} }

View File

@@ -90,6 +90,7 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
type ppSessionUser struct { type ppSessionUser struct {
UserUID string `json:"UID"` UserUID string `json:"UID"`
UserName string `json:"Name"` UserName string `json:"Name"`
Role string `json:"Role"`
BasePath string `json:"BasePath"` 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. // ACL kicks in even if the directory is created later.
} }
// upload_path rides along with base_path so anything a client app
// uploads (WebDAV sync apps, PhotoPrism's own UI) lands inside the
// user's library subtree instead of the shared originals root.
res := db.Exec(`UPDATE auth_users res := db.Exec(`UPDATE auth_users
SET base_path = ? SET base_path = ?, upload_path = ?
WHERE user_name = ? WHERE user_name = ?
AND COALESCE(base_path, '') <> ? AND (COALESCE(base_path, '') <> ? OR COALESCE(upload_path, '') <> ?)
AND deleted_at IS NULL`, AND deleted_at IS NULL`,
path, username, path) path, path, username, path, path)
if res.Error != nil { if res.Error != nil {
slog.Error("user-basepath: update failed", "user", username, "err", res.Error) slog.Error("user-basepath: update failed", "user", username, "err", res.Error)
continue continue

View File

@@ -7,10 +7,13 @@ import {
batchArchive, batchArchive,
batchDelete, batchDelete,
batchRestore, batchRestore,
bulkSetMarks,
removeFromHeap, removeFromHeap,
type PhotoMark,
type PhotoMarksMap,
type PpAlbum type PpAlbum
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions'; import { acceptDateAndKeep, cachedPhoto, toggleFavorite } from '$lib/services/photoActions';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath'; import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
import { photoNameAndDir } from '$lib/types/photoprism'; import { photoNameAndDir } from '$lib/types/photoprism';
import { queryClient } from '$lib/queryClient'; import { queryClient } from '$lib/queryClient';
@@ -36,7 +39,14 @@ import {
setDetail, setDetail,
markRemoved markRemoved
} from '$lib/stores/bulkAction.svelte'; } 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={...}`. * Optional parameters the host passes via `use:gridKeyNav={...}`.
@@ -68,8 +78,8 @@ export interface GridKeyNavParams {
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles * heap N (bare s adds to the currently-viewed heap), b/Tab toggles
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes, * left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
* ⌘A selects all visible. * ⌘A selects all visible.
* Rating + color labels are mouse-driven via the metadata sidebar — no * 05 rating, 69 Lightroom color labels, / focuses search,
* keyboard shortcuts. * ? opens the shortcut reference overlay.
* *
* Archive / restore target a synthesized "cull target list" — in priority: * Archive / restore target a synthesized "cull target list" — in priority:
* 1. multi-selection set * 1. multi-selection set
@@ -377,6 +387,55 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
await addCullTargetsToHeap(heaps[idx - 1]); await addCullTargetsToHeap(heaps[idx - 1]);
} }
// ── Rating / color-label keys (Lightroom layout) ─────────────────────
// Bare 05 set the rating (0 clears; re-keying the current value also
// clears, matching the sidebar's click-to-toggle). 69 toggle the four
// Lightroom color labels. Multi-selection stamps the whole set.
const COLOR_KEYS: Record<string, string> = { '6': 'red', '7': 'yellow', '8': 'green', '9': 'blue' };
async function markCullTargets(patch: PhotoMark, label: string) {
const ids = cullTargets();
if (ids.length === 0) {
toast.message('Nothing to mark', {
description: 'Click a photo or select some first'
});
return;
}
// Optimistic cache patch — the tile badges and facet panels read
// ['marks'], so stamping it up front makes the keystroke feel instant.
const prevMap = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
const next: PhotoMarksMap = { ...prevMap };
for (const id of ids) {
const merged: PhotoMark = { ...next[id], ...patch };
if (!merged.rating) delete merged.rating;
if (!merged.color) delete merged.color;
next[id] = merged;
}
queryClient.setQueryData(['marks'], next);
try {
await bulkSetMarks(ids, patch);
void queryClient.invalidateQueries({ queryKey: ['marks'] });
toast.success(ids.length === 1 ? label : `${label} · ${ids.length} photos`);
} catch (err) {
queryClient.setQueryData(['marks'], prevMap);
toast.error(err instanceof Error ? err.message : 'Mark failed');
}
}
function ratingOfFirstTarget(): number {
const ids = cullTargets();
if (ids.length === 0) return 0;
const marks = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
return marks[ids[0]]?.rating ?? 0;
}
function colorOfFirstTarget(): string {
const ids = cullTargets();
if (ids.length === 0) return '';
const marks = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
return marks[ids[0]]?.color ?? '';
}
async function addCullTargetsToActiveHeap() { async function addCullTargetsToActiveHeap() {
if (filters.section !== 'heap' || !filters.heapUid) { if (filters.section !== 'heap' || !filters.heapUid) {
toast.message('Press S then 19 to pick a heap'); toast.message('Press S then 19 to pick a heap');
@@ -396,6 +455,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase(); const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return; if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
// Shortcuts overlay: Esc or ? closes it; every other key is inert
// while it's up so the reference card can't trigger the actions it
// documents.
if (view.shortcutsOpen) {
if (e.key === 'Escape' || e.key === '?') {
e.preventDefault();
closeShortcuts();
}
return;
}
// Modal owns arrow / Escape / Space while it's open — it handles // Modal owns arrow / Escape / Space while it's open — it handles
// its own linear nav, close-on-Esc, and close-on-Space. Action // its own linear nav, close-on-Esc, and close-on-Space. Action
// keys (X/S/U/A/Z) still pass through because they target the // keys (X/S/U/A/Z) still pass through because they target the
@@ -431,6 +501,22 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
const meta = e.metaKey || e.ctrlKey; const meta = e.metaKey || e.ctrlKey;
const shift = e.shiftKey; const shift = e.shiftKey;
// Bare digits: rating (05, re-key toggles off) and Lightroom color
// labels (69). Runs after the S-chord so "s 3" still files to heap 3.
if (!meta && !shift && /^[0-9]$/.test(e.key)) {
e.preventDefault();
const n = parseInt(e.key, 10);
if (n <= 5) {
const value = n === 0 || ratingOfFirstTarget() === n ? 0 : n;
void markCullTargets({ rating: value }, value ? `Rated ${value}` : 'Rating cleared');
} else {
const color = COLOR_KEYS[e.key];
const value = colorOfFirstTarget() === color ? '' : color;
void markCullTargets({ color: value }, value ? `Labeled ${value}` : 'Color cleared');
}
return;
}
// Space on a focused tile opens the full-screen preview modal. // Space on a focused tile opens the full-screen preview modal.
// Matches the dblclick gesture so the user has both keyboard and // Matches the dblclick gesture so the user has both keyboard and
// mouse paths to the same surface. `e.code === 'Space'` covers // mouse paths to the same surface. `e.code === 'Space'` covers
@@ -477,6 +563,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
clearSelection(); clearSelection();
setFocused(null); setFocused(null);
return; return;
case '/':
// Jump to the search box (any page that renders one tags it
// with data-search-input).
if (meta) return;
e.preventDefault();
document.querySelector<HTMLInputElement>('[data-search-input]')?.focus();
return;
case '?':
e.preventDefault();
toggleShortcuts();
return;
case 'Tab': case 'Tab':
// Tab in the grid context = mule-image's left-sidebar toggle. // Tab in the grid context = mule-image's left-sidebar toggle.
// Browsers reserve Tab for focus traversal — preventDefault // Browsers reserve Tab for focus traversal — preventDefault
@@ -556,6 +653,12 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
e.preventDefault(); e.preventDefault();
void toggleArchive('restore'); void toggleArchive('restore');
return; return;
case 'f':
case 'F':
if (meta || shift) return;
e.preventDefault();
void toggleFavorite(cullTargets());
return;
case 'm': case 'm':
case 'M': { case 'M': {
if (meta || shift) return; if (meta || shift) return;

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,26 @@
<!-- <!--
Duplicate-resolution page body. Two panels driven by the parent Duplicate-resolution queue. Two panels driven by the parent route's
route's `activeTab` prop (URL-bound): `activeTab` prop (URL-bound):
1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live 1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; we list via HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; listed via
`stack:true` and resolve via `setPrimary` + `deleteFile`. `stack:true`, resolved via resolveStack() (setPrimary + quarantine).
2. Cross-folder — files PhotoPrism silently rejected at index time 2. Cross-folder — files PhotoPrism silently rejected at index time
because they were byte-identical to an existing entry. PhotoPrism because they were byte-identical to an existing entry. Scanned via
never adds those rows to its DB, so we scan the filesystem via the the sidecar's filesystem walk, resolved via resolveCrossFolder()
mule-sidecar. Resolution moves the unwanted copies into a (quarantine).
`.duplicates/` quarantine folder PhotoPrism's indexer ignores.
Both panels share one interaction model — a resolve-and-advance
queue: ↑/↓ or j/k rove between groups (scrollIntoView), resolving a
group removes it optimistically and auto-advances focus to whatever
now occupies that slot, so the whole queue clears without touching
the mouse. A sticky header tracks reclaimable bytes and a running
"resolved this session" tally.
The cross-folder scan auto-fires when its tab is active — with size The cross-folder scan auto-fires when its tab is active — with size
pre-filtering it stays fast (~250ms for 400 files in practice) and a pre-filtering it stays fast (~250ms for 400 files in practice) and a
long staleTime keeps tab bounces from re-running it. The button is long staleTime keeps tab bounces from re-running it.
kept for manual "rescan after I moved files" refreshes.
Tabs themselves render in the parent route's Toolbar so they line up
visually with the `/tags` pill row.
--> -->
<script lang="ts"> <script lang="ts">
import { createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createQuery, useQueryClient } from '@tanstack/svelte-query';
@@ -28,11 +30,20 @@
type CrossFolderScanResult type CrossFolderScanResult
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates'; import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import {
dupSession,
formatBytes,
resolvedCrossHashes,
resolvedStackUids
} from '$lib/services/duplicateActions.svelte';
import { userLibraryBase } from '$lib/stores/session.svelte'; import { userLibraryBase } from '$lib/stores/session.svelte';
import { nearBottom } from '$lib/actions/nearBottom';
import { toggleShortcuts, view } from '$lib/stores/view.svelte';
import { popAndRun } from '$lib/stores/undo.svelte';
import StackGroupCard from './StackGroupCard.svelte'; import StackGroupCard from './StackGroupCard.svelte';
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte'; import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { 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'; type Tab = 'stacks' | 'cross-folder';
@@ -64,97 +75,229 @@
$effect(() => { $effect(() => {
if (crossQuery.error) { if (crossQuery.error) {
toast.error( toast.error(
crossQuery.error instanceof Error crossQuery.error instanceof Error ? crossQuery.error.message : 'Duplicates scan failed'
? crossQuery.error.message
: '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> </script>
<!-- Stacks tab ----------------------------------------------------- --> <!-- Sticky progress header — shared by both tabs -->
{#if activeTab === 'stacks'} <div
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6"> class="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-background/95 px-6 py-2.5 backdrop-blur"
{#if pending} >
<InlineLoader label="Loading stacks…" /> <div class="flex items-center gap-4 text-xs text-muted-foreground">
{:else if error} <span class="font-medium text-foreground">
<EmptyState {activeGroups.length}
tone="destructive" {activeTab === 'stacks' ? 'stack' : 'group'}{activeGroups.length === 1 ? '' : 's'}
icon={AlertCircle} </span>
title="Could not load stacks" {#if reclaimableBytes > 0}
description={error instanceof Error ? error.message : 'unknown error'} <span class="flex items-center gap-1">
/> <HardDrive class="h-3 w-3" />
{:else if groups.length === 0} {formatBytes(reclaimableBytes)} reclaimable
<EmptyState icon={Copy} title="No stacks"> </span>
{#snippet descriptionSnippet()} {/if}
<p> {#if dupSession.resolved > 0}
The library stacks byte-identical (or EXIF-identical) files. If you don't have <span class="text-emerald-500">
any, this tab stays empty. Cross-folder copies dropped at index time live under Resolved {dupSession.resolved} · {formatBytes(dupSession.freedBytes)} freed this session
the Duplicates tab. </span>
</p>
{/snippet}
</EmptyState>
{:else}
<div class="space-y-3">
{#each groups as group, i (group.photo.UID)}
<StackGroupCard {group} autoFocus={i === 0} />
{/each}
</div>
{/if} {/if}
</div> </div>
{/if} {#if activeTab === 'cross-folder'}
<button
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={crossQuery.isFetching}
onclick={rescan}
>
{crossQuery.isFetching ? 'Scanning…' : 'Rescan filesystem'}
</button>
{/if}
</div>
<!-- Duplicates tab (cross-folder scan) ----------------------------- --> <!-- svelte-ignore a11y_no_static_element_interactions -->
{#if activeTab === 'cross-folder'} <div onkeydown={onQueueKeydown}>
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6"> <!-- Stacks tab ----------------------------------------------------- -->
<header class="flex items-baseline justify-between gap-3"> {#if activeTab === 'stacks'}
<p class="text-[11px] text-muted-foreground"> <div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
Byte-identical files the indexer dropped at index time. Found by scanning the {#if pending}
originals tree directly. <InlineLoader label="Loading stacks…" />
</p> {:else if error}
<button <EmptyState
type="button" tone="destructive"
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" icon={AlertCircle}
disabled={crossQuery.isFetching} title="Could not load stacks"
onclick={rescan} description={error instanceof Error ? error.message : 'unknown error'}
> />
{#if crossQuery.isFetching} {:else if liveStackGroups.length === 0}
Scanning… <EmptyState icon={Copy} title="No stacks">
{:else} {#snippet descriptionSnippet()}
Rescan filesystem <p>
{/if} The library stacks byte-identical (or EXIF-identical) files. If you don't have
</button> any, this tab stays empty. Cross-folder copies dropped at index time live under
</header> the Duplicates tab.
{#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> </p>
{/if} {/snippet}
{/snippet} </EmptyState>
</EmptyState> {:else}
{:else} <div class="space-y-3">
<div class="space-y-3"> {#each liveStackGroups.slice(0, renderCount) as group, i (group.photo.UID)}
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)} <StackGroupCard
<CrossFolderGroupCard {group} autoFocus={i === 0} /> {group}
{/each} focused={i === focusedIndex}
</div> onFocusRequest={() => (focusedIndex = i)}
{/if} onResolved={onGroupResolved}
</div> />
{/if} {/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 One duplicate stack rendered as a card. Each variant file is a tile;
tile; clicking selects it as the candidate "best". Committing promotes the selected one is the "keeper". Committing promotes the keeper to
the selected file to Primary (via `setPrimary`) and deletes the rest from Primary and moves every other file into the sidecar's `.duplicates/`
the stack (via `deleteFile` — PhotoPrism's flat `DELETE /photos/:uid/ quarantine (recoverable, undoable via ⌘Z) — resolution logic lives in
files/:fid` route). services/duplicateActions.svelte.ts.
Why DELETE instead of unstack-then-archive (which the plan started with): Keyboard (card scope — ↑/↓/j/k are NOT consumed here; they bubble to
PhotoPrism's `/unstack` returns `only originals can be unstacked` for DuplicatesView's group navigation):
sidecar JPGs and `Changes could not be saved` for live-photo HEIC+MOV - ←/→ move the keeper highlight; 19 jump straight to a file.
pairs. DELETE works for all of them — and cascades through the live- - Space opens the fullscreen compare lightbox (zoom-preserving flips).
photo group automatically, so one click resolves the whole stack. The - Enter resolves: keep selected, quarantine the rest.
on-disk file is renamed with a hash suffix (not erased), so a future
manual reindex can recover it if needed.
Keyboard: The fact rows under each thumb highlight the best value per column
- Section is tabindex=0; focusing it captures arrow keys + Enter. (largest size, highest resolution) so the winning file is obvious at
- Left/Right move the "best" highlight one file; Up/Down move by the a glance; a file that wins everything gets a "Suggested" badge.
grid's computed column count (same trick the timeline uses for
cross-row arrow nav).
- Enter commits the current selection. Esc removes focus from the card.
- The page's first card auto-focuses on mount so the user can drive
the workflow keyboard-first.
--> -->
<script lang="ts"> <script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query'; import { resolveStack } from '$lib/services/duplicateActions.svelte';
import { toast } from 'svelte-sonner';
import { deleteFile, setPrimary } from '$lib/services/photoprism';
import { thumbUrl } from '$lib/stores/session.svelte'; import { thumbUrl } from '$lib/stores/session.svelte';
import type { DuplicateGroup } from '$lib/services/adapters/duplicates'; import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
import { view } from '$lib/stores/view.svelte'; import type { PpFile } from '$lib/types/photoprism';
import CompareLightbox from './CompareLightbox.svelte';
import { Maximize2 } from 'lucide-svelte';
interface Props { interface Props {
group: DuplicateGroup; group: DuplicateGroup;
/** When true, the section auto-focuses on mount so the user can /** Roving focus — DuplicatesView owns which card is active. */
* arrow-key/Enter the workflow without reaching for the mouse. focused?: boolean;
* Only the page's first card should get this. */ /** Card was clicked/focused by pointer: tell the view to move its
autoFocus?: boolean; * roving index here. */
onFocusRequest?: () => void;
/** Resolve succeeded — view advances focus to the next group. */
onResolved?: () => void;
} }
let { group, autoFocus = false }: Props = $props(); let { group, focused = false, onFocusRequest, onResolved }: Props = $props();
const qc = useQueryClient();
let best = $state(''); let best = $state('');
let busy = $state(false); let busy = $state(false);
let compareOpen = $state(false);
let sectionEl: HTMLElement | undefined = $state(); let sectionEl: HTMLElement | undefined = $state();
let gridEl: HTMLElement | undefined = $state();
let cols = $state(1);
$effect(() => { $effect(() => {
// Seed / re-seed `best` from the prop when the underlying group // Seed / re-seed `best` from the prop when the underlying group
// changes (keyed each + UID key normally keeps this stable, but // changes; the guard keeps user clicks intact across prop swaps.
// the guard handles prop swaps without overwriting user clicks).
if (!best || !group.files.some((f) => f.UID === best)) { if (!best || !group.files.some((f) => f.UID === best)) {
best = group.bestFileUid; best = group.bestFileUid;
} }
}); });
$effect(() => { $effect(() => {
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true }); if (focused && sectionEl && !compareOpen) {
sectionEl.focus({ preventScroll: true });
sectionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}); });
// Track the grid's column count via ResizeObserver — same approach // ── Comparison facts ────────────────────────────────────────────────
// the timeline uses. Reading `gridTemplateColumns` from computed const maxSize = $derived(Math.max(...group.files.map((f) => f.Size ?? 0)));
// style is O(1) regardless of how many tiles render. const maxPixels = $derived(Math.max(...group.files.map((f) => pixels(f))));
$effect(() => { const sizesDiffer = $derived(new Set(group.files.map((f) => f.Size ?? 0)).size > 1);
if (!gridEl) return; const pixelsDiffer = $derived(new Set(group.files.map((f) => pixels(f))).size > 1);
const measure = () => { /** UID of the file that wins on every differing axis, if unique. */
if (!gridEl) return; const suggestedUid = $derived.by(() => {
const n = getComputedStyle(gridEl) const winners = group.files.filter(
.gridTemplateColumns.split(' ') (f) =>
.filter(Boolean).length; (!sizesDiffer || (f.Size ?? 0) === maxSize) &&
cols = Math.max(1, n); (!pixelsDiffer || pixels(f) === maxPixels)
}; );
measure(); return winners.length === 1 && (sizesDiffer || pixelsDiffer) ? winners[0].UID : null;
const ro = new ResizeObserver(measure);
ro.observe(gridEl);
return () => ro.disconnect();
});
// thumbnailSize changes alter cols without resizing the grid; re-
// measure on the next microtask.
$effect(() => {
void view.thumbnailSize;
queueMicrotask(() => {
if (!gridEl) return;
const n = getComputedStyle(gridEl)
.gridTemplateColumns.split(' ')
.filter(Boolean).length;
cols = Math.max(1, n);
});
}); });
function pixels(f: PpFile): number {
return (f.Width ?? 0) * (f.Height ?? 0);
}
function typeBadge(f: PpFile): string {
return (f.FileType ?? f.Name?.split('.').pop() ?? '').toUpperCase();
}
function shortPath(name: string): string { function shortPath(name: string): string {
const segs = name.split('/').filter(Boolean); const segs = name.split('/').filter(Boolean);
if (segs.length <= 2) return name; if (segs.length <= 2) return name;
return '…/' + segs.slice(-2).join('/'); return '…/' + segs.slice(-2).join('/');
} }
function dims(f: { Width?: number; Height?: number }): string { function dims(f: PpFile): string {
if (!f.Width || !f.Height) return ''; if (!f.Width || !f.Height) return '';
return `${f.Width}×${f.Height}`; return `${f.Width}×${f.Height}`;
} }
@@ -114,87 +103,57 @@
} }
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
if (busy) return; if (busy || compareOpen) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
switch (e.key) { switch (e.key) {
case 'ArrowLeft': case 'ArrowLeft':
e.preventDefault(); e.preventDefault();
e.stopPropagation();
moveBest(-1); moveBest(-1);
return; return;
case 'ArrowRight': case 'ArrowRight':
e.preventDefault(); e.preventDefault();
e.stopPropagation();
moveBest(1); moveBest(1);
return; return;
case 'ArrowUp': case ' ':
e.preventDefault(); e.preventDefault();
moveBest(-cols); e.stopPropagation();
return; compareOpen = true;
case 'ArrowDown':
e.preventDefault();
moveBest(cols);
return; return;
case 'Enter': case 'Enter':
e.preventDefault(); e.preventDefault();
e.stopPropagation();
void commit(); void commit();
return; return;
case 'Escape': default: {
(e.target as HTMLElement)?.blur(); const n = Number.parseInt(e.key, 10);
return; if (n >= 1 && n <= group.files.length) {
e.preventDefault();
e.stopPropagation();
best = group.files[n - 1].UID;
}
}
} }
} }
async function commit() { async function commit() {
if (busy || group.files.length < 2) return; if (busy || group.files.length < 2) return;
busy = true; busy = true;
const photoUid = group.photo.UID;
const losers = group.files.filter((f) => f.UID !== best);
try { try {
// 1. Promote the user's pick to Primary first (idempotent — if const ok = await resolveStack(group, best);
// it's already Primary, the call is a no-op on the server). if (ok) onResolved?.();
const currentPrimary = group.files.find((f) => f.Primary)?.UID;
if (best !== currentPrimary) {
await setPrimary(photoUid, best);
}
// 2. Delete each non-best file. PhotoPrism cascades through
// related variants in the same logical group (live-photo
// pairs, sidecar companions), so a single DELETE on one
// HEIC variant clears the whole HEIC+MOV pair in one go.
// Loop tolerates partial success — if PhotoPrism already
// cleared the file via cascade, the next DELETE 404s and
// we move on.
for (const f of losers) {
try {
await deleteFile(photoUid, f.UID);
} catch (err) {
// 404 means the file's already gone (cascade) — fine.
// Any other status means we have a real problem; bubble it.
const status = (err as { response?: { status?: number } })?.response
?.status;
if (status !== 404) throw err;
}
}
toast.success(`Resolved · kept 1 of ${group.files.length}`);
void qc.invalidateQueries({ queryKey: ['duplicates'] });
void qc.invalidateQueries({ queryKey: ['photos'] });
} catch (err) {
const msg =
err instanceof Error && err.message ? err.message : 'Resolve failed';
toast.error(msg);
} finally { } finally {
busy = false; busy = false;
} }
} }
</script> </script>
<!-- Section is focusable so we can capture arrow keys + Enter. `outline-
none` because we paint our own focus ring on .focus-visible below
(otherwise the browser default outline would clash with the tile
selection ring). -->
<!-- <!--
`role="application"` declares this as a custom keyboard widget (arrow `role="application"` declares this as a custom keyboard widget (arrow
keys + Enter, not standard reading order). The element below is a keys + Enter, not standard reading order). `<div>` rather than
`<div>` rather than `<section>` because Svelte's a11y linter treats `<section>` because Svelte's a11y linter treats `<section>` as
`<section>` as strictly non-interactive even with an explicit strictly non-interactive even with an explicit application role.
application role.
--> -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
@@ -202,10 +161,11 @@
bind:this={sectionEl} bind:this={sectionEl}
tabindex="0" tabindex="0"
role="application" role="application"
aria-label={`Duplicate stack of ${group.files.length} files — arrow keys pick the file to keep, Enter resolves`} aria-label={`Duplicate stack of ${group.files.length} files — ←/→ pick the keeper, Space compares, Enter resolves`}
onkeydown={onKeydown} onkeydown={onKeydown}
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none onfocusin={() => onFocusRequest?.()}
focus-visible:ring-2 focus-visible:ring-primary/50" class="space-y-2 rounded-md border bg-card/30 p-3 outline-none transition-colors
{focused ? 'border-primary/60 ring-1 ring-primary/40' : 'border-border'}"
> >
<header class="flex items-center justify-between gap-3"> <header class="flex items-center justify-between gap-3">
<div class="min-w-0"> <div class="min-w-0">
@@ -216,74 +176,115 @@
{group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''} {group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''}
</div> </div>
</div> </div>
<button <div class="flex shrink-0 items-center gap-2">
type="button" <button
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50" type="button"
disabled={busy || group.files.length < 2} class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
onclick={commit} disabled={busy}
title="Promote the selected file and delete the rest from this stack" onclick={() => (compareOpen = true)}
> title="Compare candidates fullscreen (zoom-preserving flips)"
Keep selected, delete rest
<kbd
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
> >
</button> <Maximize2 class="h-3 w-3" /> Compare
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Space</kbd
>
</button>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
disabled={busy || group.files.length < 2}
onclick={commit}
title="Promote the selected file; the rest move to the recoverable .duplicates/ quarantine"
>
Keep selected
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
>Enter</kbd
>
</button>
</div>
</header> </header>
<div <div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));">
bind:this={gridEl} {#each group.files as file, i (file.UID)}
class="grid gap-2"
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
>
{#each group.files as file (file.UID)}
{@const isBest = file.UID === best} {@const isBest = file.UID === best}
{@const sizeStr = sizeLabel(file.Size)} {@const sizeStr = sizeLabel(file.Size)}
{@const bestSize = sizesDiffer && (file.Size ?? 0) === maxSize}
{@const bestRes = pixelsDiffer && pixels(file) === maxPixels && pixels(file) > 0}
<button <button
type="button" type="button"
onclick={() => (best = file.UID)} onclick={() => (best = file.UID)}
class:scale-95={isBest} ondblclick={() => {
best = file.UID;
compareOpen = true;
}}
class:ring-2={isBest} class:ring-2={isBest}
class:ring-blue-500={isBest} class:ring-blue-500={isBest}
class:ring-offset-2={isBest} class:ring-offset-2={isBest}
class:ring-offset-background={isBest} class:ring-offset-background={isBest}
class:transition-[transform,box-shadow]={isBest} class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none transition-shadow focus:outline-none"
class:duration-300={isBest}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isBest}
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
> >
<div class="relative aspect-square w-full overflow-hidden"> <div class="relative aspect-square w-full overflow-hidden">
<img <img
src={thumbUrl(file.Hash, 'tile_500')} src={thumbUrl(file.Hash, 'tile_500')}
alt={file.Name} alt={file.Name}
loading="lazy" loading="lazy"
decoding="async"
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{#if isBest} {#if isBest}
<span <span
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white" class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
> >
Best Keep
</span> </span>
{/if} {:else if file.UID === suggestedUid}
{#if dims(file)}
<span <span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground" class="absolute left-1.5 top-1.5 rounded bg-emerald-600/90 px-1.5 py-0.5 text-[10px] font-semibold text-white"
title="Largest and highest-resolution file in this stack"
> >
{dims(file)} Suggested
</span> </span>
{/if} {/if}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] font-semibold text-foreground/90"
>
{i + 1}
</span>
</div> </div>
<div <div
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground" class="flex flex-col gap-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
title={`${file.Name}${sizeStr ? ` · ${sizeStr}` : ''}`} title={`${file.Name}${sizeStr ? ` · ${sizeStr}` : ''}`}
> >
<div class="truncate text-foreground/90">{shortPath(file.Name)}</div> <div class="truncate text-foreground/90">{shortPath(file.Name)}</div>
{#if sizeStr} <div class="flex items-center gap-1.5">
<div>{sizeStr}</div> {#if typeBadge(file)}
{/if} <span class="rounded bg-muted px-1 py-px font-medium">{typeBadge(file)}</span>
{/if}
{#if dims(file)}
<span class={bestRes ? 'font-semibold text-emerald-500' : ''}>{dims(file)}</span>
{/if}
{#if sizeStr}
<span class={bestSize ? 'font-semibold text-emerald-500' : ''}>{sizeStr}</span>
{/if}
</div>
</div> </div>
</button> </button>
{/each} {/each}
</div> </div>
</div> </div>
{#if compareOpen}
<CompareLightbox
files={group.files}
startUid={best}
onPick={(uid) => {
best = uid;
compareOpen = false;
sectionEl?.focus({ preventScroll: true });
}}
onClose={() => {
compareOpen = false;
sectionEl?.focus({ preventScroll: true });
}}
/>
{/if}

View File

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

@@ -69,6 +69,10 @@
* "{n} photos" affordance. Undefined keeps the badge off entirely * "{n} photos" affordance. Undefined keeps the badge off entirely
* (the picker dialog doesn't need it). */ * (the picker dialog doesn't need it). */
counts?: Record<string, number>; counts?: Record<string, number>;
/** Render every branch expanded regardless of the persisted openSet —
* the picker turns this on while a search filter is active so matches
* buried in collapsed branches stay visible. */
forceExpand?: boolean;
} }
let { let {
nodes, nodes,
@@ -80,7 +84,8 @@
onMove, onMove,
readonly = false, readonly = false,
selectedPath, selectedPath,
counts counts,
forceExpand = false
}: Props = $props(); }: Props = $props();
// Auto-expanded folders, persisted to localStorage so the tree state // Auto-expanded folders, persisted to localStorage so the tree state
@@ -145,7 +150,7 @@
<ul> <ul>
{#each nodes as node (node.path)} {#each nodes as node (node.path)}
{@const open = openSet.has(node.path)} {@const open = forceExpand || openSet.has(node.path)}
{@const active = isActive(node.path)} {@const active = isActive(node.path)}
{@const hasChildren = node.children.length > 0} {@const hasChildren = node.children.length > 0}
<li> <li>
@@ -185,11 +190,17 @@
+ badge) is one hit target — the badge was previously a dead + badge) is one hit target — the badge was previously a dead
zone right where the user's eye lands. zone right where the user's eye lands.
--> -->
<!-- In readonly (picker) mode the row carries data attributes the
move dialog uses for roving arrow-key focus, plus aria-pressed
so screen readers hear the current selection. -->
<button <button
class="flex min-w-0 flex-1 items-center pl-1 text-left" class="flex min-w-0 flex-1 items-center pl-1 text-left"
onclick={() => onPick(node.path)} onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)} ondblclick={readonly ? undefined : () => onRename?.(node.path)}
title={node.path} title={node.path}
data-move-row={readonly ? '' : undefined}
data-path={readonly ? node.path : undefined}
aria-pressed={readonly ? active : undefined}
> >
<span class="truncate">{node.name}</span> <span class="truncate">{node.name}</span>
{#if counts && counts[node.path] !== undefined} {#if counts && counts[node.path] !== undefined}
@@ -256,6 +267,7 @@
{readonly} {readonly}
{selectedPath} {selectedPath}
{counts} {counts}
{forceExpand}
/> />
{/if} {/if}
</li> </li>

View File

@@ -14,26 +14,36 @@
folder keeps its own name. The picker excludes the folder folder keeps its own name. The picker excludes the folder
itself and its descendants. itself and its descendants.
Picker reuses the readonly FolderTree; the dialog owns the selection UX model (Lightroom-style): tree is the primary surface, with a search
(`pickedPath`) so it never fights the global folderPath filter. 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"> <script lang="ts">
import { tick } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { Dialog } from 'bits-ui'; import { Dialog } from 'bits-ui';
import { createQuery, useQueryClient } from '@tanstack/svelte-query'; import { createQuery, useQueryClient } from '@tanstack/svelte-query';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte'; import { FolderInput, FolderOpen, History, Loader2, Search } from 'lucide-svelte';
import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { import {
convertHeap, convertHeap,
movePhotosToFolder, movePhotosToFolder,
moveFolder, moveFolder,
restoreMoves,
listFolders, listFolders,
type PpFolder type PpFolder
} from '$lib/services/photoprism'; } 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 { filters, setSection, setFolderPath } from '$lib/stores/filters.svelte';
import { isAuthenticated, toOriginalsPath, userLibraryBase } from '$lib/stores/session.svelte'; import { isAuthenticated, toOriginalsPath, userLibraryBase } from '$lib/stores/session.svelte';
import { moveDialog, closeMove } from '$lib/stores/moveDialog.svelte'; import { moveDialog, closeMove } from '$lib/stores/moveDialog.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import FolderTree, { buildTree } from './FolderTree.svelte'; import FolderTree, { buildTree } from './FolderTree.svelte';
const qc = useQueryClient(); const qc = useQueryClient();
@@ -50,16 +60,74 @@
const kind = $derived(subject?.kind); const kind = $derived(subject?.kind);
const open = $derived(subject !== null); 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 — // For folder reparent, exclude the folder itself and everything under it —
// you can't move a directory into its own subtree. // 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); const paths = (foldersQuery.data ?? []).map((f) => f.Path);
if (subject?.kind === 'folder') { if (subject?.kind === 'folder') {
const self = subject.path; 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 showOptions = $derived(kind === 'heap' || kind === 'photos');
const showDeleteHeap = $derived(kind === 'heap'); const showDeleteHeap = $derived(kind === 'heap');
@@ -67,6 +135,11 @@
const folderName = $derived( const folderName = $derived(
subject?.kind === 'folder' ? (subject.path.split('/').pop() ?? subject.path) : '' 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(() => { const headerTitle = $derived.by(() => {
if (subject?.kind === 'folder') return 'Move folder'; if (subject?.kind === 'folder') return 'Move folder';
const verb = mode === 'copy' ? 'Copy' : 'Move'; const verb = mode === 'copy' ? 'Copy' : 'Move';
@@ -75,25 +148,72 @@
}); });
const headerDesc = $derived.by(() => { const headerDesc = $derived.by(() => {
if (subject?.kind === 'heap') { if (subject?.kind === 'heap') {
const n = subject.heap.PhotoCount ?? 0; const n = photoCount;
return `${subject.heap.Title ?? ''} · ${n} photo${n === 1 ? '' : 's'}`; return `${subject?.kind === 'heap' ? (subject.heap.Title ?? '') : ''} · ${n} photo${n === 1 ? '' : 's'}`;
} }
if (subject?.kind === 'photos') { if (subject?.kind === 'photos') {
const n = subject.uids.length; return `${photoCount} photo${photoCount === 1 ? '' : 's'} selected`;
return `${n} photo${n === 1 ? '' : 's'} selected`;
} }
if (subject?.kind === 'folder') return `${folderName} → pick a destination`; if (subject?.kind === 'folder') return `${folderName} → pick a destination`;
return ''; return '';
}); });
let pickedPath = $state<string | null>(null); // ── Validation ───────────────────────────────────────────────────────
let mode = $state<'move' | 'copy'>('move'); /** Mirrors the sidecar's sanitizeFilename rules so bad names are caught
let subfolder = $state(''); * before the request instead of surfacing as a failed toast. */
let deleteHeap = $state(false); const subfolderError = $derived.by(() => {
let submitting = $state(false); 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 // 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(() => { $effect(() => {
void subject; void subject;
pickedPath = null; pickedPath = null;
@@ -101,6 +221,11 @@
subfolder = ''; subfolder = '';
deleteHeap = false; deleteHeap = false;
submitting = 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 // Copy mode doesn't change membership, so "delete heap after" is
@@ -109,16 +234,96 @@
if (mode === 'copy' && deleteHeap) deleteHeap = false; 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 { function moveSummary(verb: string, count: number, errors: number): string {
const tail = errors > 0 ? ` · ${errors} skipped` : ''; const tail = errors > 0 ? ` · ${errors} skipped` : '';
return `${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`; 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() { async function submit() {
const s = moveDialog.subject; const s = moveDialog.subject;
// pickedPath === '' is the root selection; distinguish it from `null` // pickedPath === '' is the root selection; distinguish it from `null`
// (nothing picked) so a falsy check doesn't wrongly block root. // (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; submitting = true;
// Snapshot the draft before closing — closeMove() nulls the subject, // Snapshot the draft before closing — closeMove() nulls the subject,
@@ -128,6 +333,7 @@
const sub = subfolder.trim() || null; const sub = subfolder.trim() || null;
const delHeap = mode === 'move' && deleteHeap; const delHeap = mode === 'move' && deleteHeap;
const labelName = folderName; const labelName = folderName;
saveRecent(dest);
// Close the dialog immediately and run the move in the background. The // 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 // move can be slow (a folder/heap with many files triggers a real
@@ -149,9 +355,14 @@
qc.invalidateQueries({ queryKey: ['photos'] }); qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
qc.invalidateQueries({ queryKey: ['heaps'] }); 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( toast.success(
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length), moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid } { id: tid, action: runUndo ? { label: 'Undo', onClick: runUndo } : undefined }
); );
if (r.heap_deleted && filters.section === 'heap' && filters.heapUid === s.heap.UID) { if (r.heap_deleted && filters.section === 'heap' && filters.heapUid === s.heap.UID) {
setSection('all-photos'); setSection('all-photos');
@@ -166,19 +377,49 @@
}); });
qc.invalidateQueries({ queryKey: ['photos'] }); qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
const runUndo = registerMoveUndo(
`Moved ${r.moved} photo${r.moved === 1 ? '' : 's'}`,
opMode === 'move' ? (r.movedFiles ?? []) : []
);
toast.success( toast.success(
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length), moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
{ id: tid } { id: tid, action: runUndo ? { label: 'Undo', onClick: runUndo } : undefined }
); );
} else { } else {
// Folder reparent (move only). Translate both the folder's own // Folder reparent (move only). Translate both the folder's own
// path and the destination parent to originals-relative for the // path and the destination parent to originals-relative for the
// sidecar, which moves real directories on disk. // sidecar, which moves real directories on disk.
await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest)); const r = await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest));
qc.invalidateQueries({ queryKey: ['photos'] }); qc.invalidateQueries({ queryKey: ['photos'] });
qc.invalidateQueries({ queryKey: ['folders'] }); qc.invalidateQueries({ queryKey: ['folders'] });
const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`; const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`;
toast.success(`Moved ${labelName}${dest === '' ? '/' : dest}`, { id: tid }); 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 we just moved the folder the timeline is showing, follow it.
if (filters.folderPath === s.path) setFolderPath(newUiPath); if (filters.folderPath === s.path) setFolderPath(newUiPath);
} }
@@ -199,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" 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 <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"> <!-- svelte-ignore a11y_no_static_element_interactions -->
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" /> <div bind:this={contentEl} onkeydown={onContentKeydown} class="grid gap-3" aria-busy={submitting}>
<div class="flex-1"> <div class="flex items-start gap-2">
<Dialog.Title class="text-sm font-semibold leading-tight"> <FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
{headerTitle} <div class="flex-1">
</Dialog.Title> <Dialog.Title class="text-sm font-semibold leading-tight">
<Dialog.Description class="mt-1 text-xs text-muted-foreground"> {headerTitle}
{headerDesc} </Dialog.Title>
</Dialog.Description> <Dialog.Description class="mt-1 text-xs text-muted-foreground">
</div> {headerDesc}
</div> </Dialog.Description>
<!-- Folder picker. Readonly FolderTree so the user can't kebab/rename
their way out of the picker mid-flow. -->
<div class="rounded-md border border-border bg-background p-2">
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
{kind === 'folder' ? 'Destination parent' : 'Destination'}
</div>
<div class="max-h-[200px] overflow-y-auto">
{#if foldersQuery.isPending}
<InlineLoader size="sm" label="Loading folders…" />
{:else if (foldersQuery.data ?? []).length === 0}
<EmptyState
size="compact"
icon={FolderOpen}
title="No folders"
description="Create one from the sidebar first."
/>
{:else}
<!-- Root row: drop straight into originals/ (the user's root)
without picking a subfolder. Empty string is the
sidecar's "root" sentinel. -->
<button
type="button"
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
class:bg-primary={pickedPath === ''}
class:text-primary-foreground={pickedPath === ''}
class:hover:bg-primary={pickedPath === ''}
onclick={() => (pickedPath = '')}
>
/
</button>
<FolderTree
nodes={folderTree}
onPick={(p) => (pickedPath = p)}
selectedPath={pickedPath}
readonly
/>
{/if}
</div>
</div>
<!-- Move/copy + subfolder, hidden for folder reparent (always a move
that keeps the folder's own name). -->
{#if showOptions}
<div class="space-y-2">
<div class="flex items-center gap-4 text-[12px]">
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="move" />
Move
</label>
<label class="flex items-center gap-1.5">
<input type="radio" bind:group={mode} value="copy" />
Copy
</label>
</div> </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> </div>
{/if}
<div class="flex items-center justify-end gap-2 pt-1"> <!-- Search over the tree — autofocused, filters live. -->
<button <div class="relative">
type="button" <Search
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent" class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
onclick={closeMove} />
disabled={submitting} <input
> bind:this={searchEl}
Cancel type="text"
</button> placeholder="Search folders…"
<button aria-label="Search folders"
type="button" bind:value={filterText}
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" 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"
onclick={submit} />
disabled={pickedPath === null || submitting} </div>
>
{#if submitting} <!-- Recent destinations — one-click chips. -->
<Loader2 class="h-3 w-3 animate-spin" /> {#if liveRecents.length > 0 && !filtering}
{/if} <div class="flex flex-wrap items-center gap-1" aria-label="Recent destinations">
{kind === 'folder' ? 'Move' : mode === 'copy' ? 'Copy' : 'Move'} <History class="h-3 w-3 text-muted-foreground" />
</button> {#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> </div>
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>

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 { view } from '$lib/stores/view.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte'; import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism'; import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
import { zoomPan, type ZoomPanState } from '$lib/actions/zoomPan';
import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte'; import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
@@ -101,6 +102,14 @@
setFocused(next); setFocused(next);
setAnchor(next); setAnchor(next);
} }
// ── Zoom & pan ───────────────────────────────────────────────────────
// Gesture handling lives in the shared zoomPan action (also used by
// the duplicates compare lightbox). Transform lives on a wrapper so
// the LQIP layer and the sharp image scale together. Resets on photo
// change via resetKey. Past 1.25× the sharp <img> switches to
// fit_2048 so zoomed pixels stay crisp.
let zp = $state<ZoomPanState>({ zoom: 1, tx: 0, ty: 0, panning: false });
</script> </script>
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4"> <div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
@@ -145,30 +154,54 @@
photoQuery.data.OriginalName ?? photoQuery.data.OriginalName ??
pf.Name ?? pf.Name ??
(isVideo(photoQuery.data) ? 'Video' : 'Photo')} (isVideo(photoQuery.data) ? 'Video' : 'Photo')}
{#if pf.Width && pf.Height} <div
<!-- LQIP layer: the same URL the grid loaded, blurred to mask use:zoomPan={{ onChange: (s) => (zp = s), resetKey: uid }}
the tile_*'s square center-crop against the sharp image's class="relative flex h-full w-full items-center justify-center overflow-hidden {zp.zoom > 1
true aspect. Sized via aspect-ratio + max-* + m-auto so it ? zp.panning
lands in the exact same bounding box as the sharp <img> ? 'cursor-grabbing'
beside it (object-contain semantics, but expressible on a : 'cursor-grab'
positioned element). Paints from the HTTP cache the moment : 'cursor-zoom-in'}"
the modal opens. --> >
<img <div
src={thumbSrc(pf.Hash, view.thumbnailSize)} class="relative flex h-full w-full items-center justify-center"
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)} class:transition-transform={!zp.panning}
alt="" class:duration-150={!zp.panning}
aria-hidden="true" style="transform: translate({zp.tx}px, {zp.ty}px) scale({zp.zoom});"
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 pf.Width && pf.Height}
/> <!-- LQIP layer: the same URL the grid loaded, blurred to mask
{/if} the tile_*'s square center-crop against the sharp image's
<img true aspect. Sized via aspect-ratio + max-* + m-auto so it
src={thumbUrl(pf.Hash, 'fit_1280')} lands in the exact same bounding box as the sharp <img>
alt={altText} beside it (object-contain semantics, but expressible on a
fetchpriority="high" positioned element). Paints from the HTTP cache the moment
decoding="async" the modal opens. -->
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl" <img
/> src={thumbSrc(pf.Hash, view.thumbnailSize)}
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
alt=""
aria-hidden="true"
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
style="aspect-ratio: {pf.Width} / {pf.Height};"
/>
{/if}
<img
src={thumbUrl(pf.Hash, zp.zoom > 1.25 ? 'fit_2048' : 'fit_1280')}
alt={altText}
fetchpriority="high"
decoding="async"
draggable="false"
class="relative max-h-full max-w-full select-none rounded-md object-contain shadow-2xl"
/>
</div>
{#if zp.zoom > 1}
<span
class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded bg-background/80 px-2 py-0.5 text-[11px] text-foreground"
>
{Math.round(zp.zoom * 100)}% · double-click to reset
</span>
{/if}
</div>
{/if} {/if}
{/if} {/if}
</div> </div>

View File

@@ -82,11 +82,13 @@
() => () =>
patchTargets( patchTargets(
ids, 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, label,
(p) => (p) =>
p.TakenAt p.TakenAt
? buildTakenAtPatch(p.TakenAt) ? buildTakenAtPatch(p.TakenAt, p)
: ({ TakenSrc: '' } as UpdatePhotoBody) : ({ TakenSrc: '' } as UpdatePhotoBody)
), ),
label label

View File

@@ -13,16 +13,19 @@
Aperture, Aperture,
ArrowUpRight, ArrowUpRight,
Calendar, Calendar,
Copy,
File, File,
Folder, Folder,
Globe, Globe,
HardDrive, HardDrive,
Heart,
ImageIcon, ImageIcon,
Loader2, Loader2,
MapPin, MapPin,
Star, Star,
Tag, Tag,
Timer, Timer,
User,
X X
} from 'lucide-svelte'; } from 'lucide-svelte';
import { import {
@@ -37,12 +40,20 @@
type UpdatePhotoBody type UpdatePhotoBody
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { invalidateFacets } from '$lib/services/bulk'; import { invalidateFacets } from '$lib/services/bulk';
import { toggleFavorite } from '$lib/services/photoActions';
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte'; import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
import { isAuthenticated } from '$lib/stores/session.svelte'; import { isAuthenticated } from '$lib/stores/session.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte'; import { push as pushUndo } from '$lib/stores/undo.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte'; import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism'; import {
import { navigateToFolder, navigateToTag } from '$lib/stores/filters.svelte'; isVideo,
photoNameAndDir,
primaryFile,
videoFile,
type PpPhoto
} from '$lib/types/photoprism';
import { navigateToFolder, navigateToTag, setSearch, setSection } from '$lib/stores/filters.svelte';
import { goto } from '$app/navigation';
import { COLOR_SWATCHES } from '$lib/utils/tagGroups'; import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
import { countryName } from '$lib/utils/countries'; import { countryName } from '$lib/utils/countries';
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath'; import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
@@ -55,14 +66,19 @@
const qc = useQueryClient(); const qc = useQueryClient();
let basename = $state(''); let basename = $state('');
let title = $state('');
let caption = $state(''); let caption = $state('');
let takenAt = $state(''); let takenAt = $state('');
let lat = $state(''); let lat = $state('');
let lng = $state(''); let lng = $state('');
let altitude = $state('');
let country = $state(''); let country = $state('');
let keywords = $state<string[]>([]); let keywords = $state<string[]>([]);
let keywordDraft = $state(''); let keywordDraft = $state('');
let renaming = $state(false); let renaming = $state(false);
let artist = $state('');
let copyright = $state('');
let license = $state('');
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory /** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
* prefix and basename. Sidecar's rename endpoint only accepts a bare * prefix and basename. Sidecar's rename endpoint only accepts a bare
@@ -76,16 +92,21 @@
$effect(() => { $effect(() => {
const pf = primaryFile(photo); const pf = primaryFile(photo);
basename = splitName(pf.Name ?? '').base; basename = splitName(pf.Name ?? '').base;
title = photo.Title ?? '';
caption = photo.Caption ?? ''; caption = photo.Caption ?? '';
takenAt = (photo.TakenAt ?? '').slice(0, 10); takenAt = (photo.TakenAt ?? '').slice(0, 10);
lat = photo.Lat ? String(photo.Lat) : ''; lat = photo.Lat ? String(photo.Lat) : '';
lng = photo.Lng ? String(photo.Lng) : ''; lng = photo.Lng ? String(photo.Lng) : '';
altitude = photo.Altitude ? String(photo.Altitude) : '';
country = photo.Country && photo.Country !== 'zz' ? photo.Country : ''; country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
const det = photo.Details ?? {}; const det = photo.Details ?? {};
keywords = (det.Keywords ?? '') keywords = (det.Keywords ?? '')
.split(',') .split(',')
.map((k) => k.trim()) .map((k) => k.trim())
.filter(Boolean); .filter(Boolean);
artist = det.Artist ?? '';
copyright = det.Copyright ?? '';
license = det.License ?? '';
}); });
const patchMutation = createMutation(() => ({ const patchMutation = createMutation(() => ({
@@ -141,6 +162,11 @@
if (caption === (photo.Caption ?? '')) return; if (caption === (photo.Caption ?? '')) return;
commit({ Caption: caption, CaptionSrc: 'manual' }); commit({ Caption: caption, CaptionSrc: 'manual' });
} }
function commitTitle() {
const next = title.trim();
if (next === (photo.Title ?? '')) return;
commit({ Title: next, TitleSrc: 'manual' });
}
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt)); const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
// Path-based date guess. Scoped to the EXIF Stripped review tab: those // Path-based date guess. Scoped to the EXIF Stripped review tab: those
// are the photos with definitionally-untrusted dates, and showing the // are the photos with definitionally-untrusted dates, and showing the
@@ -181,14 +207,16 @@
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z'; const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
const iso = `${takenAt}${tail}`; const iso = `${takenAt}${tail}`;
if (iso === photo.TakenAt) return; if (iso === photo.TakenAt) return;
commit(buildTakenAtPatch(iso)); commit(buildTakenAtPatch(iso, photo));
} }
function commitGps() { function commitGps() {
const nlat = parseFloat(lat); const nlat = parseFloat(lat);
const nlng = parseFloat(lng); const nlng = parseFloat(lng);
const nalt = parseFloat(altitude);
const patch: UpdatePhotoBody = {}; const patch: UpdatePhotoBody = {};
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat; if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng; if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng;
if (!Number.isNaN(nalt) && nalt !== photo.Altitude) patch.Altitude = nalt;
if (Object.keys(patch).length) commit(patch); if (Object.keys(patch).length) commit(patch);
} }
function commitCountry() { function commitCountry() {
@@ -198,7 +226,7 @@
commit({ Country: next || 'zz', CountrySrc: 'manual' }); commit({ Country: next || 'zz', CountrySrc: 'manual' });
} }
type DetailsKey = 'Keywords'; type DetailsKey = 'Keywords' | 'Artist' | 'Copyright' | 'License';
function commitDetails(field: DetailsKey, value: string) { function commitDetails(field: DetailsKey, value: string) {
const prev = (photo.Details ?? {})[field] ?? ''; const prev = (photo.Details ?? {})[field] ?? '';
if (value === prev) return; if (value === prev) return;
@@ -279,7 +307,42 @@
const currentRating = $derived(photoMark.rating ?? 0); const currentRating = $derived(photoMark.rating ?? 0);
const currentColor = $derived(photoMark.color ?? ''); const currentColor = $derived(photoMark.color ?? '');
// Named face markers across all file variants, deduped by subject.
// Slug mirrors PhotoPrism's slugify (lowercase, diacritics stripped,
// non-alphanumerics collapsed to '-') so the person link resolves the
// same drill URL the sidebar list uses.
function personSlug(name: string): string {
return name
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
const peopleChips = $derived.by(() => {
const seen = new Map<string, { subjUid: string; name: string; slug: string }>();
for (const f of photo.Files ?? []) {
for (const m of f.Markers ?? []) {
if (m.Invalid || !m.Name || !m.SubjUID || seen.has(m.SubjUID)) continue;
seen.set(m.SubjUID, { subjUid: m.SubjUID, name: m.Name, slug: personSlug(m.Name) });
}
}
return [...seen.values()];
});
const pf = $derived(primaryFile(photo)); const pf = $derived(primaryFile(photo));
// Video facts come from the video variant (primary is often the JPEG
// poster for Live Photos / transcoded clips).
const vf = $derived(isVideo(photo) ? videoFile(photo) : null);
const durationStr = $derived.by(() => {
// PpFile.Duration is Go time.Duration → nanoseconds.
const ns = vf?.Duration ?? 0;
if (ns <= 0) return '';
const totalSec = Math.round(ns / 1_000_000_000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
});
const dirPath = $derived(splitName(pf.Name ?? '').dir); const dirPath = $derived(splitName(pf.Name ?? '').dir);
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/'); const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—'); const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
@@ -307,6 +370,36 @@
const joined = `${make} ${model}`.trim(); const joined = `${make} ${model}`.trim();
return joined && joined !== 'Unknown' ? joined : ''; return joined && joined !== 'Unknown' ? joined : '';
} }
/** Quote for PhotoPrism's q= DSL — mirrors filters.svelte's quoteIfNeeded,
* duplicated here since that helper isn't exported. */
function quoteTerm(v: string): string {
return /^[A-Za-z0-9_-]+$/.test(v) ? v : `"${v.replace(/"/g, '\\"')}"`;
}
/** Jump to the timeline filtered by a raw DSL term (camera:/lens:) — the
* q-DSL escape hatch from the toolbar search box, triggered by click
* instead of typing. */
async function jumpToSearch(term: string): Promise<void> {
setSection('all-photos');
setSearch(term);
await goto('/', { keepFocus: true, noScroll: true });
}
async function copyExif(): Promise<void> {
const lines = [
cameraStr && `Camera: ${cameraStr}`,
lensStr && lensStr !== cameraStr && `Lens: ${lensStr}`,
exposureParts.fnum && `Aperture: ${exposureParts.fnum}`,
exposureParts.exp && `Shutter: ${exposureParts.exp}`,
exposureParts.iso && exposureParts.iso,
exposureParts.focal && `Focal length: ${exposureParts.focal}`,
photo.TakenAt && `Taken: ${photo.TakenAt}`
].filter(Boolean);
if (lines.length === 0) {
toast.message('No EXIF to copy');
return;
}
await navigator.clipboard.writeText(lines.join('\n'));
toast.success('EXIF copied');
}
function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } { function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } {
return { return {
iso: p.Iso ? `ISO ${p.Iso}` : '', iso: p.Iso ? `ISO ${p.Iso}` : '',
@@ -474,6 +567,17 @@
</span> </span>
</summary> </summary>
<div class="space-y-2 p-2 pt-1"> <div class="space-y-2 p-2 pt-1">
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Title</div>
<input
type="text"
placeholder="Add a title…"
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={title}
onblur={commitTitle}
/>
</div>
<div class="space-y-1"> <div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div> <div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
<textarea <textarea
@@ -501,6 +605,19 @@
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} /> <Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
</button> </button>
{/each} {/each}
<!-- PhotoPrism's native favorite — syncs to mobile gallery apps. -->
<button
type="button"
class="ml-2 p-0.5 transition-colors {photo.Favorite
? 'text-red-500'
: 'text-muted-foreground hover:text-foreground'}"
onclick={() => void toggleFavorite([photo.UID])}
title={photo.Favorite ? 'Remove from favorites (f)' : 'Add to favorites (f)'}
aria-pressed={photo.Favorite ?? false}
aria-label="Favorite"
>
<Heart class="h-3.5 w-3.5" fill={photo.Favorite ? 'currentColor' : 'none'} />
</button>
</div> </div>
</div> </div>
@@ -557,6 +674,27 @@
</div> </div>
</div> </div>
<!-- Recognized people — named face markers on this photo's files.
Read-only chips linking to the person's page. -->
{#if peopleChips.length > 0}
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">People</div>
<div class="flex flex-wrap gap-1">
{#each peopleChips as person (person.subjUid)}
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-1.5 py-0.5 text-[10px] hover:bg-accent"
onclick={() => void navigateToTag('people', person.slug)}
title={`View photos of ${person.name}`}
>
<User class="h-2.5 w-2.5 text-muted-foreground" />
{person.name}
</button>
{/each}
</div>
</div>
{/if}
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read- <!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-
only: editing labels requires re-indexing on PhotoPrism's only: editing labels requires re-indexing on PhotoPrism's
side. The dashed border + lower contrast distinguishes them side. The dashed border + lower contrast distinguishes them
@@ -631,6 +769,62 @@
onblur={commitCountry} onblur={commitCountry}
/> />
</label> </label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Altitude (m)</span>
<input
type="number"
step="1"
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={altitude}
onblur={commitGps}
/>
</label>
</div>
</details>
<!-- Credits — IPTC provenance fields (Artist / Copyright / License).
Closed by default; persists once opened. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('credits', false)}
ontoggle={(e) => setMetadataSection('credits', e.currentTarget.open)}
>
<summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Credits
</summary>
<div class="space-y-1.5 p-2 pt-1">
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Artist</span>
<input
type="text"
placeholder="Photographer…"
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={artist}
onblur={() => commitDetails('Artist', artist.trim())}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Copyright</span>
<input
type="text"
placeholder="© …"
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={copyright}
onblur={() => commitDetails('Copyright', copyright.trim())}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">License</span>
<input
type="text"
placeholder="e.g. CC BY-NC 4.0"
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={license}
onblur={() => commitDetails('License', license.trim())}
/>
</label>
</div> </div>
</details> </details>
@@ -641,20 +835,51 @@
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)} ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
> >
<summary <summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground" class="flex cursor-pointer items-center justify-between px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
> >
<span class="inline-flex items-center gap-1"> <span class="inline-flex items-center gap-1">
<ImageIcon class="h-3 w-3" /> File <ImageIcon class="h-3 w-3" /> File
</span> </span>
<button
type="button"
class="normal-case text-muted-foreground hover:text-foreground"
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
void copyExif();
}}
title="Copy EXIF summary"
aria-label="Copy EXIF summary"
>
<Copy class="h-3 w-3" />
</button>
</summary> </summary>
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]"> <dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
{#if cameraStr} {#if cameraStr}
<dt class="text-muted-foreground">Camera</dt> <dt class="text-muted-foreground">Camera</dt>
<dd class="text-foreground/80">{cameraStr}</dd> <dd class="min-w-0">
<button
type="button"
class="truncate text-left text-foreground/80 hover:text-foreground hover:underline"
onclick={() => void jumpToSearch(`camera:${quoteTerm(cameraStr)}`)}
title={`View other photos taken with ${cameraStr}`}
>
{cameraStr}
</button>
</dd>
{/if} {/if}
{#if lensStr && lensStr !== cameraStr} {#if lensStr && lensStr !== cameraStr}
<dt class="text-muted-foreground">Lens</dt> <dt class="text-muted-foreground">Lens</dt>
<dd class="text-foreground/80">{lensStr}</dd> <dd class="min-w-0">
<button
type="button"
class="truncate text-left text-foreground/80 hover:text-foreground hover:underline"
onclick={() => void jumpToSearch(`lens:${quoteTerm(lensStr)}`)}
title={`View other photos taken with ${lensStr}`}
>
{lensStr}
</button>
</dd>
{/if} {/if}
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal} {#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
<dt class="text-muted-foreground">Exposure</dt> <dt class="text-muted-foreground">Exposure</dt>
@@ -675,6 +900,18 @@
{/if} {/if}
<dt class="text-muted-foreground">Type</dt> <dt class="text-muted-foreground">Type</dt>
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd> <dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
{#if durationStr}
<dt class="text-muted-foreground">Duration</dt>
<dd class="text-foreground/80">{durationStr}</dd>
{/if}
{#if vf?.FPS}
<dt class="text-muted-foreground">FPS</dt>
<dd class="text-foreground/80">{Math.round(vf.FPS * 10) / 10}</dd>
{/if}
{#if vf?.Codec}
<dt class="text-muted-foreground">Codec</dt>
<dd class="text-foreground/80">{vf.Codec}</dd>
{/if}
<dt class="text-muted-foreground">Hash</dt> <dt class="text-muted-foreground">Hash</dt>
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}</dd> <dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}</dd>
<dt class="text-muted-foreground">Indexed</dt> <dt class="text-muted-foreground">Indexed</dt>

View File

@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import { import {
aggregateKeywords, aggregateKeywords,
@@ -7,11 +9,13 @@
listLabels, listLabels,
listPhotosByUids, listPhotosByUids,
listSubjects, listSubjects,
listUnnamedFaces,
type AggregatedKeyword, type AggregatedKeyword,
type PhotoMarksMap, type PhotoMarksMap,
type PpCountry, type PpCountry,
type PpLabel, type PpLabel,
type PpSubject type PpSubject,
type UnnamedFaceCluster
} from '$lib/services/photoprism'; } from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte'; import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { nearBottom } from '$lib/actions/nearBottom'; import { nearBottom } from '$lib/actions/nearBottom';
@@ -25,7 +29,7 @@
import { countryFlag, countryName } from '$lib/utils/countries'; import { countryFlag, countryName } from '$lib/utils/countries';
import type { PpPhoto } from '$lib/types/photoprism'; import type { PpPhoto } from '$lib/types/photoprism';
import { EmptyState, InlineLoader } from '$lib/components/feedback'; import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { Globe, Hash, Tag, User } from 'lucide-svelte'; import { Globe, Hash, Tag, User, UserPlus } from 'lucide-svelte';
interface Props { interface Props {
category: TagCategory; category: TagCategory;
@@ -34,6 +38,29 @@
} }
const { category, selectedValue, onSelect }: Props = $props(); const { category, selectedValue, onSelect }: Props = $props();
// "Name new faces" is a pinned row, not a subject — it needs to stay
// reachable even after every detected face has been named once (there's
// always another to catch as the library grows), so it lives outside
// the value-drives-URL selection model the rest of this sidebar uses.
// Query key matches NewFacesPanel's so the two share one cache entry.
const unnamedFacesQuery = createQuery<UnnamedFaceCluster[]>(() => ({
queryKey: ['faces', 'unnamed'],
queryFn: listUnnamedFaces,
enabled: isAuthenticated() && category === 'people',
staleTime: 60_000
}));
const unnamedFacesCount = $derived(unnamedFacesQuery.data?.length ?? 0);
const newFacesActive = $derived(page.url.searchParams.get('view') === 'new-faces');
function showNewFaces() {
// Deliberately NOT onSelect/navigateToTag — that drives the
// `[[value]]` route param, and the auto-select-first-tag effect
// below immediately overwrites a null value with the first real
// person, which is exactly the trap this row exists to escape.
// The `view` query param is independent state the page reads to
// show the naming panel instead of (or alongside) the photo grid.
void goto('/tags/people?view=new-faces', { keepFocus: true, noScroll: true });
}
let filterText = $state(''); let filterText = $state('');
// Reset the inline filter input whenever the user switches categories so // Reset the inline filter input whenever the user switches categories so
@@ -243,8 +270,16 @@
// fires when there's genuinely no selection — once a value is picked // fires when there's genuinely no selection — once a value is picked
// (by the user or by this effect), the URL drives selectedValue and // (by the user or by this effect), the URL drives selectedValue and
// the effect no-ops. // the effect no-ops.
//
// `newFacesActive` additionally suppresses it for People: navigating
// to the pinned "Name new faces" row necessarily clears selectedValue
// (it targets a bare `/tags/people` URL), and without this guard this
// effect would immediately redirect straight back to the first named
// person in the same tick — permanently hiding the naming workflow
// again the moment a second person exists to auto-select into.
$effect(() => { $effect(() => {
if (selectedValue != null) return; if (selectedValue != null) return;
if (newFacesActive) return;
if (firstValue == null) return; if (firstValue == null) return;
onSelect(firstValue, { replace: true }); onSelect(firstValue, { replace: true });
}); });
@@ -416,6 +451,36 @@
</div> </div>
{/if} {/if}
{:else if category === 'people'} {: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} {#if subjectsQuery.isPending}
<InlineLoader size="sm" label="Loading people…" /> <InlineLoader size="sm" label="Loading people…" />
{:else if subjectsQuery.isError} {:else if subjectsQuery.isError}
@@ -427,7 +492,7 @@
title={filterText ? 'No people match the filter' : 'No people yet'} title={filterText ? 'No people match the filter' : 'No people yet'}
description={filterText description={filterText
? undefined ? 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} {:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto"> <div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">

View File

@@ -17,8 +17,9 @@
import { view } from "$lib/stores/view.svelte"; import { view } from "$lib/stores/view.svelte";
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism"; import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte"; import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
import { toggleFavorite } from "$lib/services/photoActions";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
import { Loader2, Check, X } from "lucide-svelte"; import { Loader2, Check, Heart, X } from "lucide-svelte";
interface Props { interface Props {
photo: PpPhoto; photo: PpPhoto;
@@ -191,4 +192,28 @@
> >
{/if} {/if}
</button> </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> </div>

View File

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

View File

@@ -31,7 +31,7 @@ export async function listDuplicateGroups(basePath?: string): Promise<DuplicateG
const photos = await listPhotos({ const photos = await listPhotos({
q, q,
count: 200, count: 500,
merged: true, merged: true,
order: 'newest' 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, batchArchive,
batchRestore, batchRestore,
buildTakenAtPatch, buildTakenAtPatch,
likePhoto,
unlikePhoto,
updatePhoto updatePhoto
} from './photoprism'; } from './photoprism';
import { queryClient } from '$lib/queryClient'; import { queryClient } from '$lib/queryClient';
@@ -102,7 +104,7 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
originalName: p.OriginalName, originalName: p.OriginalName,
path 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); await approvePhoto(id);
return id; return id;
@@ -121,6 +123,70 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
toast.success(`Kept ${uids.length}`, { id: tid }); 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). * Archive photos. Reversible via the undo stack (Restore on ⌘Z).
*/ */

View File

@@ -343,6 +343,8 @@ export async function getPhoto(uid: string): Promise<PpPhoto> {
*/ */
export interface UpdatePhotoBody { export interface UpdatePhotoBody {
OriginalName?: string; OriginalName?: string;
Title?: string;
TitleSrc?: 'manual' | '';
Caption?: string; Caption?: string;
CaptionSrc?: 'manual' | ''; CaptionSrc?: 'manual' | '';
Archived?: boolean; Archived?: boolean;
@@ -373,17 +375,43 @@ export function isValidISODate(s: string): boolean {
return d.toISOString().slice(0, 10) === s; return d.toISOString().slice(0, 10) === s;
} }
export function buildTakenAtPatch(iso: string): UpdatePhotoBody { /** PhotoPrism serializes TakenAtLocal with a `Z` suffix even though it's
* semantically wall-clock time in the photo's TimeZone. Force-parse as
* UTC so offset math never picks up the *browser's* timezone. */
function parseAsUtc(s: string): number {
return Date.parse(/(Z|[+-]\d{2}:?\d{2})$/.test(s) ? s : s + 'Z');
}
/**
* `photo` supplies the existing TakenAt/TakenAtLocal pair so the photo's
* UTC↔local offset survives the edit. Without it (or without a prior
* pair) local falls back to UTC — correct for TimeZone-less photos.
* Previously this forced `TakenAtLocal = UTC`, which both let PhotoPrism
* clobber manual edits when recomputing local time from TimeZone and
* shifted Year/Month/Day for photos taken far from UTC.
*/
export function buildTakenAtPatch(
iso: string,
photo?: { TakenAt?: string; TakenAtLocal?: string }
): UpdatePhotoBody {
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return {}; if (Number.isNaN(d.getTime())) return {};
const utc = d.toISOString().replace(/\.\d+Z$/, 'Z'); const utc = d.toISOString().replace(/\.\d+Z$/, 'Z');
let offsetMs = 0;
if (photo?.TakenAt && photo?.TakenAtLocal) {
const a = parseAsUtc(photo.TakenAt);
const b = parseAsUtc(photo.TakenAtLocal);
if (!Number.isNaN(a) && !Number.isNaN(b)) offsetMs = b - a;
}
const local = new Date(d.getTime() + offsetMs);
return { return {
TakenAt: utc, TakenAt: utc,
TakenAtLocal: utc, TakenAtLocal: local.toISOString().replace(/\.\d+Z$/, 'Z'),
TakenSrc: 'manual', TakenSrc: 'manual',
Year: d.getUTCFullYear(), // PhotoPrism derives Year/Month/Day from local wall-clock time.
Month: d.getUTCMonth() + 1, Year: local.getUTCFullYear(),
Day: d.getUTCDate() Month: local.getUTCMonth() + 1,
Day: local.getUTCDate()
}; };
} }
@@ -694,35 +722,6 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
return Array.from(buckets.values()).sort((a, b) => b.count - a.count); 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[]> { export async function listLabels(): Promise<PpLabel[]> {
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden // `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
// low-confidence classifier hits, manually-removed labels). They're // low-confidence classifier hits, manually-removed labels). They're
@@ -762,10 +761,42 @@ export interface PpSubject {
} }
export async function listSubjects(): Promise<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' } 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> { export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
@@ -949,6 +980,9 @@ export async function deleteFolder(relPath: string): Promise<{ path: string }> {
export interface DupFileEntry { export interface DupFileEntry {
path: string; path: string;
size: number; size: number;
/** RFC3339 mtime — the only per-copy signal besides path, since all
* copies in a group are byte-identical. */
modTime?: string;
} }
export interface CrossFolderDuplicateGroup { export interface CrossFolderDuplicateGroup {
@@ -981,6 +1015,21 @@ export async function archiveDuplicatePaths(
return callSidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>; return callSidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
} }
export interface RestoreDuplicatesResult {
restored: { from: string; to: string }[];
errors: { path: string; error: string }[];
}
/** Inverse of archiveDuplicatePaths: pass the `moved` pairs from the
* archive response verbatim and the sidecar renames each quarantined
* file back to its original path. Powers undo for duplicate/stack
* resolution. */
export async function restoreDuplicatePaths(
moves: { from: string; to: string }[]
): Promise<RestoreDuplicatesResult> {
return callSidecar('POST', '/duplicates/restore', { moves }) as Promise<RestoreDuplicatesResult>;
}
// ── Heap convert (move/copy heap photos to a folder) ──────────────────────── // ── Heap convert (move/copy heap photos to a folder) ────────────────────────
// Lives on the sidecar because moving the underlying files is a filesystem // Lives on the sidecar because moving the underlying files is a filesystem
// operation PhotoPrism's API doesn't expose. The sidecar lists album members // operation PhotoPrism's API doesn't expose. The sidecar lists album members
@@ -1002,6 +1051,8 @@ export interface HeapConvertBody {
export interface HeapConvertResult { export interface HeapConvertResult {
moved: number; moved: number;
copied: number; copied: number;
/** Per-file {from,to} pairs for move mode — the undo payload. */
movedFiles: { from: string; to: string }[];
errors: { uid: string; reason: string }[]; errors: { uid: string; reason: string }[];
heap_deleted: boolean; heap_deleted: boolean;
} }
@@ -1029,6 +1080,8 @@ export interface PhotosMoveBody {
export interface PhotosMoveResult { export interface PhotosMoveResult {
moved: number; moved: number;
copied: number; copied: number;
/** Per-file {from,to} pairs for move mode — the undo payload. */
movedFiles: { from: string; to: string }[];
errors: { uid: string; reason: string }[]; errors: { uid: string; reason: string }[];
} }
@@ -1036,6 +1089,21 @@ export async function movePhotosToFolder(body: PhotosMoveBody): Promise<PhotosMo
return callSidecar('POST', '/photos/move', body) as Promise<PhotosMoveResult>; return callSidecar('POST', '/photos/move', body) as Promise<PhotosMoveResult>;
} }
export interface RestoreMovesResult {
restored: { from: string; to: string }[];
errors: { path: string; error: string }[];
}
/** Inverse of a photo/heap move: pass the `movedFiles` pairs from the
* move response verbatim and the sidecar renames each file back to its
* original folder (both ends scope-checked, no clobbering). Powers ⌘Z
* undo for moves. */
export async function restoreMoves(
moves: { from: string; to: string }[]
): Promise<RestoreMovesResult> {
return callSidecar('POST', '/files/restore-moves', { moves }) as Promise<RestoreMovesResult>;
}
// ── Reparent a folder (move the directory under a different parent) ────────── // ── Reparent a folder (move the directory under a different parent) ──────────
export interface FolderMoveResult { export interface FolderMoveResult {
@@ -1050,6 +1118,18 @@ export async function moveFolder(rel: string, targetParent: string): Promise<Fol
}) as Promise<FolderMoveResult>; }) as Promise<FolderMoveResult>;
} }
// ── Favorites ────────────────────────────────────────────────────────────────
// PhotoPrism's native favorite flag — unlike marks, this syncs to any
// PhotoPrism-compatible client app.
export async function likePhoto(uid: string): Promise<void> {
await http.post(`/photos/${encodeURIComponent(uid)}/like`);
}
export async function unlikePhoto(uid: string): Promise<void> {
await http.delete(`/photos/${encodeURIComponent(uid)}/like`);
}
// ── Photo marks (rating + color) ───────────────────────────────────────────── // ── Photo marks (rating + color) ─────────────────────────────────────────────
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed // PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
// internal fields). We store them in mule-sidecar instead. // internal fields). We store them in mule-sidecar instead.

View File

@@ -41,6 +41,25 @@ export function isTagCategory(v: unknown): v is TagCategory {
); );
} }
export type SortOrder = 'newest' | 'oldest' | 'added' | 'name';
export const SORT_ORDERS: readonly SortOrder[] = ['newest', 'oldest', 'added', 'name'] as const;
export const SORT_LABELS: Record<SortOrder, string> = {
newest: 'Newest first',
oldest: 'Oldest first',
added: 'Recently added',
name: 'File name'
};
/** Media-type chip values → PhotoPrism boolean q-DSL filters. */
export type MediaType = 'photo' | 'video' | 'raw' | 'live';
export const MEDIA_TYPES: readonly MediaType[] = ['photo', 'video', 'raw', 'live'] as const;
export const MEDIA_TYPE_LABELS: Record<MediaType, string> = {
photo: 'Photos',
video: 'Videos',
raw: 'RAW',
live: 'Live'
};
export interface FilterState { export interface FilterState {
section: Section; section: Section;
/** Heap UID, used when section === 'heap'. */ /** Heap UID, used when section === 'heap'. */
@@ -49,6 +68,14 @@ export interface FilterState {
folderPath: string | null; folderPath: string | null;
/** Free-form search text, ANDed with section-derived terms. */ /** Free-form search text, ANDed with section-derived terms. */
search: string; search: string;
/** Timeline sort order. Maps straight onto PhotoPrism's `order` param. */
sort: SortOrder;
/** Media-type chip; null = any. */
mediaType: MediaType | null;
/** Year chip; null = any. Compiles to `year:<n>`. */
year: number | null;
/** Favorites-only chip. Compiles to `favorite:true`. */
favorite: boolean;
/** /**
* Active tag-browser category and selected value. Set by the * Active tag-browser category and selected value. Set by the
* `/tags/[category]/[[value]]` route on navigation. Labels/keywords * `/tags/[category]/[[value]]` route on navigation. Labels/keywords
@@ -68,10 +95,42 @@ export const filters = $state<FilterState>({
heapUid: null, heapUid: null,
folderPath: '/', folderPath: '/',
search: '', search: '',
sort: 'newest',
mediaType: null,
year: null,
favorite: false,
tagCategory: null, tagCategory: null,
tagValue: null tagValue: null
}); });
export function setSort(sort: SortOrder): void {
filters.sort = sort;
}
export function setMediaType(t: MediaType | null): void {
filters.mediaType = t;
}
export function setYear(y: number | null): void {
filters.year = y;
}
export function setFavorite(on: boolean): void {
filters.favorite = on;
}
/** True when any toolbar chip narrows the view (excludes sort — a sort
* isn't a filter). Drives the "Clear" affordance. */
export function chipsActive(f: FilterState = filters): boolean {
return f.mediaType !== null || f.year !== null || f.favorite;
}
export function clearChips(): void {
filters.mediaType = null;
filters.year = null;
filters.favorite = false;
}
export function setSection(section: Section, heapUid: string | null = null): void { export function setSection(section: Section, heapUid: string | null = null): void {
filters.section = section; filters.section = section;
filters.heapUid = section === 'heap' ? heapUid : null; filters.heapUid = section === 'heap' ? heapUid : null;
@@ -250,7 +309,19 @@ export function filtersToQ(f: FilterState = filters): string {
parts.push(`country:${quoteIfNeeded(f.tagValue)}`); parts.push(`country:${quoteIfNeeded(f.tagValue)}`);
} }
} }
if (f.search) parts.push(quoteIfNeeded(f.search)); // Toolbar chips. PhotoPrism's boolean media filters (`video:true`,
// `photo:true`, …) are the documented DSL forms; `year:` and
// `favorite:` are plain filters.
if (f.mediaType) parts.push(`${f.mediaType}:true`);
if (f.year) parts.push(`year:${f.year}`);
if (f.favorite) parts.push('favorite:true');
// `f.search` is the raw-DSL escape hatch (toolbar cheat-sheet examples
// like `label:dog`, `taken:2024`) as well as plain free text. Only
// quote it when it has no `:` — a colon means the user (or a
// jump-to-search link) already wrote a structured term, and wrapping
// the whole thing in quotes would turn `camera:iPhone` into a literal
// phrase search for the text "camera:iPhone" instead of the operator.
if (f.search) parts.push(f.search.includes(':') ? f.search : quoteIfNeeded(f.search));
return parts.join(' '); return parts.join(' ');
} }
@@ -272,11 +343,22 @@ export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
!params.has('q'); !params.has('q');
const folderRaw = params.get('folder'); const folderRaw = params.get('folder');
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null; const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
const sortRaw = params.get('sort');
const typeRaw = params.get('type');
const yearRaw = params.get('year');
return { return {
section, section,
heapUid: params.get('heap'), heapUid: params.get('heap'),
folderPath, folderPath,
search: params.get('q') ?? '' search: params.get('q') ?? '',
sort: (SORT_ORDERS as readonly string[]).includes(sortRaw ?? '')
? (sortRaw as SortOrder)
: 'newest',
mediaType: (MEDIA_TYPES as readonly string[]).includes(typeRaw ?? '')
? (typeRaw as MediaType)
: null,
year: yearRaw && /^\d{4}$/.test(yearRaw) ? parseInt(yearRaw, 10) : null,
favorite: params.get('fav') === '1'
}; };
} }
@@ -288,5 +370,9 @@ export function filtersToUrlParams(f: FilterState = filters): URLSearchParams {
if (f.heapUid) params.set('heap', f.heapUid); if (f.heapUid) params.set('heap', f.heapUid);
if (f.folderPath) params.set('folder', f.folderPath); if (f.folderPath) params.set('folder', f.folderPath);
if (f.search) params.set('q', f.search); if (f.search) params.set('q', f.search);
if (f.sort !== 'newest') params.set('sort', f.sort);
if (f.mediaType) params.set('type', f.mediaType);
if (f.year) params.set('year', String(f.year));
if (f.favorite) params.set('fav', '1');
return params; return params;
} }

View File

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

View File

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

View File

@@ -20,6 +20,9 @@
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte'; import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
import PreviewModal from '$lib/components/preview/PreviewModal.svelte'; import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte'; import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
import ShortcutsDialog from '$lib/components/layout/ShortcutsDialog.svelte';
import CommandPalette from '$lib/components/layout/CommandPalette.svelte';
import { togglePalette } from '$lib/stores/view.svelte';
let { children } = $props(); let { children } = $props();
@@ -58,8 +61,18 @@
else stopIndexerWatch(); else stopIndexerWatch();
}); });
// ⌘K lives at the window level (not gridKeyNav) so the palette opens
// from any route and even while a form field holds focus.
function onGlobalKey(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
togglePalette();
}
}
</script> </script>
<svelte:window onkeydown={onGlobalKey} />
<svelte:head> <svelte:head>
<title>Mulimage</title> <title>Mulimage</title>
</svelte:head> </svelte:head>
@@ -122,6 +135,10 @@
store. Opened from the heap/folder kebabs, the BulkActionBar store. Opened from the heap/folder kebabs, the BulkActionBar
button, and the `m` shortcut — all through openMove(). --> button, and the `m` shortcut — all through openMove(). -->
<MoveToFolderDialog /> <MoveToFolderDialog />
<!-- Keyboard-shortcut reference, toggled by `?` via gridKeyNav. -->
<ShortcutsDialog />
<!-- ⌘K palette — jump to sections/heaps/folders + global actions. -->
<CommandPalette />
{:else} {:else}
{@render children?.()} {@render children?.()}
{/if} {/if}

View File

@@ -17,14 +17,26 @@
type PpAlbum, type PpAlbum,
} from "$lib/services/photoprism"; } from "$lib/services/photoprism";
import { import {
chipsActive,
clearChips,
consumePendingFocus, consumePendingFocus,
filters, filters,
filtersToQ, filtersToQ,
filtersToUrlParams, filtersToUrlParams,
MEDIA_TYPE_LABELS,
MEDIA_TYPES,
parseUrlParams, parseUrlParams,
setFavorite,
setMediaType,
setSearch, setSearch,
setSection, setSection,
setSort,
setYear,
SORT_LABELS,
SORT_ORDERS,
type MediaType,
type PendingFocus, type PendingFocus,
type SortOrder,
} from "$lib/stores/filters.svelte"; } from "$lib/stores/filters.svelte";
import { isAuthenticated } from "$lib/stores/session.svelte"; import { isAuthenticated } from "$lib/stores/session.svelte";
import { untrack } from "svelte"; import { untrack } from "svelte";
@@ -81,6 +93,10 @@
if (next.heapUid !== undefined) filters.heapUid = next.heapUid; if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
if (next.folderPath !== undefined) filters.folderPath = next.folderPath; if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
if (next.search !== undefined) filters.search = next.search; if (next.search !== undefined) filters.search = next.search;
if (next.sort !== undefined) filters.sort = next.sort;
if (next.mediaType !== undefined) filters.mediaType = next.mediaType;
if (next.year !== undefined) filters.year = next.year;
if (next.favorite !== undefined) filters.favorite = next.favorite;
}); });
// When the store changes from in-app actions (left-sidebar nav, search // When the store changes from in-app actions (left-sidebar nav, search
@@ -182,15 +198,20 @@
"photos", "photos",
"q", "q",
filtersToQ(filters), filtersToQ(filters),
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null }, {
count: PHOTOS_PAGE_SIZE,
anchor: anchor?.takenAt ?? null,
sort: filters.sort,
},
], ],
queryFn: ({ pageParam }) => { queryFn: ({ pageParam }) => {
const offset = pageParam as number; const offset = pageParam as number;
const baseQ = filtersToQ(filters); const baseQ = filtersToQ(filters);
// Page 0 + anchor → load a window around the anchor's date. // Page 0 + anchor → load a window around the anchor's date.
// Subsequent pages aren't reachable in anchor mode (see // Subsequent pages aren't reachable in anchor mode (see
// getNextPageParam). // getNextPageParam). Anchor windows assume chronological order,
if (offset === 0 && anchor?.takenAt) { // so any non-default sort falls back to plain paging.
if (offset === 0 && anchor?.takenAt && filters.sort === "newest") {
return listPhotosAround({ return listPhotosAround({
q: baseQ, q: baseQ,
takenAt: anchor.takenAt, takenAt: anchor.takenAt,
@@ -203,7 +224,7 @@
q: baseQ, q: baseQ,
count: PHOTOS_PAGE_SIZE, count: PHOTOS_PAGE_SIZE,
offset, offset,
order: "newest", order: filters.sort,
merged: true, merged: true,
}); });
}, },
@@ -222,7 +243,7 @@
// photos exceeding the page size keep the cursor at the same // photos exceeding the page size keep the cursor at the same
// value). To "see more," the user clears the anchor by // value). To "see more," the user clears the anchor by
// navigating fresh. // navigating fresh.
if (anchor?.takenAt) return undefined; if (anchor?.takenAt && filters.sort === "newest") return undefined;
return pages.length * PHOTOS_PAGE_SIZE; return pages.length * PHOTOS_PAGE_SIZE;
}, },
enabled: isAuthenticated(), enabled: isAuthenticated(),
@@ -800,6 +821,16 @@
setSearch(searchDraft.trim()); setSearch(searchDraft.trim());
} }
// Toolbar chips: years from the current year back to 1990 — static
// range keeps it dependency-free; PhotoPrism just returns an empty
// page for years with no photos.
const CHIP_YEARS = Array.from(
{ length: new Date().getFullYear() - 1989 },
(_, i) => new Date().getFullYear() - i,
);
const CHIP_SELECT_CLASS =
"rounded border border-input bg-background px-1.5 py-0.5 text-[11px] text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-ring";
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on // PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
// focus turns the placeholder hint into a clickable cheat-sheet. // focus turns the placeholder hint into a clickable cheat-sheet.
const SEARCH_EXAMPLES = [ const SEARCH_EXAMPLES = [
@@ -843,6 +874,7 @@
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}> <form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
<input <input
type="search" type="search"
data-search-input
placeholder={'Search · label:website / "vacation"'} placeholder={'Search · label:website / "vacation"'}
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring" class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
bind:value={searchDraft} bind:value={searchDraft}
@@ -898,6 +930,68 @@
{/if} {/if}
</form> </form>
<!-- Filter chips: sort / media type / year / favorites. Compile into
the same q-DSL the search box feeds, so they stack with search,
folders, and sections. URL-persisted for shareable views. -->
<div class="flex shrink-0 items-center gap-1" role="group" aria-label="Filters">
<select
class={CHIP_SELECT_CLASS}
value={filters.sort}
onchange={(e) => setSort(e.currentTarget.value as SortOrder)}
title="Sort order"
aria-label="Sort order"
>
{#each SORT_ORDERS as s (s)}
<option value={s}>{SORT_LABELS[s]}</option>
{/each}
</select>
<select
class={CHIP_SELECT_CLASS}
value={filters.mediaType ?? ""}
onchange={(e) => setMediaType((e.currentTarget.value || null) as MediaType | null)}
title="Media type"
aria-label="Media type"
>
<option value="">Any type</option>
{#each MEDIA_TYPES as t (t)}
<option value={t}>{MEDIA_TYPE_LABELS[t]}</option>
{/each}
</select>
<select
class={CHIP_SELECT_CLASS}
value={filters.year ? String(filters.year) : ""}
onchange={(e) => setYear(e.currentTarget.value ? parseInt(e.currentTarget.value, 10) : null)}
title="Year"
aria-label="Year"
>
<option value="">Any year</option>
{#each CHIP_YEARS as y (y)}
<option value={String(y)}>{y}</option>
{/each}
</select>
<button
type="button"
class="rounded border px-1.5 py-0.5 text-[11px] transition-colors {filters.favorite
? 'border-red-400 bg-red-500/10 text-red-500'
: 'border-input text-muted-foreground hover:bg-accent'}"
aria-pressed={filters.favorite}
onclick={() => setFavorite(!filters.favorite)}
title="Favorites only (f toggles a photo's favorite)"
>
♥ Favorites
</button>
{#if chipsActive(filters)}
<button
type="button"
class="rounded px-1.5 py-0.5 text-[11px] text-muted-foreground underline-offset-2 hover:underline"
onclick={clearChips}
title="Clear type / year / favorites filters"
>
Clear
</button>
{/if}
</div>
{#snippet trailing()} {#snippet trailing()}
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL. <!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
Persisted to localStorage via view.svelte.ts. --> Persisted to localStorage via view.svelte.ts. -->

View File

@@ -32,6 +32,7 @@
import { countryName } from '$lib/utils/countries'; import { countryName } from '$lib/utils/countries';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte'; import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import NewFacesPanel from '$lib/components/people/NewFacesPanel.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte'; import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte'; import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte'; import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
@@ -52,6 +53,16 @@
: null : null
); );
// Independent of selectedValue on purpose: the People sidebar's pinned
// "Name new faces" row sets this query param instead of the `[[value]]`
// route param, specifically so it can't be clobbered by the auto-
// select-first-tag effect (TagsBrowserSidebar) that fires whenever
// selectedValue is null — that effect is exactly what made the naming
// panel unreachable again after the first person was named.
const showNewFaces = $derived(
category === 'people' && page.url.searchParams.get('view') === 'new-faces'
);
// Mirror URL into the shared filter store so any other consumer of // Mirror URL into the shared filter store so any other consumer of
// `filters` (e.g. cross-route navigation back to `/`) sees the active // `filters` (e.g. cross-route navigation back to `/`) sees the active
// tag filter, and so `filtersToQ()` produces the correct DSL clause // tag filter, and so `filtersToQ()` produces the correct DSL clause
@@ -82,6 +93,10 @@
heapUid: null, heapUid: null,
folderPath: null, folderPath: null,
search: '', search: '',
sort: 'newest',
mediaType: null,
year: null,
favorite: false,
tagCategory: category, tagCategory: category,
tagValue: selectedValue tagValue: selectedValue
}) })
@@ -203,7 +218,7 @@
{#if category} {#if category}
<span class="text-[11px] capitalize text-muted-foreground">{category}</span> <span class="text-[11px] capitalize text-muted-foreground">{category}</span>
{/if} {/if}
{#if selectedValue} {#if selectedValue && !showNewFaces}
<span class="text-[11px] font-medium">{drillTitle}</span> <span class="text-[11px] font-medium">{drillTitle}</span>
<span class="text-[11px] text-muted-foreground"> <span class="text-[11px] text-muted-foreground">
{drillCount} photo{drillCount === 1 ? '' : 's'} {drillCount} photo{drillCount === 1 ? '' : 's'}
@@ -211,7 +226,23 @@
{/if} {/if}
</Toolbar> </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"> <main class="flex min-h-0 flex-1 items-center justify-center p-8">
<EmptyState <EmptyState
icon={Tag} icon={Tag}