5 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
26 changed files with 2493 additions and 705 deletions

View File

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

View File

@@ -22,6 +22,9 @@ const quarantineDir = ".duplicates"
type dupFileLite struct {
Path string `json:"path"`
Size int64 `json:"size"`
// RFC3339 mtime so the UI can label older/newer copies. Copies are
// byte-identical, so mtime is the only per-copy signal besides path.
ModTime string `json:"modTime,omitempty"`
}
type dupGroup struct {
@@ -128,7 +131,11 @@ func handleDupScan(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
}
g := dupGroup{Hash: h, Size: hashSize[h]}
for _, f := range files {
g.Files = append(g.Files, dupFileLite{Path: f.RelPath, Size: f.Size})
g.Files = append(g.Files, dupFileLite{
Path: f.RelPath,
Size: f.Size,
ModTime: f.ModTime.UTC().Format(time.RFC3339),
})
}
// Best-effort lookup; swallow errors. The hash query is cheap on
// PhotoPrism's side (indexed column).
@@ -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})
}
}
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

@@ -127,7 +127,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
return
}
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
moved, copied, movedPairs, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -157,6 +157,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
c.JSON(http.StatusOK, gin.H{
"moved": moved,
"copied": copied,
"movedFiles": movedPairs,
"errors": errs,
"heap_deleted": heapDeleted,
})
@@ -173,17 +174,23 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
// `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.
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode, scopeAbs string) (moved, copied int, errs []heapErr, err error) {
//
// `movedPairs` records every file that physically moved (move mode only —
// copies have no inverse pair) as originals-relative {from,to}, including
// siblings of photos that later failed partway: undo must restore whatever
// actually left its folder, not just fully-successful photos.
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode, scopeAbs string) (moved, copied int, movedPairs []dupMoved, errs []heapErr, err error) {
destAbs := targetAbs
if subfolder != "" {
destAbs = filepath.Join(targetAbs, subfolder)
if e := os.MkdirAll(destAbs, 0o755); e != nil {
return 0, 0, nil, e
return 0, 0, nil, nil, e
}
}
sourceParents := map[string]struct{}{}
errs = []heapErr{}
movedPairs = []dupMoved{}
for _, photo := range photos {
// Gather *every* originals-rooted file of the photo, not just the
@@ -282,6 +289,9 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
break
}
}
if dstRel, relErr := filepath.Rel(cfg.OriginalsRoot, dstAbs); relErr == nil {
movedPairs = append(movedPairs, dupMoved{From: srcRel, To: dstRel})
}
} else {
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
failure = cpErr.Error()
@@ -329,7 +339,7 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
fireReindex(cfg, pp, token, reindex)
}
return moved, copied, errs, nil
return moved, copied, movedPairs, errs, nil
}
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"."

View File

@@ -70,7 +70,7 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
return
}
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
moved, copied, movedPairs, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -87,9 +87,10 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
"errors", len(errs),
)
c.JSON(http.StatusOK, gin.H{
"moved": moved,
"copied": copied,
"errors": errs,
"moved": moved,
"copied": copied,
"movedFiles": movedPairs,
"errors": errs,
})
}
}
@@ -203,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})
}
}

View File

@@ -185,6 +185,27 @@ func proxyToken(r *http.Request) string {
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.
@@ -260,10 +281,15 @@ func handlePPProxy(cfg *Config, ppDb *gorm.DB) gin.HandlerFunc {
c.Request.URL.RawPath = ""
method := c.Request.Method
// Unauthenticated / token-in-URL surface: login+logout, client
// config, hash-addressed media, websocket.
// 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, "oauth/") ||
strings.HasPrefix(rest, "oidc/") ||
rest == "config" || rest == "ws" ||
strings.HasPrefix(rest, "t/") ||
strings.HasPrefix(rest, "dl/") ||
@@ -339,13 +365,28 @@ func handlePPProxy(cfg *Config, ppDb *gorm.DB) gin.HandlerFunc {
// 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.
// 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

@@ -99,15 +99,19 @@ func main() {
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.POST("/files/restore-moves", handleRestoreMoves(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
auth.POST("/duplicates/restore", handleDupRestore(cfg, pp, db))
// User-scoped proxies — require PpDSN connection.
if ppDb != nil {
auth.GET("/labels", handleLabels(pp, ppDb))
auth.GET("/counts", handleScopedCounts(ppDb))
auth.GET("/countries", handleCountries(ppDb))
auth.GET("/subjects", handleSubjects(pp, ppDb))
auth.GET("/faces/unnamed", handleUnnamedFaces(ppDb))
}
// User-scoped photos — post-filters by BasePath so review/archive

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,6 +58,17 @@
{ 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>

View File

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

View File

@@ -16,6 +16,7 @@
import { view } from '$lib/stores/view.svelte';
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
import { zoomPan, type ZoomPanState } from '$lib/actions/zoomPan';
import { EmptyState, InlineLoader } from '$lib/components/feedback';
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
@@ -103,88 +104,12 @@
}
// ── Zoom & pan ───────────────────────────────────────────────────────
// Wheel zooms around the cursor, double-click toggles 1↔2.5, drag pans
// while zoomed. Transform lives on a wrapper so the LQIP layer and the
// sharp image scale together. Resets on photo change. Past 1.25× the
// sharp <img> switches to fit_2048 so zoomed pixels stay crisp.
const MAX_ZOOM = 6;
let zoom = $state(1);
let tx = $state(0);
let ty = $state(0);
let zoomHost = $state<HTMLElement | undefined>();
let panning = $state(false);
let lastX = 0;
let lastY = 0;
$effect(() => {
void uid;
zoom = 1;
tx = 0;
ty = 0;
});
function applyZoom(next: number, clientX: number, clientY: number) {
if (!zoomHost) return;
const clamped = Math.min(MAX_ZOOM, Math.max(1, next));
if (clamped === zoom) return;
// Keep the point under the cursor fixed: translate offsets are in
// post-scale pixels around the container centre.
const rect = zoomHost.getBoundingClientRect();
const cx = clientX - rect.left - rect.width / 2;
const cy = clientY - rect.top - rect.height / 2;
const s = clamped / zoom;
tx = cx + (tx - cx) * s;
ty = cy + (ty - cy) * s;
zoom = clamped;
if (zoom === 1) {
tx = 0;
ty = 0;
}
}
function onWheel(e: WheelEvent) {
e.preventDefault();
applyZoom(zoom * Math.exp(-e.deltaY * 0.0018), e.clientX, e.clientY);
}
/** Svelte marks wheel handlers passive; zooming needs preventDefault,
* so the listener is attached manually as non-passive. */
function wheelZoom(node: HTMLElement) {
node.addEventListener('wheel', onWheel, { passive: false });
return {
destroy() {
node.removeEventListener('wheel', onWheel);
}
};
}
function onDblClickZoom(e: MouseEvent) {
if (zoom > 1) {
zoom = 1;
tx = 0;
ty = 0;
} else {
applyZoom(2.5, e.clientX, e.clientY);
}
}
function onPointerDown(e: PointerEvent) {
if (zoom === 1) return;
panning = true;
lastX = e.clientX;
lastY = e.clientY;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!panning) return;
tx += e.clientX - lastX;
ty += e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
}
function onPointerUp() {
panning = false;
}
// Gesture handling lives in the shared zoomPan action (also used by
// the duplicates compare lightbox). Transform lives on a wrapper so
// the LQIP layer and the sharp image scale together. Resets on photo
// change via resetKey. Past 1.25× the sharp <img> switches to
// fit_2048 so zoomed pixels stay crisp.
let zp = $state<ZoomPanState>({ zoom: 1, tx: 0, ty: 0, panning: false });
</script>
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
@@ -229,26 +154,19 @@
photoQuery.data.OriginalName ??
pf.Name ??
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={zoomHost}
use:wheelZoom
ondblclick={onDblClickZoom}
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
class="relative flex h-full w-full items-center justify-center overflow-hidden {zoom > 1
? panning
use:zoomPan={{ onChange: (s) => (zp = s), resetKey: uid }}
class="relative flex h-full w-full items-center justify-center overflow-hidden {zp.zoom > 1
? zp.panning
? 'cursor-grabbing'
: 'cursor-grab'
: 'cursor-zoom-in'}"
>
<div
class="relative flex h-full w-full items-center justify-center"
class:transition-transform={!panning}
class:duration-150={!panning}
style="transform: translate({tx}px, {ty}px) scale({zoom});"
class:transition-transform={!zp.panning}
class:duration-150={!zp.panning}
style="transform: translate({zp.tx}px, {zp.ty}px) scale({zp.zoom});"
>
{#if pf.Width && pf.Height}
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
@@ -268,7 +186,7 @@
/>
{/if}
<img
src={thumbUrl(pf.Hash, zoom > 1.25 ? 'fit_2048' : 'fit_1280')}
src={thumbUrl(pf.Hash, zp.zoom > 1.25 ? 'fit_2048' : 'fit_1280')}
alt={altText}
fetchpriority="high"
decoding="async"
@@ -276,11 +194,11 @@
class="relative max-h-full max-w-full select-none rounded-md object-contain shadow-2xl"
/>
</div>
{#if zoom > 1}
{#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(zoom * 100)}% · double-click to reset
{Math.round(zp.zoom * 100)}% · double-click to reset
</span>
{/if}
</div>

View File

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

View File

@@ -25,6 +25,7 @@
Star,
Tag,
Timer,
User,
X
} from 'lucide-svelte';
import {
@@ -44,7 +45,13 @@
import { isAuthenticated } from '$lib/stores/session.svelte';
import { push as pushUndo } from '$lib/stores/undo.svelte';
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
import {
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';
@@ -59,14 +66,19 @@
const qc = useQueryClient();
let basename = $state('');
let title = $state('');
let caption = $state('');
let takenAt = $state('');
let lat = $state('');
let lng = $state('');
let altitude = $state('');
let country = $state('');
let keywords = $state<string[]>([]);
let keywordDraft = $state('');
let renaming = $state(false);
let artist = $state('');
let copyright = $state('');
let license = $state('');
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
* prefix and basename. Sidecar's rename endpoint only accepts a bare
@@ -80,16 +92,21 @@
$effect(() => {
const pf = primaryFile(photo);
basename = splitName(pf.Name ?? '').base;
title = photo.Title ?? '';
caption = photo.Caption ?? '';
takenAt = (photo.TakenAt ?? '').slice(0, 10);
lat = photo.Lat ? String(photo.Lat) : '';
lng = photo.Lng ? String(photo.Lng) : '';
altitude = photo.Altitude ? String(photo.Altitude) : '';
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
const det = photo.Details ?? {};
keywords = (det.Keywords ?? '')
.split(',')
.map((k) => k.trim())
.filter(Boolean);
artist = det.Artist ?? '';
copyright = det.Copyright ?? '';
license = det.License ?? '';
});
const patchMutation = createMutation(() => ({
@@ -145,6 +162,11 @@
if (caption === (photo.Caption ?? '')) return;
commit({ Caption: caption, CaptionSrc: 'manual' });
}
function commitTitle() {
const next = title.trim();
if (next === (photo.Title ?? '')) return;
commit({ Title: next, TitleSrc: 'manual' });
}
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
// are the photos with definitionally-untrusted dates, and showing the
@@ -185,14 +207,16 @@
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
const iso = `${takenAt}${tail}`;
if (iso === photo.TakenAt) return;
commit(buildTakenAtPatch(iso));
commit(buildTakenAtPatch(iso, photo));
}
function commitGps() {
const nlat = parseFloat(lat);
const nlng = parseFloat(lng);
const nalt = parseFloat(altitude);
const patch: UpdatePhotoBody = {};
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng;
if (!Number.isNaN(nalt) && nalt !== photo.Altitude) patch.Altitude = nalt;
if (Object.keys(patch).length) commit(patch);
}
function commitCountry() {
@@ -202,7 +226,7 @@
commit({ Country: next || 'zz', CountrySrc: 'manual' });
}
type DetailsKey = 'Keywords';
type DetailsKey = 'Keywords' | 'Artist' | 'Copyright' | 'License';
function commitDetails(field: DetailsKey, value: string) {
const prev = (photo.Details ?? {})[field] ?? '';
if (value === prev) return;
@@ -283,7 +307,42 @@
const currentRating = $derived(photoMark.rating ?? 0);
const currentColor = $derived(photoMark.color ?? '');
// Named face markers across all file variants, deduped by subject.
// Slug mirrors PhotoPrism's slugify (lowercase, diacritics stripped,
// non-alphanumerics collapsed to '-') so the person link resolves the
// same drill URL the sidebar list uses.
function personSlug(name: string): string {
return name
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
const peopleChips = $derived.by(() => {
const seen = new Map<string, { subjUid: string; name: string; slug: string }>();
for (const f of photo.Files ?? []) {
for (const m of f.Markers ?? []) {
if (m.Invalid || !m.Name || !m.SubjUID || seen.has(m.SubjUID)) continue;
seen.set(m.SubjUID, { subjUid: m.SubjUID, name: m.Name, slug: personSlug(m.Name) });
}
}
return [...seen.values()];
});
const pf = $derived(primaryFile(photo));
// Video facts come from the video variant (primary is often the JPEG
// poster for Live Photos / transcoded clips).
const vf = $derived(isVideo(photo) ? videoFile(photo) : null);
const durationStr = $derived.by(() => {
// PpFile.Duration is Go time.Duration → nanoseconds.
const ns = vf?.Duration ?? 0;
if (ns <= 0) return '';
const totalSec = Math.round(ns / 1_000_000_000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}:${String(s).padStart(2, '0')}`;
});
const dirPath = $derived(splitName(pf.Name ?? '').dir);
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
@@ -508,6 +567,17 @@
</span>
</summary>
<div class="space-y-2 p-2 pt-1">
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Title</div>
<input
type="text"
placeholder="Add a title…"
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={title}
onblur={commitTitle}
/>
</div>
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
<textarea
@@ -604,6 +674,27 @@
</div>
</div>
<!-- Recognized people — named face markers on this photo's files.
Read-only chips linking to the person's page. -->
{#if peopleChips.length > 0}
<div class="space-y-1">
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">People</div>
<div class="flex flex-wrap gap-1">
{#each peopleChips as person (person.subjUid)}
<button
type="button"
class="inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-1.5 py-0.5 text-[10px] hover:bg-accent"
onclick={() => void navigateToTag('people', person.slug)}
title={`View photos of ${person.name}`}
>
<User class="h-2.5 w-2.5 text-muted-foreground" />
{person.name}
</button>
{/each}
</div>
</div>
{/if}
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-
only: editing labels requires re-indexing on PhotoPrism's
side. The dashed border + lower contrast distinguishes them
@@ -678,6 +769,62 @@
onblur={commitCountry}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Altitude (m)</span>
<input
type="number"
step="1"
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={altitude}
onblur={commitGps}
/>
</label>
</div>
</details>
<!-- Credits — IPTC provenance fields (Artist / Copyright / License).
Closed by default; persists once opened. -->
<details
class="rounded border border-border"
open={getMetadataSectionOpen('credits', false)}
ontoggle={(e) => setMetadataSection('credits', e.currentTarget.open)}
>
<summary
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
>
Credits
</summary>
<div class="space-y-1.5 p-2 pt-1">
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Artist</span>
<input
type="text"
placeholder="Photographer…"
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={artist}
onblur={() => commitDetails('Artist', artist.trim())}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">Copyright</span>
<input
type="text"
placeholder="© …"
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={copyright}
onblur={() => commitDetails('Copyright', copyright.trim())}
/>
</label>
<label class="flex flex-col gap-0.5">
<span class="text-[9px] text-muted-foreground">License</span>
<input
type="text"
placeholder="e.g. CC BY-NC 4.0"
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
bind:value={license}
onblur={() => commitDetails('License', license.trim())}
/>
</label>
</div>
</details>
@@ -753,6 +900,18 @@
{/if}
<dt class="text-muted-foreground">Type</dt>
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
{#if durationStr}
<dt class="text-muted-foreground">Duration</dt>
<dd class="text-foreground/80">{durationStr}</dd>
{/if}
{#if vf?.FPS}
<dt class="text-muted-foreground">FPS</dt>
<dd class="text-foreground/80">{Math.round(vf.FPS * 10) / 10}</dd>
{/if}
{#if vf?.Codec}
<dt class="text-muted-foreground">Codec</dt>
<dd class="text-foreground/80">{vf.Codec}</dd>
{/if}
<dt class="text-muted-foreground">Hash</dt>
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}</dd>
<dt class="text-muted-foreground">Indexed</dt>

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { createQuery } from '@tanstack/svelte-query';
import {
aggregateKeywords,
@@ -7,11 +9,13 @@
listLabels,
listPhotosByUids,
listSubjects,
listUnnamedFaces,
type AggregatedKeyword,
type PhotoMarksMap,
type PpCountry,
type PpLabel,
type PpSubject
type PpSubject,
type UnnamedFaceCluster
} from '$lib/services/photoprism';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
import { nearBottom } from '$lib/actions/nearBottom';
@@ -25,7 +29,7 @@
import { countryFlag, countryName } from '$lib/utils/countries';
import type { PpPhoto } from '$lib/types/photoprism';
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 {
category: TagCategory;
@@ -34,6 +38,29 @@
}
const { category, selectedValue, onSelect }: Props = $props();
// "Name new faces" is a pinned row, not a subject — it needs to stay
// reachable even after every detected face has been named once (there's
// always another to catch as the library grows), so it lives outside
// the value-drives-URL selection model the rest of this sidebar uses.
// Query key matches NewFacesPanel's so the two share one cache entry.
const unnamedFacesQuery = createQuery<UnnamedFaceCluster[]>(() => ({
queryKey: ['faces', 'unnamed'],
queryFn: listUnnamedFaces,
enabled: isAuthenticated() && category === 'people',
staleTime: 60_000
}));
const unnamedFacesCount = $derived(unnamedFacesQuery.data?.length ?? 0);
const newFacesActive = $derived(page.url.searchParams.get('view') === 'new-faces');
function showNewFaces() {
// Deliberately NOT onSelect/navigateToTag — that drives the
// `[[value]]` route param, and the auto-select-first-tag effect
// below immediately overwrites a null value with the first real
// person, which is exactly the trap this row exists to escape.
// The `view` query param is independent state the page reads to
// show the naming panel instead of (or alongside) the photo grid.
void goto('/tags/people?view=new-faces', { keepFocus: true, noScroll: true });
}
let filterText = $state('');
// Reset the inline filter input whenever the user switches categories so
@@ -243,8 +270,16 @@
// fires when there's genuinely no selection — once a value is picked
// (by the user or by this effect), the URL drives selectedValue and
// the effect no-ops.
//
// `newFacesActive` additionally suppresses it for People: navigating
// to the pinned "Name new faces" row necessarily clears selectedValue
// (it targets a bare `/tags/people` URL), and without this guard this
// effect would immediately redirect straight back to the first named
// person in the same tick — permanently hiding the naming workflow
// again the moment a second person exists to auto-select into.
$effect(() => {
if (selectedValue != null) return;
if (newFacesActive) return;
if (firstValue == null) return;
onSelect(firstValue, { replace: true });
});
@@ -416,6 +451,36 @@
</div>
{/if}
{:else if category === 'people'}
<!-- Pinned above the named-people list (and shown regardless of its
loading/empty/error state) so naming stays reachable even after
every currently-detected face has a name — new faces keep
appearing as the library grows. -->
<button
type="button"
class="flex h-8 w-full shrink-0 items-center gap-2 border-b border-border px-3 text-left text-[12px] leading-tight hover:bg-accent"
class:bg-primary={newFacesActive}
class:text-primary-foreground={newFacesActive}
class:hover:bg-primary={newFacesActive}
onclick={showNewFaces}
>
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full {newFacesActive
? 'bg-primary-foreground/15'
: 'bg-secondary'}"
>
<UserPlus class="h-3 w-3 {newFacesActive ? '' : 'text-muted-foreground'}" />
</span>
<span class="min-w-0 flex-1 truncate font-medium">Name new faces</span>
{#if unnamedFacesCount > 0}
<span
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {newFacesActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{unnamedFacesCount}
</span>
{/if}
</button>
{#if subjectsQuery.isPending}
<InlineLoader size="sm" label="Loading people…" />
{:else if subjectsQuery.isError}
@@ -427,7 +492,7 @@
title={filterText ? 'No people match the filter' : 'No people yet'}
description={filterText
? undefined
: 'PhotoPrism creates a person whenever it clusters detected faces. Make sure face recognition is enabled and indexed.'}
: 'A person appears here once you name a detected face — use the "Name new faces" cards on the right.'}
/>
{:else}
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">

View File

@@ -31,7 +31,7 @@ export async function listDuplicateGroups(basePath?: string): Promise<DuplicateG
const photos = await listPhotos({
q,
count: 200,
count: 500,
merged: true,
order: 'newest'
});

View File

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

View File

@@ -104,7 +104,7 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
originalName: p.OriginalName,
path
});
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`));
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`, p));
}
await approvePhoto(id);
return id;

View File

@@ -343,6 +343,8 @@ export async function getPhoto(uid: string): Promise<PpPhoto> {
*/
export interface UpdatePhotoBody {
OriginalName?: string;
Title?: string;
TitleSrc?: 'manual' | '';
Caption?: string;
CaptionSrc?: 'manual' | '';
Archived?: boolean;
@@ -373,17 +375,43 @@ export function isValidISODate(s: string): boolean {
return d.toISOString().slice(0, 10) === s;
}
export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
/** PhotoPrism serializes TakenAtLocal with a `Z` suffix even though it's
* semantically wall-clock time in the photo's TimeZone. Force-parse as
* UTC so offset math never picks up the *browser's* timezone. */
function parseAsUtc(s: string): number {
return Date.parse(/(Z|[+-]\d{2}:?\d{2})$/.test(s) ? s : s + 'Z');
}
/**
* `photo` supplies the existing TakenAt/TakenAtLocal pair so the photo's
* UTC↔local offset survives the edit. Without it (or without a prior
* pair) local falls back to UTC — correct for TimeZone-less photos.
* Previously this forced `TakenAtLocal = UTC`, which both let PhotoPrism
* clobber manual edits when recomputing local time from TimeZone and
* shifted Year/Month/Day for photos taken far from UTC.
*/
export function buildTakenAtPatch(
iso: string,
photo?: { TakenAt?: string; TakenAtLocal?: string }
): UpdatePhotoBody {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return {};
const utc = d.toISOString().replace(/\.\d+Z$/, 'Z');
let offsetMs = 0;
if (photo?.TakenAt && photo?.TakenAtLocal) {
const a = parseAsUtc(photo.TakenAt);
const b = parseAsUtc(photo.TakenAtLocal);
if (!Number.isNaN(a) && !Number.isNaN(b)) offsetMs = b - a;
}
const local = new Date(d.getTime() + offsetMs);
return {
TakenAt: utc,
TakenAtLocal: utc,
TakenAtLocal: local.toISOString().replace(/\.\d+Z$/, 'Z'),
TakenSrc: 'manual',
Year: d.getUTCFullYear(),
Month: d.getUTCMonth() + 1,
Day: d.getUTCDate()
// PhotoPrism derives Year/Month/Day from local wall-clock time.
Year: local.getUTCFullYear(),
Month: local.getUTCMonth() + 1,
Day: local.getUTCDate()
};
}
@@ -694,35 +722,6 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
}
async function hasPhotosMatching(q: string): Promise<boolean> {
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
params: { count: 1, offset: 0, q }
});
return Array.isArray(resp.data) && resp.data.length > 0;
}
async function filterByUserPhotos<T>(
items: T[],
queryFor: (item: T) => string
): Promise<T[]> {
if (userBasePath() === '') return items;
const CONCURRENCY = 8;
const out: T[] = [];
for (let i = 0; i < items.length; i += CONCURRENCY) {
const batch = items.slice(i, i + CONCURRENCY);
const checks = await Promise.all(
batch.map(async (item) => ({
item,
has: await hasPhotosMatching(queryFor(item))
}))
);
for (const { item, has } of checks) {
if (has) out.push(item);
}
}
return out;
}
export async function listLabels(): Promise<PpLabel[]> {
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
// low-confidence classifier hits, manually-removed labels). They're
@@ -762,10 +761,42 @@ export interface PpSubject {
}
export async function listSubjects(): Promise<PpSubject[]> {
const { data } = await http.get<PpSubject[]>('/subjects', {
// Sidecar proxy scopes PhotoCount (and drops out-of-scope people) with
// one SQL pass, replacing the old client-side probe-per-subject filter.
const { data } = await sidecar.get<PpSubject[]>('/api/sidecar/subjects', {
params: { count: 1000, order: 'count' }
});
return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`);
return data ?? [];
}
// ── Face clusters (unnamed people) ──────────────────────────────────────────
//
// PhotoPrism only creates a Subject once someone names a detected face
// cluster. The sidecar lists clusters awaiting a name (scoped to the
// caller's BasePath); naming goes through PhotoPrism's own flow — a PUT
// on the cluster's representative marker — which creates the Subject and
// propagates it across the whole cluster.
export interface UnnamedFaceCluster {
faceId: string;
count: number;
/** Marker crop hash — renders via the standard thumb endpoint. */
thumb: string;
markerUid: string;
}
export async function listUnnamedFaces(): Promise<UnnamedFaceCluster[]> {
const { data } = await sidecar.get<{ clusters: UnnamedFaceCluster[] }>(
'/api/sidecar/faces/unnamed'
);
return data?.clusters ?? [];
}
export async function nameFaceCluster(markerUid: string, name: string): Promise<void> {
await http.put(`/markers/${encodeURIComponent(markerUid)}`, {
Name: name,
SubjSrc: 'manual'
});
}
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
@@ -949,6 +980,9 @@ export async function deleteFolder(relPath: string): Promise<{ path: string }> {
export interface DupFileEntry {
path: string;
size: number;
/** RFC3339 mtime — the only per-copy signal besides path, since all
* copies in a group are byte-identical. */
modTime?: string;
}
export interface CrossFolderDuplicateGroup {
@@ -981,6 +1015,21 @@ export async function archiveDuplicatePaths(
return callSidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
}
export interface RestoreDuplicatesResult {
restored: { from: string; to: string }[];
errors: { path: string; error: string }[];
}
/** Inverse of archiveDuplicatePaths: pass the `moved` pairs from the
* archive response verbatim and the sidecar renames each quarantined
* file back to its original path. Powers undo for duplicate/stack
* resolution. */
export async function restoreDuplicatePaths(
moves: { from: string; to: string }[]
): Promise<RestoreDuplicatesResult> {
return callSidecar('POST', '/duplicates/restore', { moves }) as Promise<RestoreDuplicatesResult>;
}
// ── Heap convert (move/copy heap photos to a folder) ────────────────────────
// Lives on the sidecar because moving the underlying files is a filesystem
// operation PhotoPrism's API doesn't expose. The sidecar lists album members
@@ -1002,6 +1051,8 @@ export interface HeapConvertBody {
export interface HeapConvertResult {
moved: number;
copied: number;
/** Per-file {from,to} pairs for move mode — the undo payload. */
movedFiles: { from: string; to: string }[];
errors: { uid: string; reason: string }[];
heap_deleted: boolean;
}
@@ -1029,6 +1080,8 @@ export interface PhotosMoveBody {
export interface PhotosMoveResult {
moved: number;
copied: number;
/** Per-file {from,to} pairs for move mode — the undo payload. */
movedFiles: { from: string; to: string }[];
errors: { uid: string; reason: string }[];
}
@@ -1036,6 +1089,21 @@ export async function movePhotosToFolder(body: PhotosMoveBody): Promise<PhotosMo
return callSidecar('POST', '/photos/move', body) as Promise<PhotosMoveResult>;
}
export interface RestoreMovesResult {
restored: { from: string; to: string }[];
errors: { path: string; error: string }[];
}
/** Inverse of a photo/heap move: pass the `movedFiles` pairs from the
* move response verbatim and the sidecar renames each file back to its
* original folder (both ends scope-checked, no clobbering). Powers ⌘Z
* undo for moves. */
export async function restoreMoves(
moves: { from: string; to: string }[]
): Promise<RestoreMovesResult> {
return callSidecar('POST', '/files/restore-moves', { moves }) as Promise<RestoreMovesResult>;
}
// ── Reparent a folder (move the directory under a different parent) ──────────
export interface FolderMoveResult {

View File

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

View File

@@ -32,6 +32,7 @@
import { countryName } from '$lib/utils/countries';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import NewFacesPanel from '$lib/components/people/NewFacesPanel.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
@@ -52,6 +53,16 @@
: null
);
// Independent of selectedValue on purpose: the People sidebar's pinned
// "Name new faces" row sets this query param instead of the `[[value]]`
// route param, specifically so it can't be clobbered by the auto-
// select-first-tag effect (TagsBrowserSidebar) that fires whenever
// selectedValue is null — that effect is exactly what made the naming
// panel unreachable again after the first person was named.
const showNewFaces = $derived(
category === 'people' && page.url.searchParams.get('view') === 'new-faces'
);
// Mirror URL into the shared filter store so any other consumer of
// `filters` (e.g. cross-route navigation back to `/`) sees the active
// tag filter, and so `filtersToQ()` produces the correct DSL clause
@@ -207,7 +218,7 @@
{#if category}
<span class="text-[11px] capitalize text-muted-foreground">{category}</span>
{/if}
{#if selectedValue}
{#if selectedValue && !showNewFaces}
<span class="text-[11px] font-medium">{drillTitle}</span>
<span class="text-[11px] text-muted-foreground">
{drillCount} photo{drillCount === 1 ? '' : 's'}
@@ -215,7 +226,23 @@
{/if}
</Toolbar>
{#if !selectedValue}
{#if category === 'people' && (showNewFaces || !selectedValue)}
<!-- Naming workflow: reachable both on bare landing (no person picked
yet) and via the sidebar's pinned "Name new faces" row at any time
— the latter is what makes it possible to get back here after the
first person's been named, once the auto-select-first-tag effect
would otherwise always jump straight to an existing person. -->
<main class="min-h-0 flex-1 overflow-y-auto p-6">
<NewFacesPanel />
<div class="mt-8 flex items-center justify-center">
<EmptyState
icon={Tag}
title="Pick a person from the sidebar"
description="Click a row in the panel on the left to see that person's photos."
/>
</div>
</main>
{:else if !selectedValue}
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
<EmptyState
icon={Tag}