Compare commits
13 Commits
claude/ser
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a9685f64c4 | |||
| c6f31b5dfb | |||
| 246d159d93 | |||
| a239cece10 | |||
| 75008f238a | |||
| 0f65bfb94a | |||
| 9ba8d625bc | |||
| 4f04c1f7b0 | |||
| 9ef1b4c2f9 | |||
| ac7d0ac2eb | |||
| 312a4c1ee4 | |||
| e578e1ce75 | |||
| 6cbabda86b |
29
README.md
29
README.md
@@ -102,6 +102,35 @@ labels still work; the following sidecar endpoints return an OS error:
|
|||||||
PhotoPrism's `PHOTOPRISM_READONLY` is controlled separately by
|
PhotoPrism's `PHOTOPRISM_READONLY` is controlled separately by
|
||||||
`PP_READONLY` and gates its own backwrite / import paths.
|
`PP_READONLY` and gates its own backwrite / import paths.
|
||||||
|
|
||||||
|
## Mobile & third-party apps (per-user)
|
||||||
|
|
||||||
|
PhotoPrism CE does **not** enforce `auth_users.base_path` on API reads —
|
||||||
|
any authenticated user can search the whole library. The sidecar
|
||||||
|
therefore ships a scoping proxy at `/api/v1/*` (see
|
||||||
|
[`sidecar/handlers_ppproxy.go`](sidecar/handlers_ppproxy.go)) and the
|
||||||
|
reverse proxy routes the public `/api/v1` there instead of straight to
|
||||||
|
PhotoPrism. Result: any PhotoPrism-compatible app pointed at the site
|
||||||
|
sees only the logged-in user's photos.
|
||||||
|
|
||||||
|
- **Server URL for apps**: the site itself (e.g.
|
||||||
|
`https://photos.hubris.network`). Known-good client:
|
||||||
|
[Gallery for PhotoPrism](https://github.com/Radiokot/photoprism-android-client)
|
||||||
|
(Android/F-Droid).
|
||||||
|
- **Login**: the user's normal username/password. For OIDC accounts (no
|
||||||
|
password), mint an app password:
|
||||||
|
`docker exec pp-app photoprism auth add -n "gallery" -s "*" <username>`
|
||||||
|
and use it as the password in the app.
|
||||||
|
- **What's scoped**: photo/geo searches, per-photo reads and edits,
|
||||||
|
batch operations, downloads by UID. Hash-addressed media (thumbnails,
|
||||||
|
video streams, file downloads) is token-guarded and passes through.
|
||||||
|
- **What's shared** (CE has no per-user variants of these): album
|
||||||
|
*names*, labels, and people — the photos inside them stay scoped.
|
||||||
|
Album zip downloads are generated by PhotoPrism and are not scoped.
|
||||||
|
- **Uploads**: the reconciler mirrors `base_path` into `upload_path`,
|
||||||
|
so WebDAV/app uploads land inside the user's own subtree.
|
||||||
|
- Sessions with the `admin` role bypass the proxy scoping entirely (the
|
||||||
|
web client's settings/users/index dialogs need the raw API).
|
||||||
|
|
||||||
## Dev iteration loop
|
## Dev iteration loop
|
||||||
|
|
||||||
For fast iteration on the sidecar without rebuilding its image on every
|
For fast iteration on the sidecar without rebuilding its image on every
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -85,3 +87,35 @@ func ctxBasePath(c *gin.Context) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userScopeRoot returns the absolute directory the session may mutate:
|
||||||
|
// ORIGINALS_ROOT/<BasePath> for scoped users, the whole originals root for
|
||||||
|
// admins (empty BasePath). Lexical join only — callers compare it against
|
||||||
|
// paths built the same way from cfg.OriginalsRoot.
|
||||||
|
func userScopeRoot(c *gin.Context, cfg *Config) string {
|
||||||
|
base := strings.Trim(ctxBasePath(c), "/")
|
||||||
|
if base == "" {
|
||||||
|
return cfg.OriginalsRoot
|
||||||
|
}
|
||||||
|
return filepath.Join(cfg.OriginalsRoot, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireUserScope guards an already-root-resolved absolute path against
|
||||||
|
// the caller's BasePath. PhotoPrism scopes what a session can *see* by
|
||||||
|
// BasePath, but the sidecar's filesystem endpoints accept raw paths, so
|
||||||
|
// every mutation must re-check that boundary here. `strict` additionally
|
||||||
|
// rejects the scope root itself — renaming/deleting/moving the user's own
|
||||||
|
// base folder would detach their library from auth_users.base_path.
|
||||||
|
// Writes the 403 response and returns false when out of bounds.
|
||||||
|
func requireUserScope(c *gin.Context, cfg *Config, abs string, strict bool) bool {
|
||||||
|
scope := userScopeRoot(c, cfg)
|
||||||
|
if !sameOrUnder(abs, scope) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strict && abs == scope {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify your library root"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
50
sidecar/auth_test.go
Normal file
50
sidecar/auth_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func scopeCtx(basePath string) *gin.Context {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Set("basePath", basePath)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequireUserScope(t *testing.T) {
|
||||||
|
cfg := &Config{OriginalsRoot: filepath.FromSlash("/originals")}
|
||||||
|
abs := func(rel string) string { return filepath.Join(cfg.OriginalsRoot, filepath.FromSlash(rel)) }
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
basePath string
|
||||||
|
path string
|
||||||
|
strict bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"admin sees root", "", cfg.OriginalsRoot, false, true},
|
||||||
|
{"admin anywhere", "", abs("bob/x"), false, true},
|
||||||
|
{"admin strict rejects root", "", cfg.OriginalsRoot, true, false},
|
||||||
|
{"scoped inside own tree", "alice", abs("alice/2024"), false, true},
|
||||||
|
{"scoped own root non-strict", "alice", abs("alice"), false, true},
|
||||||
|
{"scoped own root strict", "alice", abs("alice"), true, false},
|
||||||
|
{"scoped other user", "alice", abs("bob/2024"), false, false},
|
||||||
|
{"scoped sibling prefix", "alice", abs("alice2/2024"), false, false},
|
||||||
|
{"scoped originals root", "alice", cfg.OriginalsRoot, false, false},
|
||||||
|
{"nested base path", "family/alice", abs("family/alice/x"), false, true},
|
||||||
|
{"nested base path parent", "family/alice", abs("family"), false, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
c := scopeCtx(tc.basePath)
|
||||||
|
if got := requireUserScope(c, cfg, tc.path, tc.strict); got != tc.want {
|
||||||
|
t.Errorf("requireUserScope(base=%q, path=%q, strict=%v) = %v, want %v",
|
||||||
|
tc.basePath, tc.path, tc.strict, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sanitizeFilename trims a user-supplied filename and rejects anything
|
// sanitizeFilename trims a user-supplied filename and rejects anything
|
||||||
@@ -168,6 +169,7 @@ type fileEntry struct {
|
|||||||
RelPath string
|
RelPath string
|
||||||
AbsPath string
|
AbsPath string
|
||||||
Size int64
|
Size int64
|
||||||
|
ModTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism
|
// supportedExts mirrors the Node prototype's whitelist. PhotoPrism
|
||||||
@@ -222,6 +224,7 @@ func walkFiles(root string) ([]fileEntry, error) {
|
|||||||
RelPath: rel,
|
RelPath: rel,
|
||||||
AbsPath: p,
|
AbsPath: p,
|
||||||
Size: info.Size(),
|
Size: info.Size(),
|
||||||
|
ModTime: info.ModTime(),
|
||||||
})
|
})
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -22,13 +22,16 @@ const quarantineDir = ".duplicates"
|
|||||||
type dupFileLite struct {
|
type dupFileLite struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
|
// RFC3339 mtime so the UI can label older/newer copies. Copies are
|
||||||
|
// byte-identical, so mtime is the only per-copy signal besides path.
|
||||||
|
ModTime string `json:"modTime,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type dupGroup struct {
|
type dupGroup struct {
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
IndexedPath *string `json:"indexedPath"`
|
IndexedPath *string `json:"indexedPath"`
|
||||||
Files []dupFileLite `json:"files"`
|
Files []dupFileLite `json:"files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// dupListPhoto is the partial photo shape we pull from PhotoPrism when
|
// dupListPhoto is the partial photo shape we pull from PhotoPrism when
|
||||||
@@ -128,7 +131,11 @@ func handleDupScan(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
g := dupGroup{Hash: h, Size: hashSize[h]}
|
g := dupGroup{Hash: h, Size: hashSize[h]}
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
g.Files = append(g.Files, dupFileLite{Path: f.RelPath, Size: f.Size})
|
g.Files = append(g.Files, dupFileLite{
|
||||||
|
Path: f.RelPath,
|
||||||
|
Size: f.Size,
|
||||||
|
ModTime: f.ModTime.UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// Best-effort lookup; swallow errors. The hash query is cheap on
|
// Best-effort lookup; swallow errors. The hash query is cheap on
|
||||||
// PhotoPrism's side (indexed column).
|
// PhotoPrism's side (indexed column).
|
||||||
@@ -271,3 +278,95 @@ func handleDupArchive(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusOK, gin.H{"moved": moved, "errors": errs})
|
c.JSON(http.StatusOK, gin.H{"moved": moved, "errors": errs})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type dupRestoreBody struct {
|
||||||
|
// Moves mirror the archive response's {from,to} pairs verbatim; the
|
||||||
|
// handler renames each `to` (quarantine path) back to its `from`.
|
||||||
|
Moves []dupMoved `json:"moves"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDupRestore is the inverse of handleDupArchive: it moves files
|
||||||
|
// out of `.duplicates/<ts>/` back to their original paths. It exists so
|
||||||
|
// the web client can offer real undo for duplicate/stack resolution —
|
||||||
|
// quarantine is only trustworthy if backing out is one keystroke.
|
||||||
|
func handleDupRestore(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
var body dupRestoreBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Moves) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "moves[] required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authz mirrors handleDupArchive: every destination (`from`) must
|
||||||
|
// live under the caller's effective library root, and every source
|
||||||
|
// (`to`) must live inside the quarantine dir — otherwise this
|
||||||
|
// endpoint would double as an arbitrary-move tool.
|
||||||
|
root := effectiveLibraryRoot(c, db)
|
||||||
|
for _, m := range body.Moves {
|
||||||
|
src := strings.Trim(m.To, "/")
|
||||||
|
if src != quarantineDir && !strings.HasPrefix(src, quarantineDir+"/") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "source not in quarantine"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if root != "" {
|
||||||
|
dst := strings.Trim(m.From, "/")
|
||||||
|
if dst != root && !strings.HasPrefix(dst, root+"/") {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library root"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreOne := func(m dupMoved) error {
|
||||||
|
srcAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.To, true)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("invalid quarantine path")
|
||||||
|
}
|
||||||
|
// The destination must not exist yet — mustExist=false resolves
|
||||||
|
// the path without requiring it on disk, and the Stat below
|
||||||
|
// refuses to clobber anything that reappeared in the meantime.
|
||||||
|
dstAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.From, false)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("invalid destination path")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dstAbs); err == nil {
|
||||||
|
return errors.New("destination already exists")
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dstAbs), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Rename(srcAbs, dstAbs); err != nil {
|
||||||
|
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err2 := os.Remove(srcAbs); err2 != nil {
|
||||||
|
return errors.New("restored but quarantine copy remove failed: " + err2.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
restored := []dupMoved{}
|
||||||
|
errs := []dupArchiveErr{}
|
||||||
|
for _, m := range body.Moves {
|
||||||
|
if err := restoreOne(m); err != nil {
|
||||||
|
errs = append(errs, dupArchiveErr{Path: m.To, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
restored = append(restored, dupMoved{From: m.To, To: m.From})
|
||||||
|
slog.Info("dup.restore", "from", m.To, "to", m.From)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(restored) > 0 {
|
||||||
|
go func() {
|
||||||
|
if err := pp.reindex(context.Background(), token, "/"); err != nil {
|
||||||
|
slog.Warn("dup.restore reindex failed", "err", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"restored": restored, "errors": errs})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, abs, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if _, err := os.Stat(abs); err == nil {
|
if _, err := os.Stat(abs); err == nil {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
|
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
|
||||||
return
|
return
|
||||||
@@ -92,6 +95,9 @@ func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, oldAbs, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
st, err := os.Stat(oldAbs)
|
st, err := os.Stat(oldAbs)
|
||||||
if err != nil || !st.IsDir() {
|
if err != nil || !st.IsDir() {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||||
@@ -142,6 +148,9 @@ func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, abs, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
st, err := os.Stat(abs)
|
st, err := os.Stat(abs)
|
||||||
if err != nil || !st.IsDir() {
|
if err != nil || !st.IsDir() {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||||
|
|||||||
@@ -67,4 +67,4 @@ func handleFoldersProxy(pp *ppClient) gin.HandlerFunc {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"folders": filtered})
|
c.JSON(http.StatusOK, gin.H{"folders": filtered})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, targetAbs, false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Pull the heap's membership via the q=album:UID query (count=1000
|
// Pull the heap's membership via the q=album:UID query (count=1000
|
||||||
// covers every realistic heap). We only need the UID list here — the
|
// covers every realistic heap). We only need the UID list here — the
|
||||||
// search's Files array is trimmed and drops videos, so we re-resolve
|
// search's Files array is trimmed and drops videos, so we re-resolve
|
||||||
@@ -124,7 +127,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
|
moved, copied, movedPairs, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -154,6 +157,7 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"moved": moved,
|
"moved": moved,
|
||||||
"copied": copied,
|
"copied": copied,
|
||||||
|
"movedFiles": movedPairs,
|
||||||
"errors": errs,
|
"errors": errs,
|
||||||
"heap_deleted": heapDeleted,
|
"heap_deleted": heapDeleted,
|
||||||
})
|
})
|
||||||
@@ -167,17 +171,26 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
// and handlePhotosMove (UID-list scoped); both resolve `photos` differently
|
// and handlePhotosMove (UID-list scoped); both resolve `photos` differently
|
||||||
// but move them identically. Returns per-photo errors in `errs`; the returned
|
// but move them identically. Returns per-photo errors in `errs`; the returned
|
||||||
// top-level error is only for a fatal precondition (subfolder mkdir failed).
|
// top-level error is only for a fatal precondition (subfolder mkdir failed).
|
||||||
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode string) (moved, copied int, errs []heapErr, err error) {
|
// `scopeAbs` is the caller's userScopeRoot — source files outside it fail
|
||||||
|
// per-photo, so a UID that resolves outside the user's BasePath (however
|
||||||
|
// PhotoPrism came to return it) can't be used to pull files across users.
|
||||||
|
//
|
||||||
|
// `movedPairs` records every file that physically moved (move mode only —
|
||||||
|
// copies have no inverse pair) as originals-relative {from,to}, including
|
||||||
|
// siblings of photos that later failed partway: undo must restore whatever
|
||||||
|
// actually left its folder, not just fully-successful photos.
|
||||||
|
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode, scopeAbs string) (moved, copied int, movedPairs []dupMoved, errs []heapErr, err error) {
|
||||||
destAbs := targetAbs
|
destAbs := targetAbs
|
||||||
if subfolder != "" {
|
if subfolder != "" {
|
||||||
destAbs = filepath.Join(targetAbs, subfolder)
|
destAbs = filepath.Join(targetAbs, subfolder)
|
||||||
if e := os.MkdirAll(destAbs, 0o755); e != nil {
|
if e := os.MkdirAll(destAbs, 0o755); e != nil {
|
||||||
return 0, 0, nil, e
|
return 0, 0, nil, nil, e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceParents := map[string]struct{}{}
|
sourceParents := map[string]struct{}{}
|
||||||
errs = []heapErr{}
|
errs = []heapErr{}
|
||||||
|
movedPairs = []dupMoved{}
|
||||||
|
|
||||||
for _, photo := range photos {
|
for _, photo := range photos {
|
||||||
// Gather *every* originals-rooted file of the photo, not just the
|
// Gather *every* originals-rooted file of the photo, not just the
|
||||||
@@ -234,8 +247,8 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
|
|||||||
for _, f := range group {
|
for _, f := range group {
|
||||||
srcRel := f.Name
|
srcRel := f.Name
|
||||||
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
||||||
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
|
if !sameOrUnder(srcAbs, scopeAbs) {
|
||||||
failure = "path escapes originals"
|
failure = "path outside your library"
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
st, statErr := os.Stat(srcAbs)
|
st, statErr := os.Stat(srcAbs)
|
||||||
@@ -276,6 +289,9 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if dstRel, relErr := filepath.Rel(cfg.OriginalsRoot, dstAbs); relErr == nil {
|
||||||
|
movedPairs = append(movedPairs, dupMoved{From: srcRel, To: dstRel})
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
|
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
|
||||||
failure = cpErr.Error()
|
failure = cpErr.Error()
|
||||||
@@ -323,7 +339,7 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
|
|||||||
fireReindex(cfg, pp, token, reindex)
|
fireReindex(cfg, pp, token, reindex)
|
||||||
}
|
}
|
||||||
|
|
||||||
return moved, copied, errs, nil
|
return moved, copied, movedPairs, errs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"."
|
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"."
|
||||||
|
|||||||
@@ -115,14 +115,14 @@ func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
|
|||||||
// PpCounts mirrors PhotoPrism's session config.count block that drives
|
// PpCounts mirrors PhotoPrism's session config.count block that drives
|
||||||
// the sidebar badges (review, archive, all, etc.).
|
// the sidebar badges (review, archive, all, etc.).
|
||||||
type PpCounts struct {
|
type PpCounts struct {
|
||||||
All int `json:"all"`
|
All int `json:"all"`
|
||||||
Photos int `json:"photos"`
|
Photos int `json:"photos"`
|
||||||
Media int `json:"media"`
|
Media int `json:"media"`
|
||||||
Videos int `json:"videos"`
|
Videos int `json:"videos"`
|
||||||
Review int `json:"review"`
|
Review int `json:"review"`
|
||||||
Archived int `json:"archived"`
|
Archived int `json:"archived"`
|
||||||
Hidden int `json:"hidden"`
|
Hidden int `json:"hidden"`
|
||||||
Private int `json:"private"`
|
Private int `json:"private"`
|
||||||
Favorites int `json:"favorites"`
|
Favorites int `json:"favorites"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,4 +168,4 @@ func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, counts)
|
c.JSON(http.StatusOK, counts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, targetAbs, false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve each photo's FULL file list via the single-photo endpoint
|
// Resolve each photo's FULL file list via the single-photo endpoint
|
||||||
// rather than the /photos search (see resolvePhotosFull) — the search
|
// rather than the /photos search (see resolvePhotosFull) — the search
|
||||||
@@ -67,7 +70,7 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
|
moved, copied, movedPairs, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -84,9 +87,10 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
"errors", len(errs),
|
"errors", len(errs),
|
||||||
)
|
)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"moved": moved,
|
"moved": moved,
|
||||||
"copied": copied,
|
"copied": copied,
|
||||||
"errors": errs,
|
"movedFiles": movedPairs,
|
||||||
|
"errors": errs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,6 +152,9 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, oldAbs, true) {
|
||||||
|
return
|
||||||
|
}
|
||||||
st, err := os.Stat(oldAbs)
|
st, err := os.Stat(oldAbs)
|
||||||
if err != nil || !st.IsDir() {
|
if err != nil || !st.IsDir() {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||||
@@ -158,6 +165,9 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, targetParentAbs, false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Can't move a folder into itself or one of its own descendants.
|
// Can't move a folder into itself or one of its own descendants.
|
||||||
if sameOrUnder(targetParentAbs, oldAbs) {
|
if sameOrUnder(targetParentAbs, oldAbs) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot move a folder into itself"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot move a folder into itself"})
|
||||||
@@ -194,3 +204,96 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type restoreMovesBody struct {
|
||||||
|
// Moves mirror the movedFiles pairs from photos-move / heap-convert
|
||||||
|
// responses verbatim; the handler renames each `to` (current location)
|
||||||
|
// back to its `from` (original location).
|
||||||
|
Moves []dupMoved `json:"moves"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRestoreMoves is the generic inverse of movePhotoFiles: it moves
|
||||||
|
// files back to where they came from, powering ⌘Z undo for photo/heap
|
||||||
|
// moves. Unlike the duplicates restore (whose sources must live in the
|
||||||
|
// .duplicates/ quarantine), both ends here are arbitrary library paths —
|
||||||
|
// so BOTH are validated against the caller's scope, and existing
|
||||||
|
// destinations are never clobbered.
|
||||||
|
//
|
||||||
|
// Route: POST /api/sidecar/files/restore-moves (behind requireSession)
|
||||||
|
func handleRestoreMoves(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
var body restoreMovesBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || len(body.Moves) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "moves[] required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := userScopeRoot(c, cfg)
|
||||||
|
type resolved struct {
|
||||||
|
srcAbs, dstAbs string
|
||||||
|
srcRel, dstRel string
|
||||||
|
}
|
||||||
|
items := make([]resolved, 0, len(body.Moves))
|
||||||
|
for _, m := range body.Moves {
|
||||||
|
srcAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.To, true)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid source path: " + m.To})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dstAbs, err := resolveUnderRoot(cfg.OriginalsRoot, m.From, false)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid destination path: " + m.From})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !sameOrUnder(srcAbs, scope) || !sameOrUnder(dstAbs, scope) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items = append(items, resolved{srcAbs: srcAbs, dstAbs: dstAbs, srcRel: m.To, dstRel: m.From})
|
||||||
|
}
|
||||||
|
|
||||||
|
restored := []dupMoved{}
|
||||||
|
errs := []dupArchiveErr{}
|
||||||
|
parents := map[string]struct{}{}
|
||||||
|
for _, it := range items {
|
||||||
|
if _, err := os.Stat(it.dstAbs); err == nil {
|
||||||
|
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: "destination already exists"})
|
||||||
|
continue
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(it.dstAbs), 0o755); err != nil {
|
||||||
|
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := os.Rename(it.srcAbs, it.dstAbs); err != nil {
|
||||||
|
if err2 := copyFile(it.srcAbs, it.dstAbs); err2 != nil {
|
||||||
|
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err2 := os.Remove(it.srcAbs); err2 != nil {
|
||||||
|
errs = append(errs, dupArchiveErr{Path: it.srcRel, Error: "restored but source remove failed: " + err2.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
restored = append(restored, dupMoved{From: it.srcRel, To: it.dstRel})
|
||||||
|
parents[filepath.Dir(it.srcRel)] = struct{}{}
|
||||||
|
parents[filepath.Dir(it.dstRel)] = struct{}{}
|
||||||
|
slog.Info("files.restore-moves", "from", it.srcRel, "to", it.dstRel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block on the reindex like movePhotoFiles does — the client
|
||||||
|
// invalidates its photo queries right after this returns, and the
|
||||||
|
// refetch must already see the restored locations.
|
||||||
|
for p := range parents {
|
||||||
|
reindex := "/"
|
||||||
|
if p != "" && p != "." {
|
||||||
|
reindex = "/" + p
|
||||||
|
}
|
||||||
|
fireReindex(cfg, pp, token, reindex)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"restored": restored, "errors": errs})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
178
sidecar/handlers_people.go
Normal file
178
sidecar/handlers_people.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// People (subjects + unnamed face clusters), scoped to the caller's
|
||||||
|
// BasePath the same way handleLabels scopes labels: proxy PhotoPrism's
|
||||||
|
// list, then one SQL pass over the caller's slice of the library to
|
||||||
|
// recompute counts and drop entries with nothing in scope.
|
||||||
|
//
|
||||||
|
// PhotoPrism CE treats subjects and face clusters as library-wide
|
||||||
|
// metadata (like albums and labels) — scoping here controls what each
|
||||||
|
// user *sees*, while the underlying entities stay shared.
|
||||||
|
|
||||||
|
// PpSubjectLite mirrors the fields the web client consumes from
|
||||||
|
// PhotoPrism's /api/v1/subjects rows.
|
||||||
|
type PpSubjectLite struct {
|
||||||
|
UID string `json:"UID"`
|
||||||
|
Type string `json:"Type"`
|
||||||
|
Slug string `json:"Slug"`
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Alias string `json:"Alias"`
|
||||||
|
Favorite bool `json:"Favorite"`
|
||||||
|
Private bool `json:"Private"`
|
||||||
|
Excluded bool `json:"Excluded"`
|
||||||
|
Hidden bool `json:"Hidden"`
|
||||||
|
PhotoCount int `json:"PhotoCount"`
|
||||||
|
FileCount int `json:"FileCount"`
|
||||||
|
Thumb string `json:"Thumb"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSubjects proxies PhotoPrism's /api/v1/subjects and post-filters
|
||||||
|
// per-subject photo counts to the caller's BasePath, dropping subjects
|
||||||
|
// whose faces never appear in the caller's photos.
|
||||||
|
//
|
||||||
|
// Route: GET /api/sidecar/subjects (behind requireSession)
|
||||||
|
func handleSubjects(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := ctxToken(c)
|
||||||
|
basePath := ctxBasePath(c)
|
||||||
|
|
||||||
|
query := c.Request.URL.RawQuery
|
||||||
|
resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/subjects?"+query, token, nil)
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "upstream subjects request failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var subjects []PpSubjectLite
|
||||||
|
if err := json.Unmarshal(resp.Body, &subjects); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse subjects"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if basePath == "" || ppDb == nil {
|
||||||
|
c.JSON(http.StatusOK, subjects)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// One query: per-subject photo count + a representative face-crop
|
||||||
|
// thumb, restricted to the caller's path subtree. The join chain is
|
||||||
|
// markers → files → photos, matching how PhotoPrism binds a face to
|
||||||
|
// a picture.
|
||||||
|
type subjStat struct {
|
||||||
|
SubjUID string `gorm:"column:subj_uid"`
|
||||||
|
Cnt int64 `gorm:"column:cnt"`
|
||||||
|
Thumb string `gorm:"column:thumb"`
|
||||||
|
}
|
||||||
|
var stats []subjStat
|
||||||
|
if err := ppDb.Raw(`
|
||||||
|
SELECT m.subj_uid AS subj_uid,
|
||||||
|
COUNT(DISTINCT p.id) AS cnt,
|
||||||
|
SUBSTRING_INDEX(GROUP_CONCAT(m.thumb ORDER BY m.size DESC SEPARATOR 0x1f), 0x1f, 1) AS thumb
|
||||||
|
FROM markers m
|
||||||
|
JOIN files f ON f.file_uid = m.file_uid AND f.file_missing = 0
|
||||||
|
JOIN photos p ON p.photo_uid = f.photo_uid AND p.deleted_at IS NULL
|
||||||
|
WHERE m.marker_type = 'face'
|
||||||
|
AND m.marker_invalid = 0
|
||||||
|
AND m.subj_uid IS NOT NULL AND m.subj_uid <> ''
|
||||||
|
AND (p.photo_path = ? OR p.photo_path LIKE ?)
|
||||||
|
GROUP BY m.subj_uid
|
||||||
|
`, basePath, basePath+"/%").Scan(&stats).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "subject stats query failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cntMap := make(map[string]int64, len(stats))
|
||||||
|
thumbMap := make(map[string]string, len(stats))
|
||||||
|
for _, s := range stats {
|
||||||
|
cntMap[s.SubjUID] = s.Cnt
|
||||||
|
thumbMap[s.SubjUID] = s.Thumb
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]PpSubjectLite, 0, len(stats))
|
||||||
|
for _, s := range subjects {
|
||||||
|
cnt, ok := cntMap[s.UID]
|
||||||
|
if !ok || cnt == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.PhotoCount = int(cnt)
|
||||||
|
if th := thumbMap[s.UID]; th != "" {
|
||||||
|
s.Thumb = th
|
||||||
|
}
|
||||||
|
filtered = append(filtered, s)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unnamedFaceCluster is one face cluster PhotoPrism has detected but
|
||||||
|
// nobody has named yet. Naming happens the same way PhotoPrism's own
|
||||||
|
// People→New tab does it: PUT /api/v1/markers/<markerUid> with
|
||||||
|
// {Name, SubjSrc:"manual"} — PhotoPrism then creates the Subject and
|
||||||
|
// propagates it across the cluster (verified against
|
||||||
|
// internal/api/markers.go + frontend/src/model/face.js).
|
||||||
|
type unnamedFaceCluster struct {
|
||||||
|
FaceID string `json:"faceId" gorm:"column:face_id"`
|
||||||
|
// Photos under the caller's scope carrying this face.
|
||||||
|
Count int64 `json:"count" gorm:"column:cnt"`
|
||||||
|
// Marker crop hash — renders via /api/v1/t/<thumb>/<token>/tile_320.
|
||||||
|
Thumb string `json:"thumb" gorm:"column:thumb"`
|
||||||
|
// Representative marker (largest face in scope) — the PUT target
|
||||||
|
// when the user names this cluster.
|
||||||
|
MarkerUID string `json:"markerUid" gorm:"column:marker_uid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnnamedFaces lists face clusters awaiting a name, scoped to the
|
||||||
|
// caller's BasePath (admins with no BasePath see the whole library).
|
||||||
|
// Ordered by in-scope photo count so the most prominent people surface
|
||||||
|
// first.
|
||||||
|
//
|
||||||
|
// Route: GET /api/sidecar/faces/unnamed (behind requireSession)
|
||||||
|
func handleUnnamedFaces(ppDb *gorm.DB) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if ppDb == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"clusters": []unnamedFaceCluster{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
basePath := ctxBasePath(c)
|
||||||
|
|
||||||
|
where := ""
|
||||||
|
args := []any{}
|
||||||
|
if basePath != "" {
|
||||||
|
where = "AND (p.photo_path = ? OR p.photo_path LIKE ?)"
|
||||||
|
args = []any{basePath, basePath + "/%"}
|
||||||
|
}
|
||||||
|
var clusters []unnamedFaceCluster
|
||||||
|
if err := ppDb.Raw(`
|
||||||
|
SELECT m.face_id AS face_id,
|
||||||
|
COUNT(DISTINCT p.id) AS cnt,
|
||||||
|
SUBSTRING_INDEX(GROUP_CONCAT(m.thumb ORDER BY m.size DESC SEPARATOR 0x1f), 0x1f, 1) AS thumb,
|
||||||
|
SUBSTRING_INDEX(GROUP_CONCAT(m.marker_uid ORDER BY m.size DESC SEPARATOR 0x1f), 0x1f, 1) AS marker_uid
|
||||||
|
FROM markers m
|
||||||
|
JOIN files f ON f.file_uid = m.file_uid AND f.file_missing = 0
|
||||||
|
JOIN photos p ON p.photo_uid = f.photo_uid AND p.deleted_at IS NULL
|
||||||
|
JOIN faces fc ON fc.id = m.face_id AND fc.face_hidden = 0
|
||||||
|
WHERE m.marker_type = 'face'
|
||||||
|
AND m.marker_invalid = 0
|
||||||
|
AND (m.subj_uid IS NULL OR m.subj_uid = '')
|
||||||
|
AND m.face_id <> ''
|
||||||
|
`+where+`
|
||||||
|
GROUP BY m.face_id
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 60
|
||||||
|
`, args...).Scan(&clusters).Error; err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "face cluster query failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if clusters == nil {
|
||||||
|
clusters = []unnamedFaceCluster{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"clusters": clusters})
|
||||||
|
}
|
||||||
|
}
|
||||||
395
sidecar/handlers_ppproxy.go
Normal file
395
sidecar/handlers_ppproxy.go
Normal file
@@ -0,0 +1,395 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
gopath "path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scoped PhotoPrism-compatible API proxy.
|
||||||
|
//
|
||||||
|
// PhotoPrism CE does not enforce auth_users.base_path on API reads — any
|
||||||
|
// authenticated user can search the whole library (`q=path:"other/*"`),
|
||||||
|
// verified empirically against this deployment. The web client compensates
|
||||||
|
// by post-filtering inside /api/sidecar/*, but third-party PhotoPrism apps
|
||||||
|
// (Gallery for PhotoPrism, Photo Uploader, …) speak to /api/v1 directly.
|
||||||
|
//
|
||||||
|
// This proxy is the public face for those apps. It forwards /api/v1/* to
|
||||||
|
// PhotoPrism with these rules for sessions that carry a BasePath:
|
||||||
|
//
|
||||||
|
// - search endpoints (photos, geo) get their `path` filter rewritten so
|
||||||
|
// results stay inside the caller's BasePath subtree;
|
||||||
|
// - per-photo reads and mutations the web client needs (metadata PUT,
|
||||||
|
// approve, like, stack file ops) are ownership-checked per UID;
|
||||||
|
// - batch archive/restore/delete validates every UID in the body against
|
||||||
|
// the PhotoPrism DB before forwarding;
|
||||||
|
// - hash-addressed media (t/, dl/, videos/) and session/config pass
|
||||||
|
// through — media URLs embed per-instance preview/download tokens and
|
||||||
|
// unguessable content hashes, the same protection PhotoPrism's own
|
||||||
|
// share links rely on;
|
||||||
|
// - album + label + subject reads and album/subject mutations pass
|
||||||
|
// through: PhotoPrism CE has no per-user albums or faces, so these are
|
||||||
|
// shared across users by design; the photos inside stay path-scoped;
|
||||||
|
// - everything else (settings, users, index, import, uploads) answers 403.
|
||||||
|
//
|
||||||
|
// Admin-role sessions (and any session without a BasePath) pass through
|
||||||
|
// fully — the web client's admin dialogs (settings, users, indexing) need
|
||||||
|
// the raw API.
|
||||||
|
|
||||||
|
// ppQPathTerm matches a `path:` filter inside PhotoPrism's q-DSL — either
|
||||||
|
// quoted (path:"a b/*") or bare (path:a/*).
|
||||||
|
var ppQPathTerm = regexp.MustCompile(`(?i)\bpath:("[^"]*"|\S+)`)
|
||||||
|
|
||||||
|
// pathValueAllowed reports whether one path-filter value stays inside base.
|
||||||
|
// PhotoPrism ORs `|`-separated alternatives inside a single value, so every
|
||||||
|
// alternative must pass. A bare `base*` (no slash) is rejected because the
|
||||||
|
// wildcard would also match sibling folders like `base2/…`.
|
||||||
|
func pathValueAllowed(val, base string) bool {
|
||||||
|
val = strings.Trim(val, `"`)
|
||||||
|
for _, alt := range strings.Split(val, "|") {
|
||||||
|
v := strings.Trim(strings.TrimSpace(alt), "/")
|
||||||
|
if v == base {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(v, base+"/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeQ rewrites a q-DSL string so its path filter cannot leave base.
|
||||||
|
// User-supplied path terms that already stay inside base are kept (the web
|
||||||
|
// client and gallery apps use them for folder drills); any term that
|
||||||
|
// escapes — or the absence of one — collapses to `path:"base/*"`.
|
||||||
|
func scopeQ(q, base string) string {
|
||||||
|
terms := ppQPathTerm.FindAllStringSubmatch(q, -1)
|
||||||
|
if len(terms) > 0 {
|
||||||
|
ok := true
|
||||||
|
for _, m := range terms {
|
||||||
|
if !pathValueAllowed(m[1], base) {
|
||||||
|
ok = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
q = strings.TrimSpace(ppQPathTerm.ReplaceAllString(q, ""))
|
||||||
|
}
|
||||||
|
scope := ` path:"` + strings.ReplaceAll(base, `"`, "") + `/*"`
|
||||||
|
return strings.TrimSpace(q + scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeSearchValues enforces the BasePath on a search request's query
|
||||||
|
// string. The q-DSL `path:` term overrides the `path` form parameter in
|
||||||
|
// PhotoPrism's parser (verified empirically), so the guarantee lives in q;
|
||||||
|
// the standalone param is validated too so it can't disagree.
|
||||||
|
func scopeSearchValues(v url.Values, base string) url.Values {
|
||||||
|
v.Set("q", scopeQ(v.Get("q"), base))
|
||||||
|
if p := v.Get("path"); p != "" && !pathValueAllowed(p, base) {
|
||||||
|
v.Del("path")
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ppProxyPhoto is the projection needed for per-UID ownership checks.
|
||||||
|
type ppProxyPhoto struct {
|
||||||
|
Path string `json:"Path"`
|
||||||
|
Files []ppFile `json:"Files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// photoWithinBase fetches one photo with the caller's own token and checks
|
||||||
|
// that it lives inside base. Fails closed on any error.
|
||||||
|
func photoWithinBase(ctx context.Context, pp *ppClient, token, uid, base string) bool {
|
||||||
|
resp, err := pp.call(ctx, http.MethodGet, "/api/v1/photos/"+url.PathEscape(uid), token, nil)
|
||||||
|
if err != nil || !resp.OK {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var p ppProxyPhoto
|
||||||
|
if err := json.Unmarshal(resp.Body, &p); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if p.Path != "" {
|
||||||
|
return p.Path == base || strings.HasPrefix(p.Path, base+"/")
|
||||||
|
}
|
||||||
|
for _, f := range p.Files {
|
||||||
|
if f.Root == "/" && strings.HasPrefix(f.Name, base+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedSession is a short-lived token→user cache so a burst of gallery
|
||||||
|
// requests doesn't double every call with a /session probe. 60s matches
|
||||||
|
// the BasePath reconciler cadence; a revoked token lives at most that long.
|
||||||
|
type cachedSession struct {
|
||||||
|
user *ppSessionUser
|
||||||
|
expiry time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]cachedSession
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sc *sessionCache) resolve(ctx context.Context, pp *ppClient, token string) *ppSessionUser {
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
sc.mu.Lock()
|
||||||
|
if e, ok := sc.m[token]; ok && now.Before(e.expiry) {
|
||||||
|
sc.mu.Unlock()
|
||||||
|
return e.user
|
||||||
|
}
|
||||||
|
sc.mu.Unlock()
|
||||||
|
user := pp.resolveSession(ctx, token)
|
||||||
|
if user == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sc.mu.Lock()
|
||||||
|
if len(sc.m) > 1024 { // hard cap; sessions are few, tokens churn rarely
|
||||||
|
sc.m = map[string]cachedSession{}
|
||||||
|
}
|
||||||
|
sc.m[token] = cachedSession{user: user, expiry: now.Add(60 * time.Second)}
|
||||||
|
sc.mu.Unlock()
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
// proxyToken pulls the session token from any header form PhotoPrism
|
||||||
|
// clients use: X-Auth-Token (canonical), Authorization: Bearer, or the
|
||||||
|
// legacy X-Session-ID.
|
||||||
|
func proxyToken(r *http.Request) string {
|
||||||
|
if t := r.Header.Get("X-Auth-Token"); t != "" {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
if a := r.Header.Get("Authorization"); strings.HasPrefix(a, "Bearer ") {
|
||||||
|
return strings.TrimPrefix(a, "Bearer ")
|
||||||
|
}
|
||||||
|
return r.Header.Get("X-Session-ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// markerWithinBase reports whether a marker's underlying photo lives under
|
||||||
|
// base. Fails closed: no DB handle or unknown marker → false.
|
||||||
|
func markerWithinBase(ppDb *gorm.DB, markerUID, base string) bool {
|
||||||
|
if ppDb == nil || markerUID == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
err := ppDb.Table("markers m").
|
||||||
|
Joins("JOIN files f ON f.file_uid = m.file_uid").
|
||||||
|
Joins("JOIN photos p ON p.photo_uid = f.photo_uid").
|
||||||
|
Where("m.marker_uid = ?", markerUID).
|
||||||
|
Where("p.deleted_at IS NULL").
|
||||||
|
Where("p.photo_path = ? OR p.photo_path LIKE ?", base, base+"/%").
|
||||||
|
Count(&n).Error
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("pp-proxy: marker ownership query failed", "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchUIDsWithinBase validates that every photo UID in a batch body lives
|
||||||
|
// under base, using one SQL query against PhotoPrism's photos table. Fails
|
||||||
|
// closed: no DB handle, unknown UIDs, or any path outside base → false.
|
||||||
|
func batchUIDsWithinBase(ppDb *gorm.DB, uids []string, base string) bool {
|
||||||
|
if ppDb == nil || len(uids) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
err := ppDb.Table("photos").
|
||||||
|
Where("photo_uid IN ?", uids).
|
||||||
|
Where("photo_path = ? OR photo_path LIKE ?", base, base+"/%").
|
||||||
|
Count(&n).Error
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("pp-proxy: batch ownership query failed", "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return n == int64(len(uids))
|
||||||
|
}
|
||||||
|
|
||||||
|
// readBatchBody consumes the request body, extracts the `photos` UID list,
|
||||||
|
// and reinstates the body so the proxy can still forward it.
|
||||||
|
func readBatchBody(r *http.Request) ([]string, bool) {
|
||||||
|
buf, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||||
|
r.Body.Close()
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(buf))
|
||||||
|
r.ContentLength = int64(len(buf))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Photos []string `json:"photos"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(buf, &body); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return body.Photos, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePPProxy returns the gin handler mounted at /api/v1/*rest.
|
||||||
|
func handlePPProxy(cfg *Config, ppDb *gorm.DB) gin.HandlerFunc {
|
||||||
|
target, err := url.Parse(cfg.PhotoprismBaseURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("pp-proxy: bad PHOTOPRISM_BASE_URL", "err", err)
|
||||||
|
return func(c *gin.Context) { c.AbortWithStatus(http.StatusBadGateway) }
|
||||||
|
}
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
|
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
slog.Warn("pp-proxy: upstream error", "path", r.URL.Path, "err", err)
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
}
|
||||||
|
// Ownership checks reuse the normal client; a dedicated instance would
|
||||||
|
// gain nothing.
|
||||||
|
pp := newPPClient(cfg.PhotoprismBaseURL)
|
||||||
|
cache := &sessionCache{m: map[string]cachedSession{}}
|
||||||
|
|
||||||
|
forbid := func(c *gin.Context) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusForbidden,
|
||||||
|
gin.H{"error": "not available through this proxy"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// The router keeps raw escapes (UseRawPath). Unescape and clean
|
||||||
|
// before classifying, then forward exactly the cleaned path — so
|
||||||
|
// `t%2F..%2Fsettings` can't be classified as media here yet reach
|
||||||
|
// /settings after PhotoPrism's own router cleans it.
|
||||||
|
unesc, err := url.PathUnescape(c.Param("rest"))
|
||||||
|
if err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "bad path"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(gopath.Clean("/"+unesc), "/")
|
||||||
|
c.Request.URL.Path = "/api/v1/" + rest
|
||||||
|
c.Request.URL.RawPath = ""
|
||||||
|
method := c.Request.Method
|
||||||
|
|
||||||
|
// Unauthenticated / token-in-URL surface: login+logout, OIDC
|
||||||
|
// login+callback (PhotoPrism's actual routes are /api/v1/oidc/login
|
||||||
|
// and /api/v1/oidc/redirect — "oauth/" was never a real PhotoPrism
|
||||||
|
// path and left the Authentik callback with no valid token yet
|
||||||
|
// falling through to the authenticated branch below, producing a
|
||||||
|
// 401 "invalid session" before the session was even established),
|
||||||
|
// client config, hash-addressed media, websocket.
|
||||||
|
passUnscoped := rest == "session" || strings.HasPrefix(rest, "session/") ||
|
||||||
|
strings.HasPrefix(rest, "oidc/") ||
|
||||||
|
rest == "config" || rest == "ws" ||
|
||||||
|
strings.HasPrefix(rest, "t/") ||
|
||||||
|
strings.HasPrefix(rest, "dl/") ||
|
||||||
|
strings.HasPrefix(rest, "videos/") ||
|
||||||
|
strings.HasPrefix(rest, "svg/")
|
||||||
|
if passUnscoped {
|
||||||
|
proxy.ServeHTTP(c.Writer, c.Request)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user := cache.resolve(c.Request.Context(), pp, proxyToken(c.Request))
|
||||||
|
if user == nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := strings.Trim(user.BasePath, "/")
|
||||||
|
if base == "" || user.Role == "admin" {
|
||||||
|
// Admins keep the raw API — the web client's settings, users,
|
||||||
|
// and indexing dialogs need it. (On this deployment the admin
|
||||||
|
// account carries a BasePath purely to default its web view.)
|
||||||
|
proxy.ServeHTTP(c.Writer, c.Request)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isGet := method == http.MethodGet || method == http.MethodHead
|
||||||
|
switch {
|
||||||
|
// Search endpoints: enforce the path scope inside the query.
|
||||||
|
case isGet && (rest == "photos" || rest == "photos/view" || rest == "geo"):
|
||||||
|
q := c.Request.URL.Query()
|
||||||
|
c.Request.URL.RawQuery = scopeSearchValues(q, base).Encode()
|
||||||
|
proxy.ServeHTTP(c.Writer, c.Request)
|
||||||
|
|
||||||
|
// Batch mutations: every UID in the body must be the caller's.
|
||||||
|
case method == http.MethodPost && (rest == "batch/photos/archive" ||
|
||||||
|
rest == "batch/photos/restore" || rest == "batch/photos/delete" ||
|
||||||
|
rest == "batch/photos/approve" || rest == "batch/photos/private"):
|
||||||
|
uids, ok := readBatchBody(c.Request)
|
||||||
|
if !ok || !batchUIDsWithinBase(ppDb, uids, base) {
|
||||||
|
forbid(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proxy.ServeHTTP(c.Writer, c.Request)
|
||||||
|
|
||||||
|
// Per-photo operations: ownership-checked per UID. Covers reads,
|
||||||
|
// metadata PUT, approve, like/unlike, download, and the stack file
|
||||||
|
// ops (set primary / unstack / delete file) the review UI uses.
|
||||||
|
case strings.HasPrefix(rest, "photos/"):
|
||||||
|
parts := strings.Split(rest, "/")
|
||||||
|
uid := parts[1]
|
||||||
|
var allowed bool
|
||||||
|
switch len(parts) {
|
||||||
|
case 2:
|
||||||
|
allowed = isGet || method == http.MethodPut
|
||||||
|
case 3:
|
||||||
|
allowed = (isGet && parts[2] == "dl") ||
|
||||||
|
(method == http.MethodPost && parts[2] == "approve") ||
|
||||||
|
(parts[2] == "like" && (method == http.MethodPost || method == http.MethodDelete))
|
||||||
|
case 4:
|
||||||
|
allowed = method == http.MethodDelete && parts[2] == "files"
|
||||||
|
case 5:
|
||||||
|
allowed = method == http.MethodPost && parts[2] == "files" &&
|
||||||
|
(parts[4] == "primary" || parts[4] == "unstack")
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
forbid(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !photoWithinBase(c.Request.Context(), pp, proxyToken(c.Request), uid, base) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "photo not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proxy.ServeHTTP(c.Writer, c.Request)
|
||||||
|
|
||||||
|
// Albums (heaps), labels, subjects, faces: shared across users by
|
||||||
|
// design in CE — reads and mutations pass through; the photos inside
|
||||||
|
// any of them stay path-scoped by the rules above. Note that naming
|
||||||
|
// a face (marker PUT below) creates/updates a shared Subject the
|
||||||
|
// same way album/label edits are shared.
|
||||||
|
case rest == "albums" || strings.HasPrefix(rest, "albums/") ||
|
||||||
|
rest == "labels" || strings.HasPrefix(rest, "labels/") ||
|
||||||
|
rest == "subjects" || strings.HasPrefix(rest, "subjects/") ||
|
||||||
|
rest == "faces" || strings.HasPrefix(rest, "faces/"):
|
||||||
|
proxy.ServeHTTP(c.Writer, c.Request)
|
||||||
|
|
||||||
|
// Marker mutations (face naming / clearing): ownership-checked —
|
||||||
|
// the marker's file must belong to a photo under the caller's
|
||||||
|
// BasePath. This is how the web client names people (PhotoPrism's
|
||||||
|
// own naming flow is PUT /markers/:uid {Name, SubjSrc:"manual"}).
|
||||||
|
case (method == http.MethodPut && strings.HasPrefix(rest, "markers/") && strings.Count(rest, "/") == 1) ||
|
||||||
|
(method == http.MethodDelete && strings.HasPrefix(rest, "markers/") && strings.HasSuffix(rest, "/subject")):
|
||||||
|
markerUID := strings.TrimSuffix(strings.TrimPrefix(rest, "markers/"), "/subject")
|
||||||
|
if !markerWithinBase(ppDb, markerUID, base) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "marker not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proxy.ServeHTTP(c.Writer, c.Request)
|
||||||
|
|
||||||
|
default:
|
||||||
|
slog.Info("pp-proxy: blocked", "user", user.UserName, "method", method, "path", rest)
|
||||||
|
forbid(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
222
sidecar/handlers_ppproxy_test.go
Normal file
222
sidecar/handlers_ppproxy_test.go
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestScopeQ(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
q string
|
||||||
|
base string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"empty q gains scope", "", "dtoro", `path:"dtoro/*"`},
|
||||||
|
{"plain search gains scope", "label:dog", "dtoro", `label:dog path:"dtoro/*"`},
|
||||||
|
{"inside path kept", `path:"dtoro/2024/*" label:dog`, "dtoro", `path:"dtoro/2024/*" label:dog`},
|
||||||
|
{"exact base kept", `path:"dtoro"`, "dtoro", `path:"dtoro"`},
|
||||||
|
{"outside path replaced", `path:"muli/*"`, "dtoro", `path:"dtoro/*"`},
|
||||||
|
{"bare term outside replaced", `path:muli/x label:dog`, "dtoro", `label:dog path:"dtoro/*"`},
|
||||||
|
{"pipe alternative escaping", `path:"dtoro/*|muli/*"`, "dtoro", `path:"dtoro/*"`},
|
||||||
|
{"pipe all inside kept", `path:"dtoro/a|dtoro/b/*"`, "dtoro", `path:"dtoro/a|dtoro/b/*"`},
|
||||||
|
{"sibling prefix rejected", `path:"dtoro2/*"`, "dtoro", `path:"dtoro/*"`},
|
||||||
|
{"bare wildcard on base rejected", `path:dtoro*`, "dtoro", `path:"dtoro/*"`},
|
||||||
|
{"mixed valid+invalid terms collapse", `path:"dtoro/a" path:"muli/b"`, "dtoro", `path:"dtoro/*"`},
|
||||||
|
{"case-insensitive filter name", `PATH:"muli/*"`, "dtoro", `path:"dtoro/*"`},
|
||||||
|
{"nested base", `path:"family/alice/x"`, "family/alice", `path:"family/alice/x"`},
|
||||||
|
{"nested base parent escape", `path:"family/*"`, "family/alice", `path:"family/alice/*"`},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := scopeQ(tc.q, tc.base); got != tc.want {
|
||||||
|
t.Errorf("scopeQ(%q, %q) = %q, want %q", tc.q, tc.base, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopeSearchValues(t *testing.T) {
|
||||||
|
v := url.Values{}
|
||||||
|
v.Set("count", "60")
|
||||||
|
v.Set("path", "muli/*")
|
||||||
|
got := scopeSearchValues(v, "dtoro")
|
||||||
|
if got.Get("path") != "" {
|
||||||
|
t.Errorf("outside path param should be dropped, got %q", got.Get("path"))
|
||||||
|
}
|
||||||
|
if got.Get("q") != `path:"dtoro/*"` {
|
||||||
|
t.Errorf("q should carry the scope, got %q", got.Get("q"))
|
||||||
|
}
|
||||||
|
if got.Get("count") != "60" {
|
||||||
|
t.Errorf("unrelated params must survive, got count=%q", got.Get("count"))
|
||||||
|
}
|
||||||
|
|
||||||
|
v2 := url.Values{}
|
||||||
|
v2.Set("path", "dtoro/2024")
|
||||||
|
got2 := scopeSearchValues(v2, "dtoro")
|
||||||
|
if got2.Get("path") != "dtoro/2024" {
|
||||||
|
t.Errorf("inside path param should be kept, got %q", got2.Get("path"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakePP stands in for PhotoPrism: answers /session per token, echoes
|
||||||
|
// every other request's method+path+query back as JSON.
|
||||||
|
func fakePP(t *testing.T) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/v1/session" && r.Method == http.MethodGet {
|
||||||
|
var user map[string]any
|
||||||
|
switch r.Header.Get("X-Auth-Token") {
|
||||||
|
case "tok-scoped":
|
||||||
|
user = map[string]any{"UID": "u1", "Name": "alice", "Role": "user", "BasePath": "alice"}
|
||||||
|
case "tok-admin":
|
||||||
|
user = map[string]any{"UID": "u0", "Name": "root", "Role": "admin", "BasePath": "alice"}
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"user": user})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(r.URL.Path, "/api/v1/photos/") && r.Method == http.MethodGet &&
|
||||||
|
strings.Count(r.URL.Path, "/") == 4 {
|
||||||
|
uid := strings.TrimPrefix(r.URL.Path, "/api/v1/photos/")
|
||||||
|
path := "alice/2024"
|
||||||
|
if strings.HasPrefix(uid, "foreign") {
|
||||||
|
path = "bob/2024"
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"Path": path})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"echo": r.Method + " " + r.URL.Path,
|
||||||
|
"query": r.URL.RawQuery,
|
||||||
|
"handled": true,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func proxyRig(t *testing.T) (*httptest.Server, func()) {
|
||||||
|
t.Helper()
|
||||||
|
up := fakePP(t)
|
||||||
|
cfg := &Config{PhotoprismBaseURL: up.URL}
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.UseRawPath = true
|
||||||
|
r.UnescapePathValues = false
|
||||||
|
r.Any("/api/v1/*rest", handlePPProxy(cfg, nil))
|
||||||
|
front := httptest.NewServer(r)
|
||||||
|
return front, func() { front.Close(); up.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func proxyReq(t *testing.T, front, method, path, token string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest(method, front+path, nil)
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("X-Auth-Token", token)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var sb strings.Builder
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := resp.Body.Read(buf)
|
||||||
|
sb.Write(buf[:n])
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp.StatusCode, sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyRouting(t *testing.T) {
|
||||||
|
front, done := proxyRig(t)
|
||||||
|
defer done()
|
||||||
|
|
||||||
|
t.Run("media passes unauthenticated", func(t *testing.T) {
|
||||||
|
code, body := proxyReq(t, front.URL, "GET", "/api/v1/t/abc/tok/tile_224", "")
|
||||||
|
if code != 200 || !strings.Contains(body, "/api/v1/t/abc/tok/tile_224") {
|
||||||
|
t.Errorf("thumb should pass through, got %d %s", code, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("scoped search gains path scope", func(t *testing.T) {
|
||||||
|
code, body := proxyReq(t, front.URL, "GET", "/api/v1/photos?count=3&q="+url.QueryEscape(`path:"bob/*"`), "tok-scoped")
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("got %d", code)
|
||||||
|
}
|
||||||
|
var out struct{ Query string }
|
||||||
|
json.Unmarshal([]byte(body), &out)
|
||||||
|
q, _ := url.ParseQuery(out.Query)
|
||||||
|
if q.Get("q") != `path:"alice/*"` {
|
||||||
|
t.Errorf("escaping q must collapse to own scope, got %q", q.Get("q"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("scoped settings blocked", func(t *testing.T) {
|
||||||
|
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/settings", "tok-scoped")
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
t.Errorf("settings should 403 for scoped user, got %d", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("admin settings passes", func(t *testing.T) {
|
||||||
|
code, body := proxyReq(t, front.URL, "GET", "/api/v1/settings", "tok-admin")
|
||||||
|
if code != 200 || !strings.Contains(body, "/api/v1/settings") {
|
||||||
|
t.Errorf("admin should pass through, got %d %s", code, body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("no token unauthorized", func(t *testing.T) {
|
||||||
|
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/photos", "")
|
||||||
|
if code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("own photo readable, foreign 404", func(t *testing.T) {
|
||||||
|
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/photos/mine123", "tok-scoped")
|
||||||
|
if code != 200 {
|
||||||
|
t.Errorf("own photo should pass, got %d", code)
|
||||||
|
}
|
||||||
|
code, _ = proxyReq(t, front.URL, "GET", "/api/v1/photos/foreign9", "tok-scoped")
|
||||||
|
if code != http.StatusNotFound {
|
||||||
|
t.Errorf("foreign photo should 404, got %d", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("encoded traversal cannot reach settings as media", func(t *testing.T) {
|
||||||
|
code, _ := proxyReq(t, front.URL, "GET", "/api/v1/t%2F..%2Fsettings", "tok-scoped")
|
||||||
|
if code != http.StatusForbidden {
|
||||||
|
t.Errorf("traversal should classify as settings and 403, got %d", code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("batch without db fails closed", func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("POST", front.URL+"/api/v1/batch/photos/archive",
|
||||||
|
strings.NewReader(`{"photos":["p1"]}`))
|
||||||
|
req.Header.Set("X-Auth-Token", "tok-scoped")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("batch with nil ppDb must 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathValueAllowed(t *testing.T) {
|
||||||
|
if pathValueAllowed(`"muli/*"`, "dtoro") {
|
||||||
|
t.Error("outside value must be rejected")
|
||||||
|
}
|
||||||
|
if !pathValueAllowed(`"dtoro/Photos/2024"`, "dtoro") {
|
||||||
|
t.Error("inside value must be allowed")
|
||||||
|
}
|
||||||
|
if pathValueAllowed("dtoro/a|muli/b", "dtoro") {
|
||||||
|
t.Error("any escaping pipe alternative must reject the whole value")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,6 +97,9 @@ func handleRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !requireUserScope(c, cfg, oldAbs, false) {
|
||||||
|
return
|
||||||
|
}
|
||||||
st, err := os.Stat(oldAbs)
|
st, err := os.Stat(oldAbs)
|
||||||
if err != nil || !st.Mode().IsRegular() {
|
if err != nil || !st.Mode().IsRegular() {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"})
|
||||||
|
|||||||
@@ -79,49 +79,59 @@ func main() {
|
|||||||
|
|
||||||
// Every other endpoint runs behind the session gate. Mounting them
|
// Every other endpoint runs behind the session gate. Mounting them
|
||||||
// under one group keeps the middleware wiring obvious.
|
// under one group keeps the middleware wiring obvious.
|
||||||
auth := r.Group("/api/sidecar", requireSession(pp))
|
auth := r.Group("/api/sidecar", requireSession(pp))
|
||||||
{
|
{
|
||||||
auth.GET("/prefs", handlePrefsGet(db))
|
auth.GET("/prefs", handlePrefsGet(db))
|
||||||
auth.PUT("/prefs", handlePrefsPut(cfg, db))
|
auth.PUT("/prefs", handlePrefsPut(cfg, db))
|
||||||
|
|
||||||
auth.GET("/photos/marks", handleMarksAll(db))
|
auth.GET("/photos/marks", handleMarksAll(db))
|
||||||
auth.GET("/photos/:uid/marks", handleMarkGet(db))
|
auth.GET("/photos/:uid/marks", handleMarkGet(db))
|
||||||
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
|
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
|
||||||
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
|
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
|
||||||
|
|
||||||
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
|
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
|
||||||
|
|
||||||
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
||||||
auth.POST("/folders/counts", handleFolderCounts(pp))
|
auth.POST("/folders/counts", handleFolderCounts(pp))
|
||||||
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
||||||
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
|
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
|
||||||
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
||||||
|
|
||||||
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||||
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
|
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
|
||||||
|
auth.POST("/files/restore-moves", handleRestoreMoves(cfg, pp))
|
||||||
|
|
||||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
|
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
|
||||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
|
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
|
||||||
|
auth.POST("/duplicates/restore", handleDupRestore(cfg, pp, db))
|
||||||
|
|
||||||
// User-scoped proxies — require PpDSN connection.
|
// User-scoped proxies — require PpDSN connection.
|
||||||
if ppDb != nil {
|
if ppDb != nil {
|
||||||
auth.GET("/labels", handleLabels(pp, ppDb))
|
auth.GET("/labels", handleLabels(pp, ppDb))
|
||||||
auth.GET("/counts", handleScopedCounts(ppDb))
|
auth.GET("/counts", handleScopedCounts(ppDb))
|
||||||
auth.GET("/countries", handleCountries(ppDb))
|
auth.GET("/countries", handleCountries(ppDb))
|
||||||
}
|
auth.GET("/subjects", handleSubjects(pp, ppDb))
|
||||||
|
auth.GET("/faces/unnamed", handleUnnamedFaces(ppDb))
|
||||||
|
}
|
||||||
|
|
||||||
// User-scoped photos — post-filters by BasePath so review/archive
|
// User-scoped photos — post-filters by BasePath so review/archive
|
||||||
// tabs only show photos the user owns.
|
// tabs only show photos the user owns.
|
||||||
auth.GET("/timeline", handlePhotos(pp))
|
auth.GET("/timeline", handlePhotos(pp))
|
||||||
|
|
||||||
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
|
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
|
||||||
// the /notes view isn't capped to the newest slice.
|
// the /notes view isn't capped to the newest slice.
|
||||||
auth.GET("/notes", handleNotes(pp))
|
auth.GET("/notes", handleNotes(pp))
|
||||||
|
|
||||||
// User-scoped folders — post-filters the folder tree by BasePath
|
// User-scoped folders — post-filters the folder tree by BasePath
|
||||||
// so the sidebar shows only folders under the user's library root.
|
// so the sidebar shows only folders under the user's library root.
|
||||||
auth.GET("/folders", handleFoldersProxy(pp))
|
auth.GET("/folders", handleFoldersProxy(pp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PhotoPrism-compatible scoped proxy — the public /api/v1 surface for
|
||||||
|
// both the web client and third-party PhotoPrism apps (Caddy routes
|
||||||
|
// /api/v1 here instead of straight to PhotoPrism, which does not
|
||||||
|
// enforce base_path in CE). See handlers_ppproxy.go for the rules.
|
||||||
|
r.Any("/api/v1/*rest", handlePPProxy(cfg, ppDb))
|
||||||
|
|
||||||
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
|
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@@ -154,4 +164,3 @@ func main() {
|
|||||||
}
|
}
|
||||||
<-idleClosed
|
<-idleClosed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
|
|||||||
type ppSessionUser struct {
|
type ppSessionUser struct {
|
||||||
UserUID string `json:"UID"`
|
UserUID string `json:"UID"`
|
||||||
UserName string `json:"Name"`
|
UserName string `json:"Name"`
|
||||||
|
Role string `json:"Role"`
|
||||||
BasePath string `json:"BasePath"`
|
BasePath string `json:"BasePath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,12 +86,15 @@ func reconcileUserBasepaths(ppDSN, originalsRoot string, mapping map[string]stri
|
|||||||
// ACL kicks in even if the directory is created later.
|
// ACL kicks in even if the directory is created later.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upload_path rides along with base_path so anything a client app
|
||||||
|
// uploads (WebDAV sync apps, PhotoPrism's own UI) lands inside the
|
||||||
|
// user's library subtree instead of the shared originals root.
|
||||||
res := db.Exec(`UPDATE auth_users
|
res := db.Exec(`UPDATE auth_users
|
||||||
SET base_path = ?
|
SET base_path = ?, upload_path = ?
|
||||||
WHERE user_name = ?
|
WHERE user_name = ?
|
||||||
AND COALESCE(base_path, '') <> ?
|
AND (COALESCE(base_path, '') <> ? OR COALESCE(upload_path, '') <> ?)
|
||||||
AND deleted_at IS NULL`,
|
AND deleted_at IS NULL`,
|
||||||
path, username, path)
|
path, path, username, path, path)
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
slog.Error("user-basepath: update failed", "user", username, "err", res.Error)
|
slog.Error("user-basepath: update failed", "user", username, "err", res.Error)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import {
|
|||||||
batchArchive,
|
batchArchive,
|
||||||
batchDelete,
|
batchDelete,
|
||||||
batchRestore,
|
batchRestore,
|
||||||
|
bulkSetMarks,
|
||||||
removeFromHeap,
|
removeFromHeap,
|
||||||
|
type PhotoMark,
|
||||||
|
type PhotoMarksMap,
|
||||||
type PpAlbum
|
type PpAlbum
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
import { acceptDateAndKeep, cachedPhoto, toggleFavorite } from '$lib/services/photoActions';
|
||||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
import { photoNameAndDir } from '$lib/types/photoprism';
|
import { photoNameAndDir } from '$lib/types/photoprism';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
@@ -36,7 +39,14 @@ import {
|
|||||||
setDetail,
|
setDetail,
|
||||||
markRemoved
|
markRemoved
|
||||||
} from '$lib/stores/bulkAction.svelte';
|
} from '$lib/stores/bulkAction.svelte';
|
||||||
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
import {
|
||||||
|
closeShortcuts,
|
||||||
|
openPreview,
|
||||||
|
toggleLeftSidebar,
|
||||||
|
toggleRightSidebar,
|
||||||
|
toggleShortcuts,
|
||||||
|
view
|
||||||
|
} from '$lib/stores/view.svelte';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
||||||
@@ -68,8 +78,8 @@ export interface GridKeyNavParams {
|
|||||||
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||||||
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
|
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
|
||||||
* ⌘A selects all visible.
|
* ⌘A selects all visible.
|
||||||
* Rating + color labels are mouse-driven via the metadata sidebar — no
|
* 0–5 rating, 6–9 Lightroom color labels, / focuses search,
|
||||||
* keyboard shortcuts.
|
* ? opens the shortcut reference overlay.
|
||||||
*
|
*
|
||||||
* Archive / restore target a synthesized "cull target list" — in priority:
|
* Archive / restore target a synthesized "cull target list" — in priority:
|
||||||
* 1. multi-selection set
|
* 1. multi-selection set
|
||||||
@@ -377,6 +387,55 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
await addCullTargetsToHeap(heaps[idx - 1]);
|
await addCullTargetsToHeap(heaps[idx - 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Rating / color-label keys (Lightroom layout) ─────────────────────
|
||||||
|
// Bare 0–5 set the rating (0 clears; re-keying the current value also
|
||||||
|
// clears, matching the sidebar's click-to-toggle). 6–9 toggle the four
|
||||||
|
// Lightroom color labels. Multi-selection stamps the whole set.
|
||||||
|
const COLOR_KEYS: Record<string, string> = { '6': 'red', '7': 'yellow', '8': 'green', '9': 'blue' };
|
||||||
|
|
||||||
|
async function markCullTargets(patch: PhotoMark, label: string) {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) {
|
||||||
|
toast.message('Nothing to mark', {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Optimistic cache patch — the tile badges and facet panels read
|
||||||
|
// ['marks'], so stamping it up front makes the keystroke feel instant.
|
||||||
|
const prevMap = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
|
||||||
|
const next: PhotoMarksMap = { ...prevMap };
|
||||||
|
for (const id of ids) {
|
||||||
|
const merged: PhotoMark = { ...next[id], ...patch };
|
||||||
|
if (!merged.rating) delete merged.rating;
|
||||||
|
if (!merged.color) delete merged.color;
|
||||||
|
next[id] = merged;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData(['marks'], next);
|
||||||
|
try {
|
||||||
|
await bulkSetMarks(ids, patch);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||||
|
toast.success(ids.length === 1 ? label : `${label} · ${ids.length} photos`);
|
||||||
|
} catch (err) {
|
||||||
|
queryClient.setQueryData(['marks'], prevMap);
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Mark failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratingOfFirstTarget(): number {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) return 0;
|
||||||
|
const marks = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
|
||||||
|
return marks[ids[0]]?.rating ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorOfFirstTarget(): string {
|
||||||
|
const ids = cullTargets();
|
||||||
|
if (ids.length === 0) return '';
|
||||||
|
const marks = queryClient.getQueryData<PhotoMarksMap>(['marks']) ?? {};
|
||||||
|
return marks[ids[0]]?.color ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
async function addCullTargetsToActiveHeap() {
|
async function addCullTargetsToActiveHeap() {
|
||||||
if (filters.section !== 'heap' || !filters.heapUid) {
|
if (filters.section !== 'heap' || !filters.heapUid) {
|
||||||
toast.message('Press S then 1–9 to pick a heap');
|
toast.message('Press S then 1–9 to pick a heap');
|
||||||
@@ -396,6 +455,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||||
|
|
||||||
|
// Shortcuts overlay: Esc or ? closes it; every other key is inert
|
||||||
|
// while it's up so the reference card can't trigger the actions it
|
||||||
|
// documents.
|
||||||
|
if (view.shortcutsOpen) {
|
||||||
|
if (e.key === 'Escape' || e.key === '?') {
|
||||||
|
e.preventDefault();
|
||||||
|
closeShortcuts();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Modal owns arrow / Escape / Space while it's open — it handles
|
// Modal owns arrow / Escape / Space while it's open — it handles
|
||||||
// its own linear nav, close-on-Esc, and close-on-Space. Action
|
// its own linear nav, close-on-Esc, and close-on-Space. Action
|
||||||
// keys (X/S/U/A/Z) still pass through because they target the
|
// keys (X/S/U/A/Z) still pass through because they target the
|
||||||
@@ -431,6 +501,22 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
const meta = e.metaKey || e.ctrlKey;
|
const meta = e.metaKey || e.ctrlKey;
|
||||||
const shift = e.shiftKey;
|
const shift = e.shiftKey;
|
||||||
|
|
||||||
|
// Bare digits: rating (0–5, re-key toggles off) and Lightroom color
|
||||||
|
// labels (6–9). Runs after the S-chord so "s 3" still files to heap 3.
|
||||||
|
if (!meta && !shift && /^[0-9]$/.test(e.key)) {
|
||||||
|
e.preventDefault();
|
||||||
|
const n = parseInt(e.key, 10);
|
||||||
|
if (n <= 5) {
|
||||||
|
const value = n === 0 || ratingOfFirstTarget() === n ? 0 : n;
|
||||||
|
void markCullTargets({ rating: value }, value ? `Rated ${value}★` : 'Rating cleared');
|
||||||
|
} else {
|
||||||
|
const color = COLOR_KEYS[e.key];
|
||||||
|
const value = colorOfFirstTarget() === color ? '' : color;
|
||||||
|
void markCullTargets({ color: value }, value ? `Labeled ${value}` : 'Color cleared');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Space on a focused tile opens the full-screen preview modal.
|
// Space on a focused tile opens the full-screen preview modal.
|
||||||
// Matches the dblclick gesture so the user has both keyboard and
|
// Matches the dblclick gesture so the user has both keyboard and
|
||||||
// mouse paths to the same surface. `e.code === 'Space'` covers
|
// mouse paths to the same surface. `e.code === 'Space'` covers
|
||||||
@@ -477,6 +563,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
clearSelection();
|
clearSelection();
|
||||||
setFocused(null);
|
setFocused(null);
|
||||||
return;
|
return;
|
||||||
|
case '/':
|
||||||
|
// Jump to the search box (any page that renders one tags it
|
||||||
|
// with data-search-input).
|
||||||
|
if (meta) return;
|
||||||
|
e.preventDefault();
|
||||||
|
document.querySelector<HTMLInputElement>('[data-search-input]')?.focus();
|
||||||
|
return;
|
||||||
|
case '?':
|
||||||
|
e.preventDefault();
|
||||||
|
toggleShortcuts();
|
||||||
|
return;
|
||||||
case 'Tab':
|
case 'Tab':
|
||||||
// Tab in the grid context = mule-image's left-sidebar toggle.
|
// Tab in the grid context = mule-image's left-sidebar toggle.
|
||||||
// Browsers reserve Tab for focus traversal — preventDefault
|
// Browsers reserve Tab for focus traversal — preventDefault
|
||||||
@@ -556,6 +653,12 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void toggleArchive('restore');
|
void toggleArchive('restore');
|
||||||
return;
|
return;
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
if (meta || shift) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void toggleFavorite(cullTargets());
|
||||||
|
return;
|
||||||
case 'm':
|
case 'm':
|
||||||
case 'M': {
|
case 'M': {
|
||||||
if (meta || shift) return;
|
if (meta || shift) return;
|
||||||
|
|||||||
130
web/src/lib/actions/zoomPan.ts
Normal file
130
web/src/lib/actions/zoomPan.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
156
web/src/lib/components/duplicates/CompareLightbox.svelte
Normal file
156
web/src/lib/components/duplicates/CompareLightbox.svelte
Normal 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 2–5 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>
|
||||||
@@ -1,43 +1,33 @@
|
|||||||
<!--
|
<!--
|
||||||
One cross-folder duplicate group rendered as a card. Lists every on-disk
|
One cross-folder duplicate group. Every copy is byte-identical (same
|
||||||
copy of the same byte-identical file. The user picks one to keep; the
|
sha1, same thumbnail) so the old N-identical-thumbnails grid told the
|
||||||
rest are archived to `.duplicates/<timestamp>/` via the sidecar.
|
user nothing — the actual decision is entirely about *which path* to
|
||||||
|
keep. Redesigned as one thumbnail + a radio-style path list.
|
||||||
|
|
||||||
Differences from StackGroupCard (which operates on PhotoPrism Files in
|
Resolution moves files (reversible, quarantine + undo) rather than
|
||||||
a single Photo stack):
|
deletes — logic lives in services/duplicateActions.svelte.ts.
|
||||||
- These photos are NOT in PhotoPrism's DB (PhotoPrism dropped them at
|
|
||||||
index time). They're files on disk only.
|
|
||||||
- Thumbnails come via `thumbUrl(hash, ...)` — content-addressed, so we
|
|
||||||
can render every copy from the same hash even though only one Photo
|
|
||||||
entry exists.
|
|
||||||
- Resolution moves files (reversible) rather than deletes (irreversible).
|
|
||||||
|
|
||||||
Same keyboard contract as StackGroupCard: arrows pick the keeper,
|
Keyboard (↑/↓/j/k bubble to DuplicatesView's group navigation):
|
||||||
Enter commits.
|
- ←/→ or 1–9 move the keeper pick.
|
||||||
|
- Enter archives every other copy.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { resolveCrossFolder } from '$lib/services/duplicateActions.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
import {
|
|
||||||
archiveDuplicatePaths,
|
|
||||||
type CrossFolderDuplicateGroup
|
|
||||||
} from '$lib/services/photoprism';
|
|
||||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||||
import { view } from '$lib/stores/view.svelte';
|
import type { CrossFolderDuplicateGroup } from '$lib/services/photoprism';
|
||||||
|
import { Check, Clock } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
group: CrossFolderDuplicateGroup;
|
group: CrossFolderDuplicateGroup;
|
||||||
/** First-card auto-focus, same pattern as StackGroupCard. */
|
focused?: boolean;
|
||||||
autoFocus?: boolean;
|
onFocusRequest?: () => void;
|
||||||
|
onResolved?: () => void;
|
||||||
}
|
}
|
||||||
let { group, autoFocus = false }: Props = $props();
|
let { group, focused = false, onFocusRequest, onResolved }: Props = $props();
|
||||||
|
|
||||||
const qc = useQueryClient();
|
|
||||||
let keep = $state('');
|
let keep = $state('');
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let sectionEl: HTMLElement | undefined = $state();
|
let sectionEl: HTMLElement | undefined = $state();
|
||||||
let gridEl: HTMLElement | undefined = $state();
|
|
||||||
let cols = $state(1);
|
|
||||||
|
|
||||||
// Seed `keep` from the indexed path when available; that's the safest
|
// Seed `keep` from the indexed path when available; that's the safest
|
||||||
// default because losing it would leave PhotoPrism with no copy. Fall
|
// default because losing it would leave PhotoPrism with no copy. Fall
|
||||||
@@ -48,38 +38,15 @@
|
|||||||
keep =
|
keep =
|
||||||
group.indexedPath && validPaths.has(group.indexedPath)
|
group.indexedPath && validPaths.has(group.indexedPath)
|
||||||
? group.indexedPath
|
? group.indexedPath
|
||||||
: group.files[0]?.path ?? '';
|
: (group.files[0]?.path ?? '');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
|
if (focused && sectionEl) {
|
||||||
});
|
sectionEl.focus({ preventScroll: true });
|
||||||
|
sectionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
// Column-count tracking — identical pattern to StackGroupCard.
|
}
|
||||||
$effect(() => {
|
|
||||||
if (!gridEl) return;
|
|
||||||
const measure = () => {
|
|
||||||
if (!gridEl) return;
|
|
||||||
const n = getComputedStyle(gridEl)
|
|
||||||
.gridTemplateColumns.split(' ')
|
|
||||||
.filter(Boolean).length;
|
|
||||||
cols = Math.max(1, n);
|
|
||||||
};
|
|
||||||
measure();
|
|
||||||
const ro = new ResizeObserver(measure);
|
|
||||||
ro.observe(gridEl);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
});
|
|
||||||
$effect(() => {
|
|
||||||
void view.thumbnailSize;
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (!gridEl) return;
|
|
||||||
const n = getComputedStyle(gridEl)
|
|
||||||
.gridTemplateColumns.split(' ')
|
|
||||||
.filter(Boolean).length;
|
|
||||||
cols = Math.max(1, n);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function sizeLabel(bytes: number): string {
|
function sizeLabel(bytes: number): string {
|
||||||
@@ -93,6 +60,38 @@
|
|||||||
return segs.slice(0, -1).join('/');
|
return segs.slice(0, -1).join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Relative age label from mtime, e.g. "3mo older" — helps break ties
|
||||||
|
* when neither copy is the indexed one. */
|
||||||
|
function relAge(iso: string | undefined, newestMs: number): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const ms = Date.parse(iso);
|
||||||
|
if (Number.isNaN(ms)) return '';
|
||||||
|
const diffDays = Math.round((newestMs - ms) / 86_400_000);
|
||||||
|
if (diffDays <= 0) return 'newest';
|
||||||
|
if (diffDays < 30) return `${diffDays}d older`;
|
||||||
|
if (diffDays < 365) return `${Math.round(diffDays / 30)}mo older`;
|
||||||
|
return `${Math.round(diffDays / 365)}y older`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newestMs = $derived(
|
||||||
|
Math.max(...group.files.map((f) => (f.modTime ? Date.parse(f.modTime) : 0)))
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Highlight the differing folder segment(s) so the eye jumps straight
|
||||||
|
* to what's actually different between two long, mostly-shared paths. */
|
||||||
|
function highlightDiff(path: string): { prefix: string; diff: string; suffix: string } {
|
||||||
|
const common = group.files
|
||||||
|
.map((f) => f.path)
|
||||||
|
.reduce((acc, p) => {
|
||||||
|
let i = 0;
|
||||||
|
while (i < acc.length && i < p.length && acc[i] === p[i]) i++;
|
||||||
|
return acc.slice(0, i);
|
||||||
|
});
|
||||||
|
// Back up to the last '/' so we don't split mid-segment.
|
||||||
|
const cut = common.lastIndexOf('/') + 1;
|
||||||
|
return { prefix: path.slice(0, cut), diff: path.slice(cut), suffix: '' };
|
||||||
|
}
|
||||||
|
|
||||||
function moveKeep(delta: number) {
|
function moveKeep(delta: number) {
|
||||||
const i = group.files.findIndex((f) => f.path === keep);
|
const i = group.files.findIndex((f) => f.path === keep);
|
||||||
if (i < 0) return;
|
if (i < 0) return;
|
||||||
@@ -102,71 +101,40 @@
|
|||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
moveKeep(-1);
|
moveKeep(-1);
|
||||||
return;
|
return;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
moveKeep(1);
|
moveKeep(1);
|
||||||
return;
|
return;
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault();
|
|
||||||
moveKeep(-cols);
|
|
||||||
return;
|
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault();
|
|
||||||
moveKeep(cols);
|
|
||||||
return;
|
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
void commit();
|
void commit();
|
||||||
return;
|
return;
|
||||||
case 'Escape':
|
default: {
|
||||||
(e.target as HTMLElement)?.blur();
|
const n = Number.parseInt(e.key, 10);
|
||||||
return;
|
if (n >= 1 && n <= group.files.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
keep = group.files[n - 1].path;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commit() {
|
async function commit() {
|
||||||
if (busy || group.files.length < 2) return;
|
if (busy || group.files.length < 2) return;
|
||||||
// Defensive guard: never archive the indexed copy. The user can
|
|
||||||
// pick a different "keeper" but the archive list is computed AFTER
|
|
||||||
// resolving that into "everything except the keeper". If they pick
|
|
||||||
// a non-indexed copy as keeper, the indexed one gets archived —
|
|
||||||
// PhotoPrism will lose its photo entry on the cleanup reindex.
|
|
||||||
// That's a legitimate user choice (they wanted to move the
|
|
||||||
// canonical copy), just call it out in the toast.
|
|
||||||
const losers = group.files.filter((f) => f.path !== keep);
|
|
||||||
if (losers.length === 0) return;
|
|
||||||
const losingIndexed =
|
|
||||||
group.indexedPath && losers.some((f) => f.path === group.indexedPath);
|
|
||||||
|
|
||||||
busy = true;
|
busy = true;
|
||||||
try {
|
try {
|
||||||
const result = await archiveDuplicatePaths(losers.map((f) => f.path));
|
const ok = await resolveCrossFolder(group, keep);
|
||||||
if (result.errors.length > 0) {
|
if (ok) onResolved?.();
|
||||||
toast.error(
|
|
||||||
`Archived ${result.moved.length}; ${result.errors.length} failed`,
|
|
||||||
{
|
|
||||||
description: result.errors[0].error
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
toast.success(
|
|
||||||
`Archived ${result.moved.length} duplicate${result.moved.length === 1 ? '' : 's'}`,
|
|
||||||
{
|
|
||||||
description: losingIndexed
|
|
||||||
? 'The previously-indexed copy was moved; the indexer will drop it on the next index pass.'
|
|
||||||
: 'Files moved to .duplicates/ inside originals.'
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : 'Archive failed');
|
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
@@ -179,87 +147,101 @@
|
|||||||
bind:this={sectionEl}
|
bind:this={sectionEl}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="application"
|
role="application"
|
||||||
aria-label={`Duplicate group · ${group.files.length} copies`}
|
aria-label={`Duplicate group of ${group.files.length} copies — ←/→ pick which path to keep, Enter archives the rest`}
|
||||||
onkeydown={onKeydown}
|
onkeydown={onKeydown}
|
||||||
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
|
onfocusin={() => onFocusRequest?.()}
|
||||||
focus-visible:ring-2 focus-visible:ring-primary/50"
|
class="flex gap-3 rounded-md border bg-card/30 p-3 outline-none transition-colors
|
||||||
|
{focused ? 'border-primary/60 ring-1 ring-primary/40' : 'border-border'}"
|
||||||
>
|
>
|
||||||
<header class="flex items-center justify-between gap-3">
|
<!-- Single thumbnail — every copy is byte-identical, so N tiles of the
|
||||||
<div class="min-w-0">
|
same image told the user nothing. -->
|
||||||
|
<div class="w-28 shrink-0">
|
||||||
|
<div class="aspect-square w-full overflow-hidden rounded-md border border-border bg-secondary">
|
||||||
|
<img
|
||||||
|
src={thumbUrl(group.hash, 'tile_500')}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 truncate text-center text-[10px] font-mono text-muted-foreground/70">
|
||||||
|
sha1 {group.hash.slice(0, 10)}…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1 space-y-2">
|
||||||
|
<header class="flex items-center justify-between gap-3">
|
||||||
<div class="text-sm font-medium text-foreground">
|
<div class="text-sm font-medium text-foreground">
|
||||||
{group.files.length} copies · {sizeLabel(group.size)} each
|
{group.files.length} copies · {sizeLabel(group.size)} each
|
||||||
</div>
|
</div>
|
||||||
<div class="truncate text-[10px] font-mono text-muted-foreground">
|
|
||||||
sha1 {group.hash.slice(0, 16)}…
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
|
||||||
disabled={busy || group.files.length < 2}
|
|
||||||
onclick={commit}
|
|
||||||
title="Move the unselected copies to .duplicates/ (reversible)"
|
|
||||||
>
|
|
||||||
Keep selected, archive rest
|
|
||||||
<kbd
|
|
||||||
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
|
||||||
>Enter</kbd
|
|
||||||
>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div
|
|
||||||
bind:this={gridEl}
|
|
||||||
class="grid gap-2"
|
|
||||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
|
||||||
>
|
|
||||||
{#each group.files as file (file.path)}
|
|
||||||
{@const isKeep = file.path === keep}
|
|
||||||
{@const isIndexed = file.path === group.indexedPath}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (keep = file.path)}
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
class:scale-95={isKeep}
|
disabled={busy || group.files.length < 2}
|
||||||
class:ring-2={isKeep}
|
onclick={commit}
|
||||||
class:ring-blue-500={isKeep}
|
title="Move the unselected copies to .duplicates/ (recoverable)"
|
||||||
class:ring-offset-2={isKeep}
|
|
||||||
class:ring-offset-background={isKeep}
|
|
||||||
class:transition-[transform,box-shadow]={isKeep}
|
|
||||||
class:duration-300={isKeep}
|
|
||||||
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isKeep}
|
|
||||||
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
|
||||||
>
|
>
|
||||||
<div class="relative aspect-square w-full overflow-hidden">
|
Keep selected path
|
||||||
<img
|
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
src={thumbUrl(group.hash, 'tile_500')}
|
>Enter</kbd
|
||||||
alt={file.path}
|
|
||||||
loading="lazy"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
{#if isKeep}
|
|
||||||
<span
|
|
||||||
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
|
||||||
>
|
|
||||||
Keep
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if isIndexed}
|
|
||||||
<span
|
|
||||||
class="absolute right-1.5 top-1.5 rounded bg-emerald-600 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
|
||||||
title="Currently in the library"
|
|
||||||
>
|
|
||||||
Indexed
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
|
|
||||||
title={file.path}
|
|
||||||
>
|
>
|
||||||
<div class="truncate text-foreground/90">{shortFolder(file.path)}</div>
|
|
||||||
<div class="truncate font-mono">{file.path.split('/').pop()}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
</header>
|
||||||
|
|
||||||
|
<!-- Radio-style path list — the actual decision surface. -->
|
||||||
|
<div class="space-y-1">
|
||||||
|
{#each group.files as file, i (file.path)}
|
||||||
|
{@const isKeep = file.path === keep}
|
||||||
|
{@const isIndexed = file.path === group.indexedPath}
|
||||||
|
{@const parts = highlightDiff(file.path)}
|
||||||
|
{@const age = relAge(file.modTime, newestMs)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (keep = file.path)}
|
||||||
|
class="flex w-full items-center gap-2.5 rounded-md border px-2.5 py-2 text-left transition-colors
|
||||||
|
{isKeep
|
||||||
|
? 'border-blue-500 bg-blue-500/10'
|
||||||
|
: 'border-transparent bg-secondary/50 hover:bg-secondary'}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border text-[10px] font-semibold
|
||||||
|
{isKeep
|
||||||
|
? 'border-blue-500 bg-blue-500 text-white'
|
||||||
|
: 'border-muted-foreground/40 text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{isKeep ? '' : i + 1}
|
||||||
|
{#if isKeep}<Check class="h-2.5 w-2.5" />{/if}
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-mono text-xs">
|
||||||
|
<span class="text-muted-foreground">{parts.prefix}</span><span
|
||||||
|
class="font-semibold text-foreground"
|
||||||
|
>{parts.diff}</span
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<span class="flex shrink-0 items-center gap-1.5 text-[10px]">
|
||||||
|
{#if isIndexed}
|
||||||
|
<span
|
||||||
|
class="rounded bg-emerald-600 px-1.5 py-0.5 font-semibold text-white"
|
||||||
|
title="Currently in the library — losing this moves the indexed copy"
|
||||||
|
>
|
||||||
|
Indexed
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if age}
|
||||||
|
<span class="flex items-center gap-0.5 text-muted-foreground" title={file.modTime}>
|
||||||
|
<Clock class="h-2.5 w-2.5" />{age}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if group.files.some((f) => f.path === group.indexedPath && f.path !== keep)}
|
||||||
|
<p class="text-[10px] text-amber-500">
|
||||||
|
Keeping a non-indexed copy — the indexed one will be archived; the indexer picks up the
|
||||||
|
survivor on its next pass.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
<!--
|
<!--
|
||||||
Duplicate-resolution page body. Two panels driven by the parent
|
Duplicate-resolution queue. Two panels driven by the parent route's
|
||||||
route's `activeTab` prop (URL-bound):
|
`activeTab` prop (URL-bound):
|
||||||
|
|
||||||
1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live
|
1. Stacks — PhotoPrism's own auto-grouped variants (RAW+JPG, Live
|
||||||
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; we list via
|
HEIC+MOV, etc.). Source of truth is PhotoPrism's DB; listed via
|
||||||
`stack:true` and resolve via `setPrimary` + `deleteFile`.
|
`stack:true`, resolved via resolveStack() (setPrimary + quarantine).
|
||||||
|
|
||||||
2. Cross-folder — files PhotoPrism silently rejected at index time
|
2. Cross-folder — files PhotoPrism silently rejected at index time
|
||||||
because they were byte-identical to an existing entry. PhotoPrism
|
because they were byte-identical to an existing entry. Scanned via
|
||||||
never adds those rows to its DB, so we scan the filesystem via the
|
the sidecar's filesystem walk, resolved via resolveCrossFolder()
|
||||||
mule-sidecar. Resolution moves the unwanted copies into a
|
(quarantine).
|
||||||
`.duplicates/` quarantine folder PhotoPrism's indexer ignores.
|
|
||||||
|
Both panels share one interaction model — a resolve-and-advance
|
||||||
|
queue: ↑/↓ or j/k rove between groups (scrollIntoView), resolving a
|
||||||
|
group removes it optimistically and auto-advances focus to whatever
|
||||||
|
now occupies that slot, so the whole queue clears without touching
|
||||||
|
the mouse. A sticky header tracks reclaimable bytes and a running
|
||||||
|
"resolved this session" tally.
|
||||||
|
|
||||||
The cross-folder scan auto-fires when its tab is active — with size
|
The cross-folder scan auto-fires when its tab is active — with size
|
||||||
pre-filtering it stays fast (~250ms for 400 files in practice) and a
|
pre-filtering it stays fast (~250ms for 400 files in practice) and a
|
||||||
long staleTime keeps tab bounces from re-running it. The button is
|
long staleTime keeps tab bounces from re-running it.
|
||||||
kept for manual "rescan after I moved files" refreshes.
|
|
||||||
|
|
||||||
Tabs themselves render in the parent route's Toolbar so they line up
|
|
||||||
visually with the `/tags` pill row.
|
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
@@ -28,11 +30,20 @@
|
|||||||
type CrossFolderScanResult
|
type CrossFolderScanResult
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
||||||
|
import {
|
||||||
|
dupSession,
|
||||||
|
formatBytes,
|
||||||
|
resolvedCrossHashes,
|
||||||
|
resolvedStackUids
|
||||||
|
} from '$lib/services/duplicateActions.svelte';
|
||||||
import { userLibraryBase } from '$lib/stores/session.svelte';
|
import { userLibraryBase } from '$lib/stores/session.svelte';
|
||||||
|
import { nearBottom } from '$lib/actions/nearBottom';
|
||||||
|
import { toggleShortcuts, view } from '$lib/stores/view.svelte';
|
||||||
|
import { popAndRun } from '$lib/stores/undo.svelte';
|
||||||
import StackGroupCard from './StackGroupCard.svelte';
|
import StackGroupCard from './StackGroupCard.svelte';
|
||||||
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
|
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
|
||||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
import { AlertCircle, CheckCircle2, Copy } from 'lucide-svelte';
|
import { AlertCircle, CheckCircle2, Copy, HardDrive } from 'lucide-svelte';
|
||||||
|
|
||||||
type Tab = 'stacks' | 'cross-folder';
|
type Tab = 'stacks' | 'cross-folder';
|
||||||
|
|
||||||
@@ -64,97 +75,229 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (crossQuery.error) {
|
if (crossQuery.error) {
|
||||||
toast.error(
|
toast.error(
|
||||||
crossQuery.error instanceof Error
|
crossQuery.error instanceof Error ? crossQuery.error.message : 'Duplicates scan failed'
|
||||||
? crossQuery.error.message
|
|
||||||
: 'Duplicates scan failed'
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const crossCount = $derived(crossQuery.data?.groups.length ?? 0);
|
// Filter out groups resolved this session but not yet reflected by a
|
||||||
|
// server refetch (PhotoPrism's cleanup reindex is async) — otherwise
|
||||||
|
// a background refetch could resurrect a group the user just cleared.
|
||||||
|
const liveStackGroups = $derived(groups.filter((g) => !resolvedStackUids.has(g.photo.UID)));
|
||||||
|
const liveCrossGroups = $derived(
|
||||||
|
(crossQuery.data?.groups ?? []).filter((g) => !resolvedCrossHashes.has(g.hash))
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeGroups = $derived(activeTab === 'stacks' ? liveStackGroups : liveCrossGroups);
|
||||||
|
|
||||||
|
// Reclaimable bytes across everything still in the queue.
|
||||||
|
const reclaimableBytes = $derived(
|
||||||
|
activeTab === 'stacks'
|
||||||
|
? liveStackGroups.reduce((sum, g) => {
|
||||||
|
const keeperSize = Math.max(...g.files.map((f) => f.Size ?? 0));
|
||||||
|
const total = g.files.reduce((s, f) => s + (f.Size ?? 0), 0);
|
||||||
|
return sum + (total - keeperSize);
|
||||||
|
}, 0)
|
||||||
|
: liveCrossGroups.reduce((sum, g) => sum + g.size * (g.files.length - 1), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Roving focus + progressive rendering ───────────────────────────
|
||||||
|
let focusedIndex = $state(0);
|
||||||
|
let renderCount = $state(30);
|
||||||
|
|
||||||
|
// Reset when the tab or the underlying list identity changes size
|
||||||
|
// class (e.g. switching tabs, or a fresh scan lands).
|
||||||
|
$effect(() => {
|
||||||
|
void activeTab;
|
||||||
|
focusedIndex = 0;
|
||||||
|
renderCount = 30;
|
||||||
|
});
|
||||||
|
|
||||||
|
function clampFocus() {
|
||||||
|
if (activeGroups.length === 0) return;
|
||||||
|
focusedIndex = Math.min(focusedIndex, activeGroups.length - 1);
|
||||||
|
}
|
||||||
|
$effect(clampFocus);
|
||||||
|
|
||||||
|
function extend() {
|
||||||
|
renderCount = Math.min(activeGroups.length, renderCount + 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFocus(delta: number) {
|
||||||
|
if (activeGroups.length === 0) return;
|
||||||
|
focusedIndex = Math.min(Math.max(0, focusedIndex + delta), activeGroups.length - 1);
|
||||||
|
if (focusedIndex >= renderCount) renderCount = Math.min(activeGroups.length, focusedIndex + 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onQueueKeydown(e: KeyboardEvent) {
|
||||||
|
if (view.shortcutsOpen) {
|
||||||
|
if (e.key === 'Escape' || e.key === '?') {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleShortcuts();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// gridKeyNav (which normally owns ⌘Z) isn't mounted on these tabs —
|
||||||
|
// wire undo here so resolving a group is reversible without
|
||||||
|
// switching to a cause tab first.
|
||||||
|
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const entry = await popAndRun();
|
||||||
|
toast[entry ? 'success' : 'message'](entry ? `Undone: ${entry.label}` : 'Nothing to undo');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
// Group cards call stopPropagation on the keys they own (arrows
|
||||||
|
// L/R, digits, Enter, Space) — only j/k/ArrowUp/ArrowDown/? reach
|
||||||
|
// here, which is exactly the group-navigation contract.
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowUp':
|
||||||
|
case 'k':
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(-1);
|
||||||
|
return;
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'j':
|
||||||
|
e.preventDefault();
|
||||||
|
moveFocus(1);
|
||||||
|
return;
|
||||||
|
case '?':
|
||||||
|
e.preventDefault();
|
||||||
|
toggleShortcuts();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group resolved — hold focus at the same index (the next group
|
||||||
|
* slides up into it) unless we were at the end. */
|
||||||
|
function onGroupResolved() {
|
||||||
|
if (focusedIndex >= activeGroups.length - 1) {
|
||||||
|
focusedIndex = Math.max(0, activeGroups.length - 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const crossCount = $derived(liveCrossGroups.length);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Stacks tab ----------------------------------------------------- -->
|
<!-- Sticky progress header — shared by both tabs -->
|
||||||
{#if activeTab === 'stacks'}
|
<div
|
||||||
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
|
class="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-background/95 px-6 py-2.5 backdrop-blur"
|
||||||
{#if pending}
|
>
|
||||||
<InlineLoader label="Loading stacks…" />
|
<div class="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
{:else if error}
|
<span class="font-medium text-foreground">
|
||||||
<EmptyState
|
{activeGroups.length}
|
||||||
tone="destructive"
|
{activeTab === 'stacks' ? 'stack' : 'group'}{activeGroups.length === 1 ? '' : 's'}
|
||||||
icon={AlertCircle}
|
</span>
|
||||||
title="Could not load stacks"
|
{#if reclaimableBytes > 0}
|
||||||
description={error instanceof Error ? error.message : 'unknown error'}
|
<span class="flex items-center gap-1">
|
||||||
/>
|
<HardDrive class="h-3 w-3" />
|
||||||
{:else if groups.length === 0}
|
{formatBytes(reclaimableBytes)} reclaimable
|
||||||
<EmptyState icon={Copy} title="No stacks">
|
</span>
|
||||||
{#snippet descriptionSnippet()}
|
{/if}
|
||||||
<p>
|
{#if dupSession.resolved > 0}
|
||||||
The library stacks byte-identical (or EXIF-identical) files. If you don't have
|
<span class="text-emerald-500">
|
||||||
any, this tab stays empty. Cross-folder copies dropped at index time live under
|
Resolved {dupSession.resolved} · {formatBytes(dupSession.freedBytes)} freed this session
|
||||||
the Duplicates tab.
|
</span>
|
||||||
</p>
|
|
||||||
{/snippet}
|
|
||||||
</EmptyState>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-3">
|
|
||||||
{#each groups as group, i (group.photo.UID)}
|
|
||||||
<StackGroupCard {group} autoFocus={i === 0} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{#if activeTab === 'cross-folder'}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
|
disabled={crossQuery.isFetching}
|
||||||
|
onclick={rescan}
|
||||||
|
>
|
||||||
|
{crossQuery.isFetching ? 'Scanning…' : 'Rescan filesystem'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
{#if activeTab === 'cross-folder'}
|
<div onkeydown={onQueueKeydown}>
|
||||||
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
|
<!-- Stacks tab ----------------------------------------------------- -->
|
||||||
<header class="flex items-baseline justify-between gap-3">
|
{#if activeTab === 'stacks'}
|
||||||
<p class="text-[11px] text-muted-foreground">
|
<div role="tabpanel" aria-label="Stack duplicates" class="px-6 py-4 pb-6">
|
||||||
Byte-identical files the indexer dropped at index time. Found by scanning the
|
{#if pending}
|
||||||
originals tree directly.
|
<InlineLoader label="Loading stacks…" />
|
||||||
</p>
|
{:else if error}
|
||||||
<button
|
<EmptyState
|
||||||
type="button"
|
tone="destructive"
|
||||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
icon={AlertCircle}
|
||||||
disabled={crossQuery.isFetching}
|
title="Could not load stacks"
|
||||||
onclick={rescan}
|
description={error instanceof Error ? error.message : 'unknown error'}
|
||||||
>
|
/>
|
||||||
{#if crossQuery.isFetching}
|
{:else if liveStackGroups.length === 0}
|
||||||
Scanning…
|
<EmptyState icon={Copy} title="No stacks">
|
||||||
{:else}
|
{#snippet descriptionSnippet()}
|
||||||
Rescan filesystem
|
<p>
|
||||||
{/if}
|
The library stacks byte-identical (or EXIF-identical) files. If you don't have
|
||||||
</button>
|
any, this tab stays empty. Cross-folder copies dropped at index time live under
|
||||||
</header>
|
the Duplicates tab.
|
||||||
|
|
||||||
{#if crossQuery.isFetching && !crossQuery.data}
|
|
||||||
<InlineLoader label="Hashing files under originals…" />
|
|
||||||
{:else if crossQuery.isError}
|
|
||||||
<EmptyState
|
|
||||||
tone="destructive"
|
|
||||||
icon={AlertCircle}
|
|
||||||
title="Scan failed"
|
|
||||||
description={crossQuery.error instanceof Error
|
|
||||||
? crossQuery.error.message
|
|
||||||
: 'unknown error'}
|
|
||||||
/>
|
|
||||||
{:else if crossCount === 0}
|
|
||||||
<EmptyState icon={CheckCircle2} title="No duplicates found">
|
|
||||||
{#snippet descriptionSnippet()}
|
|
||||||
{#if crossQuery.data}
|
|
||||||
<p class="text-[10px] text-muted-foreground/70">
|
|
||||||
scanned in {crossQuery.data.scannedMs} ms
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/snippet}
|
||||||
{/snippet}
|
</EmptyState>
|
||||||
</EmptyState>
|
{:else}
|
||||||
{:else}
|
<div class="space-y-3">
|
||||||
<div class="space-y-3">
|
{#each liveStackGroups.slice(0, renderCount) as group, i (group.photo.UID)}
|
||||||
{#each crossQuery.data?.groups ?? [] as group, i (group.hash)}
|
<StackGroupCard
|
||||||
<CrossFolderGroupCard {group} autoFocus={i === 0} />
|
{group}
|
||||||
{/each}
|
focused={i === focusedIndex}
|
||||||
</div>
|
onFocusRequest={() => (focusedIndex = i)}
|
||||||
{/if}
|
onResolved={onGroupResolved}
|
||||||
</div>
|
/>
|
||||||
{/if}
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if renderCount < liveStackGroups.length}
|
||||||
|
<div use:nearBottom={{ onHit: extend, enabled: true }} class="h-8"></div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Duplicates tab (cross-folder scan) ----------------------------- -->
|
||||||
|
{#if activeTab === 'cross-folder'}
|
||||||
|
<div role="tabpanel" aria-label="Duplicates" class="space-y-3 px-6 py-4 pb-6">
|
||||||
|
<p class="text-[11px] text-muted-foreground">
|
||||||
|
Byte-identical files the indexer dropped at index time. Found by scanning the originals
|
||||||
|
tree directly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if crossQuery.isFetching && !crossQuery.data}
|
||||||
|
<InlineLoader label="Hashing files under originals…" />
|
||||||
|
{:else if crossQuery.isError}
|
||||||
|
<EmptyState
|
||||||
|
tone="destructive"
|
||||||
|
icon={AlertCircle}
|
||||||
|
title="Scan failed"
|
||||||
|
description={crossQuery.error instanceof Error
|
||||||
|
? crossQuery.error.message
|
||||||
|
: 'unknown error'}
|
||||||
|
/>
|
||||||
|
{:else if crossCount === 0}
|
||||||
|
<EmptyState icon={CheckCircle2} title="No duplicates found">
|
||||||
|
{#snippet descriptionSnippet()}
|
||||||
|
{#if crossQuery.data}
|
||||||
|
<p class="text-[10px] text-muted-foreground/70">
|
||||||
|
scanned in {crossQuery.data.scannedMs} ms
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</EmptyState>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each liveCrossGroups.slice(0, renderCount) as group, i (group.hash)}
|
||||||
|
<CrossFolderGroupCard
|
||||||
|
{group}
|
||||||
|
focused={i === focusedIndex}
|
||||||
|
onFocusRequest={() => (focusedIndex = i)}
|
||||||
|
onResolved={onGroupResolved}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if renderCount < liveCrossGroups.length}
|
||||||
|
<div use:nearBottom={{ onHit: extend, enabled: true }} class="h-8"></div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,101 +1,90 @@
|
|||||||
<!--
|
<!--
|
||||||
One duplicate stack rendered as a card. Each variant file is a clickable
|
One duplicate stack rendered as a card. Each variant file is a tile;
|
||||||
tile; clicking selects it as the candidate "best". Committing promotes
|
the selected one is the "keeper". Committing promotes the keeper to
|
||||||
the selected file to Primary (via `setPrimary`) and deletes the rest from
|
Primary and moves every other file into the sidecar's `.duplicates/`
|
||||||
the stack (via `deleteFile` — PhotoPrism's flat `DELETE /photos/:uid/
|
quarantine (recoverable, undoable via ⌘Z) — resolution logic lives in
|
||||||
files/:fid` route).
|
services/duplicateActions.svelte.ts.
|
||||||
|
|
||||||
Why DELETE instead of unstack-then-archive (which the plan started with):
|
Keyboard (card scope — ↑/↓/j/k are NOT consumed here; they bubble to
|
||||||
PhotoPrism's `/unstack` returns `only originals can be unstacked` for
|
DuplicatesView's group navigation):
|
||||||
sidecar JPGs and `Changes could not be saved` for live-photo HEIC+MOV
|
- ←/→ move the keeper highlight; 1–9 jump straight to a file.
|
||||||
pairs. DELETE works for all of them — and cascades through the live-
|
- Space opens the fullscreen compare lightbox (zoom-preserving flips).
|
||||||
photo group automatically, so one click resolves the whole stack. The
|
- Enter resolves: keep selected, quarantine the rest.
|
||||||
on-disk file is renamed with a hash suffix (not erased), so a future
|
|
||||||
manual reindex can recover it if needed.
|
|
||||||
|
|
||||||
Keyboard:
|
The fact rows under each thumb highlight the best value per column
|
||||||
- Section is tabindex=0; focusing it captures arrow keys + Enter.
|
(largest size, highest resolution) so the winning file is obvious at
|
||||||
- Left/Right move the "best" highlight one file; Up/Down move by the
|
a glance; a file that wins everything gets a "Suggested" badge.
|
||||||
grid's computed column count (same trick the timeline uses for
|
|
||||||
cross-row arrow nav).
|
|
||||||
- Enter commits the current selection. Esc removes focus from the card.
|
|
||||||
- The page's first card auto-focuses on mount so the user can drive
|
|
||||||
the workflow keyboard-first.
|
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { resolveStack } from '$lib/services/duplicateActions.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
import { deleteFile, setPrimary } from '$lib/services/photoprism';
|
|
||||||
import { thumbUrl } from '$lib/stores/session.svelte';
|
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||||
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
||||||
import { view } from '$lib/stores/view.svelte';
|
import type { PpFile } from '$lib/types/photoprism';
|
||||||
|
import CompareLightbox from './CompareLightbox.svelte';
|
||||||
|
import { Maximize2 } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
group: DuplicateGroup;
|
group: DuplicateGroup;
|
||||||
/** When true, the section auto-focuses on mount so the user can
|
/** Roving focus — DuplicatesView owns which card is active. */
|
||||||
* arrow-key/Enter the workflow without reaching for the mouse.
|
focused?: boolean;
|
||||||
* Only the page's first card should get this. */
|
/** Card was clicked/focused by pointer: tell the view to move its
|
||||||
autoFocus?: boolean;
|
* roving index here. */
|
||||||
|
onFocusRequest?: () => void;
|
||||||
|
/** Resolve succeeded — view advances focus to the next group. */
|
||||||
|
onResolved?: () => void;
|
||||||
}
|
}
|
||||||
let { group, autoFocus = false }: Props = $props();
|
let { group, focused = false, onFocusRequest, onResolved }: Props = $props();
|
||||||
|
|
||||||
const qc = useQueryClient();
|
|
||||||
let best = $state('');
|
let best = $state('');
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
|
let compareOpen = $state(false);
|
||||||
let sectionEl: HTMLElement | undefined = $state();
|
let sectionEl: HTMLElement | undefined = $state();
|
||||||
let gridEl: HTMLElement | undefined = $state();
|
|
||||||
let cols = $state(1);
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Seed / re-seed `best` from the prop when the underlying group
|
// Seed / re-seed `best` from the prop when the underlying group
|
||||||
// changes (keyed each + UID key normally keeps this stable, but
|
// changes; the guard keeps user clicks intact across prop swaps.
|
||||||
// the guard handles prop swaps without overwriting user clicks).
|
|
||||||
if (!best || !group.files.some((f) => f.UID === best)) {
|
if (!best || !group.files.some((f) => f.UID === best)) {
|
||||||
best = group.bestFileUid;
|
best = group.bestFileUid;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (autoFocus && sectionEl) sectionEl.focus({ preventScroll: true });
|
if (focused && sectionEl && !compareOpen) {
|
||||||
|
sectionEl.focus({ preventScroll: true });
|
||||||
|
sectionEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track the grid's column count via ResizeObserver — same approach
|
// ── Comparison facts ────────────────────────────────────────────────
|
||||||
// the timeline uses. Reading `gridTemplateColumns` from computed
|
const maxSize = $derived(Math.max(...group.files.map((f) => f.Size ?? 0)));
|
||||||
// style is O(1) regardless of how many tiles render.
|
const maxPixels = $derived(Math.max(...group.files.map((f) => pixels(f))));
|
||||||
$effect(() => {
|
const sizesDiffer = $derived(new Set(group.files.map((f) => f.Size ?? 0)).size > 1);
|
||||||
if (!gridEl) return;
|
const pixelsDiffer = $derived(new Set(group.files.map((f) => pixels(f))).size > 1);
|
||||||
const measure = () => {
|
/** UID of the file that wins on every differing axis, if unique. */
|
||||||
if (!gridEl) return;
|
const suggestedUid = $derived.by(() => {
|
||||||
const n = getComputedStyle(gridEl)
|
const winners = group.files.filter(
|
||||||
.gridTemplateColumns.split(' ')
|
(f) =>
|
||||||
.filter(Boolean).length;
|
(!sizesDiffer || (f.Size ?? 0) === maxSize) &&
|
||||||
cols = Math.max(1, n);
|
(!pixelsDiffer || pixels(f) === maxPixels)
|
||||||
};
|
);
|
||||||
measure();
|
return winners.length === 1 && (sizesDiffer || pixelsDiffer) ? winners[0].UID : null;
|
||||||
const ro = new ResizeObserver(measure);
|
|
||||||
ro.observe(gridEl);
|
|
||||||
return () => ro.disconnect();
|
|
||||||
});
|
|
||||||
// thumbnailSize changes alter cols without resizing the grid; re-
|
|
||||||
// measure on the next microtask.
|
|
||||||
$effect(() => {
|
|
||||||
void view.thumbnailSize;
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (!gridEl) return;
|
|
||||||
const n = getComputedStyle(gridEl)
|
|
||||||
.gridTemplateColumns.split(' ')
|
|
||||||
.filter(Boolean).length;
|
|
||||||
cols = Math.max(1, n);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function pixels(f: PpFile): number {
|
||||||
|
return (f.Width ?? 0) * (f.Height ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeBadge(f: PpFile): string {
|
||||||
|
return (f.FileType ?? f.Name?.split('.').pop() ?? '').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
function shortPath(name: string): string {
|
function shortPath(name: string): string {
|
||||||
const segs = name.split('/').filter(Boolean);
|
const segs = name.split('/').filter(Boolean);
|
||||||
if (segs.length <= 2) return name;
|
if (segs.length <= 2) return name;
|
||||||
return '…/' + segs.slice(-2).join('/');
|
return '…/' + segs.slice(-2).join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
function dims(f: { Width?: number; Height?: number }): string {
|
function dims(f: PpFile): string {
|
||||||
if (!f.Width || !f.Height) return '';
|
if (!f.Width || !f.Height) return '';
|
||||||
return `${f.Width}×${f.Height}`;
|
return `${f.Width}×${f.Height}`;
|
||||||
}
|
}
|
||||||
@@ -114,87 +103,57 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (busy) return;
|
if (busy || compareOpen) return;
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
moveBest(-1);
|
moveBest(-1);
|
||||||
return;
|
return;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
moveBest(1);
|
moveBest(1);
|
||||||
return;
|
return;
|
||||||
case 'ArrowUp':
|
case ' ':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
moveBest(-cols);
|
e.stopPropagation();
|
||||||
return;
|
compareOpen = true;
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault();
|
|
||||||
moveBest(cols);
|
|
||||||
return;
|
return;
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
void commit();
|
void commit();
|
||||||
return;
|
return;
|
||||||
case 'Escape':
|
default: {
|
||||||
(e.target as HTMLElement)?.blur();
|
const n = Number.parseInt(e.key, 10);
|
||||||
return;
|
if (n >= 1 && n <= group.files.length) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
best = group.files[n - 1].UID;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commit() {
|
async function commit() {
|
||||||
if (busy || group.files.length < 2) return;
|
if (busy || group.files.length < 2) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
const photoUid = group.photo.UID;
|
|
||||||
const losers = group.files.filter((f) => f.UID !== best);
|
|
||||||
try {
|
try {
|
||||||
// 1. Promote the user's pick to Primary first (idempotent — if
|
const ok = await resolveStack(group, best);
|
||||||
// it's already Primary, the call is a no-op on the server).
|
if (ok) onResolved?.();
|
||||||
const currentPrimary = group.files.find((f) => f.Primary)?.UID;
|
|
||||||
if (best !== currentPrimary) {
|
|
||||||
await setPrimary(photoUid, best);
|
|
||||||
}
|
|
||||||
// 2. Delete each non-best file. PhotoPrism cascades through
|
|
||||||
// related variants in the same logical group (live-photo
|
|
||||||
// pairs, sidecar companions), so a single DELETE on one
|
|
||||||
// HEIC variant clears the whole HEIC+MOV pair in one go.
|
|
||||||
// Loop tolerates partial success — if PhotoPrism already
|
|
||||||
// cleared the file via cascade, the next DELETE 404s and
|
|
||||||
// we move on.
|
|
||||||
for (const f of losers) {
|
|
||||||
try {
|
|
||||||
await deleteFile(photoUid, f.UID);
|
|
||||||
} catch (err) {
|
|
||||||
// 404 means the file's already gone (cascade) — fine.
|
|
||||||
// Any other status means we have a real problem; bubble it.
|
|
||||||
const status = (err as { response?: { status?: number } })?.response
|
|
||||||
?.status;
|
|
||||||
if (status !== 404) throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toast.success(`Resolved · kept 1 of ${group.files.length}`);
|
|
||||||
void qc.invalidateQueries({ queryKey: ['duplicates'] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
|
||||||
} catch (err) {
|
|
||||||
const msg =
|
|
||||||
err instanceof Error && err.message ? err.message : 'Resolve failed';
|
|
||||||
toast.error(msg);
|
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Section is focusable so we can capture arrow keys + Enter. `outline-
|
|
||||||
none` because we paint our own focus ring on .focus-visible below
|
|
||||||
(otherwise the browser default outline would clash with the tile
|
|
||||||
selection ring). -->
|
|
||||||
<!--
|
<!--
|
||||||
`role="application"` declares this as a custom keyboard widget (arrow
|
`role="application"` declares this as a custom keyboard widget (arrow
|
||||||
keys + Enter, not standard reading order). The element below is a
|
keys + Enter, not standard reading order). `<div>` rather than
|
||||||
`<div>` rather than `<section>` because Svelte's a11y linter treats
|
`<section>` because Svelte's a11y linter treats `<section>` as
|
||||||
`<section>` as strictly non-interactive even with an explicit
|
strictly non-interactive even with an explicit application role.
|
||||||
application role.
|
|
||||||
-->
|
-->
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
@@ -202,10 +161,11 @@
|
|||||||
bind:this={sectionEl}
|
bind:this={sectionEl}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="application"
|
role="application"
|
||||||
aria-label={`Duplicate stack of ${group.files.length} files — arrow keys pick the file to keep, Enter resolves`}
|
aria-label={`Duplicate stack of ${group.files.length} files — ←/→ pick the keeper, Space compares, Enter resolves`}
|
||||||
onkeydown={onKeydown}
|
onkeydown={onKeydown}
|
||||||
class="space-y-2 rounded-md border border-border bg-card/30 p-3 outline-none
|
onfocusin={() => onFocusRequest?.()}
|
||||||
focus-visible:ring-2 focus-visible:ring-primary/50"
|
class="space-y-2 rounded-md border bg-card/30 p-3 outline-none transition-colors
|
||||||
|
{focused ? 'border-primary/60 ring-1 ring-primary/40' : 'border-border'}"
|
||||||
>
|
>
|
||||||
<header class="flex items-center justify-between gap-3">
|
<header class="flex items-center justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
@@ -216,74 +176,115 @@
|
|||||||
{group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''}
|
{group.photo.OriginalName ?? group.photo.FileName ?? group.photo.Name ?? ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
type="button"
|
||||||
disabled={busy || group.files.length < 2}
|
class="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
onclick={commit}
|
disabled={busy}
|
||||||
title="Promote the selected file and delete the rest from this stack"
|
onclick={() => (compareOpen = true)}
|
||||||
>
|
title="Compare candidates fullscreen (zoom-preserving flips)"
|
||||||
Keep selected, delete rest
|
|
||||||
<kbd
|
|
||||||
class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
|
||||||
>Enter</kbd
|
|
||||||
>
|
>
|
||||||
</button>
|
<Maximize2 class="h-3 w-3" /> Compare
|
||||||
|
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
>Space</kbd
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
|
disabled={busy || group.files.length < 2}
|
||||||
|
onclick={commit}
|
||||||
|
title="Promote the selected file; the rest move to the recoverable .duplicates/ quarantine"
|
||||||
|
>
|
||||||
|
Keep selected
|
||||||
|
<kbd class="rounded bg-muted px-1 py-0.5 text-[9px] font-medium text-muted-foreground"
|
||||||
|
>Enter</kbd
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div
|
<div class="grid gap-2" style="grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));">
|
||||||
bind:this={gridEl}
|
{#each group.files as file, i (file.UID)}
|
||||||
class="grid gap-2"
|
|
||||||
style="grid-template-columns: repeat(auto-fill, minmax({view.thumbnailSize}px, 1fr));"
|
|
||||||
>
|
|
||||||
{#each group.files as file (file.UID)}
|
|
||||||
{@const isBest = file.UID === best}
|
{@const isBest = file.UID === best}
|
||||||
{@const sizeStr = sizeLabel(file.Size)}
|
{@const sizeStr = sizeLabel(file.Size)}
|
||||||
|
{@const bestSize = sizesDiffer && (file.Size ?? 0) === maxSize}
|
||||||
|
{@const bestRes = pixelsDiffer && pixels(file) === maxPixels && pixels(file) > 0}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (best = file.UID)}
|
onclick={() => (best = file.UID)}
|
||||||
class:scale-95={isBest}
|
ondblclick={() => {
|
||||||
|
best = file.UID;
|
||||||
|
compareOpen = true;
|
||||||
|
}}
|
||||||
class:ring-2={isBest}
|
class:ring-2={isBest}
|
||||||
class:ring-blue-500={isBest}
|
class:ring-blue-500={isBest}
|
||||||
class:ring-offset-2={isBest}
|
class:ring-offset-2={isBest}
|
||||||
class:ring-offset-background={isBest}
|
class:ring-offset-background={isBest}
|
||||||
class:transition-[transform,box-shadow]={isBest}
|
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none transition-shadow focus:outline-none"
|
||||||
class:duration-300={isBest}
|
|
||||||
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={isBest}
|
|
||||||
class="group flex flex-col overflow-hidden rounded-md border border-border bg-secondary p-0 text-left outline-none focus:outline-none"
|
|
||||||
>
|
>
|
||||||
<div class="relative aspect-square w-full overflow-hidden">
|
<div class="relative aspect-square w-full overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(file.Hash, 'tile_500')}
|
src={thumbUrl(file.Hash, 'tile_500')}
|
||||||
alt={file.Name}
|
alt={file.Name}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{#if isBest}
|
{#if isBest}
|
||||||
<span
|
<span
|
||||||
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
class="absolute left-1.5 top-1.5 rounded bg-blue-500 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||||
>
|
>
|
||||||
Best
|
Keep
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{:else if file.UID === suggestedUid}
|
||||||
{#if dims(file)}
|
|
||||||
<span
|
<span
|
||||||
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] text-foreground"
|
class="absolute left-1.5 top-1.5 rounded bg-emerald-600/90 px-1.5 py-0.5 text-[10px] font-semibold text-white"
|
||||||
|
title="Largest and highest-resolution file in this stack"
|
||||||
>
|
>
|
||||||
{dims(file)}
|
Suggested
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
<span
|
||||||
|
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1.5 py-0.5 text-[10px] font-semibold text-foreground/90"
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="space-y-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
|
class="flex flex-col gap-0.5 px-2 py-1.5 text-[10px] leading-tight text-muted-foreground"
|
||||||
title={`${file.Name}${sizeStr ? ` · ${sizeStr}` : ''}`}
|
title={`${file.Name}${sizeStr ? ` · ${sizeStr}` : ''}`}
|
||||||
>
|
>
|
||||||
<div class="truncate text-foreground/90">{shortPath(file.Name)}</div>
|
<div class="truncate text-foreground/90">{shortPath(file.Name)}</div>
|
||||||
{#if sizeStr}
|
<div class="flex items-center gap-1.5">
|
||||||
<div>{sizeStr}</div>
|
{#if typeBadge(file)}
|
||||||
{/if}
|
<span class="rounded bg-muted px-1 py-px font-medium">{typeBadge(file)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if dims(file)}
|
||||||
|
<span class={bestRes ? 'font-semibold text-emerald-500' : ''}>{dims(file)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if sizeStr}
|
||||||
|
<span class={bestSize ? 'font-semibold text-emerald-500' : ''}>{sizeStr}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if compareOpen}
|
||||||
|
<CompareLightbox
|
||||||
|
files={group.files}
|
||||||
|
startUid={best}
|
||||||
|
onPick={(uid) => {
|
||||||
|
best = uid;
|
||||||
|
compareOpen = false;
|
||||||
|
sectionEl?.focus({ preventScroll: true });
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
compareOpen = false;
|
||||||
|
sectionEl?.focus({ preventScroll: true });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
200
web/src/lib/components/layout/CommandPalette.svelte
Normal file
200
web/src/lib/components/layout/CommandPalette.svelte
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<!--
|
||||||
|
⌘K command palette. Jump to any section, heap, folder, or tag category,
|
||||||
|
plus a few global actions (dark mode, shortcut overlay). Data comes from
|
||||||
|
the same TanStack queries the sidebar already keeps warm (['heaps'],
|
||||||
|
['folders', …]), so opening the palette costs no extra fetches once the
|
||||||
|
app has booted. bits-ui's Command owns filtering and keyboard selection.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { Command, Dialog } from 'bits-ui';
|
||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import { toggleMode } from 'mode-watcher';
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
EyeOff,
|
||||||
|
Folder,
|
||||||
|
Image,
|
||||||
|
Keyboard,
|
||||||
|
Layers,
|
||||||
|
ListChecks,
|
||||||
|
Moon,
|
||||||
|
NotebookPen,
|
||||||
|
Tags,
|
||||||
|
Users
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
import { listFolders, listHeaps, type PpAlbum, type PpFolder } from '$lib/services/photoprism';
|
||||||
|
import { isAuthenticated, userLibraryBase } from '$lib/stores/session.svelte';
|
||||||
|
import { closePalette, toggleShortcuts, view } from '$lib/stores/view.svelte';
|
||||||
|
|
||||||
|
const heapsQuery = createQuery<PpAlbum[]>(() => ({
|
||||||
|
queryKey: ['heaps'],
|
||||||
|
queryFn: listHeaps,
|
||||||
|
enabled: isAuthenticated() && view.paletteOpen
|
||||||
|
}));
|
||||||
|
const foldersQuery = createQuery<PpFolder[]>(() => ({
|
||||||
|
queryKey: ['folders', userLibraryBase()],
|
||||||
|
queryFn: listFolders,
|
||||||
|
staleTime: 30_000,
|
||||||
|
enabled: isAuthenticated() && view.paletteOpen
|
||||||
|
}));
|
||||||
|
|
||||||
|
function run(fn: () => void) {
|
||||||
|
closePalette();
|
||||||
|
fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
const go = (path: string) => () => run(() => void goto(path));
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
label: string;
|
||||||
|
icon: typeof Image;
|
||||||
|
action: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTIONS: Entry[] = [
|
||||||
|
{ label: 'All photos', icon: Image, action: go('/') },
|
||||||
|
{ label: 'Review queue', icon: ListChecks, action: go('/review') },
|
||||||
|
{ label: 'Archive', icon: Archive, action: go('/?section=archive') },
|
||||||
|
{ label: 'Hidden', icon: EyeOff, action: go('/?section=hidden') },
|
||||||
|
{ label: 'Notes', icon: NotebookPen, action: go('/notes') },
|
||||||
|
{ label: 'Tags', icon: Tags, action: go('/tags/labels') },
|
||||||
|
{ label: 'People', icon: Users, action: go('/tags/people') },
|
||||||
|
{ label: 'Duplicates', icon: Layers, action: go('/review?tab=stacks') }
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACTIONS: Entry[] = [
|
||||||
|
{ label: 'Toggle dark mode', icon: Moon, action: () => run(toggleMode) },
|
||||||
|
{ label: 'Keyboard shortcuts', icon: Keyboard, action: () => run(toggleShortcuts) }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Folders can number in the hundreds; the palette lists them all and
|
||||||
|
// lets Command's fuzzy filter narrow. Sorted shallow-first so top-level
|
||||||
|
// folders surface before deep ones on an empty query.
|
||||||
|
const folderEntries = $derived(
|
||||||
|
[...(foldersQuery.data ?? [])]
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.Path.split('/').length - b.Path.split('/').length || a.Path.localeCompare(b.Path)
|
||||||
|
)
|
||||||
|
.slice(0, 400)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog.Root
|
||||||
|
open={view.paletteOpen}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) closePalette();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay class="fixed inset-0 z-[80] bg-black/50 backdrop-blur-sm" />
|
||||||
|
<Dialog.Content
|
||||||
|
class="fixed left-1/2 top-24 z-[81] w-[min(560px,92vw)] -translate-x-1/2 overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-xl"
|
||||||
|
aria-label="Command palette"
|
||||||
|
>
|
||||||
|
<Command.Root class="flex max-h-[60vh] flex-col">
|
||||||
|
<Command.Input
|
||||||
|
class="w-full border-b border-border bg-transparent px-4 py-3 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
|
placeholder="Jump to a view, heap, or folder…"
|
||||||
|
/>
|
||||||
|
<Command.List class="overflow-y-auto p-1.5">
|
||||||
|
<Command.Viewport>
|
||||||
|
<Command.Empty class="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
No matches.
|
||||||
|
</Command.Empty>
|
||||||
|
|
||||||
|
<Command.Group>
|
||||||
|
<Command.GroupHeading
|
||||||
|
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Go to
|
||||||
|
</Command.GroupHeading>
|
||||||
|
<Command.GroupItems>
|
||||||
|
{#each SECTIONS as s (s.label)}
|
||||||
|
<Command.Item
|
||||||
|
value={s.label}
|
||||||
|
onSelect={s.action}
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
|
||||||
|
>
|
||||||
|
<s.icon class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{s.label}
|
||||||
|
</Command.Item>
|
||||||
|
{/each}
|
||||||
|
</Command.GroupItems>
|
||||||
|
</Command.Group>
|
||||||
|
|
||||||
|
{#if (heapsQuery.data ?? []).length > 0}
|
||||||
|
<Command.Group>
|
||||||
|
<Command.GroupHeading
|
||||||
|
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Heaps
|
||||||
|
</Command.GroupHeading>
|
||||||
|
<Command.GroupItems>
|
||||||
|
{#each heapsQuery.data ?? [] as heap (heap.UID)}
|
||||||
|
<Command.Item
|
||||||
|
value={`heap ${heap.Title}`}
|
||||||
|
onSelect={go(`/?section=heap&heap=${heap.UID}`)}
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
|
||||||
|
>
|
||||||
|
<Layers class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{heap.Title}
|
||||||
|
{#if heap.PhotoCount}
|
||||||
|
<span class="ml-auto text-[10px] text-muted-foreground">
|
||||||
|
{heap.PhotoCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</Command.Item>
|
||||||
|
{/each}
|
||||||
|
</Command.GroupItems>
|
||||||
|
</Command.Group>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if folderEntries.length > 0}
|
||||||
|
<Command.Group>
|
||||||
|
<Command.GroupHeading
|
||||||
|
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Folders
|
||||||
|
</Command.GroupHeading>
|
||||||
|
<Command.GroupItems>
|
||||||
|
{#each folderEntries as folder (folder.Path)}
|
||||||
|
<Command.Item
|
||||||
|
value={`folder ${folder.Path}`}
|
||||||
|
onSelect={go(`/?folder=${encodeURIComponent(folder.Path)}`)}
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
|
||||||
|
>
|
||||||
|
<Folder class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span class="truncate">{folder.Path}</span>
|
||||||
|
</Command.Item>
|
||||||
|
{/each}
|
||||||
|
</Command.GroupItems>
|
||||||
|
</Command.Group>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Command.Group>
|
||||||
|
<Command.GroupHeading
|
||||||
|
class="px-2 pb-1 pt-2 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Actions
|
||||||
|
</Command.GroupHeading>
|
||||||
|
<Command.GroupItems>
|
||||||
|
{#each ACTIONS as a (a.label)}
|
||||||
|
<Command.Item
|
||||||
|
value={a.label}
|
||||||
|
onSelect={a.action}
|
||||||
|
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs data-selected:bg-accent"
|
||||||
|
>
|
||||||
|
<a.icon class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
{a.label}
|
||||||
|
</Command.Item>
|
||||||
|
{/each}
|
||||||
|
</Command.GroupItems>
|
||||||
|
</Command.Group>
|
||||||
|
</Command.Viewport>
|
||||||
|
</Command.List>
|
||||||
|
</Command.Root>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -69,6 +69,10 @@
|
|||||||
* "{n} photos" affordance. Undefined keeps the badge off entirely
|
* "{n} photos" affordance. Undefined keeps the badge off entirely
|
||||||
* (the picker dialog doesn't need it). */
|
* (the picker dialog doesn't need it). */
|
||||||
counts?: Record<string, number>;
|
counts?: Record<string, number>;
|
||||||
|
/** Render every branch expanded regardless of the persisted openSet —
|
||||||
|
* the picker turns this on while a search filter is active so matches
|
||||||
|
* buried in collapsed branches stay visible. */
|
||||||
|
forceExpand?: boolean;
|
||||||
}
|
}
|
||||||
let {
|
let {
|
||||||
nodes,
|
nodes,
|
||||||
@@ -80,7 +84,8 @@
|
|||||||
onMove,
|
onMove,
|
||||||
readonly = false,
|
readonly = false,
|
||||||
selectedPath,
|
selectedPath,
|
||||||
counts
|
counts,
|
||||||
|
forceExpand = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Auto-expanded folders, persisted to localStorage so the tree state
|
// Auto-expanded folders, persisted to localStorage so the tree state
|
||||||
@@ -145,7 +150,7 @@
|
|||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{#each nodes as node (node.path)}
|
{#each nodes as node (node.path)}
|
||||||
{@const open = openSet.has(node.path)}
|
{@const open = forceExpand || openSet.has(node.path)}
|
||||||
{@const active = isActive(node.path)}
|
{@const active = isActive(node.path)}
|
||||||
{@const hasChildren = node.children.length > 0}
|
{@const hasChildren = node.children.length > 0}
|
||||||
<li>
|
<li>
|
||||||
@@ -185,11 +190,17 @@
|
|||||||
+ badge) is one hit target — the badge was previously a dead
|
+ badge) is one hit target — the badge was previously a dead
|
||||||
zone right where the user's eye lands.
|
zone right where the user's eye lands.
|
||||||
-->
|
-->
|
||||||
|
<!-- In readonly (picker) mode the row carries data attributes the
|
||||||
|
move dialog uses for roving arrow-key focus, plus aria-pressed
|
||||||
|
so screen readers hear the current selection. -->
|
||||||
<button
|
<button
|
||||||
class="flex min-w-0 flex-1 items-center pl-1 text-left"
|
class="flex min-w-0 flex-1 items-center pl-1 text-left"
|
||||||
onclick={() => onPick(node.path)}
|
onclick={() => onPick(node.path)}
|
||||||
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
|
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
|
||||||
title={node.path}
|
title={node.path}
|
||||||
|
data-move-row={readonly ? '' : undefined}
|
||||||
|
data-path={readonly ? node.path : undefined}
|
||||||
|
aria-pressed={readonly ? active : undefined}
|
||||||
>
|
>
|
||||||
<span class="truncate">{node.name}</span>
|
<span class="truncate">{node.name}</span>
|
||||||
{#if counts && counts[node.path] !== undefined}
|
{#if counts && counts[node.path] !== undefined}
|
||||||
@@ -256,6 +267,7 @@
|
|||||||
{readonly}
|
{readonly}
|
||||||
{selectedPath}
|
{selectedPath}
|
||||||
{counts}
|
{counts}
|
||||||
|
{forceExpand}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -14,26 +14,36 @@
|
|||||||
folder keeps its own name. The picker excludes the folder
|
folder keeps its own name. The picker excludes the folder
|
||||||
itself and its descendants.
|
itself and its descendants.
|
||||||
|
|
||||||
Picker reuses the readonly FolderTree; the dialog owns the selection
|
UX model (Lightroom-style): tree is the primary surface, with a search
|
||||||
(`pickedPath`) so it never fights the global folderPath filter.
|
field on top that filters it live (matches + their ancestors, force-
|
||||||
|
expanded). Arrow keys rove through visible rows with selection following
|
||||||
|
focus; Enter confirms; recent destinations render as one-click chips.
|
||||||
|
Moves are undoable via ⌘Z / the toast's Undo action — the sidecar returns
|
||||||
|
per-file {from,to} pairs and /files/restore-moves plays them backwards.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import { Dialog } from 'bits-ui';
|
import { Dialog } from 'bits-ui';
|
||||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte';
|
import { FolderInput, FolderOpen, History, Loader2, Search } from 'lucide-svelte';
|
||||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
import {
|
import {
|
||||||
convertHeap,
|
convertHeap,
|
||||||
movePhotosToFolder,
|
movePhotosToFolder,
|
||||||
moveFolder,
|
moveFolder,
|
||||||
|
restoreMoves,
|
||||||
listFolders,
|
listFolders,
|
||||||
type PpFolder
|
type PpFolder
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
|
import { cachedPhoto } from '$lib/services/photoActions';
|
||||||
|
import { photoNameAndDir } from '$lib/types/photoprism';
|
||||||
import { filters, setSection, setFolderPath } from '$lib/stores/filters.svelte';
|
import { filters, setSection, setFolderPath } from '$lib/stores/filters.svelte';
|
||||||
import { isAuthenticated, toOriginalsPath, userLibraryBase } from '$lib/stores/session.svelte';
|
import { isAuthenticated, toOriginalsPath, userLibraryBase } from '$lib/stores/session.svelte';
|
||||||
import { moveDialog, closeMove } from '$lib/stores/moveDialog.svelte';
|
import { moveDialog, closeMove } from '$lib/stores/moveDialog.svelte';
|
||||||
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||||
|
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
@@ -50,16 +60,74 @@
|
|||||||
const kind = $derived(subject?.kind);
|
const kind = $derived(subject?.kind);
|
||||||
const open = $derived(subject !== null);
|
const open = $derived(subject !== null);
|
||||||
|
|
||||||
|
let pickedPath = $state<string | null>(null);
|
||||||
|
let mode = $state<'move' | 'copy'>('move');
|
||||||
|
let subfolder = $state('');
|
||||||
|
let deleteHeap = $state(false);
|
||||||
|
let submitting = $state(false);
|
||||||
|
let filterText = $state('');
|
||||||
|
let searchEl = $state<HTMLInputElement | undefined>();
|
||||||
|
let contentEl = $state<HTMLElement | undefined>();
|
||||||
|
let recents = $state<string[]>([]);
|
||||||
|
|
||||||
|
// ── Recent destinations (Lightroom's "recent folders" affordance) ───
|
||||||
|
const RECENTS_KEY = $derived(`mule_move_recents:${userLibraryBase()}`);
|
||||||
|
function loadRecents(): string[] {
|
||||||
|
if (!browser) return [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RECENTS_KEY);
|
||||||
|
const arr = raw ? (JSON.parse(raw) as string[]) : [];
|
||||||
|
return Array.isArray(arr) ? arr : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function saveRecent(path: string) {
|
||||||
|
if (!browser) return;
|
||||||
|
const next = [path, ...recents.filter((p) => p !== path)].slice(0, 5);
|
||||||
|
recents = next;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RECENTS_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* quota — recents are a nicety */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only offer recents that still exist (or the root sentinel '').
|
||||||
|
const liveRecents = $derived.by(() => {
|
||||||
|
const paths = new Set((foldersQuery.data ?? []).map((f) => f.Path));
|
||||||
|
return recents.filter((p) => p === '' || paths.has(p));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tree building: subject exclusion + search filter ─────────────────
|
||||||
// For folder reparent, exclude the folder itself and everything under it —
|
// For folder reparent, exclude the folder itself and everything under it —
|
||||||
// you can't move a directory into its own subtree.
|
// you can't move a directory into its own subtree.
|
||||||
const folderTree = $derived.by(() => {
|
const basePaths = $derived.by(() => {
|
||||||
const paths = (foldersQuery.data ?? []).map((f) => f.Path);
|
const paths = (foldersQuery.data ?? []).map((f) => f.Path);
|
||||||
if (subject?.kind === 'folder') {
|
if (subject?.kind === 'folder') {
|
||||||
const self = subject.path;
|
const self = subject.path;
|
||||||
return buildTree(paths.filter((p) => p !== self && !p.startsWith(self + '/')));
|
return paths.filter((p) => p !== self && !p.startsWith(self + '/'));
|
||||||
}
|
}
|
||||||
return buildTree(paths);
|
return paths;
|
||||||
});
|
});
|
||||||
|
const filtering = $derived(filterText.trim().length > 0);
|
||||||
|
const folderTree = $derived.by(() => {
|
||||||
|
if (!filtering) return buildTree(basePaths);
|
||||||
|
// Keep matches plus every ancestor so the hit's branch renders whole;
|
||||||
|
// forceExpand on the tree makes the branch visible without touching
|
||||||
|
// the sidebar's persisted open/collapse state.
|
||||||
|
const q = filterText.trim().toLowerCase();
|
||||||
|
const keep = new Set<string>();
|
||||||
|
for (const p of basePaths) {
|
||||||
|
if (!p.toLowerCase().includes(q)) continue;
|
||||||
|
const parts = p.split('/');
|
||||||
|
for (let i = 1; i <= parts.length; i++) {
|
||||||
|
keep.add(parts.slice(0, i).join('/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Intersect with basePaths so folder-subject exclusion survives.
|
||||||
|
return buildTree(basePaths.filter((p) => keep.has(p)));
|
||||||
|
});
|
||||||
|
const treeIsEmpty = $derived((foldersQuery.data ?? []).length === 0);
|
||||||
|
|
||||||
const showOptions = $derived(kind === 'heap' || kind === 'photos');
|
const showOptions = $derived(kind === 'heap' || kind === 'photos');
|
||||||
const showDeleteHeap = $derived(kind === 'heap');
|
const showDeleteHeap = $derived(kind === 'heap');
|
||||||
@@ -67,6 +135,11 @@
|
|||||||
const folderName = $derived(
|
const folderName = $derived(
|
||||||
subject?.kind === 'folder' ? (subject.path.split('/').pop() ?? subject.path) : ''
|
subject?.kind === 'folder' ? (subject.path.split('/').pop() ?? subject.path) : ''
|
||||||
);
|
);
|
||||||
|
const photoCount = $derived.by(() => {
|
||||||
|
if (subject?.kind === 'heap') return subject.heap.PhotoCount ?? 0;
|
||||||
|
if (subject?.kind === 'photos') return subject.uids.length;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
const headerTitle = $derived.by(() => {
|
const headerTitle = $derived.by(() => {
|
||||||
if (subject?.kind === 'folder') return 'Move folder';
|
if (subject?.kind === 'folder') return 'Move folder';
|
||||||
const verb = mode === 'copy' ? 'Copy' : 'Move';
|
const verb = mode === 'copy' ? 'Copy' : 'Move';
|
||||||
@@ -75,25 +148,72 @@
|
|||||||
});
|
});
|
||||||
const headerDesc = $derived.by(() => {
|
const headerDesc = $derived.by(() => {
|
||||||
if (subject?.kind === 'heap') {
|
if (subject?.kind === 'heap') {
|
||||||
const n = subject.heap.PhotoCount ?? 0;
|
const n = photoCount;
|
||||||
return `${subject.heap.Title ?? ''} · ${n} photo${n === 1 ? '' : 's'}`;
|
return `${subject?.kind === 'heap' ? (subject.heap.Title ?? '') : ''} · ${n} photo${n === 1 ? '' : 's'}`;
|
||||||
}
|
}
|
||||||
if (subject?.kind === 'photos') {
|
if (subject?.kind === 'photos') {
|
||||||
const n = subject.uids.length;
|
return `${photoCount} photo${photoCount === 1 ? '' : 's'} selected`;
|
||||||
return `${n} photo${n === 1 ? '' : 's'} selected`;
|
|
||||||
}
|
}
|
||||||
if (subject?.kind === 'folder') return `${folderName} → pick a destination`;
|
if (subject?.kind === 'folder') return `${folderName} → pick a destination`;
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
|
|
||||||
let pickedPath = $state<string | null>(null);
|
// ── Validation ───────────────────────────────────────────────────────
|
||||||
let mode = $state<'move' | 'copy'>('move');
|
/** Mirrors the sidecar's sanitizeFilename rules so bad names are caught
|
||||||
let subfolder = $state('');
|
* before the request instead of surfacing as a failed toast. */
|
||||||
let deleteHeap = $state(false);
|
const subfolderError = $derived.by(() => {
|
||||||
let submitting = $state(false);
|
const t = subfolder.trim();
|
||||||
|
if (!t) return null;
|
||||||
|
if (t.length > 240) return 'Name is too long';
|
||||||
|
if (t.startsWith('.')) return "Can't start with a dot";
|
||||||
|
if (/[/\\\u0000]/.test(t)) return 'Slashes aren’t allowed — one level only';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-disable "Move" when every selected photo already sits in the target
|
||||||
|
// folder (only decidable when all photos are in the query cache — unknown
|
||||||
|
// photos fail open and the sidecar reports "already in target" per photo).
|
||||||
|
const allAlreadyInTarget = $derived.by(() => {
|
||||||
|
if (subject?.kind !== 'photos' || mode !== 'move') return false;
|
||||||
|
if (pickedPath === null || subfolder.trim()) return false;
|
||||||
|
const dest = toOriginalsPath(pickedPath);
|
||||||
|
let known = 0;
|
||||||
|
for (const uid of subject.uids) {
|
||||||
|
const p = cachedPhoto(uid);
|
||||||
|
if (!p) return false;
|
||||||
|
known++;
|
||||||
|
if (photoNameAndDir(p).path !== dest) return false;
|
||||||
|
}
|
||||||
|
return known > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSubmit = $derived(
|
||||||
|
pickedPath !== null && !submitting && !subfolderError && !allAlreadyInTarget
|
||||||
|
);
|
||||||
|
const disabledReason = $derived.by(() => {
|
||||||
|
if (pickedPath === null) return 'Pick a destination folder first';
|
||||||
|
if (subfolderError) return subfolderError;
|
||||||
|
if (allAlreadyInTarget) return 'Everything is already in this folder';
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const confirmLabel = $derived.by(() => {
|
||||||
|
if (kind === 'folder') return `Move “${folderName}”`;
|
||||||
|
const verb = mode === 'copy' ? 'Copy' : 'Move';
|
||||||
|
return `${verb} ${photoCount} photo${photoCount === 1 ? '' : 's'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Live destination preview under the tree.
|
||||||
|
const destPreview = $derived.by(() => {
|
||||||
|
if (pickedPath === null) return null;
|
||||||
|
const base = pickedPath === '' ? '/' : pickedPath;
|
||||||
|
const sub = !subfolderError && subfolder.trim() ? subfolder.trim() : '';
|
||||||
|
return sub ? (pickedPath === '' ? sub : `${base}/${sub}`) : base;
|
||||||
|
});
|
||||||
|
|
||||||
// Reset draft state whenever a new subject is picked (or the dialog closes
|
// Reset draft state whenever a new subject is picked (or the dialog closes
|
||||||
// and reopens), so the form is blank on every fresh open.
|
// and reopens), so the form is blank on every fresh open. Autofocus the
|
||||||
|
// search field once the portal has rendered.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void subject;
|
void subject;
|
||||||
pickedPath = null;
|
pickedPath = null;
|
||||||
@@ -101,6 +221,11 @@
|
|||||||
subfolder = '';
|
subfolder = '';
|
||||||
deleteHeap = false;
|
deleteHeap = false;
|
||||||
submitting = false;
|
submitting = false;
|
||||||
|
filterText = '';
|
||||||
|
if (subject !== null) {
|
||||||
|
recents = loadRecents();
|
||||||
|
void tick().then(() => searchEl?.focus());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy mode doesn't change membership, so "delete heap after" is
|
// Copy mode doesn't change membership, so "delete heap after" is
|
||||||
@@ -109,16 +234,96 @@
|
|||||||
if (mode === 'copy' && deleteHeap) deleteHeap = false;
|
if (mode === 'copy' && deleteHeap) deleteHeap = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Roving arrow-key focus: selection follows focus ─────────────────
|
||||||
|
function visibleRows(): HTMLElement[] {
|
||||||
|
if (!contentEl) return [];
|
||||||
|
return Array.from(contentEl.querySelectorAll<HTMLElement>('[data-move-row]'));
|
||||||
|
}
|
||||||
|
function onContentKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
// Enter confirms from anywhere in the dialog once a destination is
|
||||||
|
// picked — including the search and subfolder inputs. Row buttons
|
||||||
|
// also fire their own click (re-picking themselves) first, which
|
||||||
|
// is harmless.
|
||||||
|
const inSearch = e.target === searchEl;
|
||||||
|
if (canSubmit && !(inSearch && pickedPath === null)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void submit();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||||
|
const rows = visibleRows();
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
const active = document.activeElement as HTMLElement | null;
|
||||||
|
const idx = rows.findIndex((r) => r === active);
|
||||||
|
let next: HTMLElement | undefined;
|
||||||
|
if (idx === -1) {
|
||||||
|
// Entering the tree from the search box (or anywhere else).
|
||||||
|
next = e.key === 'ArrowDown' ? rows[0] : rows[rows.length - 1];
|
||||||
|
} else {
|
||||||
|
const ni = idx + (e.key === 'ArrowDown' ? 1 : -1);
|
||||||
|
if (ni < 0) {
|
||||||
|
// Off the top — hand focus back to the search field.
|
||||||
|
e.preventDefault();
|
||||||
|
searchEl?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next = rows[Math.min(ni, rows.length - 1)];
|
||||||
|
}
|
||||||
|
if (next) {
|
||||||
|
e.preventDefault();
|
||||||
|
next.focus();
|
||||||
|
next.scrollIntoView({ block: 'nearest' });
|
||||||
|
// Selection follows focus (ARIA listbox convention) — arrowing
|
||||||
|
// through the tree is the same as clicking each row.
|
||||||
|
pickedPath = next.dataset.path ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function moveSummary(verb: string, count: number, errors: number): string {
|
function moveSummary(verb: string, count: number, errors: number): string {
|
||||||
const tail = errors > 0 ? ` · ${errors} skipped` : '';
|
const tail = errors > 0 ? ` · ${errors} skipped` : '';
|
||||||
return `${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`;
|
return `${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Register an undo that plays the sidecar's moved pairs backwards, and
|
||||||
|
* attach it to the success toast. Runs at most once. */
|
||||||
|
function registerMoveUndo(
|
||||||
|
label: string,
|
||||||
|
moves: { from: string; to: string }[],
|
||||||
|
extraInvalidate?: () => void
|
||||||
|
): (() => void) | undefined {
|
||||||
|
if (moves.length === 0) return undefined;
|
||||||
|
let undone = false;
|
||||||
|
const undo = async () => {
|
||||||
|
if (undone) return;
|
||||||
|
undone = true;
|
||||||
|
try {
|
||||||
|
const res = await restoreMoves(moves);
|
||||||
|
if (res.errors.length > 0) {
|
||||||
|
toast.error(`Restore failed for ${res.errors.length} file(s)`, {
|
||||||
|
description: res.errors[0].error
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.success(`Moved back ${res.restored.length} file(s)`);
|
||||||
|
}
|
||||||
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
|
extraInvalidate?.();
|
||||||
|
} catch (err) {
|
||||||
|
undone = false; // network failure — files unmoved, allow retry
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Undo failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pushUndo(label, undo);
|
||||||
|
return () => void undo();
|
||||||
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const s = moveDialog.subject;
|
const s = moveDialog.subject;
|
||||||
// pickedPath === '' is the root selection; distinguish it from `null`
|
// pickedPath === '' is the root selection; distinguish it from `null`
|
||||||
// (nothing picked) so a falsy check doesn't wrongly block root.
|
// (nothing picked) so a falsy check doesn't wrongly block root.
|
||||||
if (!s || pickedPath === null || submitting) return;
|
if (!s || pickedPath === null || submitting || !canSubmit) return;
|
||||||
submitting = true;
|
submitting = true;
|
||||||
|
|
||||||
// Snapshot the draft before closing — closeMove() nulls the subject,
|
// Snapshot the draft before closing — closeMove() nulls the subject,
|
||||||
@@ -128,6 +333,7 @@
|
|||||||
const sub = subfolder.trim() || null;
|
const sub = subfolder.trim() || null;
|
||||||
const delHeap = mode === 'move' && deleteHeap;
|
const delHeap = mode === 'move' && deleteHeap;
|
||||||
const labelName = folderName;
|
const labelName = folderName;
|
||||||
|
saveRecent(dest);
|
||||||
|
|
||||||
// Close the dialog immediately and run the move in the background. The
|
// Close the dialog immediately and run the move in the background. The
|
||||||
// move can be slow (a folder/heap with many files triggers a real
|
// move can be slow (a folder/heap with many files triggers a real
|
||||||
@@ -149,9 +355,14 @@
|
|||||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
|
const runUndo = registerMoveUndo(
|
||||||
|
`Moved heap “${s.heap.Title ?? ''}” (${r.moved} photos)${r.heap_deleted ? ' — heap itself not restored' : ''}`,
|
||||||
|
opMode === 'move' ? (r.movedFiles ?? []) : [],
|
||||||
|
() => qc.invalidateQueries({ queryKey: ['heaps'] })
|
||||||
|
);
|
||||||
toast.success(
|
toast.success(
|
||||||
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
|
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
|
||||||
{ id: tid }
|
{ id: tid, action: runUndo ? { label: 'Undo', onClick: runUndo } : undefined }
|
||||||
);
|
);
|
||||||
if (r.heap_deleted && filters.section === 'heap' && filters.heapUid === s.heap.UID) {
|
if (r.heap_deleted && filters.section === 'heap' && filters.heapUid === s.heap.UID) {
|
||||||
setSection('all-photos');
|
setSection('all-photos');
|
||||||
@@ -166,19 +377,49 @@
|
|||||||
});
|
});
|
||||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
|
const runUndo = registerMoveUndo(
|
||||||
|
`Moved ${r.moved} photo${r.moved === 1 ? '' : 's'}`,
|
||||||
|
opMode === 'move' ? (r.movedFiles ?? []) : []
|
||||||
|
);
|
||||||
toast.success(
|
toast.success(
|
||||||
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
|
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
|
||||||
{ id: tid }
|
{ id: tid, action: runUndo ? { label: 'Undo', onClick: runUndo } : undefined }
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Folder reparent (move only). Translate both the folder's own
|
// Folder reparent (move only). Translate both the folder's own
|
||||||
// path and the destination parent to originals-relative for the
|
// path and the destination parent to originals-relative for the
|
||||||
// sidecar, which moves real directories on disk.
|
// sidecar, which moves real directories on disk.
|
||||||
await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest));
|
const r = await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest));
|
||||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`;
|
const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`;
|
||||||
toast.success(`Moved ${labelName} → ${dest === '' ? '/' : dest}`, { id: tid });
|
const oldUiPath = s.path;
|
||||||
|
// Inverse of a folder move is another folder move, back under
|
||||||
|
// the old parent (both paths originals-relative from the
|
||||||
|
// response — independent of UI base-path prefixes).
|
||||||
|
let undone = false;
|
||||||
|
const undo = async () => {
|
||||||
|
if (undone) return;
|
||||||
|
undone = true;
|
||||||
|
try {
|
||||||
|
await moveFolder(
|
||||||
|
r.newPath,
|
||||||
|
r.oldPath.includes('/') ? r.oldPath.slice(0, r.oldPath.lastIndexOf('/')) : ''
|
||||||
|
);
|
||||||
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
|
if (filters.folderPath === newUiPath) setFolderPath(oldUiPath);
|
||||||
|
toast.success(`Moved “${labelName}” back`);
|
||||||
|
} catch (err) {
|
||||||
|
undone = false;
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Undo failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pushUndo(`Moved folder “${labelName}”`, undo);
|
||||||
|
toast.success(`Moved ${labelName} → ${dest === '' ? '/' : dest}`, {
|
||||||
|
id: tid,
|
||||||
|
action: { label: 'Undo', onClick: () => void undo() }
|
||||||
|
});
|
||||||
// If we just moved the folder the timeline is showing, follow it.
|
// If we just moved the folder the timeline is showing, follow it.
|
||||||
if (filters.folderPath === s.path) setFolderPath(newUiPath);
|
if (filters.folderPath === s.path) setFolderPath(newUiPath);
|
||||||
}
|
}
|
||||||
@@ -199,112 +440,179 @@
|
|||||||
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||||
/>
|
/>
|
||||||
<Dialog.Content
|
<Dialog.Content
|
||||||
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[520px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[520px] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-2">
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
<div bind:this={contentEl} onkeydown={onContentKeydown} class="grid gap-3" aria-busy={submitting}>
|
||||||
<div class="flex-1">
|
<div class="flex items-start gap-2">
|
||||||
<Dialog.Title class="text-sm font-semibold leading-tight">
|
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||||
{headerTitle}
|
<div class="flex-1">
|
||||||
</Dialog.Title>
|
<Dialog.Title class="text-sm font-semibold leading-tight">
|
||||||
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
{headerTitle}
|
||||||
{headerDesc}
|
</Dialog.Title>
|
||||||
</Dialog.Description>
|
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
||||||
</div>
|
{headerDesc}
|
||||||
</div>
|
</Dialog.Description>
|
||||||
|
|
||||||
<!-- Folder picker. Readonly FolderTree so the user can't kebab/rename
|
|
||||||
their way out of the picker mid-flow. -->
|
|
||||||
<div class="rounded-md border border-border bg-background p-2">
|
|
||||||
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
|
||||||
{kind === 'folder' ? 'Destination parent' : 'Destination'}
|
|
||||||
</div>
|
|
||||||
<div class="max-h-[200px] overflow-y-auto">
|
|
||||||
{#if foldersQuery.isPending}
|
|
||||||
<InlineLoader size="sm" label="Loading folders…" />
|
|
||||||
{:else if (foldersQuery.data ?? []).length === 0}
|
|
||||||
<EmptyState
|
|
||||||
size="compact"
|
|
||||||
icon={FolderOpen}
|
|
||||||
title="No folders"
|
|
||||||
description="Create one from the sidebar first."
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<!-- Root row: drop straight into originals/ (the user's root)
|
|
||||||
without picking a subfolder. Empty string is the
|
|
||||||
sidecar's "root" sentinel. -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
|
|
||||||
class:bg-primary={pickedPath === ''}
|
|
||||||
class:text-primary-foreground={pickedPath === ''}
|
|
||||||
class:hover:bg-primary={pickedPath === ''}
|
|
||||||
onclick={() => (pickedPath = '')}
|
|
||||||
>
|
|
||||||
/
|
|
||||||
</button>
|
|
||||||
<FolderTree
|
|
||||||
nodes={folderTree}
|
|
||||||
onPick={(p) => (pickedPath = p)}
|
|
||||||
selectedPath={pickedPath}
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Move/copy + subfolder, hidden for folder reparent (always a move
|
|
||||||
that keeps the folder's own name). -->
|
|
||||||
{#if showOptions}
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-4 text-[12px]">
|
|
||||||
<label class="flex items-center gap-1.5">
|
|
||||||
<input type="radio" bind:group={mode} value="move" />
|
|
||||||
Move
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5">
|
|
||||||
<input type="radio" bind:group={mode} value="copy" />
|
|
||||||
Copy
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<label class="flex flex-col gap-1 text-[12px]">
|
|
||||||
<span class="text-muted-foreground">New subfolder (optional)</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. 2024-summer"
|
|
||||||
bind:value={subfolder}
|
|
||||||
class="rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{#if showDeleteHeap}
|
|
||||||
<label class="flex items-center gap-1.5 text-[12px]">
|
|
||||||
<input type="checkbox" bind:checked={deleteHeap} disabled={mode === 'copy'} />
|
|
||||||
<span class:text-muted-foreground={mode === 'copy'}>Delete heap after move</span>
|
|
||||||
</label>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-2 pt-1">
|
<!-- Search over the tree — autofocused, filters live. -->
|
||||||
<button
|
<div class="relative">
|
||||||
type="button"
|
<Search
|
||||||
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
|
class="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
||||||
onclick={closeMove}
|
/>
|
||||||
disabled={submitting}
|
<input
|
||||||
>
|
bind:this={searchEl}
|
||||||
Cancel
|
type="text"
|
||||||
</button>
|
placeholder="Search folders…"
|
||||||
<button
|
aria-label="Search folders"
|
||||||
type="button"
|
bind:value={filterText}
|
||||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
class="w-full rounded border border-input bg-background py-1.5 pl-7 pr-2 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
onclick={submit}
|
/>
|
||||||
disabled={pickedPath === null || submitting}
|
</div>
|
||||||
>
|
|
||||||
{#if submitting}
|
<!-- Recent destinations — one-click chips. -->
|
||||||
<Loader2 class="h-3 w-3 animate-spin" />
|
{#if liveRecents.length > 0 && !filtering}
|
||||||
{/if}
|
<div class="flex flex-wrap items-center gap-1" aria-label="Recent destinations">
|
||||||
{kind === 'folder' ? 'Move' : mode === 'copy' ? 'Copy' : 'Move'}
|
<History class="h-3 w-3 text-muted-foreground" />
|
||||||
</button>
|
{#each liveRecents as r (r)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="max-w-[160px] truncate rounded-full border px-2 py-0.5 text-[10px] transition-colors
|
||||||
|
{pickedPath === r
|
||||||
|
? 'border-primary bg-primary text-primary-foreground'
|
||||||
|
: 'border-border bg-secondary/60 text-muted-foreground hover:bg-accent hover:text-foreground'}"
|
||||||
|
onclick={() => (pickedPath = r)}
|
||||||
|
title={r === '' ? '/' : r}
|
||||||
|
>
|
||||||
|
{r === '' ? '/' : r.split('/').pop()}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Folder picker. Readonly FolderTree so the user can't kebab/rename
|
||||||
|
their way out of the picker mid-flow. -->
|
||||||
|
<div class="rounded-md border border-border bg-background p-2">
|
||||||
|
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||||
|
{kind === 'folder' ? 'Destination parent' : 'Destination'}
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[220px] overflow-y-auto">
|
||||||
|
{#if foldersQuery.isPending}
|
||||||
|
<InlineLoader size="sm" label="Loading folders…" />
|
||||||
|
{:else if treeIsEmpty}
|
||||||
|
<EmptyState
|
||||||
|
size="compact"
|
||||||
|
icon={FolderOpen}
|
||||||
|
title="No folders"
|
||||||
|
description="Create one from the sidebar first."
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<!-- Root row: drop straight into originals/ (the user's root)
|
||||||
|
without picking a subfolder. Empty string is the
|
||||||
|
sidecar's "root" sentinel. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
|
||||||
|
class:bg-primary={pickedPath === ''}
|
||||||
|
class:text-primary-foreground={pickedPath === ''}
|
||||||
|
class:hover:bg-primary={pickedPath === ''}
|
||||||
|
onclick={() => (pickedPath = '')}
|
||||||
|
data-move-row=""
|
||||||
|
data-path=""
|
||||||
|
aria-pressed={pickedPath === ''}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</button>
|
||||||
|
{#if filtering && folderTree.length === 0}
|
||||||
|
<p class="px-2 py-2 text-[11px] text-muted-foreground">
|
||||||
|
No folders match “{filterText.trim()}”.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<FolderTree
|
||||||
|
nodes={folderTree}
|
||||||
|
onPick={(p) => (pickedPath = p)}
|
||||||
|
selectedPath={pickedPath}
|
||||||
|
readonly
|
||||||
|
forceExpand={filtering}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Move/copy + subfolder, hidden for folder reparent (always a move
|
||||||
|
that keeps the folder's own name). -->
|
||||||
|
{#if showOptions}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 text-[12px]"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label="Move or copy"
|
||||||
|
>
|
||||||
|
<label class="flex items-center gap-1.5">
|
||||||
|
<input type="radio" bind:group={mode} value="move" />
|
||||||
|
Move
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1.5">
|
||||||
|
<input type="radio" bind:group={mode} value="copy" />
|
||||||
|
Copy
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="flex flex-col gap-1 text-[12px]">
|
||||||
|
<span class="text-muted-foreground">New subfolder (optional)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. 2024-summer"
|
||||||
|
aria-label="New subfolder name"
|
||||||
|
aria-invalid={Boolean(subfolderError)}
|
||||||
|
bind:value={subfolder}
|
||||||
|
class="rounded border bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring
|
||||||
|
{subfolderError ? 'border-destructive' : 'border-input'}"
|
||||||
|
/>
|
||||||
|
{#if subfolderError}
|
||||||
|
<span class="text-[11px] text-destructive" role="alert">{subfolderError}</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
{#if showDeleteHeap}
|
||||||
|
<label class="flex items-center gap-1.5 text-[12px]">
|
||||||
|
<input type="checkbox" bind:checked={deleteHeap} disabled={mode === 'copy'} />
|
||||||
|
<span class:text-muted-foreground={mode === 'copy'}>Delete heap after move</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Live destination preview -->
|
||||||
|
{#if destPreview !== null}
|
||||||
|
<p class="truncate text-[11px] text-muted-foreground" aria-live="polite">
|
||||||
|
{kind === 'folder' ? `Moving “${folderName}”` : `${mode === 'copy' ? 'Copying' : 'Moving'} ${photoCount} photo${photoCount === 1 ? '' : 's'}`}
|
||||||
|
<span class="text-foreground/70"> → {destPreview}</span>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
|
||||||
|
onclick={closeMove}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||||
|
onclick={submit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
title={disabledReason}
|
||||||
|
>
|
||||||
|
{#if submitting}
|
||||||
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
|
|||||||
131
web/src/lib/components/layout/ShortcutsDialog.svelte
Normal file
131
web/src/lib/components/layout/ShortcutsDialog.svelte
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<!--
|
||||||
|
Keyboard-shortcut reference overlay, opened with `?` (and the toolbar
|
||||||
|
help affordance). Read-only: gridKeyNav swallows every key except
|
||||||
|
Esc / ? while it's up, so nothing here can fire the actions it lists.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { closeShortcuts, view } from '$lib/stores/view.svelte';
|
||||||
|
import { X } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
keys: string[];
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
interface Group {
|
||||||
|
title: string;
|
||||||
|
rows: Row[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUPS: Group[] = [
|
||||||
|
{
|
||||||
|
title: 'Navigate',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['↑', '↓', '←', '→'], desc: 'Move focus in the grid' },
|
||||||
|
{ keys: ['Shift', '+', 'Arrows'], desc: 'Extend selection' },
|
||||||
|
{ keys: ['Space'], desc: 'Open / close preview' },
|
||||||
|
{ keys: ['Esc'], desc: 'Collapse selection, then clear' },
|
||||||
|
{ keys: ['/'], desc: 'Focus search' },
|
||||||
|
{ keys: ['⌘', 'A'], desc: 'Select all visible' },
|
||||||
|
{ keys: ['⌘', 'K'], desc: 'Command palette' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Rate & label',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['1', '…', '5'], desc: 'Set rating (re-key to clear)' },
|
||||||
|
{ keys: ['0'], desc: 'Clear rating' },
|
||||||
|
{ keys: ['6', '7', '8', '9'], desc: 'Color label: red / yellow / green / blue' },
|
||||||
|
{ keys: ['F'], desc: 'Toggle favorite' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Act',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['X'], desc: 'Archive (Delete in Archive view)' },
|
||||||
|
{ keys: ['U'], desc: 'Restore from archive' },
|
||||||
|
{ keys: ['S'], desc: 'Keep (review) · add to heap' },
|
||||||
|
{ keys: ['S', 'then', '1–9'], desc: 'Add to heap N' },
|
||||||
|
{ keys: ['A'], desc: 'Accept date & keep (EXIF review)' },
|
||||||
|
{ keys: ['M'], desc: 'Move to folder' },
|
||||||
|
{ keys: ['⌘', 'Z'], desc: 'Undo last action' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Panels',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['B'], desc: 'Toggle left sidebar' },
|
||||||
|
{ keys: ['Tab'], desc: 'Toggle left sidebar' },
|
||||||
|
{ keys: ['I'], desc: 'Toggle info sidebar' },
|
||||||
|
{ keys: ['?'], desc: 'This overlay' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Stacks & Duplicates',
|
||||||
|
rows: [
|
||||||
|
{ keys: ['↑', '↓', 'j', 'k'], desc: 'Move between groups' },
|
||||||
|
{ keys: ['←', '→'], desc: 'Pick which file/copy to keep' },
|
||||||
|
{ keys: ['1', '…', '9'], desc: 'Jump straight to a file/copy' },
|
||||||
|
{ keys: ['Space'], desc: 'Compare candidates fullscreen (stacks)' },
|
||||||
|
{ keys: ['Enter'], desc: 'Resolve: keep selected, quarantine rest' },
|
||||||
|
{ keys: ['⌘', 'Z'], desc: 'Undo — restores quarantined files' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if view.shortcutsOpen}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[70] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) closeShortcuts();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Keyboard shortcuts"
|
||||||
|
class="max-h-[85vh] w-[min(680px,92vw)] overflow-y-auto rounded-lg border border-border bg-popover p-5 text-popover-foreground shadow-xl"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold">Keyboard shortcuts</h2>
|
||||||
|
<button
|
||||||
|
class="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
onclick={closeShortcuts}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-x-8 gap-y-4 sm:grid-cols-2">
|
||||||
|
{#each GROUPS as group (group.title)}
|
||||||
|
<section>
|
||||||
|
<h3 class="mb-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.title}
|
||||||
|
</h3>
|
||||||
|
<dl class="space-y-1">
|
||||||
|
{#each group.rows as row (row.desc)}
|
||||||
|
<div class="flex items-center justify-between gap-3 text-xs">
|
||||||
|
<dt class="text-muted-foreground">{row.desc}</dt>
|
||||||
|
<dd class="flex shrink-0 items-center gap-0.5">
|
||||||
|
{#each row.keys as k (k)}
|
||||||
|
{#if k === 'then' || k === '+' || k === '…'}
|
||||||
|
<span class="px-0.5 text-[10px] text-muted-foreground">{k}</span>
|
||||||
|
{:else}
|
||||||
|
<kbd
|
||||||
|
class="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] leading-none"
|
||||||
|
>
|
||||||
|
{k}
|
||||||
|
</kbd>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
112
web/src/lib/components/people/NewFacesPanel.svelte
Normal file
112
web/src/lib/components/people/NewFacesPanel.svelte
Normal 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}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
import { view } from '$lib/stores/view.svelte';
|
import { view } from '$lib/stores/view.svelte';
|
||||||
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
||||||
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
import { zoomPan, type ZoomPanState } from '$lib/actions/zoomPan';
|
||||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
|
import { AlertCircle, Image as ImageIcon } from 'lucide-svelte';
|
||||||
|
|
||||||
@@ -101,6 +102,14 @@
|
|||||||
setFocused(next);
|
setFocused(next);
|
||||||
setAnchor(next);
|
setAnchor(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Zoom & pan ───────────────────────────────────────────────────────
|
||||||
|
// Gesture handling lives in the shared zoomPan action (also used by
|
||||||
|
// the duplicates compare lightbox). Transform lives on a wrapper so
|
||||||
|
// the LQIP layer and the sharp image scale together. Resets on photo
|
||||||
|
// change via resetKey. Past 1.25× the sharp <img> switches to
|
||||||
|
// fit_2048 so zoomed pixels stay crisp.
|
||||||
|
let zp = $state<ZoomPanState>({ zoom: 1, tx: 0, ty: 0, panning: false });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
|
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
|
||||||
@@ -145,30 +154,54 @@
|
|||||||
photoQuery.data.OriginalName ??
|
photoQuery.data.OriginalName ??
|
||||||
pf.Name ??
|
pf.Name ??
|
||||||
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
|
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
|
||||||
{#if pf.Width && pf.Height}
|
<div
|
||||||
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
use:zoomPan={{ onChange: (s) => (zp = s), resetKey: uid }}
|
||||||
the tile_*'s square center-crop against the sharp image's
|
class="relative flex h-full w-full items-center justify-center overflow-hidden {zp.zoom > 1
|
||||||
true aspect. Sized via aspect-ratio + max-* + m-auto so it
|
? zp.panning
|
||||||
lands in the exact same bounding box as the sharp <img>
|
? 'cursor-grabbing'
|
||||||
beside it (object-contain semantics, but expressible on a
|
: 'cursor-grab'
|
||||||
positioned element). Paints from the HTTP cache the moment
|
: 'cursor-zoom-in'}"
|
||||||
the modal opens. -->
|
>
|
||||||
<img
|
<div
|
||||||
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
class="relative flex h-full w-full items-center justify-center"
|
||||||
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
class:transition-transform={!zp.panning}
|
||||||
alt=""
|
class:duration-150={!zp.panning}
|
||||||
aria-hidden="true"
|
style="transform: translate({zp.tx}px, {zp.ty}px) scale({zp.zoom});"
|
||||||
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
|
>
|
||||||
style="aspect-ratio: {pf.Width} / {pf.Height};"
|
{#if pf.Width && pf.Height}
|
||||||
/>
|
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
||||||
{/if}
|
the tile_*'s square center-crop against the sharp image's
|
||||||
<img
|
true aspect. Sized via aspect-ratio + max-* + m-auto so it
|
||||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
lands in the exact same bounding box as the sharp <img>
|
||||||
alt={altText}
|
beside it (object-contain semantics, but expressible on a
|
||||||
fetchpriority="high"
|
positioned element). Paints from the HTTP cache the moment
|
||||||
decoding="async"
|
the modal opens. -->
|
||||||
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
<img
|
||||||
/>
|
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
||||||
|
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
|
||||||
|
style="aspect-ratio: {pf.Width} / {pf.Height};"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<img
|
||||||
|
src={thumbUrl(pf.Hash, zp.zoom > 1.25 ? 'fit_2048' : 'fit_1280')}
|
||||||
|
alt={altText}
|
||||||
|
fetchpriority="high"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
class="relative max-h-full max-w-full select-none rounded-md object-contain shadow-2xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{#if zp.zoom > 1}
|
||||||
|
<span
|
||||||
|
class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded bg-background/80 px-2 py-0.5 text-[11px] text-foreground"
|
||||||
|
>
|
||||||
|
{Math.round(zp.zoom * 100)}% · double-click to reset
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -82,11 +82,13 @@
|
|||||||
() =>
|
() =>
|
||||||
patchTargets(
|
patchTargets(
|
||||||
ids,
|
ids,
|
||||||
buildTakenAtPatch(iso),
|
// Per-photo patch so each photo keeps its own UTC↔local
|
||||||
|
// offset when the date is stamped across a selection.
|
||||||
|
(p) => buildTakenAtPatch(iso, p),
|
||||||
label,
|
label,
|
||||||
(p) =>
|
(p) =>
|
||||||
p.TakenAt
|
p.TakenAt
|
||||||
? buildTakenAtPatch(p.TakenAt)
|
? buildTakenAtPatch(p.TakenAt, p)
|
||||||
: ({ TakenSrc: '' } as UpdatePhotoBody)
|
: ({ TakenSrc: '' } as UpdatePhotoBody)
|
||||||
),
|
),
|
||||||
label
|
label
|
||||||
|
|||||||
@@ -13,16 +13,19 @@
|
|||||||
Aperture,
|
Aperture,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Calendar,
|
Calendar,
|
||||||
|
Copy,
|
||||||
File,
|
File,
|
||||||
Folder,
|
Folder,
|
||||||
Globe,
|
Globe,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
Heart,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
MapPin,
|
MapPin,
|
||||||
Star,
|
Star,
|
||||||
Tag,
|
Tag,
|
||||||
Timer,
|
Timer,
|
||||||
|
User,
|
||||||
X
|
X
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import {
|
import {
|
||||||
@@ -37,12 +40,20 @@
|
|||||||
type UpdatePhotoBody
|
type UpdatePhotoBody
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { invalidateFacets } from '$lib/services/bulk';
|
import { invalidateFacets } from '$lib/services/bulk';
|
||||||
|
import { toggleFavorite } from '$lib/services/photoActions';
|
||||||
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||||
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
import {
|
||||||
import { navigateToFolder, navigateToTag } from '$lib/stores/filters.svelte';
|
isVideo,
|
||||||
|
photoNameAndDir,
|
||||||
|
primaryFile,
|
||||||
|
videoFile,
|
||||||
|
type PpPhoto
|
||||||
|
} from '$lib/types/photoprism';
|
||||||
|
import { navigateToFolder, navigateToTag, setSearch, setSection } from '$lib/stores/filters.svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||||
import { countryName } from '$lib/utils/countries';
|
import { countryName } from '$lib/utils/countries';
|
||||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||||
@@ -55,14 +66,19 @@
|
|||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
let basename = $state('');
|
let basename = $state('');
|
||||||
|
let title = $state('');
|
||||||
let caption = $state('');
|
let caption = $state('');
|
||||||
let takenAt = $state('');
|
let takenAt = $state('');
|
||||||
let lat = $state('');
|
let lat = $state('');
|
||||||
let lng = $state('');
|
let lng = $state('');
|
||||||
|
let altitude = $state('');
|
||||||
let country = $state('');
|
let country = $state('');
|
||||||
let keywords = $state<string[]>([]);
|
let keywords = $state<string[]>([]);
|
||||||
let keywordDraft = $state('');
|
let keywordDraft = $state('');
|
||||||
let renaming = $state(false);
|
let renaming = $state(false);
|
||||||
|
let artist = $state('');
|
||||||
|
let copyright = $state('');
|
||||||
|
let license = $state('');
|
||||||
|
|
||||||
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
|
/** Split `pf.Name` (a relative path like `foo/bar/IMG.jpg`) into directory
|
||||||
* prefix and basename. Sidecar's rename endpoint only accepts a bare
|
* prefix and basename. Sidecar's rename endpoint only accepts a bare
|
||||||
@@ -76,16 +92,21 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
const pf = primaryFile(photo);
|
const pf = primaryFile(photo);
|
||||||
basename = splitName(pf.Name ?? '').base;
|
basename = splitName(pf.Name ?? '').base;
|
||||||
|
title = photo.Title ?? '';
|
||||||
caption = photo.Caption ?? '';
|
caption = photo.Caption ?? '';
|
||||||
takenAt = (photo.TakenAt ?? '').slice(0, 10);
|
takenAt = (photo.TakenAt ?? '').slice(0, 10);
|
||||||
lat = photo.Lat ? String(photo.Lat) : '';
|
lat = photo.Lat ? String(photo.Lat) : '';
|
||||||
lng = photo.Lng ? String(photo.Lng) : '';
|
lng = photo.Lng ? String(photo.Lng) : '';
|
||||||
|
altitude = photo.Altitude ? String(photo.Altitude) : '';
|
||||||
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
|
country = photo.Country && photo.Country !== 'zz' ? photo.Country : '';
|
||||||
const det = photo.Details ?? {};
|
const det = photo.Details ?? {};
|
||||||
keywords = (det.Keywords ?? '')
|
keywords = (det.Keywords ?? '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((k) => k.trim())
|
.map((k) => k.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
artist = det.Artist ?? '';
|
||||||
|
copyright = det.Copyright ?? '';
|
||||||
|
license = det.License ?? '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const patchMutation = createMutation(() => ({
|
const patchMutation = createMutation(() => ({
|
||||||
@@ -141,6 +162,11 @@
|
|||||||
if (caption === (photo.Caption ?? '')) return;
|
if (caption === (photo.Caption ?? '')) return;
|
||||||
commit({ Caption: caption, CaptionSrc: 'manual' });
|
commit({ Caption: caption, CaptionSrc: 'manual' });
|
||||||
}
|
}
|
||||||
|
function commitTitle() {
|
||||||
|
const next = title.trim();
|
||||||
|
if (next === (photo.Title ?? '')) return;
|
||||||
|
commit({ Title: next, TitleSrc: 'manual' });
|
||||||
|
}
|
||||||
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
|
const takenAtValid = $derived(takenAt === '' || isValidISODate(takenAt));
|
||||||
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
|
// Path-based date guess. Scoped to the EXIF Stripped review tab: those
|
||||||
// are the photos with definitionally-untrusted dates, and showing the
|
// are the photos with definitionally-untrusted dates, and showing the
|
||||||
@@ -181,14 +207,16 @@
|
|||||||
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
|
const tail = (photo.TakenAt ?? '').slice(10) || 'T00:00:00Z';
|
||||||
const iso = `${takenAt}${tail}`;
|
const iso = `${takenAt}${tail}`;
|
||||||
if (iso === photo.TakenAt) return;
|
if (iso === photo.TakenAt) return;
|
||||||
commit(buildTakenAtPatch(iso));
|
commit(buildTakenAtPatch(iso, photo));
|
||||||
}
|
}
|
||||||
function commitGps() {
|
function commitGps() {
|
||||||
const nlat = parseFloat(lat);
|
const nlat = parseFloat(lat);
|
||||||
const nlng = parseFloat(lng);
|
const nlng = parseFloat(lng);
|
||||||
|
const nalt = parseFloat(altitude);
|
||||||
const patch: UpdatePhotoBody = {};
|
const patch: UpdatePhotoBody = {};
|
||||||
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
|
if (!Number.isNaN(nlat) && nlat !== photo.Lat) patch.Lat = nlat;
|
||||||
if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng;
|
if (!Number.isNaN(nlng) && nlng !== photo.Lng) patch.Lng = nlng;
|
||||||
|
if (!Number.isNaN(nalt) && nalt !== photo.Altitude) patch.Altitude = nalt;
|
||||||
if (Object.keys(patch).length) commit(patch);
|
if (Object.keys(patch).length) commit(patch);
|
||||||
}
|
}
|
||||||
function commitCountry() {
|
function commitCountry() {
|
||||||
@@ -198,7 +226,7 @@
|
|||||||
commit({ Country: next || 'zz', CountrySrc: 'manual' });
|
commit({ Country: next || 'zz', CountrySrc: 'manual' });
|
||||||
}
|
}
|
||||||
|
|
||||||
type DetailsKey = 'Keywords';
|
type DetailsKey = 'Keywords' | 'Artist' | 'Copyright' | 'License';
|
||||||
function commitDetails(field: DetailsKey, value: string) {
|
function commitDetails(field: DetailsKey, value: string) {
|
||||||
const prev = (photo.Details ?? {})[field] ?? '';
|
const prev = (photo.Details ?? {})[field] ?? '';
|
||||||
if (value === prev) return;
|
if (value === prev) return;
|
||||||
@@ -279,7 +307,42 @@
|
|||||||
const currentRating = $derived(photoMark.rating ?? 0);
|
const currentRating = $derived(photoMark.rating ?? 0);
|
||||||
const currentColor = $derived(photoMark.color ?? '');
|
const currentColor = $derived(photoMark.color ?? '');
|
||||||
|
|
||||||
|
// Named face markers across all file variants, deduped by subject.
|
||||||
|
// Slug mirrors PhotoPrism's slugify (lowercase, diacritics stripped,
|
||||||
|
// non-alphanumerics collapsed to '-') so the person link resolves the
|
||||||
|
// same drill URL the sidebar list uses.
|
||||||
|
function personSlug(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize('NFKD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
const peopleChips = $derived.by(() => {
|
||||||
|
const seen = new Map<string, { subjUid: string; name: string; slug: string }>();
|
||||||
|
for (const f of photo.Files ?? []) {
|
||||||
|
for (const m of f.Markers ?? []) {
|
||||||
|
if (m.Invalid || !m.Name || !m.SubjUID || seen.has(m.SubjUID)) continue;
|
||||||
|
seen.set(m.SubjUID, { subjUid: m.SubjUID, name: m.Name, slug: personSlug(m.Name) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.values()];
|
||||||
|
});
|
||||||
|
|
||||||
const pf = $derived(primaryFile(photo));
|
const pf = $derived(primaryFile(photo));
|
||||||
|
// Video facts come from the video variant (primary is often the JPEG
|
||||||
|
// poster for Live Photos / transcoded clips).
|
||||||
|
const vf = $derived(isVideo(photo) ? videoFile(photo) : null);
|
||||||
|
const durationStr = $derived.by(() => {
|
||||||
|
// PpFile.Duration is Go time.Duration → nanoseconds.
|
||||||
|
const ns = vf?.Duration ?? 0;
|
||||||
|
if (ns <= 0) return '';
|
||||||
|
const totalSec = Math.round(ns / 1_000_000_000);
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
});
|
||||||
const dirPath = $derived(splitName(pf.Name ?? '').dir);
|
const dirPath = $derived(splitName(pf.Name ?? '').dir);
|
||||||
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
|
const folderLabel = $derived(dirPath ? `${dirPath}/` : '/');
|
||||||
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
|
const dims = $derived(pf.Width && pf.Height ? `${pf.Width}×${pf.Height}` : '—');
|
||||||
@@ -307,6 +370,36 @@
|
|||||||
const joined = `${make} ${model}`.trim();
|
const joined = `${make} ${model}`.trim();
|
||||||
return joined && joined !== 'Unknown' ? joined : '';
|
return joined && joined !== 'Unknown' ? joined : '';
|
||||||
}
|
}
|
||||||
|
/** Quote for PhotoPrism's q= DSL — mirrors filters.svelte's quoteIfNeeded,
|
||||||
|
* duplicated here since that helper isn't exported. */
|
||||||
|
function quoteTerm(v: string): string {
|
||||||
|
return /^[A-Za-z0-9_-]+$/.test(v) ? v : `"${v.replace(/"/g, '\\"')}"`;
|
||||||
|
}
|
||||||
|
/** Jump to the timeline filtered by a raw DSL term (camera:/lens:) — the
|
||||||
|
* q-DSL escape hatch from the toolbar search box, triggered by click
|
||||||
|
* instead of typing. */
|
||||||
|
async function jumpToSearch(term: string): Promise<void> {
|
||||||
|
setSection('all-photos');
|
||||||
|
setSearch(term);
|
||||||
|
await goto('/', { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
async function copyExif(): Promise<void> {
|
||||||
|
const lines = [
|
||||||
|
cameraStr && `Camera: ${cameraStr}`,
|
||||||
|
lensStr && lensStr !== cameraStr && `Lens: ${lensStr}`,
|
||||||
|
exposureParts.fnum && `Aperture: ${exposureParts.fnum}`,
|
||||||
|
exposureParts.exp && `Shutter: ${exposureParts.exp}`,
|
||||||
|
exposureParts.iso && exposureParts.iso,
|
||||||
|
exposureParts.focal && `Focal length: ${exposureParts.focal}`,
|
||||||
|
photo.TakenAt && `Taken: ${photo.TakenAt}`
|
||||||
|
].filter(Boolean);
|
||||||
|
if (lines.length === 0) {
|
||||||
|
toast.message('No EXIF to copy');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(lines.join('\n'));
|
||||||
|
toast.success('EXIF copied');
|
||||||
|
}
|
||||||
function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } {
|
function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } {
|
||||||
return {
|
return {
|
||||||
iso: p.Iso ? `ISO ${p.Iso}` : '',
|
iso: p.Iso ? `ISO ${p.Iso}` : '',
|
||||||
@@ -474,6 +567,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="space-y-2 p-2 pt-1">
|
<div class="space-y-2 p-2 pt-1">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Title</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add a title…"
|
||||||
|
class="w-full rounded border border-input bg-background px-1.5 py-1 text-xs shadow-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={title}
|
||||||
|
onblur={commitTitle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
|
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">Note</div>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -501,6 +605,19 @@
|
|||||||
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
|
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
<!-- PhotoPrism's native favorite — syncs to mobile gallery apps. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-2 p-0.5 transition-colors {photo.Favorite
|
||||||
|
? 'text-red-500'
|
||||||
|
: 'text-muted-foreground hover:text-foreground'}"
|
||||||
|
onclick={() => void toggleFavorite([photo.UID])}
|
||||||
|
title={photo.Favorite ? 'Remove from favorites (f)' : 'Add to favorites (f)'}
|
||||||
|
aria-pressed={photo.Favorite ?? false}
|
||||||
|
aria-label="Favorite"
|
||||||
|
>
|
||||||
|
<Heart class="h-3.5 w-3.5" fill={photo.Favorite ? 'currentColor' : 'none'} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -557,6 +674,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recognized people — named face markers on this photo's files.
|
||||||
|
Read-only chips linking to the person's page. -->
|
||||||
|
{#if peopleChips.length > 0}
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="text-[10px] uppercase tracking-wide text-muted-foreground">People</div>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
{#each peopleChips as person (person.subjUid)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-1.5 py-0.5 text-[10px] hover:bg-accent"
|
||||||
|
onclick={() => void navigateToTag('people', person.slug)}
|
||||||
|
title={`View photos of ${person.name}`}
|
||||||
|
>
|
||||||
|
<User class="h-2.5 w-2.5 text-muted-foreground" />
|
||||||
|
{person.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-
|
<!-- Auto-labels (PhotoPrism's TensorFlow classifier output). Read-
|
||||||
only: editing labels requires re-indexing on PhotoPrism's
|
only: editing labels requires re-indexing on PhotoPrism's
|
||||||
side. The dashed border + lower contrast distinguishes them
|
side. The dashed border + lower contrast distinguishes them
|
||||||
@@ -631,6 +769,62 @@
|
|||||||
onblur={commitCountry}
|
onblur={commitCountry}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[9px] text-muted-foreground">Altitude (m)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
class="rounded border border-input bg-background px-1 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={altitude}
|
||||||
|
onblur={commitGps}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<!-- Credits — IPTC provenance fields (Artist / Copyright / License).
|
||||||
|
Closed by default; persists once opened. -->
|
||||||
|
<details
|
||||||
|
class="rounded border border-border"
|
||||||
|
open={getMetadataSectionOpen('credits', false)}
|
||||||
|
ontoggle={(e) => setMetadataSection('credits', e.currentTarget.open)}
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
|
>
|
||||||
|
Credits
|
||||||
|
</summary>
|
||||||
|
<div class="space-y-1.5 p-2 pt-1">
|
||||||
|
<label class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[9px] text-muted-foreground">Artist</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Photographer…"
|
||||||
|
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={artist}
|
||||||
|
onblur={() => commitDetails('Artist', artist.trim())}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[9px] text-muted-foreground">Copyright</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="© …"
|
||||||
|
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={copyright}
|
||||||
|
onblur={() => commitDetails('Copyright', copyright.trim())}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[9px] text-muted-foreground">License</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. CC BY-NC 4.0"
|
||||||
|
class="rounded border border-input bg-background px-1.5 py-0.5 text-[10px] focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
bind:value={license}
|
||||||
|
onblur={() => commitDetails('License', license.trim())}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -641,20 +835,51 @@
|
|||||||
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
|
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
|
||||||
>
|
>
|
||||||
<summary
|
<summary
|
||||||
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
class="flex cursor-pointer items-center justify-between px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||||
>
|
>
|
||||||
<span class="inline-flex items-center gap-1">
|
<span class="inline-flex items-center gap-1">
|
||||||
<ImageIcon class="h-3 w-3" /> File
|
<ImageIcon class="h-3 w-3" /> File
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="normal-case text-muted-foreground hover:text-foreground"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void copyExif();
|
||||||
|
}}
|
||||||
|
title="Copy EXIF summary"
|
||||||
|
aria-label="Copy EXIF summary"
|
||||||
|
>
|
||||||
|
<Copy class="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</summary>
|
</summary>
|
||||||
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
|
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
|
||||||
{#if cameraStr}
|
{#if cameraStr}
|
||||||
<dt class="text-muted-foreground">Camera</dt>
|
<dt class="text-muted-foreground">Camera</dt>
|
||||||
<dd class="text-foreground/80">{cameraStr}</dd>
|
<dd class="min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="truncate text-left text-foreground/80 hover:text-foreground hover:underline"
|
||||||
|
onclick={() => void jumpToSearch(`camera:${quoteTerm(cameraStr)}`)}
|
||||||
|
title={`View other photos taken with ${cameraStr}`}
|
||||||
|
>
|
||||||
|
{cameraStr}
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if lensStr && lensStr !== cameraStr}
|
{#if lensStr && lensStr !== cameraStr}
|
||||||
<dt class="text-muted-foreground">Lens</dt>
|
<dt class="text-muted-foreground">Lens</dt>
|
||||||
<dd class="text-foreground/80">{lensStr}</dd>
|
<dd class="min-w-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="truncate text-left text-foreground/80 hover:text-foreground hover:underline"
|
||||||
|
onclick={() => void jumpToSearch(`lens:${quoteTerm(lensStr)}`)}
|
||||||
|
title={`View other photos taken with ${lensStr}`}
|
||||||
|
>
|
||||||
|
{lensStr}
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
{/if}
|
{/if}
|
||||||
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
|
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
|
||||||
<dt class="text-muted-foreground">Exposure</dt>
|
<dt class="text-muted-foreground">Exposure</dt>
|
||||||
@@ -675,6 +900,18 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<dt class="text-muted-foreground">Type</dt>
|
<dt class="text-muted-foreground">Type</dt>
|
||||||
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
|
<dd class="text-foreground/80">{pf.FileType ?? photo.Type ?? '—'}</dd>
|
||||||
|
{#if durationStr}
|
||||||
|
<dt class="text-muted-foreground">Duration</dt>
|
||||||
|
<dd class="text-foreground/80">{durationStr}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if vf?.FPS}
|
||||||
|
<dt class="text-muted-foreground">FPS</dt>
|
||||||
|
<dd class="text-foreground/80">{Math.round(vf.FPS * 10) / 10}</dd>
|
||||||
|
{/if}
|
||||||
|
{#if vf?.Codec}
|
||||||
|
<dt class="text-muted-foreground">Codec</dt>
|
||||||
|
<dd class="text-foreground/80">{vf.Codec}</dd>
|
||||||
|
{/if}
|
||||||
<dt class="text-muted-foreground">Hash</dt>
|
<dt class="text-muted-foreground">Hash</dt>
|
||||||
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}…</dd>
|
<dd class="break-all font-mono text-foreground/70">{pf.Hash?.slice(0, 16) ?? '—'}…</dd>
|
||||||
<dt class="text-muted-foreground">Indexed</dt>
|
<dt class="text-muted-foreground">Indexed</dt>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
import {
|
import {
|
||||||
aggregateKeywords,
|
aggregateKeywords,
|
||||||
@@ -7,11 +9,13 @@
|
|||||||
listLabels,
|
listLabels,
|
||||||
listPhotosByUids,
|
listPhotosByUids,
|
||||||
listSubjects,
|
listSubjects,
|
||||||
|
listUnnamedFaces,
|
||||||
type AggregatedKeyword,
|
type AggregatedKeyword,
|
||||||
type PhotoMarksMap,
|
type PhotoMarksMap,
|
||||||
type PpCountry,
|
type PpCountry,
|
||||||
type PpLabel,
|
type PpLabel,
|
||||||
type PpSubject
|
type PpSubject,
|
||||||
|
type UnnamedFaceCluster
|
||||||
} from '$lib/services/photoprism';
|
} from '$lib/services/photoprism';
|
||||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||||
import { nearBottom } from '$lib/actions/nearBottom';
|
import { nearBottom } from '$lib/actions/nearBottom';
|
||||||
@@ -25,7 +29,7 @@
|
|||||||
import { countryFlag, countryName } from '$lib/utils/countries';
|
import { countryFlag, countryName } from '$lib/utils/countries';
|
||||||
import type { PpPhoto } from '$lib/types/photoprism';
|
import type { PpPhoto } from '$lib/types/photoprism';
|
||||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||||
import { Globe, Hash, Tag, User } from 'lucide-svelte';
|
import { Globe, Hash, Tag, User, UserPlus } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
category: TagCategory;
|
category: TagCategory;
|
||||||
@@ -34,6 +38,29 @@
|
|||||||
}
|
}
|
||||||
const { category, selectedValue, onSelect }: Props = $props();
|
const { category, selectedValue, onSelect }: Props = $props();
|
||||||
|
|
||||||
|
// "Name new faces" is a pinned row, not a subject — it needs to stay
|
||||||
|
// reachable even after every detected face has been named once (there's
|
||||||
|
// always another to catch as the library grows), so it lives outside
|
||||||
|
// the value-drives-URL selection model the rest of this sidebar uses.
|
||||||
|
// Query key matches NewFacesPanel's so the two share one cache entry.
|
||||||
|
const unnamedFacesQuery = createQuery<UnnamedFaceCluster[]>(() => ({
|
||||||
|
queryKey: ['faces', 'unnamed'],
|
||||||
|
queryFn: listUnnamedFaces,
|
||||||
|
enabled: isAuthenticated() && category === 'people',
|
||||||
|
staleTime: 60_000
|
||||||
|
}));
|
||||||
|
const unnamedFacesCount = $derived(unnamedFacesQuery.data?.length ?? 0);
|
||||||
|
const newFacesActive = $derived(page.url.searchParams.get('view') === 'new-faces');
|
||||||
|
function showNewFaces() {
|
||||||
|
// Deliberately NOT onSelect/navigateToTag — that drives the
|
||||||
|
// `[[value]]` route param, and the auto-select-first-tag effect
|
||||||
|
// below immediately overwrites a null value with the first real
|
||||||
|
// person, which is exactly the trap this row exists to escape.
|
||||||
|
// The `view` query param is independent state the page reads to
|
||||||
|
// show the naming panel instead of (or alongside) the photo grid.
|
||||||
|
void goto('/tags/people?view=new-faces', { keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
let filterText = $state('');
|
let filterText = $state('');
|
||||||
|
|
||||||
// Reset the inline filter input whenever the user switches categories so
|
// Reset the inline filter input whenever the user switches categories so
|
||||||
@@ -243,8 +270,16 @@
|
|||||||
// fires when there's genuinely no selection — once a value is picked
|
// fires when there's genuinely no selection — once a value is picked
|
||||||
// (by the user or by this effect), the URL drives selectedValue and
|
// (by the user or by this effect), the URL drives selectedValue and
|
||||||
// the effect no-ops.
|
// the effect no-ops.
|
||||||
|
//
|
||||||
|
// `newFacesActive` additionally suppresses it for People: navigating
|
||||||
|
// to the pinned "Name new faces" row necessarily clears selectedValue
|
||||||
|
// (it targets a bare `/tags/people` URL), and without this guard this
|
||||||
|
// effect would immediately redirect straight back to the first named
|
||||||
|
// person in the same tick — permanently hiding the naming workflow
|
||||||
|
// again the moment a second person exists to auto-select into.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (selectedValue != null) return;
|
if (selectedValue != null) return;
|
||||||
|
if (newFacesActive) return;
|
||||||
if (firstValue == null) return;
|
if (firstValue == null) return;
|
||||||
onSelect(firstValue, { replace: true });
|
onSelect(firstValue, { replace: true });
|
||||||
});
|
});
|
||||||
@@ -416,6 +451,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if category === 'people'}
|
{:else if category === 'people'}
|
||||||
|
<!-- Pinned above the named-people list (and shown regardless of its
|
||||||
|
loading/empty/error state) so naming stays reachable even after
|
||||||
|
every currently-detected face has a name — new faces keep
|
||||||
|
appearing as the library grows. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-full shrink-0 items-center gap-2 border-b border-border px-3 text-left text-[12px] leading-tight hover:bg-accent"
|
||||||
|
class:bg-primary={newFacesActive}
|
||||||
|
class:text-primary-foreground={newFacesActive}
|
||||||
|
class:hover:bg-primary={newFacesActive}
|
||||||
|
onclick={showNewFaces}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full {newFacesActive
|
||||||
|
? 'bg-primary-foreground/15'
|
||||||
|
: 'bg-secondary'}"
|
||||||
|
>
|
||||||
|
<UserPlus class="h-3 w-3 {newFacesActive ? '' : 'text-muted-foreground'}" />
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate font-medium">Name new faces</span>
|
||||||
|
{#if unnamedFacesCount > 0}
|
||||||
|
<span
|
||||||
|
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {newFacesActive
|
||||||
|
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||||
|
: 'bg-secondary text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{unnamedFacesCount}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
{#if subjectsQuery.isPending}
|
{#if subjectsQuery.isPending}
|
||||||
<InlineLoader size="sm" label="Loading people…" />
|
<InlineLoader size="sm" label="Loading people…" />
|
||||||
{:else if subjectsQuery.isError}
|
{:else if subjectsQuery.isError}
|
||||||
@@ -427,7 +492,7 @@
|
|||||||
title={filterText ? 'No people match the filter' : 'No people yet'}
|
title={filterText ? 'No people match the filter' : 'No people yet'}
|
||||||
description={filterText
|
description={filterText
|
||||||
? undefined
|
? undefined
|
||||||
: 'PhotoPrism creates a person whenever it clusters detected faces. Make sure face recognition is enabled and indexed.'}
|
: 'A person appears here once you name a detected face — use the "Name new faces" cards on the right.'}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
|||||||
@@ -17,8 +17,9 @@
|
|||||||
import { view } from "$lib/stores/view.svelte";
|
import { view } from "$lib/stores/view.svelte";
|
||||||
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
||||||
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
|
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
|
||||||
|
import { toggleFavorite } from "$lib/services/photoActions";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import { Loader2, Check, X } from "lucide-svelte";
|
import { Loader2, Check, Heart, X } from "lucide-svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
photo: PpPhoto;
|
photo: PpPhoto;
|
||||||
@@ -191,4 +192,28 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
<!--
|
||||||
|
Favorite heart — a *sibling* of the tile button (nested buttons are
|
||||||
|
invalid HTML and break click semantics). Filled + always visible when
|
||||||
|
favorited; otherwise fades in on hover. Mirrors the `f` shortcut.
|
||||||
|
-->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute bottom-1.5 right-1.5 z-10 rounded-full bg-background/70 p-1 backdrop-blur transition-opacity {photo.Favorite
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 focus-visible:opacity-100 group-hover:opacity-100'}"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void toggleFavorite([photo.UID]);
|
||||||
|
}}
|
||||||
|
ondblclick={(e) => e.stopPropagation()}
|
||||||
|
title={photo.Favorite ? "Remove from favorites (f)" : "Add to favorites (f)"}
|
||||||
|
aria-pressed={photo.Favorite ?? false}
|
||||||
|
aria-label="Favorite"
|
||||||
|
>
|
||||||
|
<Heart
|
||||||
|
class="h-3.5 w-3.5 {photo.Favorite ? 'text-red-500' : 'text-foreground/80'}"
|
||||||
|
fill={photo.Favorite ? "currentColor" : "none"}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ export const queryClient = new QueryClient({
|
|||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
retry: 1
|
retry: 1,
|
||||||
|
// The indexer WebSocket (stores/indexer.svelte.ts) already
|
||||||
|
// invalidates ['photos'] and friends on live changes, so a
|
||||||
|
// window-focus refetch only adds a redundant full-timeline
|
||||||
|
// re-render (visible flash) every time the tab regains focus.
|
||||||
|
refetchOnWindowFocus: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export async function listDuplicateGroups(basePath?: string): Promise<DuplicateG
|
|||||||
|
|
||||||
const photos = await listPhotos({
|
const photos = await listPhotos({
|
||||||
q,
|
q,
|
||||||
count: 200,
|
count: 500,
|
||||||
merged: true,
|
merged: true,
|
||||||
order: 'newest'
|
order: 'newest'
|
||||||
});
|
});
|
||||||
|
|||||||
230
web/src/lib/services/duplicateActions.svelte.ts
Normal file
230
web/src/lib/services/duplicateActions.svelte.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
batchArchive,
|
batchArchive,
|
||||||
batchRestore,
|
batchRestore,
|
||||||
buildTakenAtPatch,
|
buildTakenAtPatch,
|
||||||
|
likePhoto,
|
||||||
|
unlikePhoto,
|
||||||
updatePhoto
|
updatePhoto
|
||||||
} from './photoprism';
|
} from './photoprism';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
@@ -102,7 +104,7 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
|||||||
originalName: p.OriginalName,
|
originalName: p.OriginalName,
|
||||||
path
|
path
|
||||||
});
|
});
|
||||||
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`));
|
if (guess) await updatePhoto(p, buildTakenAtPatch(`${guess.iso}T00:00:00Z`, p));
|
||||||
}
|
}
|
||||||
await approvePhoto(id);
|
await approvePhoto(id);
|
||||||
return id;
|
return id;
|
||||||
@@ -121,6 +123,70 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
|||||||
toast.success(`Kept ${uids.length}`, { id: tid });
|
toast.success(`Kept ${uids.length}`, { id: tid });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Patch `Favorite` on every cached copy of the uids (timeline pages,
|
||||||
|
* per-photo detail) so hearts flip instantly without a refetch. */
|
||||||
|
function patchFavoriteCaches(uids: string[], value: boolean): void {
|
||||||
|
const target = new Set(uids);
|
||||||
|
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
|
||||||
|
for (const [key, data] of lists) {
|
||||||
|
if (!data) continue;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
key,
|
||||||
|
(data as PpPhoto[]).map((p) => (target.has(p.UID) ? { ...p, Favorite: value } : p))
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||||
|
if (!Array.isArray(pages)) continue;
|
||||||
|
queryClient.setQueryData(key, {
|
||||||
|
...(data as object),
|
||||||
|
pages: pages.map((pg) =>
|
||||||
|
pg.map((p) => (target.has(p.UID) ? { ...p, Favorite: value } : p))
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const uid of uids) {
|
||||||
|
const p = queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
||||||
|
if (p) queryClient.setQueryData(['photo', uid], { ...p, Favorite: value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle PhotoPrism's native favorite flag on a set of photos. Target
|
||||||
|
* state comes from the first uid (mixed selections converge). Optimistic
|
||||||
|
* cache flip with rollback; undo re-toggles.
|
||||||
|
*/
|
||||||
|
export async function toggleFavorite(uids: string[]): Promise<void> {
|
||||||
|
if (uids.length === 0) {
|
||||||
|
toast.message('Nothing to favorite', {
|
||||||
|
description: 'Click a photo or select some first'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = !(cachedPhoto(uids[0])?.Favorite ?? false);
|
||||||
|
patchFavoriteCaches(uids, value);
|
||||||
|
const { errors } = await batchEdit(uids, (id) => (value ? likePhoto(id) : unlikePhoto(id)));
|
||||||
|
if (errors.length) {
|
||||||
|
patchFavoriteCaches(uids, !value);
|
||||||
|
toast.error(`Favorite failed on ${errors.length}`, { description: errors[0].message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
value
|
||||||
|
? uids.length === 1
|
||||||
|
? 'Added to favorites'
|
||||||
|
: `Favorited ${uids.length}`
|
||||||
|
: uids.length === 1
|
||||||
|
? 'Removed from favorites'
|
||||||
|
: `Unfavorited ${uids.length}`
|
||||||
|
);
|
||||||
|
pushUndo(value ? `Favorited ${uids.length}` : `Unfavorited ${uids.length}`, async () => {
|
||||||
|
patchFavoriteCaches(uids, !value);
|
||||||
|
await batchEdit(uids, (id) => (value ? unlikePhoto(id) : likePhoto(id)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
|
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -343,6 +343,8 @@ export async function getPhoto(uid: string): Promise<PpPhoto> {
|
|||||||
*/
|
*/
|
||||||
export interface UpdatePhotoBody {
|
export interface UpdatePhotoBody {
|
||||||
OriginalName?: string;
|
OriginalName?: string;
|
||||||
|
Title?: string;
|
||||||
|
TitleSrc?: 'manual' | '';
|
||||||
Caption?: string;
|
Caption?: string;
|
||||||
CaptionSrc?: 'manual' | '';
|
CaptionSrc?: 'manual' | '';
|
||||||
Archived?: boolean;
|
Archived?: boolean;
|
||||||
@@ -373,17 +375,43 @@ export function isValidISODate(s: string): boolean {
|
|||||||
return d.toISOString().slice(0, 10) === s;
|
return d.toISOString().slice(0, 10) === s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildTakenAtPatch(iso: string): UpdatePhotoBody {
|
/** PhotoPrism serializes TakenAtLocal with a `Z` suffix even though it's
|
||||||
|
* semantically wall-clock time in the photo's TimeZone. Force-parse as
|
||||||
|
* UTC so offset math never picks up the *browser's* timezone. */
|
||||||
|
function parseAsUtc(s: string): number {
|
||||||
|
return Date.parse(/(Z|[+-]\d{2}:?\d{2})$/.test(s) ? s : s + 'Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `photo` supplies the existing TakenAt/TakenAtLocal pair so the photo's
|
||||||
|
* UTC↔local offset survives the edit. Without it (or without a prior
|
||||||
|
* pair) local falls back to UTC — correct for TimeZone-less photos.
|
||||||
|
* Previously this forced `TakenAtLocal = UTC`, which both let PhotoPrism
|
||||||
|
* clobber manual edits when recomputing local time from TimeZone and
|
||||||
|
* shifted Year/Month/Day for photos taken far from UTC.
|
||||||
|
*/
|
||||||
|
export function buildTakenAtPatch(
|
||||||
|
iso: string,
|
||||||
|
photo?: { TakenAt?: string; TakenAtLocal?: string }
|
||||||
|
): UpdatePhotoBody {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
if (Number.isNaN(d.getTime())) return {};
|
if (Number.isNaN(d.getTime())) return {};
|
||||||
const utc = d.toISOString().replace(/\.\d+Z$/, 'Z');
|
const utc = d.toISOString().replace(/\.\d+Z$/, 'Z');
|
||||||
|
let offsetMs = 0;
|
||||||
|
if (photo?.TakenAt && photo?.TakenAtLocal) {
|
||||||
|
const a = parseAsUtc(photo.TakenAt);
|
||||||
|
const b = parseAsUtc(photo.TakenAtLocal);
|
||||||
|
if (!Number.isNaN(a) && !Number.isNaN(b)) offsetMs = b - a;
|
||||||
|
}
|
||||||
|
const local = new Date(d.getTime() + offsetMs);
|
||||||
return {
|
return {
|
||||||
TakenAt: utc,
|
TakenAt: utc,
|
||||||
TakenAtLocal: utc,
|
TakenAtLocal: local.toISOString().replace(/\.\d+Z$/, 'Z'),
|
||||||
TakenSrc: 'manual',
|
TakenSrc: 'manual',
|
||||||
Year: d.getUTCFullYear(),
|
// PhotoPrism derives Year/Month/Day from local wall-clock time.
|
||||||
Month: d.getUTCMonth() + 1,
|
Year: local.getUTCFullYear(),
|
||||||
Day: d.getUTCDate()
|
Month: local.getUTCMonth() + 1,
|
||||||
|
Day: local.getUTCDate()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,35 +722,6 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
|||||||
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
|
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hasPhotosMatching(q: string): Promise<boolean> {
|
|
||||||
const resp = await sidecar.get<PpPhoto[]>('/api/sidecar/timeline', {
|
|
||||||
params: { count: 1, offset: 0, q }
|
|
||||||
});
|
|
||||||
return Array.isArray(resp.data) && resp.data.length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function filterByUserPhotos<T>(
|
|
||||||
items: T[],
|
|
||||||
queryFor: (item: T) => string
|
|
||||||
): Promise<T[]> {
|
|
||||||
if (userBasePath() === '') return items;
|
|
||||||
const CONCURRENCY = 8;
|
|
||||||
const out: T[] = [];
|
|
||||||
for (let i = 0; i < items.length; i += CONCURRENCY) {
|
|
||||||
const batch = items.slice(i, i + CONCURRENCY);
|
|
||||||
const checks = await Promise.all(
|
|
||||||
batch.map(async (item) => ({
|
|
||||||
item,
|
|
||||||
has: await hasPhotosMatching(queryFor(item))
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
for (const { item, has } of checks) {
|
|
||||||
if (has) out.push(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listLabels(): Promise<PpLabel[]> {
|
export async function listLabels(): Promise<PpLabel[]> {
|
||||||
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
||||||
// low-confidence classifier hits, manually-removed labels). They're
|
// low-confidence classifier hits, manually-removed labels). They're
|
||||||
@@ -762,10 +761,42 @@ export interface PpSubject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listSubjects(): Promise<PpSubject[]> {
|
export async function listSubjects(): Promise<PpSubject[]> {
|
||||||
const { data } = await http.get<PpSubject[]>('/subjects', {
|
// Sidecar proxy scopes PhotoCount (and drops out-of-scope people) with
|
||||||
|
// one SQL pass, replacing the old client-side probe-per-subject filter.
|
||||||
|
const { data } = await sidecar.get<PpSubject[]>('/api/sidecar/subjects', {
|
||||||
params: { count: 1000, order: 'count' }
|
params: { count: 1000, order: 'count' }
|
||||||
});
|
});
|
||||||
return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`);
|
return data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Face clusters (unnamed people) ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// PhotoPrism only creates a Subject once someone names a detected face
|
||||||
|
// cluster. The sidecar lists clusters awaiting a name (scoped to the
|
||||||
|
// caller's BasePath); naming goes through PhotoPrism's own flow — a PUT
|
||||||
|
// on the cluster's representative marker — which creates the Subject and
|
||||||
|
// propagates it across the whole cluster.
|
||||||
|
|
||||||
|
export interface UnnamedFaceCluster {
|
||||||
|
faceId: string;
|
||||||
|
count: number;
|
||||||
|
/** Marker crop hash — renders via the standard thumb endpoint. */
|
||||||
|
thumb: string;
|
||||||
|
markerUid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listUnnamedFaces(): Promise<UnnamedFaceCluster[]> {
|
||||||
|
const { data } = await sidecar.get<{ clusters: UnnamedFaceCluster[] }>(
|
||||||
|
'/api/sidecar/faces/unnamed'
|
||||||
|
);
|
||||||
|
return data?.clusters ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function nameFaceCluster(markerUid: string, name: string): Promise<void> {
|
||||||
|
await http.put(`/markers/${encodeURIComponent(markerUid)}`, {
|
||||||
|
Name: name,
|
||||||
|
SubjSrc: 'manual'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
|
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
|
||||||
@@ -949,6 +980,9 @@ export async function deleteFolder(relPath: string): Promise<{ path: string }> {
|
|||||||
export interface DupFileEntry {
|
export interface DupFileEntry {
|
||||||
path: string;
|
path: string;
|
||||||
size: number;
|
size: number;
|
||||||
|
/** RFC3339 mtime — the only per-copy signal besides path, since all
|
||||||
|
* copies in a group are byte-identical. */
|
||||||
|
modTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CrossFolderDuplicateGroup {
|
export interface CrossFolderDuplicateGroup {
|
||||||
@@ -981,6 +1015,21 @@ export async function archiveDuplicatePaths(
|
|||||||
return callSidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
|
return callSidecar('POST', '/duplicates/archive', { paths }) as Promise<ArchiveDuplicatesResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RestoreDuplicatesResult {
|
||||||
|
restored: { from: string; to: string }[];
|
||||||
|
errors: { path: string; error: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of archiveDuplicatePaths: pass the `moved` pairs from the
|
||||||
|
* archive response verbatim and the sidecar renames each quarantined
|
||||||
|
* file back to its original path. Powers undo for duplicate/stack
|
||||||
|
* resolution. */
|
||||||
|
export async function restoreDuplicatePaths(
|
||||||
|
moves: { from: string; to: string }[]
|
||||||
|
): Promise<RestoreDuplicatesResult> {
|
||||||
|
return callSidecar('POST', '/duplicates/restore', { moves }) as Promise<RestoreDuplicatesResult>;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Heap convert (move/copy heap photos to a folder) ────────────────────────
|
// ── Heap convert (move/copy heap photos to a folder) ────────────────────────
|
||||||
// Lives on the sidecar because moving the underlying files is a filesystem
|
// Lives on the sidecar because moving the underlying files is a filesystem
|
||||||
// operation PhotoPrism's API doesn't expose. The sidecar lists album members
|
// operation PhotoPrism's API doesn't expose. The sidecar lists album members
|
||||||
@@ -1002,6 +1051,8 @@ export interface HeapConvertBody {
|
|||||||
export interface HeapConvertResult {
|
export interface HeapConvertResult {
|
||||||
moved: number;
|
moved: number;
|
||||||
copied: number;
|
copied: number;
|
||||||
|
/** Per-file {from,to} pairs for move mode — the undo payload. */
|
||||||
|
movedFiles: { from: string; to: string }[];
|
||||||
errors: { uid: string; reason: string }[];
|
errors: { uid: string; reason: string }[];
|
||||||
heap_deleted: boolean;
|
heap_deleted: boolean;
|
||||||
}
|
}
|
||||||
@@ -1029,6 +1080,8 @@ export interface PhotosMoveBody {
|
|||||||
export interface PhotosMoveResult {
|
export interface PhotosMoveResult {
|
||||||
moved: number;
|
moved: number;
|
||||||
copied: number;
|
copied: number;
|
||||||
|
/** Per-file {from,to} pairs for move mode — the undo payload. */
|
||||||
|
movedFiles: { from: string; to: string }[];
|
||||||
errors: { uid: string; reason: string }[];
|
errors: { uid: string; reason: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1036,6 +1089,21 @@ export async function movePhotosToFolder(body: PhotosMoveBody): Promise<PhotosMo
|
|||||||
return callSidecar('POST', '/photos/move', body) as Promise<PhotosMoveResult>;
|
return callSidecar('POST', '/photos/move', body) as Promise<PhotosMoveResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RestoreMovesResult {
|
||||||
|
restored: { from: string; to: string }[];
|
||||||
|
errors: { path: string; error: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of a photo/heap move: pass the `movedFiles` pairs from the
|
||||||
|
* move response verbatim and the sidecar renames each file back to its
|
||||||
|
* original folder (both ends scope-checked, no clobbering). Powers ⌘Z
|
||||||
|
* undo for moves. */
|
||||||
|
export async function restoreMoves(
|
||||||
|
moves: { from: string; to: string }[]
|
||||||
|
): Promise<RestoreMovesResult> {
|
||||||
|
return callSidecar('POST', '/files/restore-moves', { moves }) as Promise<RestoreMovesResult>;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Reparent a folder (move the directory under a different parent) ──────────
|
// ── Reparent a folder (move the directory under a different parent) ──────────
|
||||||
|
|
||||||
export interface FolderMoveResult {
|
export interface FolderMoveResult {
|
||||||
@@ -1050,6 +1118,18 @@ export async function moveFolder(rel: string, targetParent: string): Promise<Fol
|
|||||||
}) as Promise<FolderMoveResult>;
|
}) as Promise<FolderMoveResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Favorites ────────────────────────────────────────────────────────────────
|
||||||
|
// PhotoPrism's native favorite flag — unlike marks, this syncs to any
|
||||||
|
// PhotoPrism-compatible client app.
|
||||||
|
|
||||||
|
export async function likePhoto(uid: string): Promise<void> {
|
||||||
|
await http.post(`/photos/${encodeURIComponent(uid)}/like`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unlikePhoto(uid: string): Promise<void> {
|
||||||
|
await http.delete(`/photos/${encodeURIComponent(uid)}/like`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Photo marks (rating + color) ─────────────────────────────────────────────
|
// ── Photo marks (rating + color) ─────────────────────────────────────────────
|
||||||
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
|
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
|
||||||
// internal fields). We store them in mule-sidecar instead.
|
// internal fields). We store them in mule-sidecar instead.
|
||||||
|
|||||||
@@ -41,6 +41,25 @@ export function isTagCategory(v: unknown): v is TagCategory {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SortOrder = 'newest' | 'oldest' | 'added' | 'name';
|
||||||
|
export const SORT_ORDERS: readonly SortOrder[] = ['newest', 'oldest', 'added', 'name'] as const;
|
||||||
|
export const SORT_LABELS: Record<SortOrder, string> = {
|
||||||
|
newest: 'Newest first',
|
||||||
|
oldest: 'Oldest first',
|
||||||
|
added: 'Recently added',
|
||||||
|
name: 'File name'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Media-type chip values → PhotoPrism boolean q-DSL filters. */
|
||||||
|
export type MediaType = 'photo' | 'video' | 'raw' | 'live';
|
||||||
|
export const MEDIA_TYPES: readonly MediaType[] = ['photo', 'video', 'raw', 'live'] as const;
|
||||||
|
export const MEDIA_TYPE_LABELS: Record<MediaType, string> = {
|
||||||
|
photo: 'Photos',
|
||||||
|
video: 'Videos',
|
||||||
|
raw: 'RAW',
|
||||||
|
live: 'Live'
|
||||||
|
};
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
section: Section;
|
section: Section;
|
||||||
/** Heap UID, used when section === 'heap'. */
|
/** Heap UID, used when section === 'heap'. */
|
||||||
@@ -49,6 +68,14 @@ export interface FilterState {
|
|||||||
folderPath: string | null;
|
folderPath: string | null;
|
||||||
/** Free-form search text, ANDed with section-derived terms. */
|
/** Free-form search text, ANDed with section-derived terms. */
|
||||||
search: string;
|
search: string;
|
||||||
|
/** Timeline sort order. Maps straight onto PhotoPrism's `order` param. */
|
||||||
|
sort: SortOrder;
|
||||||
|
/** Media-type chip; null = any. */
|
||||||
|
mediaType: MediaType | null;
|
||||||
|
/** Year chip; null = any. Compiles to `year:<n>`. */
|
||||||
|
year: number | null;
|
||||||
|
/** Favorites-only chip. Compiles to `favorite:true`. */
|
||||||
|
favorite: boolean;
|
||||||
/**
|
/**
|
||||||
* Active tag-browser category and selected value. Set by the
|
* Active tag-browser category and selected value. Set by the
|
||||||
* `/tags/[category]/[[value]]` route on navigation. Labels/keywords
|
* `/tags/[category]/[[value]]` route on navigation. Labels/keywords
|
||||||
@@ -68,10 +95,42 @@ export const filters = $state<FilterState>({
|
|||||||
heapUid: null,
|
heapUid: null,
|
||||||
folderPath: '/',
|
folderPath: '/',
|
||||||
search: '',
|
search: '',
|
||||||
|
sort: 'newest',
|
||||||
|
mediaType: null,
|
||||||
|
year: null,
|
||||||
|
favorite: false,
|
||||||
tagCategory: null,
|
tagCategory: null,
|
||||||
tagValue: null
|
tagValue: null
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function setSort(sort: SortOrder): void {
|
||||||
|
filters.sort = sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMediaType(t: MediaType | null): void {
|
||||||
|
filters.mediaType = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setYear(y: number | null): void {
|
||||||
|
filters.year = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFavorite(on: boolean): void {
|
||||||
|
filters.favorite = on;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when any toolbar chip narrows the view (excludes sort — a sort
|
||||||
|
* isn't a filter). Drives the "Clear" affordance. */
|
||||||
|
export function chipsActive(f: FilterState = filters): boolean {
|
||||||
|
return f.mediaType !== null || f.year !== null || f.favorite;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearChips(): void {
|
||||||
|
filters.mediaType = null;
|
||||||
|
filters.year = null;
|
||||||
|
filters.favorite = false;
|
||||||
|
}
|
||||||
|
|
||||||
export function setSection(section: Section, heapUid: string | null = null): void {
|
export function setSection(section: Section, heapUid: string | null = null): void {
|
||||||
filters.section = section;
|
filters.section = section;
|
||||||
filters.heapUid = section === 'heap' ? heapUid : null;
|
filters.heapUid = section === 'heap' ? heapUid : null;
|
||||||
@@ -250,7 +309,19 @@ export function filtersToQ(f: FilterState = filters): string {
|
|||||||
parts.push(`country:${quoteIfNeeded(f.tagValue)}`);
|
parts.push(`country:${quoteIfNeeded(f.tagValue)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (f.search) parts.push(quoteIfNeeded(f.search));
|
// Toolbar chips. PhotoPrism's boolean media filters (`video:true`,
|
||||||
|
// `photo:true`, …) are the documented DSL forms; `year:` and
|
||||||
|
// `favorite:` are plain filters.
|
||||||
|
if (f.mediaType) parts.push(`${f.mediaType}:true`);
|
||||||
|
if (f.year) parts.push(`year:${f.year}`);
|
||||||
|
if (f.favorite) parts.push('favorite:true');
|
||||||
|
// `f.search` is the raw-DSL escape hatch (toolbar cheat-sheet examples
|
||||||
|
// like `label:dog`, `taken:2024`) as well as plain free text. Only
|
||||||
|
// quote it when it has no `:` — a colon means the user (or a
|
||||||
|
// jump-to-search link) already wrote a structured term, and wrapping
|
||||||
|
// the whole thing in quotes would turn `camera:iPhone` into a literal
|
||||||
|
// phrase search for the text "camera:iPhone" instead of the operator.
|
||||||
|
if (f.search) parts.push(f.search.includes(':') ? f.search : quoteIfNeeded(f.search));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,11 +343,22 @@ export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
|||||||
!params.has('q');
|
!params.has('q');
|
||||||
const folderRaw = params.get('folder');
|
const folderRaw = params.get('folder');
|
||||||
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
|
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
|
||||||
|
const sortRaw = params.get('sort');
|
||||||
|
const typeRaw = params.get('type');
|
||||||
|
const yearRaw = params.get('year');
|
||||||
return {
|
return {
|
||||||
section,
|
section,
|
||||||
heapUid: params.get('heap'),
|
heapUid: params.get('heap'),
|
||||||
folderPath,
|
folderPath,
|
||||||
search: params.get('q') ?? ''
|
search: params.get('q') ?? '',
|
||||||
|
sort: (SORT_ORDERS as readonly string[]).includes(sortRaw ?? '')
|
||||||
|
? (sortRaw as SortOrder)
|
||||||
|
: 'newest',
|
||||||
|
mediaType: (MEDIA_TYPES as readonly string[]).includes(typeRaw ?? '')
|
||||||
|
? (typeRaw as MediaType)
|
||||||
|
: null,
|
||||||
|
year: yearRaw && /^\d{4}$/.test(yearRaw) ? parseInt(yearRaw, 10) : null,
|
||||||
|
favorite: params.get('fav') === '1'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,5 +370,9 @@ export function filtersToUrlParams(f: FilterState = filters): URLSearchParams {
|
|||||||
if (f.heapUid) params.set('heap', f.heapUid);
|
if (f.heapUid) params.set('heap', f.heapUid);
|
||||||
if (f.folderPath) params.set('folder', f.folderPath);
|
if (f.folderPath) params.set('folder', f.folderPath);
|
||||||
if (f.search) params.set('q', f.search);
|
if (f.search) params.set('q', f.search);
|
||||||
|
if (f.sort !== 'newest') params.set('sort', f.sort);
|
||||||
|
if (f.mediaType) params.set('type', f.mediaType);
|
||||||
|
if (f.year) params.set('year', String(f.year));
|
||||||
|
if (f.favorite) params.set('fav', '1');
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export const view = $state<{
|
|||||||
* persisted — a refresh always returns to the grid.
|
* persisted — a refresh always returns to the grid.
|
||||||
*/
|
*/
|
||||||
previewOpen: boolean;
|
previewOpen: boolean;
|
||||||
|
/** Ephemeral: true while the keyboard-shortcuts overlay is open. */
|
||||||
|
shortcutsOpen: boolean;
|
||||||
|
/** Ephemeral: true while the ⌘K command palette is open. */
|
||||||
|
paletteOpen: boolean;
|
||||||
metadataSections: Record<string, boolean>;
|
metadataSections: Record<string, boolean>;
|
||||||
}>({
|
}>({
|
||||||
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
||||||
@@ -107,6 +111,8 @@ export const view = $state<{
|
|||||||
),
|
),
|
||||||
tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false,
|
tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false,
|
||||||
previewOpen: false,
|
previewOpen: false,
|
||||||
|
shortcutsOpen: false,
|
||||||
|
paletteOpen: false,
|
||||||
metadataSections:
|
metadataSections:
|
||||||
initial.metadataSections && typeof initial.metadataSections === 'object'
|
initial.metadataSections && typeof initial.metadataSections === 'object'
|
||||||
? { ...initial.metadataSections }
|
? { ...initial.metadataSections }
|
||||||
@@ -164,6 +170,22 @@ export function togglePreview(): void {
|
|||||||
view.previewOpen = !view.previewOpen;
|
view.previewOpen = !view.previewOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toggleShortcuts(): void {
|
||||||
|
view.shortcutsOpen = !view.shortcutsOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeShortcuts(): void {
|
||||||
|
view.shortcutsOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function togglePalette(): void {
|
||||||
|
view.paletteOpen = !view.paletteOpen;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closePalette(): void {
|
||||||
|
view.paletteOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
export function setThumbnailSize(size: ThumbnailSize): void {
|
export function setThumbnailSize(size: ThumbnailSize): void {
|
||||||
view.thumbnailSize = size;
|
view.thumbnailSize = size;
|
||||||
persist();
|
persist();
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export interface PpPhoto {
|
|||||||
Height?: number;
|
Height?: number;
|
||||||
Rating?: number;
|
Rating?: number;
|
||||||
Color?: string | number;
|
Color?: string | number;
|
||||||
|
Favorite?: boolean;
|
||||||
Archived?: boolean;
|
Archived?: boolean;
|
||||||
Files?: PpFile[];
|
Files?: PpFile[];
|
||||||
Lat?: number;
|
Lat?: number;
|
||||||
@@ -256,10 +257,17 @@ export function isVideo(p: PpPhoto): boolean {
|
|||||||
|
|
||||||
/** Return the Files[] entry that carries the actual video stream. Falls back
|
/** Return the Files[] entry that carries the actual video stream. Falls back
|
||||||
* to primaryFile() if no video MediaType is present (shouldn't happen for
|
* to primaryFile() if no video MediaType is present (shouldn't happen for
|
||||||
* Type === 'video' but keeps the call site total). */
|
* Type === 'video' but keeps the call site total).
|
||||||
|
*
|
||||||
|
* PhotoPrism serializes MediaType as the bare word "video" (verified
|
||||||
|
* against prod), not a MIME type — the old `startsWith('video/')` check
|
||||||
|
* never matched, so this always fell back to the JPEG poster and video
|
||||||
|
* facts (duration/codec/fps) were unreachable. */
|
||||||
export function videoFile(p: PpPhoto): PpFile {
|
export function videoFile(p: PpPhoto): PpFile {
|
||||||
const files = p.Files ?? [];
|
const files = p.Files ?? [];
|
||||||
const v = files.find((f) => f.MediaType?.startsWith('video/'));
|
const v = files.find(
|
||||||
|
(f) => f.MediaType === 'video' || f.MediaType?.startsWith('video/')
|
||||||
|
);
|
||||||
return v ?? primaryFile(p);
|
return v ?? primaryFile(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,6 +283,34 @@ export interface PpFile {
|
|||||||
Size?: number;
|
Size?: number;
|
||||||
FileType?: string;
|
FileType?: string;
|
||||||
MediaType?: string;
|
MediaType?: string;
|
||||||
|
Codec?: string;
|
||||||
|
/** Video duration in nanoseconds (Go time.Duration serialization). */
|
||||||
|
Duration?: number;
|
||||||
|
FPS?: number;
|
||||||
|
Frames?: number;
|
||||||
|
/** EXIF orientation 1–8. */
|
||||||
|
Orientation?: number;
|
||||||
|
HDR?: boolean;
|
||||||
|
Projection?: string;
|
||||||
|
/** Face/subject markers detected in this file (present on
|
||||||
|
* GET /photos/:uid responses). */
|
||||||
|
Markers?: PpMarker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A detected region in a file — for our purposes always a face. Named
|
||||||
|
* markers carry the subject they were matched to. */
|
||||||
|
export interface PpMarker {
|
||||||
|
UID: string;
|
||||||
|
Type?: string;
|
||||||
|
Src?: string;
|
||||||
|
Name?: string;
|
||||||
|
SubjUID?: string;
|
||||||
|
SubjSrc?: string;
|
||||||
|
FaceID?: string;
|
||||||
|
Invalid?: boolean;
|
||||||
|
Score?: number;
|
||||||
|
/** Crop hash renderable via the standard thumb endpoint. */
|
||||||
|
Thumb?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PpThumbSize =
|
export type PpThumbSize =
|
||||||
|
|||||||
@@ -20,6 +20,9 @@
|
|||||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||||
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
||||||
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
|
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
|
||||||
|
import ShortcutsDialog from '$lib/components/layout/ShortcutsDialog.svelte';
|
||||||
|
import CommandPalette from '$lib/components/layout/CommandPalette.svelte';
|
||||||
|
import { togglePalette } from '$lib/stores/view.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -58,8 +61,18 @@
|
|||||||
else stopIndexerWatch();
|
else stopIndexerWatch();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ⌘K lives at the window level (not gridKeyNav) so the palette opens
|
||||||
|
// from any route and even while a form field holds focus.
|
||||||
|
function onGlobalKey(e: KeyboardEvent) {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePalette();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onGlobalKey} />
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Mulimage</title>
|
<title>Mulimage</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
@@ -122,6 +135,10 @@
|
|||||||
store. Opened from the heap/folder kebabs, the BulkActionBar
|
store. Opened from the heap/folder kebabs, the BulkActionBar
|
||||||
button, and the `m` shortcut — all through openMove(). -->
|
button, and the `m` shortcut — all through openMove(). -->
|
||||||
<MoveToFolderDialog />
|
<MoveToFolderDialog />
|
||||||
|
<!-- Keyboard-shortcut reference, toggled by `?` via gridKeyNav. -->
|
||||||
|
<ShortcutsDialog />
|
||||||
|
<!-- ⌘K palette — jump to sections/heaps/folders + global actions. -->
|
||||||
|
<CommandPalette />
|
||||||
{:else}
|
{:else}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -17,14 +17,26 @@
|
|||||||
type PpAlbum,
|
type PpAlbum,
|
||||||
} from "$lib/services/photoprism";
|
} from "$lib/services/photoprism";
|
||||||
import {
|
import {
|
||||||
|
chipsActive,
|
||||||
|
clearChips,
|
||||||
consumePendingFocus,
|
consumePendingFocus,
|
||||||
filters,
|
filters,
|
||||||
filtersToQ,
|
filtersToQ,
|
||||||
filtersToUrlParams,
|
filtersToUrlParams,
|
||||||
|
MEDIA_TYPE_LABELS,
|
||||||
|
MEDIA_TYPES,
|
||||||
parseUrlParams,
|
parseUrlParams,
|
||||||
|
setFavorite,
|
||||||
|
setMediaType,
|
||||||
setSearch,
|
setSearch,
|
||||||
setSection,
|
setSection,
|
||||||
|
setSort,
|
||||||
|
setYear,
|
||||||
|
SORT_LABELS,
|
||||||
|
SORT_ORDERS,
|
||||||
|
type MediaType,
|
||||||
type PendingFocus,
|
type PendingFocus,
|
||||||
|
type SortOrder,
|
||||||
} from "$lib/stores/filters.svelte";
|
} from "$lib/stores/filters.svelte";
|
||||||
import { isAuthenticated } from "$lib/stores/session.svelte";
|
import { isAuthenticated } from "$lib/stores/session.svelte";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
@@ -81,6 +93,10 @@
|
|||||||
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
||||||
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
|
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
|
||||||
if (next.search !== undefined) filters.search = next.search;
|
if (next.search !== undefined) filters.search = next.search;
|
||||||
|
if (next.sort !== undefined) filters.sort = next.sort;
|
||||||
|
if (next.mediaType !== undefined) filters.mediaType = next.mediaType;
|
||||||
|
if (next.year !== undefined) filters.year = next.year;
|
||||||
|
if (next.favorite !== undefined) filters.favorite = next.favorite;
|
||||||
});
|
});
|
||||||
|
|
||||||
// When the store changes from in-app actions (left-sidebar nav, search
|
// When the store changes from in-app actions (left-sidebar nav, search
|
||||||
@@ -182,15 +198,20 @@
|
|||||||
"photos",
|
"photos",
|
||||||
"q",
|
"q",
|
||||||
filtersToQ(filters),
|
filtersToQ(filters),
|
||||||
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
|
{
|
||||||
|
count: PHOTOS_PAGE_SIZE,
|
||||||
|
anchor: anchor?.takenAt ?? null,
|
||||||
|
sort: filters.sort,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
queryFn: ({ pageParam }) => {
|
queryFn: ({ pageParam }) => {
|
||||||
const offset = pageParam as number;
|
const offset = pageParam as number;
|
||||||
const baseQ = filtersToQ(filters);
|
const baseQ = filtersToQ(filters);
|
||||||
// Page 0 + anchor → load a window around the anchor's date.
|
// Page 0 + anchor → load a window around the anchor's date.
|
||||||
// Subsequent pages aren't reachable in anchor mode (see
|
// Subsequent pages aren't reachable in anchor mode (see
|
||||||
// getNextPageParam).
|
// getNextPageParam). Anchor windows assume chronological order,
|
||||||
if (offset === 0 && anchor?.takenAt) {
|
// so any non-default sort falls back to plain paging.
|
||||||
|
if (offset === 0 && anchor?.takenAt && filters.sort === "newest") {
|
||||||
return listPhotosAround({
|
return listPhotosAround({
|
||||||
q: baseQ,
|
q: baseQ,
|
||||||
takenAt: anchor.takenAt,
|
takenAt: anchor.takenAt,
|
||||||
@@ -203,7 +224,7 @@
|
|||||||
q: baseQ,
|
q: baseQ,
|
||||||
count: PHOTOS_PAGE_SIZE,
|
count: PHOTOS_PAGE_SIZE,
|
||||||
offset,
|
offset,
|
||||||
order: "newest",
|
order: filters.sort,
|
||||||
merged: true,
|
merged: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -222,7 +243,7 @@
|
|||||||
// photos exceeding the page size keep the cursor at the same
|
// photos exceeding the page size keep the cursor at the same
|
||||||
// value). To "see more," the user clears the anchor by
|
// value). To "see more," the user clears the anchor by
|
||||||
// navigating fresh.
|
// navigating fresh.
|
||||||
if (anchor?.takenAt) return undefined;
|
if (anchor?.takenAt && filters.sort === "newest") return undefined;
|
||||||
return pages.length * PHOTOS_PAGE_SIZE;
|
return pages.length * PHOTOS_PAGE_SIZE;
|
||||||
},
|
},
|
||||||
enabled: isAuthenticated(),
|
enabled: isAuthenticated(),
|
||||||
@@ -800,6 +821,16 @@
|
|||||||
setSearch(searchDraft.trim());
|
setSearch(searchDraft.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Toolbar chips: years from the current year back to 1990 — static
|
||||||
|
// range keeps it dependency-free; PhotoPrism just returns an empty
|
||||||
|
// page for years with no photos.
|
||||||
|
const CHIP_YEARS = Array.from(
|
||||||
|
{ length: new Date().getFullYear() - 1989 },
|
||||||
|
(_, i) => new Date().getFullYear() - i,
|
||||||
|
);
|
||||||
|
const CHIP_SELECT_CLASS =
|
||||||
|
"rounded border border-input bg-background px-1.5 py-0.5 text-[11px] text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-ring";
|
||||||
|
|
||||||
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
|
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
|
||||||
// focus turns the placeholder hint into a clickable cheat-sheet.
|
// focus turns the placeholder hint into a clickable cheat-sheet.
|
||||||
const SEARCH_EXAMPLES = [
|
const SEARCH_EXAMPLES = [
|
||||||
@@ -843,6 +874,7 @@
|
|||||||
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
|
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
|
data-search-input
|
||||||
placeholder={'Search · label:website / "vacation"'}
|
placeholder={'Search · label:website / "vacation"'}
|
||||||
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
bind:value={searchDraft}
|
bind:value={searchDraft}
|
||||||
@@ -898,6 +930,68 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Filter chips: sort / media type / year / favorites. Compile into
|
||||||
|
the same q-DSL the search box feeds, so they stack with search,
|
||||||
|
folders, and sections. URL-persisted for shareable views. -->
|
||||||
|
<div class="flex shrink-0 items-center gap-1" role="group" aria-label="Filters">
|
||||||
|
<select
|
||||||
|
class={CHIP_SELECT_CLASS}
|
||||||
|
value={filters.sort}
|
||||||
|
onchange={(e) => setSort(e.currentTarget.value as SortOrder)}
|
||||||
|
title="Sort order"
|
||||||
|
aria-label="Sort order"
|
||||||
|
>
|
||||||
|
{#each SORT_ORDERS as s (s)}
|
||||||
|
<option value={s}>{SORT_LABELS[s]}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
class={CHIP_SELECT_CLASS}
|
||||||
|
value={filters.mediaType ?? ""}
|
||||||
|
onchange={(e) => setMediaType((e.currentTarget.value || null) as MediaType | null)}
|
||||||
|
title="Media type"
|
||||||
|
aria-label="Media type"
|
||||||
|
>
|
||||||
|
<option value="">Any type</option>
|
||||||
|
{#each MEDIA_TYPES as t (t)}
|
||||||
|
<option value={t}>{MEDIA_TYPE_LABELS[t]}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
class={CHIP_SELECT_CLASS}
|
||||||
|
value={filters.year ? String(filters.year) : ""}
|
||||||
|
onchange={(e) => setYear(e.currentTarget.value ? parseInt(e.currentTarget.value, 10) : null)}
|
||||||
|
title="Year"
|
||||||
|
aria-label="Year"
|
||||||
|
>
|
||||||
|
<option value="">Any year</option>
|
||||||
|
{#each CHIP_YEARS as y (y)}
|
||||||
|
<option value={String(y)}>{y}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded border px-1.5 py-0.5 text-[11px] transition-colors {filters.favorite
|
||||||
|
? 'border-red-400 bg-red-500/10 text-red-500'
|
||||||
|
: 'border-input text-muted-foreground hover:bg-accent'}"
|
||||||
|
aria-pressed={filters.favorite}
|
||||||
|
onclick={() => setFavorite(!filters.favorite)}
|
||||||
|
title="Favorites only (f toggles a photo's favorite)"
|
||||||
|
>
|
||||||
|
♥ Favorites
|
||||||
|
</button>
|
||||||
|
{#if chipsActive(filters)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-1.5 py-0.5 text-[11px] text-muted-foreground underline-offset-2 hover:underline"
|
||||||
|
onclick={clearChips}
|
||||||
|
title="Clear type / year / favorites filters"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
|
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
|
||||||
Persisted to localStorage via view.svelte.ts. -->
|
Persisted to localStorage via view.svelte.ts. -->
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
import { countryName } from '$lib/utils/countries';
|
import { countryName } from '$lib/utils/countries';
|
||||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||||
|
import NewFacesPanel from '$lib/components/people/NewFacesPanel.svelte';
|
||||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||||
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
|
||||||
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
|
||||||
@@ -52,6 +53,16 @@
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Independent of selectedValue on purpose: the People sidebar's pinned
|
||||||
|
// "Name new faces" row sets this query param instead of the `[[value]]`
|
||||||
|
// route param, specifically so it can't be clobbered by the auto-
|
||||||
|
// select-first-tag effect (TagsBrowserSidebar) that fires whenever
|
||||||
|
// selectedValue is null — that effect is exactly what made the naming
|
||||||
|
// panel unreachable again after the first person was named.
|
||||||
|
const showNewFaces = $derived(
|
||||||
|
category === 'people' && page.url.searchParams.get('view') === 'new-faces'
|
||||||
|
);
|
||||||
|
|
||||||
// Mirror URL into the shared filter store so any other consumer of
|
// Mirror URL into the shared filter store so any other consumer of
|
||||||
// `filters` (e.g. cross-route navigation back to `/`) sees the active
|
// `filters` (e.g. cross-route navigation back to `/`) sees the active
|
||||||
// tag filter, and so `filtersToQ()` produces the correct DSL clause
|
// tag filter, and so `filtersToQ()` produces the correct DSL clause
|
||||||
@@ -82,6 +93,10 @@
|
|||||||
heapUid: null,
|
heapUid: null,
|
||||||
folderPath: null,
|
folderPath: null,
|
||||||
search: '',
|
search: '',
|
||||||
|
sort: 'newest',
|
||||||
|
mediaType: null,
|
||||||
|
year: null,
|
||||||
|
favorite: false,
|
||||||
tagCategory: category,
|
tagCategory: category,
|
||||||
tagValue: selectedValue
|
tagValue: selectedValue
|
||||||
})
|
})
|
||||||
@@ -203,7 +218,7 @@
|
|||||||
{#if category}
|
{#if category}
|
||||||
<span class="text-[11px] capitalize text-muted-foreground">{category}</span>
|
<span class="text-[11px] capitalize text-muted-foreground">{category}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if selectedValue}
|
{#if selectedValue && !showNewFaces}
|
||||||
<span class="text-[11px] font-medium">{drillTitle}</span>
|
<span class="text-[11px] font-medium">{drillTitle}</span>
|
||||||
<span class="text-[11px] text-muted-foreground">
|
<span class="text-[11px] text-muted-foreground">
|
||||||
{drillCount} photo{drillCount === 1 ? '' : 's'}
|
{drillCount} photo{drillCount === 1 ? '' : 's'}
|
||||||
@@ -211,7 +226,23 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
|
|
||||||
{#if !selectedValue}
|
{#if category === 'people' && (showNewFaces || !selectedValue)}
|
||||||
|
<!-- Naming workflow: reachable both on bare landing (no person picked
|
||||||
|
yet) and via the sidebar's pinned "Name new faces" row at any time
|
||||||
|
— the latter is what makes it possible to get back here after the
|
||||||
|
first person's been named, once the auto-select-first-tag effect
|
||||||
|
would otherwise always jump straight to an existing person. -->
|
||||||
|
<main class="min-h-0 flex-1 overflow-y-auto p-6">
|
||||||
|
<NewFacesPanel />
|
||||||
|
<div class="mt-8 flex items-center justify-center">
|
||||||
|
<EmptyState
|
||||||
|
icon={Tag}
|
||||||
|
title="Pick a person from the sidebar"
|
||||||
|
description="Click a row in the panel on the left to see that person's photos."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{:else if !selectedValue}
|
||||||
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
|
<main class="flex min-h-0 flex-1 items-center justify-center p-8">
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={Tag}
|
icon={Tag}
|
||||||
|
|||||||
Reference in New Issue
Block a user