Compare commits
20 Commits
claude/inf
...
0f65bfb94a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f65bfb94a | |||
| 9ba8d625bc | |||
| 4f04c1f7b0 | |||
| 9ef1b4c2f9 | |||
| ac7d0ac2eb | |||
| 312a4c1ee4 | |||
| e578e1ce75 | |||
| 6cbabda86b | |||
| 634abc2a95 | |||
| ba5684d120 | |||
| 74bae78270 | |||
| 52ab3b6840 | |||
| 400b215036 | |||
| e1e508671e | |||
| a52f171946 | |||
| e124809ad5 | |||
| ad6e733622 | |||
| 6d9b236ef6 | |||
| 669e5fde33 | |||
| 277fdc5a53 |
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
|
||||
`PP_READONLY` and gates its own backwrite / import paths.
|
||||
|
||||
## Mobile & third-party apps (per-user)
|
||||
|
||||
PhotoPrism CE does **not** enforce `auth_users.base_path` on API reads —
|
||||
any authenticated user can search the whole library. The sidecar
|
||||
therefore ships a scoping proxy at `/api/v1/*` (see
|
||||
[`sidecar/handlers_ppproxy.go`](sidecar/handlers_ppproxy.go)) and the
|
||||
reverse proxy routes the public `/api/v1` there instead of straight to
|
||||
PhotoPrism. Result: any PhotoPrism-compatible app pointed at the site
|
||||
sees only the logged-in user's photos.
|
||||
|
||||
- **Server URL for apps**: the site itself (e.g.
|
||||
`https://photos.hubris.network`). Known-good client:
|
||||
[Gallery for PhotoPrism](https://github.com/Radiokot/photoprism-android-client)
|
||||
(Android/F-Droid).
|
||||
- **Login**: the user's normal username/password. For OIDC accounts (no
|
||||
password), mint an app password:
|
||||
`docker exec pp-app photoprism auth add -n "gallery" -s "*" <username>`
|
||||
and use it as the password in the app.
|
||||
- **What's scoped**: photo/geo searches, per-photo reads and edits,
|
||||
batch operations, downloads by UID. Hash-addressed media (thumbnails,
|
||||
video streams, file downloads) is token-guarded and passes through.
|
||||
- **What's shared** (CE has no per-user variants of these): album
|
||||
*names*, labels, and people — the photos inside them stay scoped.
|
||||
Album zip downloads are generated by PhotoPrism and are not scoped.
|
||||
- **Uploads**: the reconciler mirrors `base_path` into `upload_path`,
|
||||
so WebDAV/app uploads land inside the user's own subtree.
|
||||
- Sessions with the `admin` role bypass the proxy scoping entirely (the
|
||||
web client's settings/users/index dialogs need the raw API).
|
||||
|
||||
## Dev iteration loop
|
||||
|
||||
For fast iteration on the sidecar without rebuilding its image on every
|
||||
|
||||
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -85,3 +87,35 @@ func ctxBasePath(c *gin.Context) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// userScopeRoot returns the absolute directory the session may mutate:
|
||||
// ORIGINALS_ROOT/<BasePath> for scoped users, the whole originals root for
|
||||
// admins (empty BasePath). Lexical join only — callers compare it against
|
||||
// paths built the same way from cfg.OriginalsRoot.
|
||||
func userScopeRoot(c *gin.Context, cfg *Config) string {
|
||||
base := strings.Trim(ctxBasePath(c), "/")
|
||||
if base == "" {
|
||||
return cfg.OriginalsRoot
|
||||
}
|
||||
return filepath.Join(cfg.OriginalsRoot, base)
|
||||
}
|
||||
|
||||
// requireUserScope guards an already-root-resolved absolute path against
|
||||
// the caller's BasePath. PhotoPrism scopes what a session can *see* by
|
||||
// BasePath, but the sidecar's filesystem endpoints accept raw paths, so
|
||||
// every mutation must re-check that boundary here. `strict` additionally
|
||||
// rejects the scope root itself — renaming/deleting/moving the user's own
|
||||
// base folder would detach their library from auth_users.base_path.
|
||||
// Writes the 403 response and returns false when out of bounds.
|
||||
func requireUserScope(c *gin.Context, cfg *Config, abs string, strict bool) bool {
|
||||
scope := userScopeRoot(c, cfg)
|
||||
if !sameOrUnder(abs, scope) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library"})
|
||||
return false
|
||||
}
|
||||
if strict && abs == scope {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify your library root"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
50
sidecar/auth_test.go
Normal file
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,21 @@ type Mark struct {
|
||||
// nothing surprising lands in the schema.
|
||||
func (Mark) TableName() string { return "marks" }
|
||||
|
||||
// UserPref holds the per-user, server-side preferences PhotoPrism's account
|
||||
// model has no slot for. Today that's just `IndexPath` — the originals-
|
||||
// relative sub-folder (under the user's BasePath) the web client re-roots the
|
||||
// Library tree to and scopes the reindex to. Empty string = "whole folder".
|
||||
// Keyed by username so each user has independent prefs, matching `Mark`.
|
||||
type UserPref struct {
|
||||
UserName string `gorm:"primaryKey;size:128;column:user_name" json:"-"`
|
||||
IndexPath string `gorm:"size:1024;column:index_path" json:"indexPath"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"-"`
|
||||
}
|
||||
|
||||
// TableName pins the table name (GORM would pluralise to `user_prefs` anyway,
|
||||
// but pin it explicitly to stay consistent with Mark).
|
||||
func (UserPref) TableName() string { return "user_prefs" }
|
||||
|
||||
// asJSON returns the wire shape clients expect — same flat object the
|
||||
// Node prototype emitted. An empty Mark (rating=nil, color=nil) renders
|
||||
// as `{}` which the client treats as "no mark on this photo".
|
||||
@@ -56,7 +71,7 @@ func openDB(dsn string) (*gorm.DB, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.AutoMigrate(&Mark{}); err != nil {
|
||||
if err := db.AutoMigrate(&Mark{}, &UserPref{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
|
||||
@@ -108,6 +108,37 @@ func uniqueName(destDir, basename string) (abs, name string, ok bool) {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// uniqueStem finds a base name (extension stripped) that is free for *every*
|
||||
// extension in `exts` under destDir, appending `-1`, `-2`, … on collision —
|
||||
// the multi-file analogue of uniqueName. Moving a photo's originals siblings
|
||||
// (e.g. IMG_1234.JPG + IMG_1234.MOV) under a single shared stem keeps
|
||||
// PhotoPrism stacking them as one photo after reindex; picking the stem once
|
||||
// for the whole group is what stops the video from being orphaned under a
|
||||
// differently-suffixed name than its poster. Caps at 1000 attempts to match
|
||||
// uniqueName. The passed extensions keep their on-disk case (we compare
|
||||
// case-sensitively via os.Stat, which is correct on the case-sensitive
|
||||
// volumes PhotoPrism targets).
|
||||
func uniqueStem(destDir, primaryBase string, exts []string) (stem string, ok bool) {
|
||||
base := strings.TrimSuffix(primaryBase, filepath.Ext(primaryBase))
|
||||
for i := 0; i < 1000; i++ {
|
||||
candidate := base
|
||||
if i > 0 {
|
||||
candidate = base + "-" + itoa(i)
|
||||
}
|
||||
free := true
|
||||
for _, ext := range exts {
|
||||
if _, err := os.Stat(filepath.Join(destDir, candidate+ext)); !errors.Is(err, os.ErrNotExist) {
|
||||
free = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if free {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// itoa is the tiny stdlib-free formatter we use inside hot loops.
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
|
||||
70
sidecar/handlers_countries.go
Normal file
70
sidecar/handlers_countries.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PpCountry is the country aggregation row returned to the client: a
|
||||
// 2-letter ISO 3166-1 code, the user-scoped photo count, and a representative
|
||||
// thumb hash for the sidebar row.
|
||||
type PpCountry struct {
|
||||
Code string `json:"Code"`
|
||||
PhotoCount int `json:"PhotoCount"`
|
||||
Thumb string `json:"Thumb"`
|
||||
}
|
||||
|
||||
// handleCountries aggregates photos.photo_country directly against
|
||||
// PhotoPrism's DB (no upstream proxy needed — this is a simple GROUP BY)
|
||||
// and scopes the result to the caller's BasePath, mirroring handleLabels.
|
||||
//
|
||||
// Route: GET /api/sidecar/countries (behind requireSession, ppDb != nil)
|
||||
func handleCountries(ppDb *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
basePath := ctxBasePath(c)
|
||||
|
||||
type countryStat struct {
|
||||
Code string `gorm:"column:code"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
ThumbHash string `gorm:"column:thumb_hash"`
|
||||
}
|
||||
var stats []countryStat
|
||||
|
||||
query := ppDb.Table("photos p").
|
||||
Select(`p.photo_country AS code,
|
||||
COUNT(DISTINCT p.id) AS cnt,
|
||||
COALESCE(MIN(f.file_hash), '') AS thumb_hash`).
|
||||
Joins(`LEFT JOIN files f ON f.photo_uid = p.photo_uid
|
||||
AND f.file_primary = 1
|
||||
AND f.file_missing = 0`).
|
||||
Where("p.deleted_at IS NULL").
|
||||
Where("p.photo_country != '' AND p.photo_country != 'zz'")
|
||||
|
||||
if basePath != "" {
|
||||
prefix := basePath + "/%"
|
||||
query = query.Where("(p.photo_path = ? OR p.photo_path LIKE ?)", basePath, prefix)
|
||||
}
|
||||
|
||||
if err := query.
|
||||
Group("p.photo_country").
|
||||
Having("cnt > 0").
|
||||
Order("cnt DESC").
|
||||
Scan(&stats).Error; err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "country stats query failed"})
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]PpCountry, 0, len(stats))
|
||||
for _, s := range stats {
|
||||
out = append(out, PpCountry{
|
||||
Code: s.Code,
|
||||
PhotoCount: int(s.Cnt),
|
||||
Thumb: s.ThumbHash,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const quarantineDir = ".duplicates"
|
||||
@@ -23,10 +25,10 @@ type dupFileLite struct {
|
||||
}
|
||||
|
||||
type dupGroup struct {
|
||||
Hash string `json:"hash"`
|
||||
Size int64 `json:"size"`
|
||||
IndexedPath *string `json:"indexedPath"`
|
||||
Files []dupFileLite `json:"files"`
|
||||
Hash string `json:"hash"`
|
||||
Size int64 `json:"size"`
|
||||
IndexedPath *string `json:"indexedPath"`
|
||||
Files []dupFileLite `json:"files"`
|
||||
}
|
||||
|
||||
// dupListPhoto is the partial photo shape we pull from PhotoPrism when
|
||||
@@ -36,17 +38,41 @@ type dupListPhoto struct {
|
||||
Files []ppFile `json:"Files"`
|
||||
}
|
||||
|
||||
func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
func handleDupScan(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
start := time.Now()
|
||||
slog.Info("dup.scan starting", "root", cfg.OriginalsRoot)
|
||||
|
||||
all, err := walkFiles(cfg.OriginalsRoot)
|
||||
// Scope the walk to the user's effective library root (BasePath +
|
||||
// chosen index sub-path), same as the folders/timeline/reindex scope —
|
||||
// otherwise a narrowed root would still surface every other user's
|
||||
// files in the cross-folder duplicate scan. "" means whole library
|
||||
// (today's admin-without-BasePath default).
|
||||
root := effectiveLibraryRoot(c, db)
|
||||
scanRoot := cfg.OriginalsRoot
|
||||
if root != "" {
|
||||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, root, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid library root"})
|
||||
return
|
||||
}
|
||||
scanRoot = abs
|
||||
}
|
||||
slog.Info("dup.scan starting", "root", scanRoot)
|
||||
|
||||
all, err := walkFiles(scanRoot)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// walkFiles computes RelPath relative to scanRoot; re-prefix with the
|
||||
// scoped sub-path so RelPath stays originals-root-relative, matching
|
||||
// what handleDupArchive (and the rest of the API) expects.
|
||||
if root != "" {
|
||||
for i := range all {
|
||||
all[i].RelPath = root + "/" + all[i].RelPath
|
||||
}
|
||||
}
|
||||
|
||||
// Group by size first: byte-identical files necessarily share size,
|
||||
// so size-collision is a cheap O(N) prefilter that lets us skip
|
||||
@@ -149,7 +175,7 @@ type dupArchiveErr struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
func handleDupArchive(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
var body dupArchiveBody
|
||||
@@ -158,6 +184,23 @@ func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// Authz: every path must live under the caller's effective library
|
||||
// root. The scan above already only ever returns paths from there,
|
||||
// but this endpoint takes paths straight from the request body, so a
|
||||
// scoped (non-admin, or admin-with-sub-path) user could otherwise
|
||||
// pass an arbitrary originals-relative path and archive (move) files
|
||||
// outside their own folder.
|
||||
root := effectiveLibraryRoot(c, db)
|
||||
if root != "" {
|
||||
for _, p := range body.Paths {
|
||||
clean := strings.Trim(p, "/")
|
||||
if clean != root && !strings.HasPrefix(clean, root+"/") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library root"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each archive batch lands in its own timestamped subdir so the
|
||||
// user can browse what was quarantined when (and recover by hand
|
||||
// if they change their mind).
|
||||
|
||||
@@ -47,6 +47,9 @@ func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
if !requireUserScope(c, cfg, abs, true) {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(abs); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "already exists"})
|
||||
return
|
||||
@@ -92,6 +95,9 @@ func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
if !requireUserScope(c, cfg, oldAbs, true) {
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(oldAbs)
|
||||
if err != nil || !st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||
@@ -142,6 +148,9 @@ func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"})
|
||||
return
|
||||
}
|
||||
if !requireUserScope(c, cfg, abs, true) {
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil || !st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||
|
||||
@@ -67,4 +67,4 @@ func handleFoldersProxy(pp *ppClient) gin.HandlerFunc {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"folders": filtered})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +90,13 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||
return
|
||||
}
|
||||
// Pull the heap's photos via the q=album:UID query. count=1000 covers
|
||||
// every realistic heap; merged=true expands stacked variants so we
|
||||
// move the JPG/HEIC sibling alongside the primary.
|
||||
if !requireUserScope(c, cfg, targetAbs, false) {
|
||||
return
|
||||
}
|
||||
// Pull the heap's membership via the q=album:UID query (count=1000
|
||||
// covers every realistic heap). We only need the UID list here — the
|
||||
// search's Files array is trimmed and drops videos, so we re-resolve
|
||||
// each photo's full file set below via resolvePhotosFull.
|
||||
q := url.QueryEscape("album:" + albumUID)
|
||||
listURL := "/api/v1/photos?q=" + q + "&count=1000&merged=true"
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
|
||||
@@ -104,17 +108,31 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
|
||||
return
|
||||
}
|
||||
var photos []heapPhoto
|
||||
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
||||
var listed []heapPhoto
|
||||
if err := json.Unmarshal(resp.Body, &listed); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
|
||||
return
|
||||
}
|
||||
uids := make([]string, 0, len(listed))
|
||||
for _, p := range listed {
|
||||
uids = append(uids, p.UID)
|
||||
}
|
||||
|
||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
|
||||
// Re-fetch each photo's complete file list so videos (and other multi-
|
||||
// file photos) move whole — the album search alone would orphan the
|
||||
// .mov. See resolvePhotosFull.
|
||||
photos, resolveErrs, err := resolvePhotosFull(c.Request.Context(), pp, token, uids)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
errs = append(resolveErrs, errs...)
|
||||
|
||||
heapDeleted := false
|
||||
if deleteHeap {
|
||||
@@ -152,7 +170,10 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
// and handlePhotosMove (UID-list scoped); both resolve `photos` differently
|
||||
// but move them identically. Returns per-photo errors in `errs`; the returned
|
||||
// top-level error is only for a fatal precondition (subfolder mkdir failed).
|
||||
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode string) (moved, copied int, errs []heapErr, err error) {
|
||||
// `scopeAbs` is the caller's userScopeRoot — source files outside it fail
|
||||
// per-photo, so a UID that resolves outside the user's BasePath (however
|
||||
// PhotoPrism came to return it) can't be used to pull files across users.
|
||||
func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto, targetAbs, subfolder, mode, scopeAbs string) (moved, copied int, errs []heapErr, err error) {
|
||||
destAbs := targetAbs
|
||||
if subfolder != "" {
|
||||
destAbs = filepath.Join(targetAbs, subfolder)
|
||||
@@ -165,77 +186,124 @@ func movePhotoFiles(cfg *Config, pp *ppClient, token string, photos []heapPhoto,
|
||||
errs = []heapErr{}
|
||||
|
||||
for _, photo := range photos {
|
||||
// Pick the file to physically move. PhotoPrism's "primary" file
|
||||
// for a HEIC photo is the generated `.HEIC.jpg` preview that
|
||||
// lives in storage/sidecar (Root=="sidecar"), not in originals
|
||||
// — moving that path would fail "file missing on disk" every
|
||||
// time. Prefer the primary that lives in originals (Root=="/")
|
||||
// and fall back to the first originals-rooted file. PhotoPrism
|
||||
// regenerates sidecars on reindex, so they don't need to follow.
|
||||
var file ppFile
|
||||
found := false
|
||||
// Gather *every* originals-rooted file of the photo, not just the
|
||||
// primary. A video, Live Photo, or RAW+JPG pair keeps several files
|
||||
// under Root "/" (e.g. the poster IMG.JPG and its IMG.MOV), and they
|
||||
// must travel together — moving only the primary orphans the rest, so
|
||||
// the photo looks "moved" in PhotoPrism (the poster defines its path)
|
||||
// while the actual video is left behind and silently breaks. Sidecar-
|
||||
// rooted files (Root=="sidecar": HEIC previews, .json) are regenerated
|
||||
// on reindex and intentionally skipped. Pick the stem from the primary
|
||||
// (or the first originals file) so the siblings re-stack under one name.
|
||||
var group []ppFile
|
||||
var primary ppFile
|
||||
havePrimary := false
|
||||
for _, f := range photo.Files {
|
||||
if f.Root == "/" && f.Primary {
|
||||
file, found = f, true
|
||||
break
|
||||
if f.Root != "/" {
|
||||
continue
|
||||
}
|
||||
group = append(group, f)
|
||||
if f.Primary && !havePrimary {
|
||||
primary, havePrimary = f, true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
for _, f := range photo.Files {
|
||||
if f.Root == "/" {
|
||||
file, found = f, true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
if len(group) == 0 {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
|
||||
continue
|
||||
}
|
||||
srcRel := file.Name
|
||||
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
||||
if !sameOrUnder(srcAbs, cfg.OriginalsRoot) {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
|
||||
continue
|
||||
if !havePrimary {
|
||||
primary = group[0]
|
||||
}
|
||||
st, statErr := os.Stat(srcAbs)
|
||||
if statErr != nil || !st.Mode().IsRegular() {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
|
||||
continue
|
||||
|
||||
// Choose one collision-free stem for the whole group up front, so the
|
||||
// siblings land as `<stem>.JPG`, `<stem>.MOV`, … and stay stacked.
|
||||
exts := make([]string, 0, len(group))
|
||||
extSeen := map[string]struct{}{}
|
||||
for _, f := range group {
|
||||
ext := filepath.Ext(f.Name)
|
||||
if _, dup := extSeen[ext]; !dup {
|
||||
extSeen[ext] = struct{}{}
|
||||
exts = append(exts, ext)
|
||||
}
|
||||
}
|
||||
if filepath.Dir(srcAbs) == destAbs {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
|
||||
continue
|
||||
}
|
||||
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
|
||||
stem, ok := uniqueStem(destAbs, filepath.Base(primary.Name), exts)
|
||||
if !ok {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
|
||||
continue
|
||||
}
|
||||
dstAbs := filepath.Join(destAbs, name)
|
||||
if mode == "move" {
|
||||
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil {
|
||||
// Cross-device renames fail with EXDEV — fall back to
|
||||
// copy+remove so a library that spans filesystems still
|
||||
// works.
|
||||
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: mvErr.Error()})
|
||||
continue
|
||||
}
|
||||
if err2 := os.Remove(srcAbs); err2 != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
// Move/copy each sibling. A failure on any one fails the whole photo
|
||||
// (surfaced in errs) rather than leaving a half-moved stack unreported.
|
||||
var failure string
|
||||
movedAny := false
|
||||
usedNames := map[string]struct{}{}
|
||||
for _, f := range group {
|
||||
srcRel := f.Name
|
||||
srcAbs := filepath.Join(cfg.OriginalsRoot, srcRel)
|
||||
if !sameOrUnder(srcAbs, scopeAbs) {
|
||||
failure = "path outside your library"
|
||||
break
|
||||
}
|
||||
moved++
|
||||
} else {
|
||||
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: cpErr.Error()})
|
||||
st, statErr := os.Stat(srcAbs)
|
||||
if statErr != nil || !st.Mode().IsRegular() {
|
||||
failure = "file missing on disk"
|
||||
break
|
||||
}
|
||||
if filepath.Dir(srcAbs) == destAbs {
|
||||
// Already in the target folder — nothing to do for this sibling,
|
||||
// but the photo isn't an error just because one file is in place.
|
||||
continue
|
||||
}
|
||||
name := stem + filepath.Ext(srcAbs)
|
||||
// Two originals files sharing an extension (rare) would collide on
|
||||
// the shared stem; keep the extra one's own unique name so neither
|
||||
// overwrites the other.
|
||||
if _, clash := usedNames[name]; clash {
|
||||
_, n, uok := uniqueName(destAbs, filepath.Base(srcAbs))
|
||||
if !uok {
|
||||
failure = "too many collisions"
|
||||
break
|
||||
}
|
||||
name = n
|
||||
}
|
||||
usedNames[name] = struct{}{}
|
||||
dstAbs := filepath.Join(destAbs, name)
|
||||
if mode == "move" {
|
||||
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil {
|
||||
// Cross-device renames fail with EXDEV — fall back to
|
||||
// copy+remove so a library that spans filesystems still
|
||||
// works.
|
||||
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
||||
failure = mvErr.Error()
|
||||
break
|
||||
}
|
||||
if err2 := os.Remove(srcAbs); err2 != nil {
|
||||
failure = "rename ok, source remove failed: " + err2.Error()
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
|
||||
failure = cpErr.Error()
|
||||
break
|
||||
}
|
||||
}
|
||||
movedAny = true
|
||||
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
||||
}
|
||||
if failure != "" {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: failure})
|
||||
continue
|
||||
}
|
||||
if !movedAny {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
|
||||
continue
|
||||
}
|
||||
if mode == "move" {
|
||||
moved++
|
||||
} else {
|
||||
copied++
|
||||
}
|
||||
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
||||
}
|
||||
|
||||
// Reindex the destination + every source parent so PhotoPrism's DB
|
||||
|
||||
@@ -115,14 +115,14 @@ func handleLabels(pp *ppClient, ppDb *gorm.DB) gin.HandlerFunc {
|
||||
// PpCounts mirrors PhotoPrism's session config.count block that drives
|
||||
// the sidebar badges (review, archive, all, etc.).
|
||||
type PpCounts struct {
|
||||
All int `json:"all"`
|
||||
Photos int `json:"photos"`
|
||||
Media int `json:"media"`
|
||||
Videos int `json:"videos"`
|
||||
Review int `json:"review"`
|
||||
Archived int `json:"archived"`
|
||||
Hidden int `json:"hidden"`
|
||||
Private int `json:"private"`
|
||||
All int `json:"all"`
|
||||
Photos int `json:"photos"`
|
||||
Media int `json:"media"`
|
||||
Videos int `json:"videos"`
|
||||
Review int `json:"review"`
|
||||
Archived int `json:"archived"`
|
||||
Hidden int `json:"hidden"`
|
||||
Private int `json:"private"`
|
||||
Favorites int `json:"favorites"`
|
||||
}
|
||||
|
||||
@@ -168,4 +168,4 @@ func handleScopedCounts(ppDb *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
c.JSON(http.StatusOK, counts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -56,33 +56,28 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||
return
|
||||
}
|
||||
if !requireUserScope(c, cfg, targetAbs, false) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the photos via a single q=uid:a|b|c query. PhotoPrism's
|
||||
// search treats `|` as OR within a filter value, so one round-trip
|
||||
// covers the whole selection; merged=true pulls stacked variants so
|
||||
// the JPG/HEIC sibling travels with its primary.
|
||||
q := url.QueryEscape("uid:" + strings.Join(body.UIDs, "|"))
|
||||
listURL := "/api/v1/photos?q=" + q + "&count=" + itoa(len(body.UIDs)) + "&merged=true"
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
|
||||
// Resolve each photo's FULL file list via the single-photo endpoint
|
||||
// rather than the /photos search (see resolvePhotosFull) — the search
|
||||
// drops a photo's video file from its trimmed Files array and filters
|
||||
// videos out by quality/review, so the .mov never gets listed to move.
|
||||
photos, resolveErrs, err := resolvePhotosFull(c.Request.Context(), pp, token, body.UIDs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !resp.OK {
|
||||
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
|
||||
return
|
||||
}
|
||||
var photos []heapPhoto
|
||||
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
|
||||
return
|
||||
}
|
||||
|
||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
|
||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode, userScopeRoot(c, cfg))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Surface UIDs PhotoPrism couldn't resolve alongside any per-file
|
||||
// errors so the client's "N skipped" summary stays accurate.
|
||||
errs = append(resolveErrs, errs...)
|
||||
|
||||
slog.Info("photos.move",
|
||||
"requested", len(body.UIDs),
|
||||
@@ -99,6 +94,36 @@ func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePhotosFull fetches each photo's complete file list via the
|
||||
// single-photo endpoint (GET /photos/:uid). Use this instead of the /photos
|
||||
// search whenever you need every file of a photo: the search — even with
|
||||
// merged=true — can return a trimmed Files array that omits the photo's video
|
||||
// file, and it applies PhotoPrism's default quality/review/archive filters.
|
||||
// Both silently drop videos (which PhotoPrism routinely files under review)
|
||||
// from a move. The per-UID lookup returns every file and ignores those
|
||||
// filters. UIDs PhotoPrism can't resolve are returned in `errs` so the batch
|
||||
// continues; a transport-level failure aborts with a fatal error. Mirrors
|
||||
// handleRename's single-photo resolution.
|
||||
func resolvePhotosFull(ctx context.Context, pp *ppClient, token string, uids []string) (photos []heapPhoto, errs []heapErr, err error) {
|
||||
photos = make([]heapPhoto, 0, len(uids))
|
||||
for _, uid := range uids {
|
||||
resp, e := pp.call(ctx, http.MethodGet, "/api/v1/photos/"+url.PathEscape(uid), token, nil)
|
||||
if e != nil {
|
||||
return nil, nil, e
|
||||
}
|
||||
if !resp.OK {
|
||||
errs = append(errs, heapErr{UID: uid, Reason: "photo not found"})
|
||||
continue
|
||||
}
|
||||
var p heapPhoto
|
||||
if e := json.Unmarshal(resp.Body, &p); e != nil {
|
||||
return nil, nil, e
|
||||
}
|
||||
photos = append(photos, p)
|
||||
}
|
||||
return photos, errs, nil
|
||||
}
|
||||
|
||||
type folderMoveBody struct {
|
||||
// Originals-relative destination parent. ""/"/"/"." mean the root.
|
||||
TargetParent string `json:"targetParent"`
|
||||
@@ -126,6 +151,9 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
if !requireUserScope(c, cfg, oldAbs, true) {
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(oldAbs)
|
||||
if err != nil || !st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||
@@ -136,6 +164,9 @@ func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"})
|
||||
return
|
||||
}
|
||||
if !requireUserScope(c, cfg, targetParentAbs, false) {
|
||||
return
|
||||
}
|
||||
// Can't move a folder into itself or one of its own descendants.
|
||||
if sameOrUnder(targetParentAbs, oldAbs) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot move a folder into itself"})
|
||||
|
||||
354
sidecar/handlers_ppproxy.go
Normal file
354
sidecar/handlers_ppproxy.go
Normal file
@@ -0,0 +1,354 @@
|
||||
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")
|
||||
}
|
||||
|
||||
// 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, client
|
||||
// config, hash-addressed media, websocket.
|
||||
passUnscoped := rest == "session" || strings.HasPrefix(rest, "session/") ||
|
||||
strings.HasPrefix(rest, "oauth/") ||
|
||||
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.
|
||||
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)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
117
sidecar/handlers_prefs.go
Normal file
117
sidecar/handlers_prefs.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Per-user preferences the PhotoPrism account model can't hold. Currently a
|
||||
// single field — the index sub-path the web client re-roots the Library tree
|
||||
// to and scopes the reindex to. Stored in the sidecar's own DB keyed by
|
||||
// username (see UserPref in db.go); never touches PhotoPrism's auth_users.
|
||||
|
||||
// prefsBody is the wire shape for GET responses and PUT requests alike.
|
||||
type prefsBody struct {
|
||||
IndexPath string `json:"indexPath"`
|
||||
}
|
||||
|
||||
// loadUserPref reads the row for a user, returning a zero-value pref (empty
|
||||
// IndexPath) when none exists yet — the "whole folder" default.
|
||||
func loadUserPref(db *gorm.DB, userName string) (UserPref, error) {
|
||||
var p UserPref
|
||||
err := db.Where("user_name = ?", userName).First(&p).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return UserPref{UserName: userName}, nil
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func handlePrefsGet(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
p, err := loadUserPref(db, ctxUserName(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, prefsBody{IndexPath: p.IndexPath})
|
||||
}
|
||||
}
|
||||
|
||||
// handlePrefsPut validates the requested index sub-path lives under the user's
|
||||
// BasePath (an existing directory, no traversal) and upserts it. An empty
|
||||
// string clears the sub-path back to "whole folder".
|
||||
func handlePrefsPut(cfg *Config, db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body prefsBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
|
||||
// Normalise to originals-relative, no leading/trailing slashes —
|
||||
// the same shape the web client and auth_users.base_path use.
|
||||
sub := strings.Trim(strings.TrimSpace(body.IndexPath), "/")
|
||||
|
||||
if sub != "" {
|
||||
// The sub-path is relative to the user's BasePath; resolve the
|
||||
// combined originals-relative path and require it to be an
|
||||
// existing directory inside the originals root. resolveUnderRoot
|
||||
// already rejects traversal and symlink escapes.
|
||||
base := strings.Trim(ctxBasePath(c), "/")
|
||||
combined := sub
|
||||
if base != "" {
|
||||
combined = base + "/" + sub
|
||||
}
|
||||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, combined, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid index path: " + err.Error()})
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil || !info.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "index path is not a folder"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
userName := ctxUserName(c)
|
||||
p := UserPref{UserName: userName, IndexPath: sub, UpdatedAt: time.Now().UTC()}
|
||||
// Upsert: a clear (sub == "") persists an empty string rather than
|
||||
// deleting the row, so the GET path stays a single code branch.
|
||||
if err := db.Save(&p).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, prefsBody{IndexPath: sub})
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveLibraryRoot returns the requesting user's working library root,
|
||||
// originals-relative with no leading/trailing slash: their BasePath narrowed
|
||||
// by their chosen index sub-path (if any). Mirrors the web client's
|
||||
// `userLibraryBase()` — handlers that walk the filesystem on a user's behalf
|
||||
// (duplicate scan/archive) should scope to this instead of cfg.OriginalsRoot
|
||||
// so a narrowed root also narrows what those handlers can see or touch.
|
||||
// Returns "" for "whole library" (no BasePath and no sub-path set — today's
|
||||
// admin default).
|
||||
func effectiveLibraryRoot(c *gin.Context, db *gorm.DB) string {
|
||||
base := strings.Trim(ctxBasePath(c), "/")
|
||||
pref, err := loadUserPref(db, ctxUserName(c))
|
||||
sub := ""
|
||||
if err == nil {
|
||||
sub = strings.Trim(pref.IndexPath, "/")
|
||||
}
|
||||
if sub == "" {
|
||||
return base
|
||||
}
|
||||
if base == "" {
|
||||
return sub
|
||||
}
|
||||
return base + "/" + sub
|
||||
}
|
||||
@@ -97,6 +97,9 @@ func handleRename(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"})
|
||||
return
|
||||
}
|
||||
if !requireUserScope(c, cfg, oldAbs, false) {
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(oldAbs)
|
||||
if err != nil || !st.Mode().IsRegular() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"})
|
||||
|
||||
@@ -79,45 +79,55 @@ func main() {
|
||||
|
||||
// Every other endpoint runs behind the session gate. Mounting them
|
||||
// under one group keeps the middleware wiring obvious.
|
||||
auth := r.Group("/api/sidecar", requireSession(pp))
|
||||
{
|
||||
auth.GET("/photos/marks", handleMarksAll(db))
|
||||
auth.GET("/photos/:uid/marks", handleMarkGet(db))
|
||||
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
|
||||
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
|
||||
auth := r.Group("/api/sidecar", requireSession(pp))
|
||||
{
|
||||
auth.GET("/prefs", handlePrefsGet(db))
|
||||
auth.PUT("/prefs", handlePrefsPut(cfg, db))
|
||||
|
||||
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
|
||||
auth.GET("/photos/marks", handleMarksAll(db))
|
||||
auth.GET("/photos/:uid/marks", handleMarkGet(db))
|
||||
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
|
||||
auth.POST("/photos/marks/bulk", handleMarkBulk(db))
|
||||
|
||||
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
||||
auth.POST("/folders/counts", handleFolderCounts(pp))
|
||||
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
||||
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
|
||||
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
||||
auth.POST("/files/:uid/rename", handleRename(cfg, pp))
|
||||
|
||||
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
|
||||
auth.POST("/folders", handleFolderCreate(cfg, pp))
|
||||
auth.POST("/folders/counts", handleFolderCounts(pp))
|
||||
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
||||
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
|
||||
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
||||
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
|
||||
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
|
||||
|
||||
// User-scoped proxies — require PpDSN connection.
|
||||
if ppDb != nil {
|
||||
auth.GET("/labels", handleLabels(pp, ppDb))
|
||||
auth.GET("/counts", handleScopedCounts(ppDb))
|
||||
}
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
|
||||
|
||||
// User-scoped photos — post-filters by BasePath so review/archive
|
||||
// tabs only show photos the user owns.
|
||||
auth.GET("/timeline", handlePhotos(pp))
|
||||
// User-scoped proxies — require PpDSN connection.
|
||||
if ppDb != nil {
|
||||
auth.GET("/labels", handleLabels(pp, ppDb))
|
||||
auth.GET("/counts", handleScopedCounts(ppDb))
|
||||
auth.GET("/countries", handleCountries(ppDb))
|
||||
}
|
||||
|
||||
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
|
||||
// the /notes view isn't capped to the newest slice.
|
||||
auth.GET("/notes", handleNotes(pp))
|
||||
// User-scoped photos — post-filters by BasePath so review/archive
|
||||
// tabs only show photos the user owns.
|
||||
auth.GET("/timeline", handlePhotos(pp))
|
||||
|
||||
// User-scoped folders — post-filters the folder tree by BasePath
|
||||
// so the sidebar shows only folders under the user's library root.
|
||||
auth.GET("/folders", handleFoldersProxy(pp))
|
||||
}
|
||||
// Photos carrying a Note (Caption) — pages PhotoPrism fully so
|
||||
// the /notes view isn't capped to the newest slice.
|
||||
auth.GET("/notes", handleNotes(pp))
|
||||
|
||||
// User-scoped folders — post-filters the folder tree by BasePath
|
||||
// so the sidebar shows only folders under the user's library root.
|
||||
auth.GET("/folders", handleFoldersProxy(pp))
|
||||
}
|
||||
|
||||
// PhotoPrism-compatible scoped proxy — the public /api/v1 surface for
|
||||
// both the web client and third-party PhotoPrism apps (Caddy routes
|
||||
// /api/v1 here instead of straight to PhotoPrism, which does not
|
||||
// enforce base_path in CE). See handlers_ppproxy.go for the rules.
|
||||
r.Any("/api/v1/*rest", handlePPProxy(cfg, ppDb))
|
||||
|
||||
addr := cfg.ListenAddr + ":" + itoa(cfg.Port)
|
||||
srv := &http.Server{
|
||||
@@ -150,4 +160,3 @@ func main() {
|
||||
}
|
||||
<-idleClosed
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
|
||||
type ppSessionUser struct {
|
||||
UserUID string `json:"UID"`
|
||||
UserName string `json:"Name"`
|
||||
Role string `json:"Role"`
|
||||
BasePath string `json:"BasePath"`
|
||||
}
|
||||
|
||||
|
||||
@@ -86,12 +86,15 @@ func reconcileUserBasepaths(ppDSN, originalsRoot string, mapping map[string]stri
|
||||
// ACL kicks in even if the directory is created later.
|
||||
}
|
||||
|
||||
// upload_path rides along with base_path so anything a client app
|
||||
// uploads (WebDAV sync apps, PhotoPrism's own UI) lands inside the
|
||||
// user's library subtree instead of the shared originals root.
|
||||
res := db.Exec(`UPDATE auth_users
|
||||
SET base_path = ?
|
||||
SET base_path = ?, upload_path = ?
|
||||
WHERE user_name = ?
|
||||
AND COALESCE(base_path, '') <> ?
|
||||
AND (COALESCE(base_path, '') <> ? OR COALESCE(upload_path, '') <> ?)
|
||||
AND deleted_at IS NULL`,
|
||||
path, username, path)
|
||||
path, path, username, path, path)
|
||||
if res.Error != nil {
|
||||
slog.Error("user-basepath: update failed", "user", username, "err", res.Error)
|
||||
continue
|
||||
|
||||
247
web/package-lock.json
generated
247
web/package-lock.json
generated
@@ -14,7 +14,6 @@
|
||||
"bits-ui": "^2.18.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"svelte-sonner": "^1.1.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
@@ -148,110 +147,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
|
||||
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.5.tgz",
|
||||
"integrity": "sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"gl-style-format": "dist/gl-style-format.mjs",
|
||||
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.9.tgz",
|
||||
"integrity": "sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
|
||||
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@maplibre/geojson-vt": "^5.0.4",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"pbf": "^4.0.1",
|
||||
"supercluster": "^8.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
|
||||
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
@@ -998,12 +893,6 @@
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
@@ -1014,15 +903,6 @@
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/supercluster": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -1248,12 +1128,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
|
||||
@@ -1451,12 +1325,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -1553,18 +1421,6 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
@@ -1879,40 +1735,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.24.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
|
||||
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.1.0",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@mapbox/whoots-js": "^3.1.0",
|
||||
"@maplibre/geojson-vt": "^6.1.0",
|
||||
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
|
||||
"@maplibre/mlt": "^1.1.8",
|
||||
"@maplibre/vt-pbf": "^4.3.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"earcut": "^3.0.2",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.0.2",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1952,15 +1774,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mode-watcher": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
|
||||
@@ -2050,12 +1863,6 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
@@ -2086,18 +1893,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2147,18 +1942,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
@@ -2168,12 +1951,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -2188,15 +1965,6 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||
@@ -2309,15 +2077,6 @@
|
||||
"inline-style-parser": "0.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/supercluster": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.55.7",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
|
||||
@@ -2489,12 +2248,6 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"bits-ui": "^2.18.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"svelte-sonner": "^1.1.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
|
||||
@@ -7,10 +7,13 @@ import {
|
||||
batchArchive,
|
||||
batchDelete,
|
||||
batchRestore,
|
||||
bulkSetMarks,
|
||||
removeFromHeap,
|
||||
type PhotoMark,
|
||||
type PhotoMarksMap,
|
||||
type PpAlbum
|
||||
} from '$lib/services/photoprism';
|
||||
import { acceptDateAndKeep, cachedPhoto } from '$lib/services/photoActions';
|
||||
import { acceptDateAndKeep, cachedPhoto, toggleFavorite } from '$lib/services/photoActions';
|
||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
import { photoNameAndDir } from '$lib/types/photoprism';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
@@ -34,10 +37,16 @@ import {
|
||||
removedBulk,
|
||||
failBulk,
|
||||
setDetail,
|
||||
markRemoved,
|
||||
clearRemoved
|
||||
markRemoved
|
||||
} from '$lib/stores/bulkAction.svelte';
|
||||
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
||||
import {
|
||||
closeShortcuts,
|
||||
openPreview,
|
||||
toggleLeftSidebar,
|
||||
toggleRightSidebar,
|
||||
toggleShortcuts,
|
||||
view
|
||||
} from '$lib/stores/view.svelte';
|
||||
|
||||
/**
|
||||
* Optional parameters the host passes via `use:gridKeyNav={...}`.
|
||||
@@ -69,8 +78,8 @@ export interface GridKeyNavParams {
|
||||
* heap N (bare s adds to the currently-viewed heap), b/Tab toggles
|
||||
* left sidebar, i toggles right sidebar, esc clears, ⌘Z undoes,
|
||||
* ⌘A selects all visible.
|
||||
* Rating + color labels are mouse-driven via the metadata sidebar — no
|
||||
* keyboard shortcuts.
|
||||
* 0–5 rating, 6–9 Lightroom color labels, / focuses search,
|
||||
* ? opens the shortcut reference overlay.
|
||||
*
|
||||
* Archive / restore target a synthesized "cull target list" — in priority:
|
||||
* 1. multi-selection set
|
||||
@@ -207,17 +216,19 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
if (target) {
|
||||
// Destructive removal: flash a red cross, then pull the tiles out of
|
||||
// the grid immediately (markRemoved) rather than waiting on the slow
|
||||
// server-reconcile refetch. clearRemoved once the refetch settles so
|
||||
// the archived-filtered page replaces the optimistic hide.
|
||||
// server-reconcile refetch. The grid reconciles `removedIds` against
|
||||
// the cache and drops each id once the archived-filtered page has
|
||||
// actually replaced it (see +page.svelte), so we don't clear here —
|
||||
// clearing on this action's own settle raced other in-flight archives
|
||||
// and flashed photos back in.
|
||||
removedBulk(doneLabel, ids);
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
await delay(500);
|
||||
markRemoved(ids);
|
||||
invalidatePhotos(ids);
|
||||
const settled = queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
void settled.then(() => clearRemoved(ids));
|
||||
} else {
|
||||
doneBulk(doneLabel, ids);
|
||||
focusAfter(ids);
|
||||
@@ -267,9 +278,10 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
await delay(500);
|
||||
markRemoved(ids);
|
||||
invalidatePhotos(ids);
|
||||
const settled = queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
// removedIds is reconciled against the cache in +page.svelte; no
|
||||
// settle-driven clear here (see toggleArchive note above).
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
void settled.then(() => clearRemoved(ids));
|
||||
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||||
}
|
||||
|
||||
@@ -375,6 +387,55 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
await addCullTargetsToHeap(heaps[idx - 1]);
|
||||
}
|
||||
|
||||
// ── Rating / color-label keys (Lightroom layout) ─────────────────────
|
||||
// Bare 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() {
|
||||
if (filters.section !== 'heap' || !filters.heapUid) {
|
||||
toast.message('Press S then 1–9 to pick a heap');
|
||||
@@ -394,6 +455,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||
|
||||
// Shortcuts overlay: Esc or ? closes it; every other key is inert
|
||||
// while it's up so the reference card can't trigger the actions it
|
||||
// documents.
|
||||
if (view.shortcutsOpen) {
|
||||
if (e.key === 'Escape' || e.key === '?') {
|
||||
e.preventDefault();
|
||||
closeShortcuts();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Modal owns arrow / Escape / Space while it's open — it handles
|
||||
// its own linear nav, close-on-Esc, and close-on-Space. Action
|
||||
// keys (X/S/U/A/Z) still pass through because they target the
|
||||
@@ -429,6 +501,22 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
const meta = e.metaKey || e.ctrlKey;
|
||||
const shift = e.shiftKey;
|
||||
|
||||
// Bare digits: rating (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.
|
||||
// Matches the dblclick gesture so the user has both keyboard and
|
||||
// mouse paths to the same surface. `e.code === 'Space'` covers
|
||||
@@ -475,6 +563,17 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
clearSelection();
|
||||
setFocused(null);
|
||||
return;
|
||||
case '/':
|
||||
// Jump to the search box (any page that renders one tags it
|
||||
// with data-search-input).
|
||||
if (meta) return;
|
||||
e.preventDefault();
|
||||
document.querySelector<HTMLInputElement>('[data-search-input]')?.focus();
|
||||
return;
|
||||
case '?':
|
||||
e.preventDefault();
|
||||
toggleShortcuts();
|
||||
return;
|
||||
case 'Tab':
|
||||
// Tab in the grid context = mule-image's left-sidebar toggle.
|
||||
// Browsers reserve Tab for focus traversal — preventDefault
|
||||
@@ -554,6 +653,12 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
e.preventDefault();
|
||||
void toggleArchive('restore');
|
||||
return;
|
||||
case 'f':
|
||||
case 'F':
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
void toggleFavorite(cullTargets());
|
||||
return;
|
||||
case 'm':
|
||||
case 'M': {
|
||||
if (meta || shift) return;
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
type CrossFolderScanResult
|
||||
} from '$lib/services/photoprism';
|
||||
import type { DuplicateGroup } from '$lib/services/adapters/duplicates';
|
||||
import { userLibraryBase } from '$lib/stores/session.svelte';
|
||||
import StackGroupCard from './StackGroupCard.svelte';
|
||||
import CrossFolderGroupCard from './CrossFolderGroupCard.svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
@@ -50,14 +51,14 @@
|
||||
// "Rescan filesystem" button invalidates to force a re-scan after
|
||||
// the user has moved files around.
|
||||
const crossQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||
queryKey: ['duplicates-cross-folder'],
|
||||
queryKey: ['duplicates-cross-folder', userLibraryBase()],
|
||||
queryFn: scanCrossFolderDuplicates,
|
||||
enabled: activeTab === 'cross-folder',
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
|
||||
function rescan() {
|
||||
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder'] });
|
||||
void qc.invalidateQueries({ queryKey: ['duplicates-cross-folder', userLibraryBase()] });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
|
||||
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>
|
||||
@@ -41,7 +41,7 @@
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { untrack } from 'svelte';
|
||||
import { FolderInput, FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import { ChevronRight, FolderInput, FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
|
||||
import Self from './FolderTree.svelte';
|
||||
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
||||
|
||||
@@ -164,19 +164,21 @@
|
||||
>
|
||||
{#if hasChildren}
|
||||
<button
|
||||
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
|
||||
class="flex h-[18px] w-5 items-center justify-center rounded hover:text-foreground"
|
||||
class:text-muted-foreground={!active}
|
||||
onclick={() => toggle(node.path)}
|
||||
title={open ? 'Collapse' : 'Expand'}
|
||||
aria-label={open ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
{open ? '▾' : '▸'}
|
||||
<ChevronRight
|
||||
class="h-4 w-4 transition-transform duration-150 {open ? 'rotate-90' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Spacer keeps childless siblings aligned with their chevroned
|
||||
peers at every depth, so labels share a common left edge
|
||||
across the sidebar (folders, heaps, views, manage). -->
|
||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||
<span class="inline-block h-[18px] w-5" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<!--
|
||||
Count badge lives INSIDE the button so the entire row (label
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
<!--
|
||||
General app preferences. The UI tab owns the SvelteKit shell's
|
||||
light/dark/system theme (mode-watcher) plus the per-user UI knobs
|
||||
PhotoPrism's /settings exposes. Search and Maps follow the same
|
||||
pattern — server prefs round-trip via /api/v1/settings.
|
||||
General app preferences. Two tabs: the SvelteKit shell's
|
||||
light/dark/system theme (mode-watcher) and the signed-in user's account
|
||||
(identity + password change).
|
||||
|
||||
The Library admin dialog and this one share the ['settings'] cache,
|
||||
so saves from either invalidate the other.
|
||||
PhotoPrism's own per-user UI/search/maps knobs used to live here too, but
|
||||
they only steer PhotoPrism's bundled SPA — which mulimage's users never
|
||||
see — so they were removed. mulimage's own view prefs live in the view
|
||||
store; the library admin knobs live under Folders → ⚙ (SettingsDialog).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Dialog, Tabs } from 'bits-ui';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { createMutation } from '@tanstack/svelte-query';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Loader2, Monitor, Moon, Settings as SettingsIcon, Sun, X } from 'lucide-svelte';
|
||||
import {
|
||||
getSettings,
|
||||
saveSettings,
|
||||
setUserPassword,
|
||||
type PpSettings
|
||||
} from '$lib/services/photoprism';
|
||||
import { setUserPassword } from '$lib/services/photoprism';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -27,9 +23,7 @@
|
||||
}
|
||||
let { open, onClose }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
let activeTab = $state<'ui' | 'search' | 'maps' | 'account'>('ui');
|
||||
let activeTab = $state<'ui' | 'account'>('ui');
|
||||
|
||||
// ── Account tab — password change ─────────────────────────────────────
|
||||
let pwOld = $state('');
|
||||
@@ -59,107 +53,6 @@
|
||||
{ value: 'system', label: 'System', Icon: Monitor }
|
||||
] as const;
|
||||
|
||||
// PhotoPrism palette names from its built-in themes. Any value
|
||||
// outside this list is preserved verbatim (see `withCurrent`).
|
||||
const ppThemes = [
|
||||
'default',
|
||||
'abyss',
|
||||
'gemstone',
|
||||
'grayscale',
|
||||
'lavender',
|
||||
'legacy',
|
||||
'neon',
|
||||
'onyx',
|
||||
'raspberry',
|
||||
'shadow',
|
||||
'yellowstone'
|
||||
];
|
||||
|
||||
// IETF subtags PhotoPrism ships translations for. Extend without
|
||||
// fear — `withCurrent` keeps unknown values visible.
|
||||
const ppLanguages = [
|
||||
'en', 'de', 'es', 'fr', 'it', 'pt', 'nl', 'pl', 'cs', 'sk',
|
||||
'sv', 'no', 'da', 'fi', 'hu', 'ro', 'bg', 'el', 'ru', 'uk',
|
||||
'tr', 'ar', 'he', 'hi', 'vi', 'th', 'ja', 'ko', 'zh'
|
||||
];
|
||||
|
||||
const ppStartPages = [
|
||||
'default',
|
||||
'browse',
|
||||
'albums',
|
||||
'calendar',
|
||||
'moments',
|
||||
'people',
|
||||
'places',
|
||||
'labels',
|
||||
'states',
|
||||
'library'
|
||||
];
|
||||
|
||||
const ppMapStyles = ['default', 'streets', 'hybrid', 'topographique', 'offline'];
|
||||
|
||||
// Returns `opts` with `current` prepended if it's set and not
|
||||
// already in the list — so e.g. an experimental theme name in the
|
||||
// server response shows up selected and editable instead of
|
||||
// silently being overwritten by the dropdown's default.
|
||||
function withCurrent(opts: string[], current?: string): string[] {
|
||||
if (!current) return opts;
|
||||
return opts.includes(current) ? opts : [current, ...opts];
|
||||
}
|
||||
|
||||
const settingsQuery = createQuery<PpSettings>(() => ({
|
||||
queryKey: ['settings'],
|
||||
queryFn: getSettings,
|
||||
enabled: open
|
||||
}));
|
||||
|
||||
/**
|
||||
* Some PhotoPrism deployments return `/settings` without the
|
||||
* `ui` / `search` / `maps` keys (older versions, custom edits to
|
||||
* settings.yml). The form's `bind:value={draft.ui!.theme}` etc.
|
||||
* non-null-asserts those sub-objects — when they're missing the
|
||||
* assertion lies and the bind getter throws on the next tick. Force
|
||||
* the shape on every clone so every binding has a real object to
|
||||
* write into, and so `draft.ui` is never null while `draft` is non-
|
||||
* null (template gates only check `draft`).
|
||||
*/
|
||||
function normalize(s: PpSettings): PpSettings {
|
||||
return {
|
||||
...s,
|
||||
ui: s.ui ?? {},
|
||||
search: s.search ?? {},
|
||||
maps: s.maps ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
let draft = $state<PpSettings | null>(null);
|
||||
// Re-clone on each open so reopening the dialog shows the freshest
|
||||
// server state. Eagerly nulling on close used to introduce a window
|
||||
// where Dialog's exit animation kept the form mounted while draft
|
||||
// was already null — and bind:value getters read null, triggering
|
||||
// "$.get(...) is null" / can't access .ui at runtime. Resetting on
|
||||
// open instead avoids that race entirely.
|
||||
$effect(() => {
|
||||
if (open && settingsQuery.data) {
|
||||
draft = normalize(structuredClone(settingsQuery.data));
|
||||
}
|
||||
});
|
||||
|
||||
const saveMut = createMutation(() => ({
|
||||
mutationFn: (patch: PpSettings) => saveSettings(patch),
|
||||
onSuccess: (next) => {
|
||||
qc.setQueryData(['settings'], next);
|
||||
draft = normalize(structuredClone(next));
|
||||
toast.success('Settings saved');
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Could not save settings')
|
||||
}));
|
||||
|
||||
function resetDraft() {
|
||||
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
|
||||
}
|
||||
|
||||
const selectClass =
|
||||
'rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring';
|
||||
</script>
|
||||
@@ -198,7 +91,7 @@
|
||||
|
||||
<Tabs.Root bind:value={activeTab}>
|
||||
<Tabs.List class="mb-3 flex gap-1 border-b border-border">
|
||||
{#each ['ui', 'search', 'maps', 'account'] as const as t (t)}
|
||||
{#each ['ui', 'account'] as const as t (t)}
|
||||
<Tabs.Trigger
|
||||
value={t}
|
||||
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
|
||||
@@ -208,8 +101,7 @@
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
|
||||
<!-- UI — local app theme (mode-watcher) on top, then the
|
||||
PhotoPrism per-user UI knobs that go to /settings. -->
|
||||
<!-- UI — local app theme (mode-watcher). Persists itself; no Save. -->
|
||||
<Tabs.Content value="ui" class="space-y-4 text-[12px] outline-none">
|
||||
<section class="space-y-2">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
@@ -239,126 +131,10 @@
|
||||
Light/dark for this app. Persists locally; no Save needed.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{#if settingsQuery.isPending}
|
||||
<p class="px-1 text-muted-foreground">Loading server settings…</p>
|
||||
{:else if settingsQuery.isError}
|
||||
<p class="px-1 text-destructive">Could not load server settings.</p>
|
||||
{:else if draft}
|
||||
<section class="space-y-3">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Server UI
|
||||
</h3>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Theme</span>
|
||||
<select bind:value={draft.ui!.theme} class={selectClass}>
|
||||
{#each withCurrent(ppThemes, draft.ui!.theme) as v (v)}
|
||||
<option value={v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Language</span>
|
||||
<select bind:value={draft.ui!.language} class={selectClass}>
|
||||
{#each withCurrent(ppLanguages, draft.ui!.language) as v (v)}
|
||||
<option value={v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Time zone</span>
|
||||
<!-- IANA tz list is ~400 entries, browser support varies; use
|
||||
a datalist so we get autocomplete without spamming a
|
||||
gigantic <select>. "Local" is PhotoPrism's special
|
||||
"follow system" sentinel. -->
|
||||
<input
|
||||
type="text"
|
||||
list="general-tz-list"
|
||||
placeholder="Local"
|
||||
bind:value={draft.ui!.timeZone}
|
||||
class={selectClass}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Start page</span>
|
||||
<select bind:value={draft.ui!.startPage} class={selectClass}>
|
||||
{#each withCurrent(ppStartPages, draft.ui!.startPage) as v (v)}
|
||||
<option value={v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.ui!.scrollbar} />
|
||||
Always show scrollbars
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.ui!.zoom} />
|
||||
Allow image zoom
|
||||
</label>
|
||||
</section>
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
|
||||
{#if settingsQuery.isPending && activeTab !== 'ui' && activeTab !== 'account'}
|
||||
<Tabs.Content value={activeTab} class="outline-none">
|
||||
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
|
||||
</Tabs.Content>
|
||||
{:else if settingsQuery.isError && activeTab !== 'ui' && activeTab !== 'account'}
|
||||
<Tabs.Content value={activeTab} class="outline-none">
|
||||
<p class="px-1 text-[12px] text-destructive">
|
||||
Could not load settings.
|
||||
</p>
|
||||
</Tabs.Content>
|
||||
{:else if draft}
|
||||
<Tabs.Content value="search" class="space-y-3 text-[12px] outline-none">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.search!.listView} />
|
||||
Default to list view
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.search!.showTitles} />
|
||||
Show titles
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.search!.showCaptions} />
|
||||
Show captions
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">
|
||||
Batch size (-1 = server default)
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={draft.search!.batchSize}
|
||||
class={selectClass}
|
||||
/>
|
||||
</label>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="maps" class="space-y-3 text-[12px] outline-none">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Style</span>
|
||||
<select bind:value={draft.maps!.style} class={selectClass}>
|
||||
{#each withCurrent(ppMapStyles, draft.maps!.style) as v (v)}
|
||||
<option value={v}>{v}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">
|
||||
Animation duration (ms, 0 = off)
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
bind:value={draft.maps!.animate}
|
||||
class={selectClass}
|
||||
/>
|
||||
</label>
|
||||
</Tabs.Content>
|
||||
{/if}
|
||||
|
||||
<!-- Account — independent of /settings; reads from the session
|
||||
store and round-trips its own mutation. -->
|
||||
<!-- Account — reads from the session store and round-trips its own
|
||||
password mutation. -->
|
||||
<Tabs.Content value="account" class="space-y-4 text-[12px] outline-none">
|
||||
<section class="space-y-2">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
@@ -448,59 +224,6 @@
|
||||
</form>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<!-- Datalist for time-zone autocomplete. Falls back to the
|
||||
"Local" sentinel when the browser can't enumerate the
|
||||
IANA list (older Safari, etc.). -->
|
||||
<datalist id="general-tz-list">
|
||||
<option value="Local"></option>
|
||||
{#each tzOptions() as tz (tz)}<option value={tz}></option>{/each}
|
||||
</datalist>
|
||||
|
||||
<!-- Save/Revert apply to draft (the PhotoPrism /settings round
|
||||
trip). The App theme group above persists itself, so we
|
||||
only show the action row when there's something to save.
|
||||
Account tab has its own Update-password button, so skip. -->
|
||||
{#if draft && activeTab !== 'account'}
|
||||
<div class="flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
|
||||
onclick={resetDraft}
|
||||
disabled={saveMut.isPending}
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
onclick={() => draft && saveMut.mutate(draft)}
|
||||
disabled={saveMut.isPending}
|
||||
>
|
||||
{#if saveMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<script lang="ts" module>
|
||||
// `Intl.supportedValuesOf` is a 2022+ API; older browsers (Safari
|
||||
// 15.3 and below) return undefined here. The component handles that
|
||||
// by simply showing only the "Local" sentinel in the datalist.
|
||||
export function tzOptions(): string[] {
|
||||
const fn = (Intl as unknown as {
|
||||
supportedValuesOf?: (k: string) => string[];
|
||||
}).supportedValuesOf;
|
||||
if (typeof fn !== 'function') return [];
|
||||
try {
|
||||
return fn('timeZone');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
deleteFolder,
|
||||
deleteHeap,
|
||||
duplicateHeap,
|
||||
getIndexSubpath,
|
||||
heapDownloadUrl,
|
||||
listFolders,
|
||||
listHeaps,
|
||||
@@ -19,6 +20,7 @@
|
||||
renameFolder,
|
||||
renameHeap,
|
||||
scanCrossFolderDuplicates,
|
||||
startIndex,
|
||||
triggerDownload,
|
||||
type CrossFolderScanResult,
|
||||
type PpAlbum,
|
||||
@@ -42,14 +44,25 @@
|
||||
type Section,
|
||||
type TagCategory
|
||||
} from '$lib/stores/filters.svelte';
|
||||
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
isAuthenticated,
|
||||
prefs,
|
||||
session,
|
||||
setIndexSubpathState,
|
||||
userBasePath,
|
||||
userLibraryBase,
|
||||
toOriginalsPath,
|
||||
toUserPath
|
||||
} from '$lib/stores/session.svelte';
|
||||
import { openMove } from '$lib/stores/moveDialog.svelte';
|
||||
import { indexer } from '$lib/stores/indexer.svelte';
|
||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
|
||||
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
||||
import SettingsDialog from './SettingsDialog.svelte';
|
||||
import UsersDialog from './UsersDialog.svelte';
|
||||
import {
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download,
|
||||
FolderInput,
|
||||
@@ -59,6 +72,7 @@
|
||||
LogOut,
|
||||
Moon,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Sun,
|
||||
Trash2,
|
||||
@@ -74,25 +88,48 @@
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
// Keyed on the effective library base (BasePath + chosen index sub-path)
|
||||
// so re-rooting refetches, and so the post-bootstrap identity change forces
|
||||
// a fresh fetch instead of leaving the query wedged in pending/idle (the
|
||||
// old `gcTime: 0` + `enabled` toggle could strand it there on first paint).
|
||||
const foldersQuery = createQuery<PpFolder[]>(() => ({
|
||||
queryKey: ['folders'],
|
||||
queryKey: ['folders', userLibraryBase()],
|
||||
queryFn: listFolders,
|
||||
enabled: isAuthenticated(),
|
||||
gcTime: 0
|
||||
staleTime: 30_000,
|
||||
retry: 2,
|
||||
refetchOnMount: 'always'
|
||||
}));
|
||||
|
||||
// Hydrate the per-user index sub-path into the session store on load so the
|
||||
// Library tree re-roots to it without waiting for the settings dialog to be
|
||||
// opened. Shares the ['prefs'] key with SettingsDialog's setter.
|
||||
const prefsQuery = createQuery<string>(() => ({
|
||||
queryKey: ['prefs'],
|
||||
queryFn: getIndexSubpath,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 5 * 60_000
|
||||
}));
|
||||
$effect(() => {
|
||||
if (prefsQuery.data !== undefined) setIndexSubpathState(prefsQuery.data);
|
||||
});
|
||||
|
||||
// Stacks + cross-folder duplicate caches are warmed here so the
|
||||
// /duplicates view (and its review tab strip) hits a warm cache. The
|
||||
// sidebar only observes these — cross-folder is an O(disk) scan, so it
|
||||
// stays enabled:false and the duplicates page populates it on first visit.
|
||||
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates'],
|
||||
queryFn: listDuplicateGroups,
|
||||
queryKey: ['duplicates', userLibraryBase()],
|
||||
queryFn: () => listDuplicateGroups(userLibraryBase()),
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 60_000
|
||||
}));
|
||||
// The cross-folder scan is server-scoped to the caller's effective
|
||||
// library root (sidecar reads BasePath + the stored index sub-path
|
||||
// itself), but the query is still keyed on userLibraryBase() so changing
|
||||
// the index folder invalidates the stale, differently-scoped result.
|
||||
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||
queryKey: ['duplicates-cross-folder'],
|
||||
queryKey: ['duplicates-cross-folder', userLibraryBase()],
|
||||
queryFn: scanCrossFolderDuplicates,
|
||||
enabled: false,
|
||||
staleTime: 5 * 60_000
|
||||
@@ -168,45 +205,13 @@
|
||||
if (browser) localStorage.setItem(ROOT_OPEN_KEY, rootExpanded ? '1' : '0');
|
||||
}
|
||||
|
||||
// Tags-submenu collapse state. Same dedicated-key pattern as `rootExpanded`
|
||||
// above (keeping it out of `view.metadataSections`, which is reserved for
|
||||
// the right-sidebar metadata panel). Defaults to collapsed so the sidebar
|
||||
// doesn't grow on first paint.
|
||||
const TAGS_OPEN_KEY = 'mule_tags_expanded';
|
||||
let tagsExpanded = $state(loadTagsExpanded());
|
||||
function loadTagsExpanded(): boolean {
|
||||
if (!browser) return false;
|
||||
const raw = localStorage.getItem(TAGS_OPEN_KEY);
|
||||
return raw === '1';
|
||||
}
|
||||
function toggleTags() {
|
||||
tagsExpanded = !tagsExpanded;
|
||||
if (browser) localStorage.setItem(TAGS_OPEN_KEY, tagsExpanded ? '1' : '0');
|
||||
}
|
||||
|
||||
// Review-submenu collapse state. Mirrors `tagsExpanded` so the Review
|
||||
// row in Manage can expose the same set of tabs the /review page shows
|
||||
// (cause groups + duplicates panels). Defaults to collapsed.
|
||||
const REVIEW_OPEN_KEY = 'mule_review_expanded';
|
||||
let reviewExpanded = $state(loadReviewExpanded());
|
||||
function loadReviewExpanded(): boolean {
|
||||
if (!browser) return false;
|
||||
return localStorage.getItem(REVIEW_OPEN_KEY) === '1';
|
||||
}
|
||||
function toggleReview() {
|
||||
reviewExpanded = !reviewExpanded;
|
||||
if (browser) localStorage.setItem(REVIEW_OPEN_KEY, reviewExpanded ? '1' : '0');
|
||||
}
|
||||
|
||||
// Cause-tab list is dynamic (only buckets with hits show up on /review),
|
||||
// so the sidebar mirrors that by reusing the same query. Gated on
|
||||
// `reviewExpanded` to avoid paying the /photos round-trip for users who
|
||||
// never expand the section; the queryKey is shared with the /review page
|
||||
// so visiting that route warms the cache for free.
|
||||
// so the sidebar mirrors that by reusing the same query. The queryKey is
|
||||
// shared with the /review page so visiting that route warms the cache for free.
|
||||
const reviewGroupsQuery = createQuery<ReviewGroup[]>(() => ({
|
||||
queryKey: ['review-groups'],
|
||||
queryFn: listReviewGroups,
|
||||
enabled: isAuthenticated() && reviewExpanded,
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 30_000
|
||||
}));
|
||||
|
||||
@@ -236,7 +241,8 @@
|
||||
keywords: 'Keywords',
|
||||
people: 'People',
|
||||
colors: 'Colors',
|
||||
ratings: 'Ratings'
|
||||
ratings: 'Ratings',
|
||||
countries: 'Countries'
|
||||
};
|
||||
|
||||
function isTagCategoryActive(cat: TagCategory): boolean {
|
||||
@@ -265,6 +271,17 @@
|
||||
session.user?.DisplayName?.trim() || session.user?.Name || '/'
|
||||
);
|
||||
|
||||
// When the user has narrowed their library to an index sub-folder, the
|
||||
// root row stands for that sub-folder — surface its leaf name so it's
|
||||
// obvious the tree is re-rooted rather than showing the whole account.
|
||||
const rootSubLabel = $derived(
|
||||
prefs.indexSubpath === '' ? '' : (prefs.indexSubpath.split('/').pop() ?? '')
|
||||
);
|
||||
const rootTitle = $derived.by(() => {
|
||||
const base = userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`;
|
||||
return prefs.indexSubpath === '' ? base : `${base} → ${prefs.indexSubpath}`;
|
||||
});
|
||||
|
||||
async function onSignOut() {
|
||||
await logout();
|
||||
await goto('/login', { replaceState: true });
|
||||
@@ -281,10 +298,15 @@
|
||||
}
|
||||
|
||||
const createFolderMut = createMutation(() => ({
|
||||
mutationFn: (relPath: string) => createFolder(relPath),
|
||||
// The sidebar deals in user-relative paths (BasePath stripped); the
|
||||
// sidecar operates on originals-relative paths. Translate on the way
|
||||
// out (toOriginalsPath) and back for display (toUserPath), exactly like
|
||||
// the move flow — otherwise a BasePath user's folder ops resolve to the
|
||||
// wrong directory and the sidecar returns "invalid path".
|
||||
mutationFn: (relPath: string) => createFolder(toOriginalsPath(relPath)),
|
||||
onSuccess: (r) => {
|
||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
toast.success(`Folder created: ${r.path}`);
|
||||
toast.success(`Folder created: ${toUserPath(r.path)}`);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Could not create folder')
|
||||
@@ -292,36 +314,59 @@
|
||||
|
||||
const renameFolderMut = createMutation(() => ({
|
||||
mutationFn: (args: { rel: string; newName: string }) =>
|
||||
renameFolder(args.rel, args.newName),
|
||||
renameFolder(toOriginalsPath(args.rel), args.newName),
|
||||
onSuccess: (r) => {
|
||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
// Handler returns originals-relative paths; map back to the UI's
|
||||
// user-relative space before comparing/navigating.
|
||||
const oldUi = toUserPath(r.oldPath);
|
||||
const newUi = toUserPath(r.newPath);
|
||||
// If the active folder filter was on this folder, follow the rename.
|
||||
if (filters.folderPath === r.oldPath) {
|
||||
setFolderPath(r.newPath);
|
||||
const params = new URLSearchParams({ folder: r.newPath });
|
||||
if (filters.folderPath === oldUi) {
|
||||
setFolderPath(newUi);
|
||||
const params = new URLSearchParams({ folder: newUi });
|
||||
void goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
||||
}
|
||||
toast.success(`Renamed: ${r.oldPath} → ${r.newPath}`);
|
||||
toast.success(`Renamed: ${oldUi} → ${newUi}`);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Rename failed')
|
||||
}));
|
||||
|
||||
const deleteFolderMut = createMutation(() => ({
|
||||
mutationFn: (rel: string) => deleteFolder(rel),
|
||||
mutationFn: (rel: string) => deleteFolder(toOriginalsPath(rel)),
|
||||
onSuccess: (r) => {
|
||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
if (filters.folderPath && filters.folderPath.startsWith(r.path)) {
|
||||
const ui = toUserPath(r.path);
|
||||
if (filters.folderPath && filters.folderPath.startsWith(ui)) {
|
||||
setFolderPath(null);
|
||||
void goto('/', { keepFocus: true, noScroll: true });
|
||||
}
|
||||
toast.success(`Folder deleted: ${r.path}`);
|
||||
toast.success(`Folder deleted: ${ui}`);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed')
|
||||
}));
|
||||
|
||||
// One-click "reindex new files": kicks off a scan of the whole library
|
||||
// with rescan off, so PhotoPrism only picks up files it hasn't indexed
|
||||
// yet. Progress streams in via the WebSocket indexer pill, and the grid
|
||||
// auto-refreshes as new tiles land (see indexer store). Guarded against
|
||||
// double-trigger while a scan is already running.
|
||||
async function onReindex() {
|
||||
if (indexer.active) return;
|
||||
const tid = toast.loading('Starting reindex…');
|
||||
try {
|
||||
// Scope the one-click reindex to the effective library root
|
||||
// (BasePath + chosen index sub-path) rather than the whole library.
|
||||
await startIndex({ path: '/' + toOriginalsPath('/'), rescan: false, cleanup: false });
|
||||
toast.success('Reindex started — new files will appear as they’re found', { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Reindex failed', { id: tid });
|
||||
}
|
||||
}
|
||||
|
||||
function onCreateFolder(parent: string | null = null) {
|
||||
const name = prompt(parent ? `New subfolder under "${parent}"` : 'New folder name')?.trim();
|
||||
if (!name) return;
|
||||
@@ -377,7 +422,7 @@
|
||||
//
|
||||
// `getCount` is a getter (not a snapshot) so the badge reads the latest
|
||||
// derived value on every render — the arrays themselves are constant.
|
||||
// Map and Tags intentionally render without a count badge; the count
|
||||
// Tags intentionally renders without a count badge; the count
|
||||
// columns inside the TagsBrowserSidebar are the canonical surface for
|
||||
// per-tag totals. Review rolls in the duplicates tabs hosted under
|
||||
// /review — stacks always contributes; cross-folder only contributes
|
||||
@@ -392,10 +437,9 @@
|
||||
// separate "everything regardless of folder" destination would just
|
||||
// duplicate it for users whose photos live under the root.
|
||||
const views: ViewItem[] = [
|
||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => undefined }
|
||||
// Tags is rendered as a bespoke expandable block below the
|
||||
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
|
||||
// Ratings) and a chevron, neither of which fits the flat
|
||||
// Ratings/Countries) and a chevron, neither of which fits the flat
|
||||
// section/route ViewItem shape. Notes lives under that expandable
|
||||
// alongside the tag categories.
|
||||
];
|
||||
@@ -484,6 +528,17 @@
|
||||
<span class="flex-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Library
|
||||
</span>
|
||||
<button
|
||||
class="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||
class:opacity-0={!indexer.active}
|
||||
class:opacity-100={indexer.active}
|
||||
onclick={onReindex}
|
||||
disabled={indexer.active}
|
||||
title="Reindex new files"
|
||||
aria-label="Reindex new files"
|
||||
>
|
||||
<RefreshCw class="h-3 w-3 {indexer.active ? 'animate-spin' : ''}" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-0.5 text-muted-foreground opacity-0 hover:bg-accent hover:text-foreground group-hover/header:opacity-100"
|
||||
onclick={() => (settingsOpen = true)}
|
||||
@@ -518,26 +573,33 @@
|
||||
{#if hasSubfolders}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[18px] w-4 items-center justify-center text-[10px]"
|
||||
class="flex h-[18px] w-5 items-center justify-center rounded hover:text-foreground"
|
||||
class:text-muted-foreground={!rootActive}
|
||||
onclick={toggleRoot}
|
||||
title={rootExpanded ? 'Collapse' : 'Expand'}
|
||||
aria-label={rootExpanded ? 'Collapse root' : 'Expand root'}
|
||||
>
|
||||
{rootExpanded ? '▾' : '▸'}
|
||||
<ChevronRight
|
||||
class="h-4 w-4 transition-transform duration-150 {rootExpanded ? 'rotate-90' : ''}"
|
||||
/>
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Spacer keeps chevronless rows aligned with their chevroned
|
||||
peers, so labels share a common left edge across the sidebar. -->
|
||||
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
|
||||
<span class="inline-block h-[18px] w-5" aria-hidden="true"></span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center pl-1 text-left"
|
||||
class="flex min-w-0 flex-1 items-center gap-1 pl-1 text-left"
|
||||
onclick={() => pickFolder('/')}
|
||||
title={userBasePath() === '' ? 'Your library' : `Your library (${userBasePath()})`}
|
||||
title={rootTitle}
|
||||
>
|
||||
<span class="truncate">{rootLabel}</span>
|
||||
{#if rootSubLabel}
|
||||
<span class="truncate text-muted-foreground" class:text-primary-foreground={rootActive}>
|
||||
/ {rootSubLabel}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- Root-row kebab. Only "New subfolder" applies — root itself
|
||||
can't be renamed or deleted, so those entries are omitted
|
||||
@@ -555,7 +617,7 @@
|
||||
</KebabMenu>
|
||||
</div>
|
||||
</div>
|
||||
{#if foldersQuery.isPending}
|
||||
{#if foldersQuery.isLoading}
|
||||
<InlineLoader size="sm" label="Loading folders…" />
|
||||
{:else if foldersQuery.isError}
|
||||
<EmptyState size="compact" tone="destructive" icon={FolderOpen} title="Failed to load folders" description="Try reloading the page." />
|
||||
@@ -683,63 +745,34 @@
|
||||
{#each views as v (v.kind === 'section' ? `s:${v.id}` : `r:${v.href}`)}
|
||||
{@render viewRow(v)}
|
||||
{/each}
|
||||
<!--
|
||||
Tags expandable. Whole row is a toggle (chevron + label); there is
|
||||
no landing page at /tags — selecting a sub-category is the only way
|
||||
into a real view. Counts intentionally live in the TagsBrowserSidebar
|
||||
(secondary sidebar) so this row stays a pure navigator.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
style="padding-left: 4px;"
|
||||
onclick={toggleTags}
|
||||
title={tagsExpanded ? 'Collapse tags' : 'Expand tags'}
|
||||
aria-expanded={tagsExpanded}
|
||||
>
|
||||
<span
|
||||
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
|
||||
>
|
||||
{tagsExpanded ? '▾' : '▸'}
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||
<span class="truncate">Tags</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if tagsExpanded}
|
||||
<!--
|
||||
Notes lives alongside the tag categories — same indent and row
|
||||
chrome — but routes to /notes rather than /tags/*. Tucked at
|
||||
the top of the expandable so it's the first thing the user
|
||||
sees when opening Tags.
|
||||
-->
|
||||
<!-- Notes -->
|
||||
{#if true}
|
||||
{@const notesActive = isNotesActive()}
|
||||
<a
|
||||
href="/notes"
|
||||
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={notesActive}
|
||||
class:text-primary-foreground={notesActive}
|
||||
class:hover:bg-primary={notesActive}
|
||||
style="padding-left: 36px;"
|
||||
>
|
||||
<span class="truncate">Notes</span>
|
||||
</a>
|
||||
{#each TAG_CATEGORIES as cat (cat)}
|
||||
{@const active = isTagCategoryActive(cat)}
|
||||
<a
|
||||
href={`/tags/${cat}`}
|
||||
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
style="padding-left: 36px;"
|
||||
onmouseenter={cat === 'keywords' ? prefetchKeywords : undefined}
|
||||
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
|
||||
>
|
||||
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
<!-- Tag categories -->
|
||||
{#each TAG_CATEGORIES as cat (cat)}
|
||||
{@const active = isTagCategoryActive(cat)}
|
||||
<a
|
||||
href={`/tags/${cat}`}
|
||||
class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
onmouseenter={cat === 'keywords' ? prefetchKeywords : undefined}
|
||||
onfocus={cat === 'keywords' ? prefetchKeywords : undefined}
|
||||
>
|
||||
<span class="truncate">{TAG_CATEGORY_LABELS[cat]}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Manage — curation flows that decide a photo's fate. Same
|
||||
@@ -751,57 +784,28 @@
|
||||
Manage
|
||||
</span>
|
||||
</div>
|
||||
<!--
|
||||
Review expandable. Mirrors the Tags affordance — pure toggle
|
||||
with no landing page; the only way into a tab is to expand and
|
||||
pick a subitem. Cause buckets are dynamic (only buckets with
|
||||
hits show up); Stacks/Cross-folder are always present.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="group flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
style="padding-left: 4px;"
|
||||
onclick={toggleReview}
|
||||
title={reviewExpanded ? 'Collapse review' : 'Expand review'}
|
||||
aria-expanded={reviewExpanded}
|
||||
>
|
||||
<span
|
||||
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
|
||||
<!-- Review tabs -->
|
||||
{#each reviewTabs as t (t.id)}
|
||||
{@const active = isReviewTabActive(t.id)}
|
||||
<a
|
||||
href={`/review?tab=${t.id}`}
|
||||
class="flex h-[22px] items-center rounded pl-6 pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
>
|
||||
{reviewExpanded ? '▾' : '▸'}
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||
<span class="truncate">Review</span>
|
||||
</span>
|
||||
</button>
|
||||
{#if reviewExpanded}
|
||||
{#each reviewTabs as t (t.id)}
|
||||
{@const active = isReviewTabActive(t.id)}
|
||||
<a
|
||||
href={`/review?tab=${t.id}`}
|
||||
class="flex h-[22px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
style="padding-left: 36px;"
|
||||
>
|
||||
<span class="truncate">{t.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
<!--
|
||||
Hidden lives under Review since it's the resting place for
|
||||
photos dismissed during review. Section-nav (not a ?tab=),
|
||||
so it's a button that flips filters.section like the flat
|
||||
Manage entries — just with the subitem indent.
|
||||
-->
|
||||
<span class="truncate">{t.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
<!-- Hidden -->
|
||||
{#if true}
|
||||
{@const hiddenActive = isActive('hidden')}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-[22px] w-full items-center rounded pr-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
class="flex h-[22px] w-full items-center rounded pl-6 pr-2 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={hiddenActive}
|
||||
class:text-primary-foreground={hiddenActive}
|
||||
class:hover:bg-primary={hiddenActive}
|
||||
style="padding-left: 36px;"
|
||||
onclick={() => navigateTo('hidden')}
|
||||
>
|
||||
<span class="truncate">Hidden</span>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
type PpFolder
|
||||
} from '$lib/services/photoprism';
|
||||
import { filters, setSection, setFolderPath } from '$lib/stores/filters.svelte';
|
||||
import { isAuthenticated, toOriginalsPath } from '$lib/stores/session.svelte';
|
||||
import { isAuthenticated, toOriginalsPath, userLibraryBase } from '$lib/stores/session.svelte';
|
||||
import { moveDialog, closeMove } from '$lib/stores/moveDialog.svelte';
|
||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
// Reuse the same folders cache the sidebar uses — same key so we share the
|
||||
// in-flight request, and the picker invalidates it on success.
|
||||
const foldersQuery = createQuery<PpFolder[]>(() => ({
|
||||
queryKey: ['folders'],
|
||||
queryKey: ['folders', userLibraryBase()],
|
||||
queryFn: listFolders,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
@@ -120,19 +120,38 @@
|
||||
// (nothing picked) so a falsy check doesn't wrongly block root.
|
||||
if (!s || pickedPath === null || submitting) return;
|
||||
submitting = true;
|
||||
|
||||
// Snapshot the draft before closing — closeMove() nulls the subject,
|
||||
// which the reset effect uses to wipe pickedPath/mode/subfolder.
|
||||
const dest = pickedPath;
|
||||
const opMode = mode;
|
||||
const sub = subfolder.trim() || null;
|
||||
const delHeap = mode === 'move' && deleteHeap;
|
||||
const labelName = folderName;
|
||||
|
||||
// Close the dialog immediately and run the move in the background. The
|
||||
// move can be slow (a folder/heap with many files triggers a real
|
||||
// disk move + reindex) and its progress surfaces in the header pill;
|
||||
// keeping the modal + overlay up would hide exactly the feedback the
|
||||
// user is waiting on. Mirrors the archive flow (toast + header pill).
|
||||
closeMove();
|
||||
|
||||
const verbing = opMode === 'copy' ? 'Copying' : 'Moving';
|
||||
const tid = toast.loading(`${verbing}…`);
|
||||
try {
|
||||
if (s.kind === 'heap') {
|
||||
const r = await convertHeap(s.heap.UID, {
|
||||
targetFolder: toOriginalsPath(pickedPath),
|
||||
mode,
|
||||
subfolder: subfolder.trim() || null,
|
||||
deleteHeap: mode === 'move' && deleteHeap
|
||||
targetFolder: toOriginalsPath(dest),
|
||||
mode: opMode,
|
||||
subfolder: sub,
|
||||
deleteHeap: delHeap
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||
toast.success(
|
||||
moveSummary(mode === 'copy' ? 'Copied' : 'Moved', mode === 'copy' ? r.copied : r.moved, r.errors.length)
|
||||
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
|
||||
{ id: tid }
|
||||
);
|
||||
if (r.heap_deleted && filters.section === 'heap' && filters.heapUid === s.heap.UID) {
|
||||
setSection('all-photos');
|
||||
@@ -141,32 +160,30 @@
|
||||
} else if (s.kind === 'photos') {
|
||||
const r = await movePhotosToFolder({
|
||||
uids: s.uids,
|
||||
targetFolder: toOriginalsPath(pickedPath),
|
||||
mode,
|
||||
subfolder: subfolder.trim() || null
|
||||
targetFolder: toOriginalsPath(dest),
|
||||
mode: opMode,
|
||||
subfolder: sub
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
toast.success(
|
||||
moveSummary(mode === 'copy' ? 'Copied' : 'Moved', mode === 'copy' ? r.copied : r.moved, r.errors.length)
|
||||
moveSummary(opMode === 'copy' ? 'Copied' : 'Moved', opMode === 'copy' ? r.copied : r.moved, r.errors.length),
|
||||
{ id: tid }
|
||||
);
|
||||
} else {
|
||||
// Folder reparent (move only). Translate both the folder's own
|
||||
// path and the destination parent to originals-relative for the
|
||||
// sidecar, which moves real directories on disk.
|
||||
await moveFolder(toOriginalsPath(s.path), toOriginalsPath(pickedPath));
|
||||
await moveFolder(toOriginalsPath(s.path), toOriginalsPath(dest));
|
||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
const newUiPath = pickedPath === '' ? folderName : `${pickedPath}/${folderName}`;
|
||||
toast.success(`Moved ${folderName} → ${pickedPath === '' ? '/' : pickedPath}`);
|
||||
const newUiPath = dest === '' ? labelName : `${dest}/${labelName}`;
|
||||
toast.success(`Moved ${labelName} → ${dest === '' ? '/' : dest}`, { id: tid });
|
||||
// If we just moved the folder the timeline is showing, follow it.
|
||||
if (filters.folderPath === s.path) setFolderPath(newUiPath);
|
||||
}
|
||||
closeMove();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Move failed');
|
||||
} finally {
|
||||
submitting = false;
|
||||
toast.error(err instanceof Error ? err.message : 'Move failed', { id: tid });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,24 +9,30 @@
|
||||
import { Dialog, Tabs } from 'bits-ui';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { AlertCircle, CheckCircle2, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
|
||||
import { AlertCircle, CheckCircle2, FolderOpen, Loader2, RefreshCw, Settings, X } from 'lucide-svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import {
|
||||
cancelImport,
|
||||
cancelIndex,
|
||||
getConfig,
|
||||
getErrors,
|
||||
getSettings,
|
||||
getIndexSubpath,
|
||||
listFoldersUnderBase,
|
||||
saveSettings,
|
||||
startImport,
|
||||
setIndexSubpath,
|
||||
startIndex,
|
||||
type ImportBody,
|
||||
type IndexBody,
|
||||
type PpFolder,
|
||||
type PpLogEntry,
|
||||
type PpSettings
|
||||
} from '$lib/services/photoprism';
|
||||
import type { PpClientConfig } from '$lib/types/photoprism';
|
||||
import { userBasePath } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
prefs,
|
||||
setIndexSubpathState,
|
||||
toOriginalsPath
|
||||
} from '$lib/stores/session.svelte';
|
||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -36,7 +42,7 @@
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
let activeTab = $state<'library' | 'index' | 'import' | 'logs' | 'about'>('library');
|
||||
let activeTab = $state<'library' | 'index' | 'logs' | 'about'>('library');
|
||||
|
||||
// ── Library tab ───────────────────────────────────────────────────────
|
||||
// Pull settings only while the dialog is open so we don't keep them
|
||||
@@ -62,7 +68,6 @@
|
||||
return {
|
||||
...s,
|
||||
index: s.index ?? {},
|
||||
import: s.import ?? {},
|
||||
stack: s.stack ?? {},
|
||||
download: s.download ?? {}
|
||||
};
|
||||
@@ -94,17 +99,68 @@
|
||||
if (settingsQuery.data) draft = normalize(structuredClone(settingsQuery.data));
|
||||
}
|
||||
|
||||
// ── Index folder (per-user, server-side) ──────────────────────────────
|
||||
// The originals-relative sub-folder, under the user's BasePath, that the
|
||||
// whole app re-roots to (Library tree) and the reindex scopes to. Picked
|
||||
// from the *full* BasePath tree (listFoldersUnderBase) so the user can
|
||||
// choose any sub-folder — including ones outside the current root. Stored
|
||||
// by the sidecar; mirrored into the `prefs` store so the sidebar reacts.
|
||||
const subpathFoldersQuery = createQuery<PpFolder[]>(() => ({
|
||||
queryKey: ['folders-under-base'],
|
||||
queryFn: listFoldersUnderBase,
|
||||
enabled: open && activeTab === 'library'
|
||||
}));
|
||||
const subpathTree = $derived(
|
||||
buildTree((subpathFoldersQuery.data ?? []).map((f) => f.Path))
|
||||
);
|
||||
|
||||
// Hydrate the picker selection from the server pref when the dialog opens,
|
||||
// so it reflects the current choice instead of the in-memory store alone.
|
||||
const indexPrefQuery = createQuery<string>(() => ({
|
||||
queryKey: ['prefs'],
|
||||
queryFn: getIndexSubpath,
|
||||
enabled: open
|
||||
}));
|
||||
// Local selection: '' = whole folder. Seeded from the store, then from the
|
||||
// server pref once it loads.
|
||||
let pickedSubpath = $state<string>(prefs.indexSubpath);
|
||||
$effect(() => {
|
||||
if (open && indexPrefQuery.data !== undefined) {
|
||||
pickedSubpath = indexPrefQuery.data;
|
||||
}
|
||||
});
|
||||
|
||||
const saveSubpathMut = createMutation(() => ({
|
||||
mutationFn: (sub: string) => setIndexSubpath(sub),
|
||||
onSuccess: (saved) => {
|
||||
setIndexSubpathState(saved);
|
||||
qc.setQueryData(['prefs'], saved);
|
||||
// Re-root the sidebar tree + grid: both are keyed on the effective
|
||||
// library base, which just changed.
|
||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
toast.success(saved === '' ? 'Indexing whole folder' : `Index folder: ${saved}`);
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Could not save index folder')
|
||||
}));
|
||||
|
||||
// ── Index tab ─────────────────────────────────────────────────────────
|
||||
// Default the reindex path to the user's BasePath when scoping is on,
|
||||
// so non-admins (and admins-with-BasePath) only rescan their own
|
||||
// subtree. PhotoPrism's /index expects originals-relative paths with
|
||||
// a leading slash; `'/'` means the whole library.
|
||||
const _bp = userBasePath();
|
||||
// Default the reindex path to the effective library root (BasePath +
|
||||
// chosen index sub-path), so a manual run only rescans the user's working
|
||||
// subtree. PhotoPrism's /index expects originals-relative paths with a
|
||||
// leading slash; `'/'` means the whole library.
|
||||
let indexForm = $state<IndexBody>({
|
||||
path: _bp === '' ? '/' : `/${_bp}`,
|
||||
path: '/' + toOriginalsPath('/'),
|
||||
rescan: false,
|
||||
cleanup: false
|
||||
});
|
||||
// SettingsDialog is mounted (open=false) before the index sub-path
|
||||
// hydrates, so re-seed the manual-run path to the effective library root
|
||||
// each time the dialog opens (and whenever the chosen root changes).
|
||||
$effect(() => {
|
||||
if (open) indexForm.path = '/' + toOriginalsPath('/');
|
||||
});
|
||||
const startIndexMut = createMutation(() => ({
|
||||
mutationFn: (b: IndexBody) => startIndex(b),
|
||||
onSuccess: (r) => toast.success(r.message || 'Indexing complete'),
|
||||
@@ -118,21 +174,6 @@
|
||||
toast.error(err instanceof Error ? err.message : 'Cancel failed')
|
||||
}));
|
||||
|
||||
// ── Import tab ────────────────────────────────────────────────────────
|
||||
let importForm = $state<ImportBody>({ path: '/', move: false, dest: '' });
|
||||
const startImportMut = createMutation(() => ({
|
||||
mutationFn: (b: ImportBody) => startImport(b),
|
||||
onSuccess: (r) => toast.success(r.message || 'Import complete'),
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Import failed')
|
||||
}));
|
||||
const cancelImportMut = createMutation(() => ({
|
||||
mutationFn: () => cancelImport(),
|
||||
onSuccess: () => toast.success('Import canceled'),
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Cancel failed')
|
||||
}));
|
||||
|
||||
// ── Logs tab ──────────────────────────────────────────────────────────
|
||||
// Poll while the Logs tab is showing; pause otherwise so the dialog
|
||||
// doesn't burn requests when the user is in another tab.
|
||||
@@ -237,7 +278,7 @@
|
||||
<Tabs.List
|
||||
class="mb-3 flex gap-1 border-b border-border"
|
||||
>
|
||||
{#each ['library', 'index', 'import', 'logs', 'about'] as const as t (t)}
|
||||
{#each ['library', 'index', 'logs', 'about'] as const as t (t)}
|
||||
<Tabs.Trigger
|
||||
value={t}
|
||||
class="-mb-px border-b-2 border-transparent px-3 py-1.5 text-[12px] capitalize text-muted-foreground hover:text-foreground data-[state=active]:border-primary data-[state=active]:text-foreground"
|
||||
@@ -248,7 +289,73 @@
|
||||
</Tabs.List>
|
||||
|
||||
<!-- Library — general settings -->
|
||||
<Tabs.Content value="library" class="outline-none">
|
||||
<Tabs.Content value="library" class="space-y-4 outline-none">
|
||||
<!-- Index folder — the per-user sub-folder the Library tree
|
||||
re-roots to and the reindex scopes to. Picked from the
|
||||
full BasePath tree so any sub-folder is reachable. -->
|
||||
<section class="space-y-2 text-[12px]">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Index folder
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Pick the sub-folder PhotoPrism should treat as your library
|
||||
root. The folder tree re-roots here and the reindex only scans
|
||||
this subtree. Leave on “Whole folder” to use everything.
|
||||
</p>
|
||||
<div class="rounded-md border border-border bg-background p-2">
|
||||
<div class="max-h-[180px] overflow-y-auto">
|
||||
{#if subpathFoldersQuery.isPending}
|
||||
<InlineLoader size="sm" label="Loading folders…" />
|
||||
{:else if subpathFoldersQuery.isError}
|
||||
<EmptyState
|
||||
size="compact"
|
||||
tone="destructive"
|
||||
icon={FolderOpen}
|
||||
title="Could not load folders"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Whole-folder reset: '' is the "no sub-path" sentinel. -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
|
||||
class:bg-primary={pickedSubpath === ''}
|
||||
class:text-primary-foreground={pickedSubpath === ''}
|
||||
class:hover:bg-primary={pickedSubpath === ''}
|
||||
onclick={() => (pickedSubpath = '')}
|
||||
>
|
||||
Whole folder
|
||||
</button>
|
||||
{#if (subpathFoldersQuery.data ?? []).length > 0}
|
||||
<FolderTree
|
||||
nodes={subpathTree}
|
||||
onPick={(p) => (pickedSubpath = p)}
|
||||
selectedPath={pickedSubpath}
|
||||
readonly
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate text-[11px] text-muted-foreground">
|
||||
Current: {prefs.indexSubpath === '' ? 'Whole folder' : prefs.indexSubpath}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
onclick={() => saveSubpathMut.mutate(pickedSubpath)}
|
||||
disabled={saveSubpathMut.isPending || pickedSubpath === prefs.indexSubpath}
|
||||
>
|
||||
{#if saveSubpathMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
Set index folder
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="h-px bg-border"></div>
|
||||
|
||||
{#if settingsQuery.isPending}
|
||||
<p class="px-1 text-[12px] text-muted-foreground">Loading settings…</p>
|
||||
{:else if settingsQuery.isError}
|
||||
@@ -284,25 +391,6 @@
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Importer defaults
|
||||
</h3>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={draft.import!.move} />
|
||||
Move (instead of copy) on import
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Default destination subpath</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 2026/05"
|
||||
bind:value={draft.import!.dest}
|
||||
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Stacks
|
||||
@@ -411,34 +499,6 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Features — PhotoPrism's gating bag. Render only the
|
||||
keys actually present in the response (PP version
|
||||
drift), labelled human-readably. -->
|
||||
{#if draft.features && Object.keys(draft.features).length > 0}
|
||||
<section class="space-y-1.5">
|
||||
<h3 class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Features
|
||||
</h3>
|
||||
<p class="text-muted-foreground">
|
||||
Toggling a feature off hides it from PhotoPrism's own
|
||||
UI and disables the underlying API surface.
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
{#each Object.keys(draft.features).sort() as key (key)}
|
||||
{#if typeof draft.features![key] === 'boolean'}
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={draft.features![key]}
|
||||
/>
|
||||
<span class="capitalize">{key}</span>
|
||||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-end gap-2">
|
||||
@@ -511,58 +571,6 @@
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Import — manual import run -->
|
||||
<Tabs.Content value="import" class="space-y-3 text-[12px] outline-none">
|
||||
<p class="text-muted-foreground">
|
||||
Pulls files from the import folder into the library. With "move"
|
||||
enabled, files are deleted from the import folder after a
|
||||
successful import.
|
||||
</p>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Source path</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={importForm.path}
|
||||
placeholder="/"
|
||||
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" bind:checked={importForm.move} />
|
||||
Move files (don't copy) after import
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-muted-foreground">Destination subpath (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={importForm.dest}
|
||||
placeholder="e.g. 2026/05"
|
||||
class="rounded border border-input bg-background px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1 hover:bg-accent disabled:opacity-50"
|
||||
onclick={() => cancelImportMut.mutate()}
|
||||
disabled={cancelImportMut.isPending || startImportMut.isPending}
|
||||
>
|
||||
Cancel current
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
onclick={() => startImportMut.mutate(importForm)}
|
||||
disabled={startImportMut.isPending}
|
||||
>
|
||||
{#if startImportMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
Start import
|
||||
</button>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- About — version, library counts, env-driven config help -->
|
||||
<Tabs.Content value="about" class="space-y-4 text-[12px] outline-none">
|
||||
{#if configQuery.isPending}
|
||||
|
||||
120
web/src/lib/components/layout/ShortcutsDialog.svelte
Normal file
120
web/src/lib/components/layout/ShortcutsDialog.svelte
Normal file
@@ -0,0 +1,120 @@
|
||||
<!--
|
||||
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' }
|
||||
]
|
||||
}
|
||||
];
|
||||
</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}
|
||||
@@ -101,6 +101,90 @@
|
||||
setFocused(next);
|
||||
setAnchor(next);
|
||||
}
|
||||
|
||||
// ── Zoom & pan ───────────────────────────────────────────────────────
|
||||
// Wheel zooms around the cursor, double-click toggles 1↔2.5, drag pans
|
||||
// while zoomed. Transform lives on a wrapper so the LQIP layer and the
|
||||
// sharp image scale together. Resets on photo change. Past 1.25× the
|
||||
// sharp <img> switches to fit_2048 so zoomed pixels stay crisp.
|
||||
const MAX_ZOOM = 6;
|
||||
let zoom = $state(1);
|
||||
let tx = $state(0);
|
||||
let ty = $state(0);
|
||||
let zoomHost = $state<HTMLElement | undefined>();
|
||||
let panning = $state(false);
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
|
||||
$effect(() => {
|
||||
void uid;
|
||||
zoom = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
});
|
||||
|
||||
function applyZoom(next: number, clientX: number, clientY: number) {
|
||||
if (!zoomHost) return;
|
||||
const clamped = Math.min(MAX_ZOOM, Math.max(1, next));
|
||||
if (clamped === zoom) return;
|
||||
// Keep the point under the cursor fixed: translate offsets are in
|
||||
// post-scale pixels around the container centre.
|
||||
const rect = zoomHost.getBoundingClientRect();
|
||||
const cx = clientX - rect.left - rect.width / 2;
|
||||
const cy = clientY - rect.top - rect.height / 2;
|
||||
const s = clamped / zoom;
|
||||
tx = cx + (tx - cx) * s;
|
||||
ty = cy + (ty - cy) * s;
|
||||
zoom = clamped;
|
||||
if (zoom === 1) {
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
applyZoom(zoom * Math.exp(-e.deltaY * 0.0018), e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
/** Svelte marks wheel handlers passive; zooming needs preventDefault,
|
||||
* so the listener is attached manually as non-passive. */
|
||||
function wheelZoom(node: HTMLElement) {
|
||||
node.addEventListener('wheel', onWheel, { passive: false });
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener('wheel', onWheel);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function onDblClickZoom(e: MouseEvent) {
|
||||
if (zoom > 1) {
|
||||
zoom = 1;
|
||||
tx = 0;
|
||||
ty = 0;
|
||||
} else {
|
||||
applyZoom(2.5, e.clientX, e.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (zoom === 1) return;
|
||||
panning = true;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!panning) return;
|
||||
tx += e.clientX - lastX;
|
||||
ty += e.clientY - lastY;
|
||||
lastX = e.clientX;
|
||||
lastY = e.clientY;
|
||||
}
|
||||
function onPointerUp() {
|
||||
panning = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex h-full w-full items-center justify-center bg-black/40 p-4">
|
||||
@@ -145,30 +229,61 @@
|
||||
photoQuery.data.OriginalName ??
|
||||
pf.Name ??
|
||||
(isVideo(photoQuery.data) ? 'Video' : 'Photo')}
|
||||
{#if pf.Width && pf.Height}
|
||||
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
||||
the tile_*'s square center-crop against the sharp image's
|
||||
true aspect. Sized via aspect-ratio + max-* + m-auto so it
|
||||
lands in the exact same bounding box as the sharp <img>
|
||||
beside it (object-contain semantics, but expressible on a
|
||||
positioned element). Paints from the HTTP cache the moment
|
||||
the modal opens. -->
|
||||
<img
|
||||
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
||||
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
|
||||
style="aspect-ratio: {pf.Width} / {pf.Height};"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 'fit_1280')}
|
||||
alt={altText}
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="relative max-h-full max-w-full rounded-md object-contain shadow-2xl"
|
||||
/>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
bind:this={zoomHost}
|
||||
use:wheelZoom
|
||||
ondblclick={onDblClickZoom}
|
||||
onpointerdown={onPointerDown}
|
||||
onpointermove={onPointerMove}
|
||||
onpointerup={onPointerUp}
|
||||
onpointercancel={onPointerUp}
|
||||
class="relative flex h-full w-full items-center justify-center overflow-hidden {zoom > 1
|
||||
? panning
|
||||
? 'cursor-grabbing'
|
||||
: 'cursor-grab'
|
||||
: 'cursor-zoom-in'}"
|
||||
>
|
||||
<div
|
||||
class="relative flex h-full w-full items-center justify-center"
|
||||
class:transition-transform={!panning}
|
||||
class:duration-150={!panning}
|
||||
style="transform: translate({tx}px, {ty}px) scale({zoom});"
|
||||
>
|
||||
{#if pf.Width && pf.Height}
|
||||
<!-- LQIP layer: the same URL the grid loaded, blurred to mask
|
||||
the tile_*'s square center-crop against the sharp image's
|
||||
true aspect. Sized via aspect-ratio + max-* + m-auto so it
|
||||
lands in the exact same bounding box as the sharp <img>
|
||||
beside it (object-contain semantics, but expressible on a
|
||||
positioned element). Paints from the HTTP cache the moment
|
||||
the modal opens. -->
|
||||
<img
|
||||
src={thumbSrc(pf.Hash, view.thumbnailSize)}
|
||||
srcset={thumbSrcSet(pf.Hash, view.thumbnailSize)}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute inset-0 m-auto max-h-full max-w-full rounded-md object-cover blur-2xl"
|
||||
style="aspect-ratio: {pf.Width} / {pf.Height};"
|
||||
/>
|
||||
{/if}
|
||||
<img
|
||||
src={thumbUrl(pf.Hash, 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 zoom > 1}
|
||||
<span
|
||||
class="absolute bottom-2 left-1/2 -translate-x-1/2 rounded bg-background/80 px-2 py-0.5 text-[11px] text-foreground"
|
||||
>
|
||||
{Math.round(zoom * 100)}% · double-click to reset
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
PUT (Details fields need the full body).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -14,12 +13,14 @@
|
||||
Aperture,
|
||||
ArrowUpRight,
|
||||
Calendar,
|
||||
Copy,
|
||||
File,
|
||||
Folder,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Heart,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Star,
|
||||
Tag,
|
||||
@@ -38,13 +39,16 @@
|
||||
type UpdatePhotoBody
|
||||
} from '$lib/services/photoprism';
|
||||
import { invalidateFacets } from '$lib/services/bulk';
|
||||
import { toggleFavorite } from '$lib/services/photoActions';
|
||||
import { startBulk, doneBulk, failBulk } from '$lib/stores/bulkAction.svelte';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { navigateToFolder } from '$lib/stores/filters.svelte';
|
||||
import { navigateToFolder, navigateToTag, setSearch, setSection } from '$lib/stores/filters.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||
import { countryName } from '$lib/utils/countries';
|
||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
|
||||
interface Props {
|
||||
@@ -307,6 +311,36 @@
|
||||
const joined = `${make} ${model}`.trim();
|
||||
return joined && joined !== 'Unknown' ? joined : '';
|
||||
}
|
||||
/** Quote for PhotoPrism's q= DSL — mirrors filters.svelte's quoteIfNeeded,
|
||||
* duplicated here since that helper isn't exported. */
|
||||
function quoteTerm(v: string): string {
|
||||
return /^[A-Za-z0-9_-]+$/.test(v) ? v : `"${v.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
/** Jump to the timeline filtered by a raw DSL term (camera:/lens:) — the
|
||||
* q-DSL escape hatch from the toolbar search box, triggered by click
|
||||
* instead of typing. */
|
||||
async function jumpToSearch(term: string): Promise<void> {
|
||||
setSection('all-photos');
|
||||
setSearch(term);
|
||||
await goto('/', { keepFocus: true, noScroll: true });
|
||||
}
|
||||
async function copyExif(): Promise<void> {
|
||||
const lines = [
|
||||
cameraStr && `Camera: ${cameraStr}`,
|
||||
lensStr && lensStr !== cameraStr && `Lens: ${lensStr}`,
|
||||
exposureParts.fnum && `Aperture: ${exposureParts.fnum}`,
|
||||
exposureParts.exp && `Shutter: ${exposureParts.exp}`,
|
||||
exposureParts.iso && exposureParts.iso,
|
||||
exposureParts.focal && `Focal length: ${exposureParts.focal}`,
|
||||
photo.TakenAt && `Taken: ${photo.TakenAt}`
|
||||
].filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
toast.message('No EXIF to copy');
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(lines.join('\n'));
|
||||
toast.success('EXIF copied');
|
||||
}
|
||||
function formatExposureParts(p: PpPhoto): { iso: string; fnum: string; focal: string; exp: string } {
|
||||
return {
|
||||
iso: p.Iso ? `ISO ${p.Iso}` : '',
|
||||
@@ -432,28 +466,22 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Location (read-only label + open-on-map icon). The arrow-up-
|
||||
right icon flies the in-app map to the photo's coordinates at
|
||||
zoom 17 (close enough for the photo's marker to be its own,
|
||||
out of any cluster). Hidden when the photo has no
|
||||
coordinates. -->
|
||||
<!-- Location (read-only label + jump-to-country icon). Hidden when
|
||||
the photo has no resolved country. -->
|
||||
<div class="flex items-center gap-2">
|
||||
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
|
||||
{placeLabel || 'No location'}
|
||||
</span>
|
||||
{#if photo.Lat && photo.Lng}
|
||||
{#if photo.Country && photo.Country !== 'zz'}
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
onclick={() =>
|
||||
void goto(
|
||||
`/map?lat=${photo.Lat}&lng=${photo.Lng}&zoom=17&focus=${photo.UID}`
|
||||
)}
|
||||
title="Open on map"
|
||||
aria-label="Open on map"
|
||||
onclick={() => void navigateToTag('countries', photo.Country ?? null)}
|
||||
title={`View other photos from ${countryName(photo.Country)}`}
|
||||
aria-label={`View other photos from ${countryName(photo.Country)}`}
|
||||
>
|
||||
<MapIcon class="h-3 w-3" />
|
||||
<Globe class="h-3 w-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -507,6 +535,19 @@
|
||||
<Star class="h-3.5 w-3.5" fill={currentRating >= n ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
{/each}
|
||||
<!-- PhotoPrism's native favorite — syncs to mobile gallery apps. -->
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 p-0.5 transition-colors {photo.Favorite
|
||||
? 'text-red-500'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => void toggleFavorite([photo.UID])}
|
||||
title={photo.Favorite ? 'Remove from favorites (f)' : 'Add to favorites (f)'}
|
||||
aria-pressed={photo.Favorite ?? false}
|
||||
aria-label="Favorite"
|
||||
>
|
||||
<Heart class="h-3.5 w-3.5" fill={photo.Favorite ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -647,20 +688,51 @@
|
||||
ontoggle={(e) => setMetadataSection('file', e.currentTarget.open)}
|
||||
>
|
||||
<summary
|
||||
class="cursor-pointer px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
class="flex cursor-pointer items-center justify-between px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ImageIcon class="h-3 w-3" /> File
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="normal-case text-muted-foreground hover:text-foreground"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void copyExif();
|
||||
}}
|
||||
title="Copy EXIF summary"
|
||||
aria-label="Copy EXIF summary"
|
||||
>
|
||||
<Copy class="h-3 w-3" />
|
||||
</button>
|
||||
</summary>
|
||||
<dl class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5 p-2 pt-1 text-[10px]">
|
||||
{#if cameraStr}
|
||||
<dt class="text-muted-foreground">Camera</dt>
|
||||
<dd class="text-foreground/80">{cameraStr}</dd>
|
||||
<dd class="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
class="truncate text-left text-foreground/80 hover:text-foreground hover:underline"
|
||||
onclick={() => void jumpToSearch(`camera:${quoteTerm(cameraStr)}`)}
|
||||
title={`View other photos taken with ${cameraStr}`}
|
||||
>
|
||||
{cameraStr}
|
||||
</button>
|
||||
</dd>
|
||||
{/if}
|
||||
{#if lensStr && lensStr !== cameraStr}
|
||||
<dt class="text-muted-foreground">Lens</dt>
|
||||
<dd class="text-foreground/80">{lensStr}</dd>
|
||||
<dd class="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
class="truncate text-left text-foreground/80 hover:text-foreground hover:underline"
|
||||
onclick={() => void jumpToSearch(`lens:${quoteTerm(lensStr)}`)}
|
||||
title={`View other photos taken with ${lensStr}`}
|
||||
>
|
||||
{lensStr}
|
||||
</button>
|
||||
</dd>
|
||||
{/if}
|
||||
{#if exposureParts.fnum || exposureParts.exp || exposureParts.iso || exposureParts.focal}
|
||||
<dt class="text-muted-foreground">Exposure</dt>
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import {
|
||||
aggregateKeywords,
|
||||
getAllMarks,
|
||||
listCountries,
|
||||
listLabels,
|
||||
listPhotosByUids,
|
||||
listSubjects,
|
||||
type AggregatedKeyword,
|
||||
type PhotoMarksMap,
|
||||
type PpCountry,
|
||||
type PpLabel,
|
||||
type PpSubject
|
||||
} from '$lib/services/photoprism';
|
||||
@@ -20,9 +22,10 @@
|
||||
COLOR_SWATCHES,
|
||||
starLabel
|
||||
} from '$lib/utils/tagGroups';
|
||||
import { countryFlag, countryName } from '$lib/utils/countries';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import { Hash, Tag, User } from 'lucide-svelte';
|
||||
import { Globe, Hash, Tag, User } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
category: TagCategory;
|
||||
@@ -64,6 +67,12 @@
|
||||
enabled: isAuthenticated() && category === 'people'
|
||||
}));
|
||||
|
||||
const countriesQuery = createQuery<PpCountry[]>(() => ({
|
||||
queryKey: ['countries'],
|
||||
queryFn: listCountries,
|
||||
enabled: isAuthenticated() && category === 'countries'
|
||||
}));
|
||||
|
||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||
queryKey: ['marks'],
|
||||
queryFn: getAllMarks,
|
||||
@@ -125,6 +134,19 @@
|
||||
);
|
||||
});
|
||||
|
||||
// PhotoPrism returns countries unsorted; sort by photo count descending so
|
||||
// the most-photographed countries surface first (mirrors labels/people).
|
||||
const countriesSorted = $derived(
|
||||
[...(countriesQuery.data ?? [])].sort((a, b) => b.PhotoCount - a.PhotoCount)
|
||||
);
|
||||
const filteredCountries = $derived.by(() => {
|
||||
const q = filterText.trim().toLowerCase();
|
||||
if (!q) return countriesSorted;
|
||||
return countriesSorted.filter((c) =>
|
||||
countryName(c.Code).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const ratingGroups = $derived(
|
||||
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
|
||||
);
|
||||
@@ -152,9 +174,11 @@
|
||||
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
|
||||
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
|
||||
const visibleSubjects = $derived(filteredSubjects.slice(0, visibleCount));
|
||||
const visibleCountries = $derived(filteredCountries.slice(0, visibleCount));
|
||||
const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
|
||||
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
|
||||
const hasMoreSubjects = $derived(visibleCount < filteredSubjects.length);
|
||||
const hasMoreCountries = $derived(visibleCount < filteredCountries.length);
|
||||
|
||||
function loadMore() {
|
||||
visibleCount += PAGE_SIZE;
|
||||
@@ -179,6 +203,9 @@
|
||||
const s = String(r);
|
||||
if (selectedValue !== s) onSelect(s);
|
||||
}
|
||||
function pickCountry(code: string) {
|
||||
if (selectedValue !== code) onSelect(code);
|
||||
}
|
||||
|
||||
// First non-empty entry for the active category. Labels/keywords are
|
||||
// already sorted by count desc, so [0] is the most-used tag; colors
|
||||
@@ -203,6 +230,9 @@
|
||||
const g = ratingGroups[0];
|
||||
return g ? String(g.rating) : null;
|
||||
}
|
||||
if (category === 'countries') {
|
||||
return countriesSorted[0]?.Code ?? null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -228,11 +258,16 @@
|
||||
? 'People'
|
||||
: category === 'colors'
|
||||
? 'Colors'
|
||||
: 'Ratings'
|
||||
: category === 'countries'
|
||||
? 'Countries'
|
||||
: 'Ratings'
|
||||
);
|
||||
|
||||
const showFilterInput = $derived(
|
||||
category === 'labels' || category === 'keywords' || category === 'people'
|
||||
category === 'labels' ||
|
||||
category === 'keywords' ||
|
||||
category === 'people' ||
|
||||
category === 'countries'
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -448,6 +483,69 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if category === 'countries'}
|
||||
{#if countriesQuery.isPending}
|
||||
<InlineLoader size="sm" label="Loading countries…" />
|
||||
{:else if countriesQuery.isError}
|
||||
<EmptyState size="compact" tone="destructive" title="Failed to load countries" />
|
||||
{:else if filteredCountries.length === 0}
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={Globe}
|
||||
title={filterText ? 'No countries match the filter' : 'No geotagged photos yet'}
|
||||
/>
|
||||
{:else}
|
||||
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#each visibleCountries as countryRow (countryRow.Code)}
|
||||
{@const active = countryRow.Code === selectedValue}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
onclick={() => pickCountry(countryRow.Code)}
|
||||
title={countryName(countryRow.Code)}
|
||||
>
|
||||
{#if countryRow.Thumb}
|
||||
<img
|
||||
src={thumbUrl(countryRow.Thumb, 'tile_50')}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
class="h-5 w-5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span class="flex h-5 w-5 shrink-0 items-center justify-center text-[14px]">
|
||||
{countryFlag(countryRow.Code)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1 truncate">{countryName(countryRow.Code)}</span>
|
||||
<span
|
||||
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{countryRow.PhotoCount}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
<div
|
||||
use:nearBottom={{
|
||||
onHit: loadMore,
|
||||
enabled: hasMoreCountries,
|
||||
root: scrollEl ?? null,
|
||||
preloadPx: 400
|
||||
}}
|
||||
class="h-px"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{#if hasMoreCountries}
|
||||
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
|
||||
Loading more… ({visibleCount} / {filteredCountries.length})
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if category === 'colors'}
|
||||
{#if marksQuery.isPending || marksPoolQuery.isPending}
|
||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>
|
||||
|
||||
@@ -33,8 +33,7 @@
|
||||
doneBulk,
|
||||
removedBulk,
|
||||
failBulk,
|
||||
markRemoved,
|
||||
clearRemoved
|
||||
markRemoved
|
||||
} from '$lib/stores/bulkAction.svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import { Layers } from 'lucide-svelte';
|
||||
@@ -156,15 +155,14 @@
|
||||
throw e;
|
||||
} finally {
|
||||
busy = false;
|
||||
const settled = Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ['photos'] }),
|
||||
qc.invalidateQueries({ queryKey: ['marks'] }),
|
||||
qc.invalidateQueries({ queryKey: ['review-groups'] })
|
||||
]);
|
||||
// Clear the optimistic-removal overlay only once the refetch has
|
||||
// landed, so tiles never flash back in before the fresh (archived-
|
||||
// filtered) page replaces the old one.
|
||||
if (bulk) void settled.then(() => clearRemoved(bulk.ids));
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
void qc.invalidateQueries({ queryKey: ['marks'] });
|
||||
void qc.invalidateQueries({ queryKey: ['review-groups'] });
|
||||
// The optimistic-removal overlay (removedIds) is reconciled against
|
||||
// the cache in +page.svelte — each id drops once the fresh, archived-
|
||||
// filtered page has actually replaced it. Clearing here off this
|
||||
// action's own settle raced other in-flight removals and flashed
|
||||
// tiles back in.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
import { view } from "$lib/stores/view.svelte";
|
||||
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
||||
import { bulkPhotoStates } from "$lib/stores/bulkAction.svelte";
|
||||
import { toggleFavorite } from "$lib/services/photoActions";
|
||||
import { fade } from "svelte/transition";
|
||||
import { Loader2, Check, X } from "lucide-svelte";
|
||||
import { Loader2, Check, Heart, X } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
photo: PpPhoto;
|
||||
@@ -191,4 +192,28 @@
|
||||
>
|
||||
{/if}
|
||||
</button>
|
||||
<!--
|
||||
Favorite heart — a *sibling* of the tile button (nested buttons are
|
||||
invalid HTML and break click semantics). Filled + always visible when
|
||||
favorited; otherwise fades in on hover. Mirrors the `f` shortcut.
|
||||
-->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute bottom-1.5 right-1.5 z-10 rounded-full bg-background/70 p-1 backdrop-blur transition-opacity {photo.Favorite
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 focus-visible:opacity-100 group-hover:opacity-100'}"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleFavorite([photo.UID]);
|
||||
}}
|
||||
ondblclick={(e) => e.stopPropagation()}
|
||||
title={photo.Favorite ? "Remove from favorites (f)" : "Add to favorites (f)"}
|
||||
aria-pressed={photo.Favorite ?? false}
|
||||
aria-label="Favorite"
|
||||
>
|
||||
<Heart
|
||||
class="h-3.5 w-3.5 {photo.Favorite ? 'text-red-500' : 'text-foreground/80'}"
|
||||
fill={photo.Favorite ? "currentColor" : "none"}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,12 @@ export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1
|
||||
retry: 1,
|
||||
// The indexer WebSocket (stores/indexer.svelte.ts) already
|
||||
// invalidates ['photos'] and friends on live changes, so a
|
||||
// window-focus refetch only adds a redundant full-timeline
|
||||
// re-render (visible flash) every time the tab regains focus.
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -24,9 +24,13 @@ export interface DuplicateGroup {
|
||||
bestFileUid: string;
|
||||
}
|
||||
|
||||
export async function listDuplicateGroups(): Promise<DuplicateGroup[]> {
|
||||
export async function listDuplicateGroups(basePath?: string): Promise<DuplicateGroup[]> {
|
||||
// Build query: stack:true + optional path filter
|
||||
const pathFilter = basePath ? ` path:${basePath}*` : '';
|
||||
const q = `stack:true${pathFilter}`;
|
||||
|
||||
const photos = await listPhotos({
|
||||
q: 'stack:true',
|
||||
q,
|
||||
count: 200,
|
||||
merged: true,
|
||||
order: 'newest'
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
batchArchive,
|
||||
batchRestore,
|
||||
buildTakenAtPatch,
|
||||
likePhoto,
|
||||
unlikePhoto,
|
||||
updatePhoto
|
||||
} from './photoprism';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
@@ -121,6 +123,70 @@ export async function acceptDateAndKeep(uids: string[]): Promise<void> {
|
||||
toast.success(`Kept ${uids.length}`, { id: tid });
|
||||
}
|
||||
|
||||
/** Patch `Favorite` on every cached copy of the uids (timeline pages,
|
||||
* per-photo detail) so hearts flip instantly without a refetch. */
|
||||
function patchFavoriteCaches(uids: string[], value: boolean): void {
|
||||
const target = new Set(uids);
|
||||
const lists = queryClient.getQueriesData({ queryKey: ['photos'] });
|
||||
for (const [key, data] of lists) {
|
||||
if (!data) continue;
|
||||
if (Array.isArray(data)) {
|
||||
queryClient.setQueryData(
|
||||
key,
|
||||
(data as PpPhoto[]).map((p) => (target.has(p.UID) ? { ...p, Favorite: value } : p))
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||
if (!Array.isArray(pages)) continue;
|
||||
queryClient.setQueryData(key, {
|
||||
...(data as object),
|
||||
pages: pages.map((pg) =>
|
||||
pg.map((p) => (target.has(p.UID) ? { ...p, Favorite: value } : p))
|
||||
)
|
||||
});
|
||||
}
|
||||
for (const uid of uids) {
|
||||
const p = queryClient.getQueryData<PpPhoto>(['photo', uid]);
|
||||
if (p) queryClient.setQueryData(['photo', uid], { ...p, Favorite: value });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle PhotoPrism's native favorite flag on a set of photos. Target
|
||||
* state comes from the first uid (mixed selections converge). Optimistic
|
||||
* cache flip with rollback; undo re-toggles.
|
||||
*/
|
||||
export async function toggleFavorite(uids: string[]): Promise<void> {
|
||||
if (uids.length === 0) {
|
||||
toast.message('Nothing to favorite', {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const value = !(cachedPhoto(uids[0])?.Favorite ?? false);
|
||||
patchFavoriteCaches(uids, value);
|
||||
const { errors } = await batchEdit(uids, (id) => (value ? likePhoto(id) : unlikePhoto(id)));
|
||||
if (errors.length) {
|
||||
patchFavoriteCaches(uids, !value);
|
||||
toast.error(`Favorite failed on ${errors.length}`, { description: errors[0].message });
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
value
|
||||
? uids.length === 1
|
||||
? 'Added to favorites'
|
||||
: `Favorited ${uids.length}`
|
||||
: uids.length === 1
|
||||
? 'Removed from favorites'
|
||||
: `Unfavorited ${uids.length}`
|
||||
);
|
||||
pushUndo(value ? `Favorited ${uids.length}` : `Unfavorited ${uids.length}`, async () => {
|
||||
patchFavoriteCaches(uids, !value);
|
||||
await batchEdit(uids, (id) => (value ? unlikePhoto(id) : likePhoto(id)));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive photos. Reversible via the undo stack (Restore on ⌘Z).
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
session,
|
||||
toOriginalsPath,
|
||||
toUserPath,
|
||||
userBasePath
|
||||
userBasePath,
|
||||
userLibraryBase
|
||||
} from '$lib/stores/session.svelte';
|
||||
import { primaryFile } from '$lib/types/photoprism';
|
||||
import type {
|
||||
@@ -503,23 +504,45 @@ export interface PpFolder {
|
||||
* row itself is dropped — the sidebar synthesises the root entry. When
|
||||
* BasePath is empty (today's admin default) this is a no-op.
|
||||
*/
|
||||
export async function listFolders(): Promise<PpFolder[]> {
|
||||
async function fetchFolders(): Promise<PpFolder[]> {
|
||||
const { data } = await sidecar.get<{ folders?: PpFolder[] }>(
|
||||
'/api/sidecar/folders',
|
||||
{ params: { recursive: true, uncached: true, files: false } }
|
||||
);
|
||||
const bp = userBasePath();
|
||||
// Sidecar already filters by BasePath; the frontend still applies the
|
||||
// filter + path rewrite as a safety net for admin (bp="") and for any
|
||||
// folders that might have slipped through.
|
||||
const folders = data.folders ?? [];
|
||||
if (bp === '') return folders;
|
||||
return data.folders ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a flat folder list to those at/under `base` (server-absolute,
|
||||
* originals-relative) and rewrite each `Path` to be `base`-relative, dropping
|
||||
* the `base` row itself. `base === ''` (whole library) is a no-op. Sidecar
|
||||
* already filters by BasePath; this is the frontend's safety net + the
|
||||
* narrowing to the chosen index sub-path.
|
||||
*/
|
||||
function scopeFolders(folders: PpFolder[], base: string): PpFolder[] {
|
||||
if (base === '') return folders;
|
||||
return folders
|
||||
.filter((f) => f.Path === bp || f.Path.startsWith(bp + '/'))
|
||||
.map((f) => ({ ...f, Path: toUserPath(f.Path) }))
|
||||
.filter((f) => f.Path === base || f.Path.startsWith(base + '/'))
|
||||
.map((f) => ({ ...f, Path: f.Path === base ? '' : f.Path.slice(base.length + 1) }))
|
||||
.filter((f) => f.Path !== '');
|
||||
}
|
||||
|
||||
export async function listFolders(): Promise<PpFolder[]> {
|
||||
// Scoped to the *effective* library root (BasePath + chosen index
|
||||
// sub-path) so the sidebar tree re-roots to whatever the user picked.
|
||||
return scopeFolders(await fetchFolders(), userLibraryBase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `listFolders` but scoped to the user's *whole* BasePath, ignoring the
|
||||
* chosen index sub-path. The index-folder picker uses this so the user can
|
||||
* choose any sub-folder of their library as a new root — including ones
|
||||
* outside the current sub-path.
|
||||
*/
|
||||
export async function listFoldersUnderBase(): Promise<PpFolder[]> {
|
||||
return scopeFolders(await fetchFolders(), userBasePath());
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-folder photo count for each `paths[]` entry. PhotoPrism's `/folders`
|
||||
* endpoint reports `FileCount: 0` even when populated, so the count has
|
||||
@@ -560,35 +583,19 @@ export async function listFolderCounts(paths: string[]): Promise<Record<string,
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Geo ──────────────────────────────────────────────────────────────────────
|
||||
// ── Countries ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PpGeoFeature {
|
||||
type: 'Feature';
|
||||
id: string;
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
properties: {
|
||||
UID: string;
|
||||
Hash: string;
|
||||
Title?: string;
|
||||
TakenAt?: string;
|
||||
FavId?: number;
|
||||
};
|
||||
export interface PpCountry {
|
||||
Code: string;
|
||||
PhotoCount: number;
|
||||
Thumb?: string;
|
||||
}
|
||||
|
||||
export interface PpGeoCollection {
|
||||
type: 'FeatureCollection';
|
||||
features: PpGeoFeature[];
|
||||
bbox?: number[];
|
||||
}
|
||||
|
||||
export async function listGeo(q = ''): Promise<PpGeoCollection> {
|
||||
// PhotoPrism's `/geo` returns a GeoJSON FeatureCollection of every
|
||||
// matching geocoded photo. MapLibre's native clustering handles 50k+
|
||||
// points without breaking a sweat (PhotoPrism upstream documents
|
||||
// 500k); we ask for a generous cap that covers realistic libraries.
|
||||
const { data } = await http.get<PpGeoCollection>('/geo', {
|
||||
params: { count: 50000, q: q || undefined }
|
||||
});
|
||||
export async function listCountries(): Promise<PpCountry[]> {
|
||||
// Self-contained sidecar aggregation (groups photos.photo_country directly,
|
||||
// no PhotoPrism proxy round-trip) so counts/thumbs are scoped to the
|
||||
// caller's BasePath the same way /labels and /counts are.
|
||||
const { data } = await sidecar.get<PpCountry[]>('/api/sidecar/countries');
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1043,6 +1050,18 @@ export async function moveFolder(rel: string, targetParent: string): Promise<Fol
|
||||
}) as Promise<FolderMoveResult>;
|
||||
}
|
||||
|
||||
// ── Favorites ────────────────────────────────────────────────────────────────
|
||||
// PhotoPrism's native favorite flag — unlike marks, this syncs to any
|
||||
// PhotoPrism-compatible client app.
|
||||
|
||||
export async function likePhoto(uid: string): Promise<void> {
|
||||
await http.post(`/photos/${encodeURIComponent(uid)}/like`);
|
||||
}
|
||||
|
||||
export async function unlikePhoto(uid: string): Promise<void> {
|
||||
await http.delete(`/photos/${encodeURIComponent(uid)}/like`);
|
||||
}
|
||||
|
||||
// ── Photo marks (rating + color) ─────────────────────────────────────────────
|
||||
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
|
||||
// internal fields). We store them in mule-sidecar instead.
|
||||
@@ -1093,26 +1112,17 @@ export async function renameOnDisk(photoUid: string, newName: string): Promise<R
|
||||
// ── Settings / Admin ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Thin wrappers over PhotoPrism's admin endpoints driving the settings dialog
|
||||
// (Library / Index / Import / Logs). Shapes are deliberately partial — newer
|
||||
// (Library / Index / Logs). Shapes are deliberately partial — newer
|
||||
// PhotoPrism versions ship extra fields we don't render, and the POST endpoint
|
||||
// merges server-side, so it's safe to round-trip an incomplete object.
|
||||
|
||||
// PhotoPrism's /settings payload. muleimage only drives the indexer/stack/
|
||||
// download knobs from its own UI — the `ui`/`search`/`maps`/`import`/`features`
|
||||
// blocks PhotoPrism also returns only steer PhotoPrism's own SPA (which our
|
||||
// users never see), so they're intentionally omitted here and never surfaced.
|
||||
// The `[k: string]` index signature means an unknown round-tripped block is
|
||||
// preserved on save without us having to model it.
|
||||
export interface PpSettings {
|
||||
ui?: {
|
||||
theme?: string;
|
||||
language?: string;
|
||||
timeZone?: string;
|
||||
startPage?: string;
|
||||
scrollbar?: boolean;
|
||||
zoom?: boolean;
|
||||
};
|
||||
search?: {
|
||||
batchSize?: number;
|
||||
listView?: boolean;
|
||||
showTitles?: boolean;
|
||||
showCaptions?: boolean;
|
||||
};
|
||||
maps?: { animate?: number; style?: string };
|
||||
index?: {
|
||||
path?: string;
|
||||
convert?: boolean;
|
||||
@@ -1122,7 +1132,6 @@ export interface PpSettings {
|
||||
skipRaw?: boolean;
|
||||
skipHidden?: boolean;
|
||||
};
|
||||
import?: { path?: string; move?: boolean; dest?: string };
|
||||
stack?: { uuid?: boolean; meta?: boolean; name?: boolean };
|
||||
download?: {
|
||||
name?: string;
|
||||
@@ -1133,42 +1142,27 @@ export interface PpSettings {
|
||||
crc32?: boolean;
|
||||
sha1?: boolean;
|
||||
};
|
||||
/**
|
||||
* PhotoPrism's feature-flag bag. Each key gates a UI surface (and the
|
||||
* matching API endpoints) inside PP's own SPA — disabling `share` for
|
||||
* example hides every share button. Optional because older PP versions
|
||||
* don't return the block; the Library tab only renders toggles for
|
||||
* keys it actually sees in the response.
|
||||
*/
|
||||
features?: {
|
||||
archive?: boolean;
|
||||
private?: boolean;
|
||||
review?: boolean;
|
||||
files?: boolean;
|
||||
folders?: boolean;
|
||||
moments?: boolean;
|
||||
calendar?: boolean;
|
||||
places?: boolean;
|
||||
edit?: boolean;
|
||||
share?: boolean;
|
||||
library?: boolean;
|
||||
import?: boolean;
|
||||
logs?: boolean;
|
||||
search?: boolean;
|
||||
account?: boolean;
|
||||
settings?: boolean;
|
||||
services?: boolean;
|
||||
people?: boolean;
|
||||
labels?: boolean;
|
||||
download?: boolean;
|
||||
upload?: boolean;
|
||||
delete?: boolean;
|
||||
ratings?: boolean;
|
||||
[k: string]: boolean | undefined;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
// ── Per-user prefs (sidecar) ──────────────────────────────────────────────────
|
||||
//
|
||||
// The index sub-path: an originals-relative folder under the user's BasePath
|
||||
// that re-roots the Library tree and scopes the reindex. Stored server-side by
|
||||
// the sidecar, keyed by username. Empty string = "whole folder".
|
||||
|
||||
export async function getIndexSubpath(): Promise<string> {
|
||||
const data = (await callSidecar('GET', '/prefs')) as { indexPath?: string };
|
||||
return (data.indexPath ?? '').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
export async function setIndexSubpath(indexPath: string): Promise<string> {
|
||||
const data = (await callSidecar('PUT', '/prefs', {
|
||||
indexPath: indexPath.replace(/^\/+|\/+$/g, '')
|
||||
})) as { indexPath?: string };
|
||||
return (data.indexPath ?? '').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
export async function getSettings(): Promise<PpSettings> {
|
||||
const { data } = await http.get<PpSettings>('/settings');
|
||||
return data;
|
||||
@@ -1199,26 +1193,6 @@ export async function cancelIndex(): Promise<void> {
|
||||
await http.delete('/index');
|
||||
}
|
||||
|
||||
export interface ImportBody {
|
||||
path?: string;
|
||||
move?: boolean;
|
||||
dest?: string;
|
||||
}
|
||||
|
||||
export async function startImport(body: ImportBody = {}): Promise<{ message: string }> {
|
||||
const { data } = await http.post<{ message: string }>('/import', {
|
||||
path: '/',
|
||||
move: false,
|
||||
dest: '',
|
||||
...body
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function cancelImport(): Promise<void> {
|
||||
await http.delete('/import');
|
||||
}
|
||||
|
||||
export interface PpLogEntry {
|
||||
Time: string;
|
||||
Level: string;
|
||||
|
||||
@@ -17,14 +17,21 @@ export type Section =
|
||||
| 'hidden'
|
||||
| 'heap';
|
||||
|
||||
export type TagCategory = 'labels' | 'keywords' | 'people' | 'colors' | 'ratings';
|
||||
export type TagCategory =
|
||||
| 'labels'
|
||||
| 'keywords'
|
||||
| 'people'
|
||||
| 'colors'
|
||||
| 'ratings'
|
||||
| 'countries';
|
||||
|
||||
export const TAG_CATEGORIES: readonly TagCategory[] = [
|
||||
'labels',
|
||||
'keywords',
|
||||
'people',
|
||||
'colors',
|
||||
'ratings'
|
||||
'ratings',
|
||||
'countries'
|
||||
] as const;
|
||||
|
||||
export function isTagCategory(v: unknown): v is TagCategory {
|
||||
@@ -34,6 +41,25 @@ export function isTagCategory(v: unknown): v is TagCategory {
|
||||
);
|
||||
}
|
||||
|
||||
export type SortOrder = 'newest' | 'oldest' | 'added' | 'name';
|
||||
export const SORT_ORDERS: readonly SortOrder[] = ['newest', 'oldest', 'added', 'name'] as const;
|
||||
export const SORT_LABELS: Record<SortOrder, string> = {
|
||||
newest: 'Newest first',
|
||||
oldest: 'Oldest first',
|
||||
added: 'Recently added',
|
||||
name: 'File name'
|
||||
};
|
||||
|
||||
/** Media-type chip values → PhotoPrism boolean q-DSL filters. */
|
||||
export type MediaType = 'photo' | 'video' | 'raw' | 'live';
|
||||
export const MEDIA_TYPES: readonly MediaType[] = ['photo', 'video', 'raw', 'live'] as const;
|
||||
export const MEDIA_TYPE_LABELS: Record<MediaType, string> = {
|
||||
photo: 'Photos',
|
||||
video: 'Videos',
|
||||
raw: 'RAW',
|
||||
live: 'Live'
|
||||
};
|
||||
|
||||
export interface FilterState {
|
||||
section: Section;
|
||||
/** Heap UID, used when section === 'heap'. */
|
||||
@@ -42,6 +68,14 @@ export interface FilterState {
|
||||
folderPath: string | null;
|
||||
/** Free-form search text, ANDed with section-derived terms. */
|
||||
search: string;
|
||||
/** Timeline sort order. Maps straight onto PhotoPrism's `order` param. */
|
||||
sort: SortOrder;
|
||||
/** Media-type chip; null = any. */
|
||||
mediaType: MediaType | null;
|
||||
/** Year chip; null = any. Compiles to `year:<n>`. */
|
||||
year: number | null;
|
||||
/** Favorites-only chip. Compiles to `favorite:true`. */
|
||||
favorite: boolean;
|
||||
/**
|
||||
* Active tag-browser category and selected value. Set by the
|
||||
* `/tags/[category]/[[value]]` route on navigation. Labels/keywords
|
||||
@@ -61,10 +95,42 @@ export const filters = $state<FilterState>({
|
||||
heapUid: null,
|
||||
folderPath: '/',
|
||||
search: '',
|
||||
sort: 'newest',
|
||||
mediaType: null,
|
||||
year: null,
|
||||
favorite: false,
|
||||
tagCategory: null,
|
||||
tagValue: null
|
||||
});
|
||||
|
||||
export function setSort(sort: SortOrder): void {
|
||||
filters.sort = sort;
|
||||
}
|
||||
|
||||
export function setMediaType(t: MediaType | null): void {
|
||||
filters.mediaType = t;
|
||||
}
|
||||
|
||||
export function setYear(y: number | null): void {
|
||||
filters.year = y;
|
||||
}
|
||||
|
||||
export function setFavorite(on: boolean): void {
|
||||
filters.favorite = on;
|
||||
}
|
||||
|
||||
/** True when any toolbar chip narrows the view (excludes sort — a sort
|
||||
* isn't a filter). Drives the "Clear" affordance. */
|
||||
export function chipsActive(f: FilterState = filters): boolean {
|
||||
return f.mediaType !== null || f.year !== null || f.favorite;
|
||||
}
|
||||
|
||||
export function clearChips(): void {
|
||||
filters.mediaType = null;
|
||||
filters.year = null;
|
||||
filters.favorite = false;
|
||||
}
|
||||
|
||||
export function setSection(section: Section, heapUid: string | null = null): void {
|
||||
filters.section = section;
|
||||
filters.heapUid = section === 'heap' ? heapUid : null;
|
||||
@@ -239,9 +305,23 @@ export function filtersToQ(f: FilterState = filters): string {
|
||||
parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`);
|
||||
} else if (f.tagCategory === 'people') {
|
||||
parts.push(`person:${quoteIfNeeded(f.tagValue)}`);
|
||||
} else if (f.tagCategory === 'countries') {
|
||||
parts.push(`country:${quoteIfNeeded(f.tagValue)}`);
|
||||
}
|
||||
}
|
||||
if (f.search) parts.push(quoteIfNeeded(f.search));
|
||||
// Toolbar chips. PhotoPrism's boolean media filters (`video:true`,
|
||||
// `photo:true`, …) are the documented DSL forms; `year:` and
|
||||
// `favorite:` are plain filters.
|
||||
if (f.mediaType) parts.push(`${f.mediaType}:true`);
|
||||
if (f.year) parts.push(`year:${f.year}`);
|
||||
if (f.favorite) parts.push('favorite:true');
|
||||
// `f.search` is the raw-DSL escape hatch (toolbar cheat-sheet examples
|
||||
// like `label:dog`, `taken:2024`) as well as plain free text. Only
|
||||
// quote it when it has no `:` — a colon means the user (or a
|
||||
// jump-to-search link) already wrote a structured term, and wrapping
|
||||
// the whole thing in quotes would turn `camera:iPhone` into a literal
|
||||
// phrase search for the text "camera:iPhone" instead of the operator.
|
||||
if (f.search) parts.push(f.search.includes(':') ? f.search : quoteIfNeeded(f.search));
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
@@ -263,11 +343,22 @@ export function parseUrlParams(params: URLSearchParams): Partial<FilterState> {
|
||||
!params.has('q');
|
||||
const folderRaw = params.get('folder');
|
||||
const folderPath = folderRaw !== null ? folderRaw : bare ? '/' : null;
|
||||
const sortRaw = params.get('sort');
|
||||
const typeRaw = params.get('type');
|
||||
const yearRaw = params.get('year');
|
||||
return {
|
||||
section,
|
||||
heapUid: params.get('heap'),
|
||||
folderPath,
|
||||
search: params.get('q') ?? ''
|
||||
search: params.get('q') ?? '',
|
||||
sort: (SORT_ORDERS as readonly string[]).includes(sortRaw ?? '')
|
||||
? (sortRaw as SortOrder)
|
||||
: 'newest',
|
||||
mediaType: (MEDIA_TYPES as readonly string[]).includes(typeRaw ?? '')
|
||||
? (typeRaw as MediaType)
|
||||
: null,
|
||||
year: yearRaw && /^\d{4}$/.test(yearRaw) ? parseInt(yearRaw, 10) : null,
|
||||
favorite: params.get('fav') === '1'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -279,5 +370,9 @@ export function filtersToUrlParams(f: FilterState = filters): URLSearchParams {
|
||||
if (f.heapUid) params.set('heap', f.heapUid);
|
||||
if (f.folderPath) params.set('folder', f.folderPath);
|
||||
if (f.search) params.set('q', f.search);
|
||||
if (f.sort !== 'newest') params.set('sort', f.sort);
|
||||
if (f.mediaType) params.set('type', f.mediaType);
|
||||
if (f.year) params.set('year', String(f.year));
|
||||
if (f.favorite) params.set('fav', '1');
|
||||
return params;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import { isAuthenticated, session } from './session.svelte';
|
||||
|
||||
/**
|
||||
@@ -44,6 +45,27 @@ let lastFileUpdateAt = 0;
|
||||
let pendingFileTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingFileName: string | undefined;
|
||||
|
||||
// Newly indexed photos sort newest-first, so they land at the top of the
|
||||
// timeline. Refetch the photos query as files stream in so the user watches
|
||||
// new tiles arrive without a manual reload — but on a much coarser cadence
|
||||
// than the per-file pill throttle, since a timeline refetch is far heavier
|
||||
// than a label swap. Tracked independently of `lastFileUpdateAt` so the two
|
||||
// throttles don't interfere.
|
||||
const PHOTOS_REFETCH_THROTTLE_MS = 2000;
|
||||
let lastPhotosInvalidateAt = 0;
|
||||
|
||||
function invalidatePhotosGrid(): void {
|
||||
if (!browser || !isAuthenticated()) return;
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
}
|
||||
|
||||
function invalidatePhotosGridThrottled(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastPhotosInvalidateAt < PHOTOS_REFETCH_THROTTLE_MS) return;
|
||||
lastPhotosInvalidateAt = now;
|
||||
invalidatePhotosGrid();
|
||||
}
|
||||
|
||||
function url(): string {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${location.host}/api/v1/ws`;
|
||||
@@ -134,6 +156,8 @@ function handleMessage(raw: string): void {
|
||||
const fileName =
|
||||
(data.fileName as string | undefined) ?? (data.baseName as string | undefined);
|
||||
setActiveThrottled('Indexing', fileName);
|
||||
// Stream newly indexed files into the grid as the scan runs.
|
||||
invalidatePhotosGridThrottled();
|
||||
return;
|
||||
}
|
||||
case 'index.updating': {
|
||||
@@ -148,6 +172,8 @@ function handleMessage(raw: string): void {
|
||||
case 'index.completed': {
|
||||
const seconds = typeof data.seconds === 'number' ? data.seconds : undefined;
|
||||
setCompleted(seconds !== undefined ? `Indexed in ${seconds}s` : 'Index complete');
|
||||
// Final refetch so the grid lands on the fully-indexed result.
|
||||
invalidatePhotosGrid();
|
||||
return;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -57,6 +57,10 @@ export function adoptSession(resp: PpSessionResponse, cfg?: PpClientConfig): voi
|
||||
// (Hit this with the `test` user seeing the admin's library counts
|
||||
// in the left sidebar.)
|
||||
queryClient.clear();
|
||||
// The index sub-path is per-user; drop the prior identity's value so the
|
||||
// app re-roots to the new user's whole folder until the ['prefs'] query
|
||||
// rehydrates it from the sidecar.
|
||||
prefs.indexSubpath = '';
|
||||
session.id = resp.id;
|
||||
session.accessToken = resp.access_token;
|
||||
session.previewToken = (cfg ?? resp.config)?.previewToken ?? '';
|
||||
@@ -71,6 +75,7 @@ export function clearSession(): void {
|
||||
session.previewToken = null;
|
||||
session.downloadToken = null;
|
||||
session.user = null;
|
||||
prefs.indexSubpath = '';
|
||||
if (browser) localStorage.removeItem(STORAGE_KEY);
|
||||
// Same reasoning as adoptSession — wipe the cache so the next user
|
||||
// who logs in (or the login screen itself) doesn't render with the
|
||||
@@ -163,43 +168,73 @@ export function videoUrl(hash: string, format = 'avc'): string {
|
||||
/**
|
||||
* The signed-in user's library root, originals-relative, no leading/trailing
|
||||
* slash. `""` means "whole library" — used today by admin accounts whose
|
||||
* BasePath isn't configured in PhotoPrism. Non-empty values gate every place
|
||||
* that crosses the user↔server seam (sidebar tree, timeline `path:` filter,
|
||||
* folder counts, heap convert) so each user sees only their own subtree.
|
||||
* BasePath isn't configured in PhotoPrism. This is the user's *whole* folder
|
||||
* as set on their PhotoPrism account; the working library root the rest of
|
||||
* the app re-roots to is `userLibraryBase()` (BasePath + chosen sub-path).
|
||||
*/
|
||||
export function userBasePath(): string {
|
||||
return (session.user?.BasePath ?? '').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user "index sub-path": a folder *under* the user's BasePath that they've
|
||||
* chosen as their working library root. Stored server-side by the sidecar
|
||||
* (keyed by username) and hydrated into this reactive state at startup via the
|
||||
* `['prefs']` query. Empty string = "whole folder" (no narrowing). Normalized
|
||||
* to no leading/trailing slash.
|
||||
*/
|
||||
export const prefs = $state<{ indexSubpath: string }>({ indexSubpath: '' });
|
||||
|
||||
export function setIndexSubpathState(sub: string): void {
|
||||
prefs.indexSubpath = (sub ?? '').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective working library root, originals-relative, no leading/trailing
|
||||
* slash: the user's BasePath narrowed by their chosen index sub-path. This is
|
||||
* the single point the whole app re-roots through — `toOriginalsPath` /
|
||||
* `toUserPath` (and thus the sidebar tree, timeline `path:` filter, folder
|
||||
* counts, folder CRUD, reindex) all derive from it. When both are empty it's
|
||||
* `""` (whole library), matching the prior BasePath-only behavior.
|
||||
*/
|
||||
export function userLibraryBase(): string {
|
||||
const bp = userBasePath();
|
||||
const sub = prefs.indexSubpath;
|
||||
if (sub === '') return bp;
|
||||
return bp === '' ? sub : `${bp}/${sub}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a user-relative path (what the sidebar and URL deal in) to a
|
||||
* server-absolute, originals-relative path (what PhotoPrism's `path:`
|
||||
* operator and the sidecar's filesystem ops want).
|
||||
* operator and the sidecar's filesystem ops want). Relative to the effective
|
||||
* library root (`userLibraryBase()`), so the chosen index sub-path is folded
|
||||
* in automatically.
|
||||
*
|
||||
* "" or "/" → BasePath (user's root)
|
||||
* "2024/01" → "<basePath>/2024/01"
|
||||
* "" or "/" → libraryBase (user's working root)
|
||||
* "2024/01" → "<libraryBase>/2024/01"
|
||||
* null → "" (caller decides to omit the filter entirely)
|
||||
*/
|
||||
export function toOriginalsPath(uiPath: string | null): string {
|
||||
if (uiPath === null) return '';
|
||||
const bp = userBasePath();
|
||||
const base = userLibraryBase();
|
||||
const rel = uiPath.replace(/^\/+|\/+$/g, '');
|
||||
if (rel === '') return bp;
|
||||
return bp === '' ? rel : `${bp}/${rel}`;
|
||||
if (rel === '') return base;
|
||||
return base === '' ? rel : `${base}/${rel}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `toOriginalsPath` — strips the user's BasePath prefix so the
|
||||
* UI can render `2024/01` instead of `users/alice/2024/01`. Paths that
|
||||
* are equal to the BasePath collapse to `""` (the user's root sentinel).
|
||||
* Paths outside the BasePath are returned as-is, but callers should
|
||||
* already have filtered those out via `listFolders`'s post-filter.
|
||||
* Inverse of `toOriginalsPath` — strips the effective library-root prefix so
|
||||
* the UI can render `2024/01` instead of `users/alice/2024/01`. Paths that
|
||||
* are equal to the root collapse to `""` (the user's root sentinel). Paths
|
||||
* outside the root are returned as-is, but callers should already have
|
||||
* filtered those out via `listFolders`'s post-filter.
|
||||
*/
|
||||
export function toUserPath(serverPath: string): string {
|
||||
const bp = userBasePath();
|
||||
const base = userLibraryBase();
|
||||
const sp = serverPath.replace(/^\/+|\/+$/g, '');
|
||||
if (bp === '') return sp;
|
||||
if (sp === bp) return '';
|
||||
if (sp.startsWith(bp + '/')) return sp.slice(bp.length + 1);
|
||||
if (base === '') return sp;
|
||||
if (sp === base) return '';
|
||||
if (sp.startsWith(base + '/')) return sp.slice(base.length + 1);
|
||||
return sp;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ export const view = $state<{
|
||||
* persisted — a refresh always returns to the grid.
|
||||
*/
|
||||
previewOpen: boolean;
|
||||
/** Ephemeral: true while the keyboard-shortcuts overlay is open. */
|
||||
shortcutsOpen: boolean;
|
||||
/** Ephemeral: true while the ⌘K command palette is open. */
|
||||
paletteOpen: boolean;
|
||||
metadataSections: Record<string, boolean>;
|
||||
}>({
|
||||
rightSidebarCollapsed: initial.rightSidebarCollapsed ?? false,
|
||||
@@ -107,6 +111,8 @@ export const view = $state<{
|
||||
),
|
||||
tagsBrowserCollapsed: initial.tagsBrowserCollapsed ?? false,
|
||||
previewOpen: false,
|
||||
shortcutsOpen: false,
|
||||
paletteOpen: false,
|
||||
metadataSections:
|
||||
initial.metadataSections && typeof initial.metadataSections === 'object'
|
||||
? { ...initial.metadataSections }
|
||||
@@ -164,6 +170,22 @@ export function togglePreview(): void {
|
||||
view.previewOpen = !view.previewOpen;
|
||||
}
|
||||
|
||||
export function toggleShortcuts(): void {
|
||||
view.shortcutsOpen = !view.shortcutsOpen;
|
||||
}
|
||||
|
||||
export function closeShortcuts(): void {
|
||||
view.shortcutsOpen = false;
|
||||
}
|
||||
|
||||
export function togglePalette(): void {
|
||||
view.paletteOpen = !view.paletteOpen;
|
||||
}
|
||||
|
||||
export function closePalette(): void {
|
||||
view.paletteOpen = false;
|
||||
}
|
||||
|
||||
export function setThumbnailSize(size: ThumbnailSize): void {
|
||||
view.thumbnailSize = size;
|
||||
persist();
|
||||
|
||||
@@ -130,6 +130,7 @@ export interface PpPhoto {
|
||||
Height?: number;
|
||||
Rating?: number;
|
||||
Color?: string | number;
|
||||
Favorite?: boolean;
|
||||
Archived?: boolean;
|
||||
Files?: PpFile[];
|
||||
Lat?: number;
|
||||
|
||||
29
web/src/lib/utils/countries.ts
Normal file
29
web/src/lib/utils/countries.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Country code (ISO 3166-1 alpha-2, lowercase — PhotoPrism's `Country` field
|
||||
// shape) → display helpers for the Countries tag-browser category.
|
||||
|
||||
let regionNames: Intl.DisplayNames | undefined;
|
||||
function getRegionNames(): Intl.DisplayNames | undefined {
|
||||
if (regionNames) return regionNames;
|
||||
try {
|
||||
regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||
} catch {
|
||||
regionNames = undefined;
|
||||
}
|
||||
return regionNames;
|
||||
}
|
||||
|
||||
export function countryName(code: string): string {
|
||||
if (!code) return code;
|
||||
const name = getRegionNames()?.of(code.toUpperCase());
|
||||
return name ?? code;
|
||||
}
|
||||
|
||||
const REGIONAL_INDICATOR_OFFSET = 0x1f1a5; // 0x1f1e6 ('A') - 'A'.charCodeAt(0)
|
||||
|
||||
export function countryFlag(code: string): string {
|
||||
if (!code || code.length !== 2) return '';
|
||||
const upper = code.toUpperCase();
|
||||
return Array.from(upper)
|
||||
.map((ch) => String.fromCodePoint(ch.charCodeAt(0) + REGIONAL_INDICATOR_OFFSET))
|
||||
.join('');
|
||||
}
|
||||
@@ -20,6 +20,9 @@
|
||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
||||
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
|
||||
import ShortcutsDialog from '$lib/components/layout/ShortcutsDialog.svelte';
|
||||
import CommandPalette from '$lib/components/layout/CommandPalette.svelte';
|
||||
import { togglePalette } from '$lib/stores/view.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -58,8 +61,18 @@
|
||||
else stopIndexerWatch();
|
||||
});
|
||||
|
||||
// ⌘K lives at the window level (not gridKeyNav) so the palette opens
|
||||
// from any route and even while a form field holds focus.
|
||||
function onGlobalKey(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
togglePalette();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onGlobalKey} />
|
||||
|
||||
<svelte:head>
|
||||
<title>Mulimage</title>
|
||||
</svelte:head>
|
||||
@@ -122,6 +135,10 @@
|
||||
store. Opened from the heap/folder kebabs, the BulkActionBar
|
||||
button, and the `m` shortcut — all through openMove(). -->
|
||||
<MoveToFolderDialog />
|
||||
<!-- Keyboard-shortcut reference, toggled by `?` via gridKeyNav. -->
|
||||
<ShortcutsDialog />
|
||||
<!-- ⌘K palette — jump to sections/heaps/folders + global actions. -->
|
||||
<CommandPalette />
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
|
||||
@@ -17,14 +17,26 @@
|
||||
type PpAlbum,
|
||||
} from "$lib/services/photoprism";
|
||||
import {
|
||||
chipsActive,
|
||||
clearChips,
|
||||
consumePendingFocus,
|
||||
filters,
|
||||
filtersToQ,
|
||||
filtersToUrlParams,
|
||||
MEDIA_TYPE_LABELS,
|
||||
MEDIA_TYPES,
|
||||
parseUrlParams,
|
||||
setFavorite,
|
||||
setMediaType,
|
||||
setSearch,
|
||||
setSection,
|
||||
setSort,
|
||||
setYear,
|
||||
SORT_LABELS,
|
||||
SORT_ORDERS,
|
||||
type MediaType,
|
||||
type PendingFocus,
|
||||
type SortOrder,
|
||||
} from "$lib/stores/filters.svelte";
|
||||
import { isAuthenticated } from "$lib/stores/session.svelte";
|
||||
import { untrack } from "svelte";
|
||||
@@ -37,7 +49,7 @@
|
||||
setFocused,
|
||||
setOrder,
|
||||
} from "$lib/stores/selection.svelte";
|
||||
import { removedIds } from "$lib/stores/bulkAction.svelte";
|
||||
import { removedIds, clearRemoved } from "$lib/stores/bulkAction.svelte";
|
||||
import {
|
||||
openPreview,
|
||||
setRightSidebarWidth,
|
||||
@@ -81,6 +93,10 @@
|
||||
if (next.heapUid !== undefined) filters.heapUid = next.heapUid;
|
||||
if (next.folderPath !== undefined) filters.folderPath = next.folderPath;
|
||||
if (next.search !== undefined) filters.search = next.search;
|
||||
if (next.sort !== undefined) filters.sort = next.sort;
|
||||
if (next.mediaType !== undefined) filters.mediaType = next.mediaType;
|
||||
if (next.year !== undefined) filters.year = next.year;
|
||||
if (next.favorite !== undefined) filters.favorite = next.favorite;
|
||||
});
|
||||
|
||||
// When the store changes from in-app actions (left-sidebar nav, search
|
||||
@@ -182,15 +198,20 @@
|
||||
"photos",
|
||||
"q",
|
||||
filtersToQ(filters),
|
||||
{ count: PHOTOS_PAGE_SIZE, anchor: anchor?.takenAt ?? null },
|
||||
{
|
||||
count: PHOTOS_PAGE_SIZE,
|
||||
anchor: anchor?.takenAt ?? null,
|
||||
sort: filters.sort,
|
||||
},
|
||||
],
|
||||
queryFn: ({ pageParam }) => {
|
||||
const offset = pageParam as number;
|
||||
const baseQ = filtersToQ(filters);
|
||||
// Page 0 + anchor → load a window around the anchor's date.
|
||||
// Subsequent pages aren't reachable in anchor mode (see
|
||||
// getNextPageParam).
|
||||
if (offset === 0 && anchor?.takenAt) {
|
||||
// getNextPageParam). Anchor windows assume chronological order,
|
||||
// so any non-default sort falls back to plain paging.
|
||||
if (offset === 0 && anchor?.takenAt && filters.sort === "newest") {
|
||||
return listPhotosAround({
|
||||
q: baseQ,
|
||||
takenAt: anchor.takenAt,
|
||||
@@ -203,7 +224,7 @@
|
||||
q: baseQ,
|
||||
count: PHOTOS_PAGE_SIZE,
|
||||
offset,
|
||||
order: "newest",
|
||||
order: filters.sort,
|
||||
merged: true,
|
||||
});
|
||||
},
|
||||
@@ -222,7 +243,7 @@
|
||||
// photos exceeding the page size keep the cursor at the same
|
||||
// value). To "see more," the user clears the anchor by
|
||||
// navigating fresh.
|
||||
if (anchor?.takenAt) return undefined;
|
||||
if (anchor?.takenAt && filters.sort === "newest") return undefined;
|
||||
return pages.length * PHOTOS_PAGE_SIZE;
|
||||
},
|
||||
enabled: isAuthenticated(),
|
||||
@@ -283,6 +304,20 @@
|
||||
}
|
||||
const pageCount = $derived(photosQuery.data?.pages.length ?? 0);
|
||||
|
||||
// Reconcile the optimistic-removal overlay against the actual cache.
|
||||
// `removedIds` hides a tile while its photo is still present in a loaded
|
||||
// page; we drop an id from the set only once it has genuinely left the
|
||||
// freshly-deduped cache (i.e. every page that held it has refetched
|
||||
// without it). Driving the clear from the data — rather than from each
|
||||
// archive action's invalidation promise — removes the race where settling
|
||||
// one action's refetch un-hid a photo that other, still-stale pages
|
||||
// continued to carry, making archived tiles flash back into the grid.
|
||||
$effect(() => {
|
||||
const present = new Set(dedupedAll.map((p) => p.UID));
|
||||
const gone = [...removedIds].filter((id) => !present.has(id));
|
||||
if (gone.length) clearRemoved(gone);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
setOrder(photos.map((p) => p.UID));
|
||||
});
|
||||
@@ -786,6 +821,16 @@
|
||||
setSearch(searchDraft.trim());
|
||||
}
|
||||
|
||||
// Toolbar chips: years from the current year back to 1990 — static
|
||||
// range keeps it dependency-free; PhotoPrism just returns an empty
|
||||
// page for years with no photos.
|
||||
const CHIP_YEARS = Array.from(
|
||||
{ length: new Date().getFullYear() - 1989 },
|
||||
(_, i) => new Date().getFullYear() - i,
|
||||
);
|
||||
const CHIP_SELECT_CLASS =
|
||||
"rounded border border-input bg-background px-1.5 py-0.5 text-[11px] text-foreground shadow-sm focus:outline-none focus:ring-1 focus:ring-ring";
|
||||
|
||||
// PhotoPrism's q-DSL is non-obvious; surfacing 4 working examples on
|
||||
// focus turns the placeholder hint into a clickable cheat-sheet.
|
||||
const SEARCH_EXAMPLES = [
|
||||
@@ -829,6 +874,7 @@
|
||||
<form class="relative flex items-center gap-1" onsubmit={onSearchSubmit}>
|
||||
<input
|
||||
type="search"
|
||||
data-search-input
|
||||
placeholder={'Search · label:website / "vacation"'}
|
||||
class="w-56 rounded border border-input bg-background px-2 py-0.5 text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
bind:value={searchDraft}
|
||||
@@ -884,6 +930,68 @@
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<!-- Filter chips: sort / media type / year / favorites. Compile into
|
||||
the same q-DSL the search box feeds, so they stack with search,
|
||||
folders, and sections. URL-persisted for shareable views. -->
|
||||
<div class="flex shrink-0 items-center gap-1" role="group" aria-label="Filters">
|
||||
<select
|
||||
class={CHIP_SELECT_CLASS}
|
||||
value={filters.sort}
|
||||
onchange={(e) => setSort(e.currentTarget.value as SortOrder)}
|
||||
title="Sort order"
|
||||
aria-label="Sort order"
|
||||
>
|
||||
{#each SORT_ORDERS as s (s)}
|
||||
<option value={s}>{SORT_LABELS[s]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select
|
||||
class={CHIP_SELECT_CLASS}
|
||||
value={filters.mediaType ?? ""}
|
||||
onchange={(e) => setMediaType((e.currentTarget.value || null) as MediaType | null)}
|
||||
title="Media type"
|
||||
aria-label="Media type"
|
||||
>
|
||||
<option value="">Any type</option>
|
||||
{#each MEDIA_TYPES as t (t)}
|
||||
<option value={t}>{MEDIA_TYPE_LABELS[t]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select
|
||||
class={CHIP_SELECT_CLASS}
|
||||
value={filters.year ? String(filters.year) : ""}
|
||||
onchange={(e) => setYear(e.currentTarget.value ? parseInt(e.currentTarget.value, 10) : null)}
|
||||
title="Year"
|
||||
aria-label="Year"
|
||||
>
|
||||
<option value="">Any year</option>
|
||||
{#each CHIP_YEARS as y (y)}
|
||||
<option value={String(y)}>{y}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border px-1.5 py-0.5 text-[11px] transition-colors {filters.favorite
|
||||
? 'border-red-400 bg-red-500/10 text-red-500'
|
||||
: 'border-input text-muted-foreground hover:bg-accent'}"
|
||||
aria-pressed={filters.favorite}
|
||||
onclick={() => setFavorite(!filters.favorite)}
|
||||
title="Favorites only (f toggles a photo's favorite)"
|
||||
>
|
||||
♥ Favorites
|
||||
</button>
|
||||
{#if chipsActive(filters)}
|
||||
<button
|
||||
type="button"
|
||||
class="rounded px-1.5 py-0.5 text-[11px] text-muted-foreground underline-offset-2 hover:underline"
|
||||
onclick={clearChips}
|
||||
title="Clear type / year / favorites filters"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet trailing()}
|
||||
<!-- Thumbnail size — five steps mirroring mule-image's XS/S/M/L/XL.
|
||||
Persisted to localStorage via view.svelte.ts. -->
|
||||
|
||||
@@ -1,423 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import maplibregl, {
|
||||
type GeoJSONSource,
|
||||
type MapMouseEvent,
|
||||
type MapSourceDataEvent
|
||||
} from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { goto } from '$app/navigation';
|
||||
import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte';
|
||||
import { setAnchor, setFocused, setOrder } from '$lib/stores/selection.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
const geoQuery = createQuery<PpGeoCollection>(() => ({
|
||||
queryKey: ['geo'],
|
||||
queryFn: () => listGeo(),
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
let mapEl: HTMLDivElement | undefined = $state();
|
||||
let map: maplibregl.Map | undefined;
|
||||
/** Reactive flag flipped on once the MapLibre `load` event has fired
|
||||
* and the `photos` source has been installed. The data-push `$effect`
|
||||
* depends on this — otherwise, if the geoQuery resolves before the
|
||||
* basemap style finishes loading, the effect runs with no source
|
||||
* available and never re-runs (since `map` itself is not `$state`),
|
||||
* leaving the map permanently empty. */
|
||||
let mapReady = $state(false);
|
||||
|
||||
/** Markers currently attached to the map, keyed by feature id (UIDs
|
||||
* for photos, `cluster:<clusterId>` for clusters). Diffed against the
|
||||
* current `querySourceFeatures` set on every render to add markers
|
||||
* that came into view and remove ones that scrolled out / got
|
||||
* swallowed by a cluster — PhotoPrism's `markersOnScreen` pattern.
|
||||
* See: https://github.com/photoprism/photoprism/blob/develop/frontend/src/page/places.vue */
|
||||
const markers = new Map<string, maplibregl.Marker>();
|
||||
const markersOnScreen = new Map<string, maplibregl.Marker>();
|
||||
|
||||
onMount(() => {
|
||||
if (!mapEl) return;
|
||||
map = new maplibregl.Map({
|
||||
container: mapEl,
|
||||
// PhotoPrism's default basemap style (CDN-hosted, no key required).
|
||||
// The style JSON already references the correct glyphs URL, so
|
||||
// no explicit override is needed here.
|
||||
style: 'https://cdn.photoprism.app/maps/default.json',
|
||||
center: [0, 20],
|
||||
zoom: 1,
|
||||
attributionControl: { compact: true }
|
||||
});
|
||||
map.addControl(
|
||||
new maplibregl.NavigationControl({ visualizePitch: true, showZoom: true, showCompass: true }),
|
||||
'top-right'
|
||||
);
|
||||
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
|
||||
|
||||
map.on('load', () => {
|
||||
addPhotoLayers();
|
||||
mapReady = true;
|
||||
});
|
||||
|
||||
// PhotoPrism's update strategy: re-reconcile markers on every map
|
||||
// movement, on resize (so cluster bubbles re-balance when the
|
||||
// viewport changes), on idle (catches the post-`fitBounds` settle),
|
||||
// and on `sourcedata` filtered to "source fully loaded" — that's
|
||||
// the moment MapLibre has processed clustering and
|
||||
// `querySourceFeatures` returns meaningful results.
|
||||
const onSourceData = (e: MapSourceDataEvent) => {
|
||||
if (e.sourceId === 'photos' && e.isSourceLoaded) updateMarkers();
|
||||
};
|
||||
map.on('sourcedata', onSourceData);
|
||||
map.on('move', updateMarkers);
|
||||
map.on('moveend', updateMarkers);
|
||||
map.on('resize', updateMarkers);
|
||||
map.on('idle', updateMarkers);
|
||||
|
||||
return () => {
|
||||
map?.off('sourcedata', onSourceData);
|
||||
map?.off('move', updateMarkers);
|
||||
map?.off('moveend', updateMarkers);
|
||||
map?.off('resize', updateMarkers);
|
||||
map?.off('idle', updateMarkers);
|
||||
markersOnScreen.forEach((m) => m.remove());
|
||||
markersOnScreen.clear();
|
||||
markers.clear();
|
||||
map?.remove();
|
||||
map = undefined;
|
||||
mapReady = false;
|
||||
};
|
||||
});
|
||||
|
||||
function addPhotoLayers() {
|
||||
if (!map) return;
|
||||
map.addSource('photos', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
// PhotoPrism's clustering parameters — points within ~80px merge
|
||||
// below zoom 17, individual photos render above that.
|
||||
clusterMaxZoom: 17,
|
||||
clusterRadius: 80
|
||||
});
|
||||
// Invisible layer for clusters — PhotoPrism does this so the source
|
||||
// reports cluster features via `querySourceFeatures` (which only
|
||||
// returns features actually rendered by some layer) while the
|
||||
// visual presentation is owned by HTML markers below.
|
||||
map.addLayer({
|
||||
id: 'clusters',
|
||||
type: 'circle',
|
||||
source: 'photos',
|
||||
filter: ['has', 'point_count'],
|
||||
paint: { 'circle-color': '#ffffff', 'circle-opacity': 0, 'circle-radius': 0 }
|
||||
});
|
||||
// Click an (invisible) cluster anywhere on the map → zoom to its
|
||||
// expansion level. The marker DOM also has a click handler, but
|
||||
// pointer-through to the map needs this as a fallback.
|
||||
map.on('click', 'clusters', (e: MapMouseEvent) => {
|
||||
const features = map!.queryRenderedFeatures(e.point, { layers: ['clusters'] });
|
||||
const clusterId = features[0]?.properties?.cluster_id;
|
||||
if (clusterId == null) return;
|
||||
const source = map!.getSource('photos') as GeoJSONSource;
|
||||
source.getClusterExpansionZoom(clusterId).then((zoom) => {
|
||||
const geometry = features[0]?.geometry;
|
||||
if (!geometry || geometry.type !== 'Point') return;
|
||||
map!.easeTo({ center: geometry.coordinates as [number, number], zoom });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Cluster bubble diameter, scaled by the number of contained photos
|
||||
* — mirrors PhotoPrism's `getClusterSizeFromItemCount`. */
|
||||
function clusterSize(count: number): number {
|
||||
if (count >= 10000) return 74;
|
||||
if (count >= 1000) return 70;
|
||||
if (count >= 750) return 68;
|
||||
if (count >= 200) return 66;
|
||||
if (count >= 100) return 64;
|
||||
return 60;
|
||||
}
|
||||
|
||||
/** `1234` → `"1k"`, matching PhotoPrism's `abbreviateCount`. */
|
||||
function abbreviateCount(value: number): string {
|
||||
if (value >= 1000) return `${Math.round(value / 1000)}k`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function buildPhotoMarker(uid: string, hash: string, title: string | undefined, allUids: string[]) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'marker';
|
||||
if (title) el.title = title;
|
||||
el.style.width = '50px';
|
||||
el.style.height = '50px';
|
||||
el.style.backgroundImage = `url(${thumbUrl(hash, 'tile_50')})`;
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
setOrder(allUids);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
void goto('/');
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function buildClusterMarker(clusterId: number, count: number) {
|
||||
const size = clusterSize(count);
|
||||
const el = document.createElement('div');
|
||||
el.className = 'marker';
|
||||
el.style.width = `${size}px`;
|
||||
el.style.height = `${size}px`;
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'cluster-marker';
|
||||
el.appendChild(grid);
|
||||
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'badge';
|
||||
badge.textContent = abbreviateCount(count);
|
||||
el.appendChild(badge);
|
||||
|
||||
// Fetch up to 4 sample thumbnails from the cluster's leaves and lay
|
||||
// them out as a 1 / 2 / 4-image grid (PhotoPrism's pattern). The
|
||||
// source is captured once here; `getClusterLeaves` returns a
|
||||
// Promise, so this populates asynchronously and the bubble shows a
|
||||
// dark placeholder until the thumbs arrive.
|
||||
if (map) {
|
||||
const source = map.getSource('photos') as GeoJSONSource | undefined;
|
||||
if (source && typeof source.getClusterLeaves === 'function') {
|
||||
source
|
||||
.getClusterLeaves(clusterId, 4, 0)
|
||||
.then((leaves) => {
|
||||
const previewCount = leaves.length >= 4 ? 4 : leaves.length > 1 ? 2 : 1;
|
||||
grid.style.gridTemplateColumns = previewCount === 1 ? '1fr' : '1fr 1fr';
|
||||
for (let i = 0; i < previewCount; i++) {
|
||||
const leaf = leaves[Math.floor((leaves.length * i) / previewCount)];
|
||||
const props = (leaf?.properties ?? {}) as { Hash?: string };
|
||||
if (!props.Hash) continue;
|
||||
const tile = document.createElement('div');
|
||||
tile.style.backgroundImage = `url(${thumbUrl(props.Hash, 'tile_50')})`;
|
||||
grid.appendChild(tile);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (!map) return;
|
||||
const source = map.getSource('photos') as GeoJSONSource;
|
||||
source.getClusterExpansionZoom(clusterId).then((zoom) => {
|
||||
// Use the marker's current LngLat — set just below in updateMarkers.
|
||||
const m = markers.get(`cluster:${clusterId}`);
|
||||
const ll = m?.getLngLat();
|
||||
if (!ll) return;
|
||||
map!.easeTo({ center: ll, zoom });
|
||||
});
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Reconcile HTML markers against what's currently in the rendered
|
||||
* source. PhotoPrism's `updateMarkers`. */
|
||||
function updateMarkers() {
|
||||
if (!map || !map.isStyleLoaded() || !map.getSource('photos')) return;
|
||||
const features = map.querySourceFeatures('photos');
|
||||
const allUids = (geoQuery.data?.features ?? []).map((f) => f.properties.UID);
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const f of features) {
|
||||
const props = (f.properties ?? {}) as Record<string, unknown> & {
|
||||
cluster?: boolean;
|
||||
cluster_id?: number;
|
||||
point_count?: number;
|
||||
UID?: string;
|
||||
Hash?: string;
|
||||
Title?: string;
|
||||
};
|
||||
const geom = f.geometry;
|
||||
if (geom.type !== 'Point') continue;
|
||||
const coords = geom.coordinates as [number, number];
|
||||
|
||||
let key: string;
|
||||
let buildEl: () => HTMLElement;
|
||||
if (props.cluster) {
|
||||
if (props.cluster_id == null) continue;
|
||||
key = `cluster:${props.cluster_id}`;
|
||||
const cid = props.cluster_id;
|
||||
const count = props.point_count ?? 0;
|
||||
buildEl = () => buildClusterMarker(cid, count);
|
||||
} else {
|
||||
if (!props.UID || !props.Hash) continue;
|
||||
key = props.UID;
|
||||
const uid = props.UID;
|
||||
const hash = props.Hash;
|
||||
const title = props.Title;
|
||||
buildEl = () => buildPhotoMarker(uid, hash, title, allUids);
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
let marker = markers.get(key);
|
||||
if (!marker) {
|
||||
marker = new maplibregl.Marker({ element: buildEl(), anchor: 'center' }).setLngLat(coords);
|
||||
markers.set(key, marker);
|
||||
} else {
|
||||
marker.setLngLat(coords);
|
||||
}
|
||||
if (!markersOnScreen.has(key)) {
|
||||
marker.addTo(map);
|
||||
markersOnScreen.set(key, marker);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, marker] of markersOnScreen) {
|
||||
if (!seen.has(key)) {
|
||||
marker.remove();
|
||||
markersOnScreen.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push new geo data into the source whenever the query resolves AND
|
||||
// the map is ready. Both orderings are handled: if data arrives first,
|
||||
// the effect re-runs when `mapReady` flips; if the map is ready first,
|
||||
// it re-runs when `data` arrives.
|
||||
$effect(() => {
|
||||
const data = geoQuery.data as
|
||||
| (PpGeoCollection & { bbox?: number[] })
|
||||
| undefined;
|
||||
if (!map || !mapReady || !data) return;
|
||||
const src = map.getSource('photos') as GeoJSONSource | undefined;
|
||||
if (!src) return;
|
||||
src.setData(data as GeoJSON.FeatureCollection);
|
||||
|
||||
// Drop stale markers; updateMarkers will rebuild for the current
|
||||
// visible set on the next `sourcedata` (fired by setData) or `idle`.
|
||||
markersOnScreen.forEach((m) => m.remove());
|
||||
markersOnScreen.clear();
|
||||
markers.clear();
|
||||
|
||||
// Deep-link from the RightSidebar's location open icon: `?lat=&lng=`
|
||||
// (+ optional `zoom`, `focus`) flies the map directly to the photo
|
||||
// rather than fitting to the full library extent. Strip the params
|
||||
// afterwards so a manual zoom-out + reload doesn't snap back. Falls
|
||||
// through to the default fitBounds when the params aren't present.
|
||||
const sp = new URL(window.location.href).searchParams;
|
||||
const latParam = Number(sp.get('lat'));
|
||||
const lngParam = Number(sp.get('lng'));
|
||||
if (
|
||||
(data.features?.length ?? 0) > 0 &&
|
||||
Number.isFinite(latParam) &&
|
||||
Number.isFinite(lngParam) &&
|
||||
sp.has('lat') &&
|
||||
sp.has('lng')
|
||||
) {
|
||||
const zoom = Number(sp.get('zoom')) || 17;
|
||||
map.jumpTo({ center: [lngParam, latParam], zoom });
|
||||
const stripped = new URL(window.location.href);
|
||||
stripped.searchParams.delete('lat');
|
||||
stripped.searchParams.delete('lng');
|
||||
stripped.searchParams.delete('zoom');
|
||||
stripped.searchParams.delete('focus');
|
||||
const qs = stripped.searchParams.toString();
|
||||
void goto(`/map${qs ? `?${qs}` : ''}`, {
|
||||
replaceState: true,
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fit to data extent on the first non-empty load — prefer the
|
||||
// server-provided bbox (PhotoPrism returns one), else compute from
|
||||
// the features.
|
||||
if ((data.features?.length ?? 0) > 0) {
|
||||
let bounds: maplibregl.LngLatBoundsLike | null = null;
|
||||
if (Array.isArray(data.bbox) && data.bbox.length === 4) {
|
||||
bounds = [
|
||||
[data.bbox[0], data.bbox[1]],
|
||||
[data.bbox[2], data.bbox[3]]
|
||||
];
|
||||
} else {
|
||||
const b = new maplibregl.LngLatBounds();
|
||||
for (const f of data.features as PpGeoFeature[]) {
|
||||
const c = f.geometry.coordinates as [number, number];
|
||||
if (Number.isFinite(c[0]) && Number.isFinite(c[1])) b.extend(c);
|
||||
}
|
||||
if (!b.isEmpty()) bounds = b;
|
||||
}
|
||||
if (bounds) map.fitBounds(bounds, { padding: 60, maxZoom: 17, animate: false });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Map
|
||||
</span>
|
||||
{#snippet trailing()}
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{geoQuery.data?.features?.length ?? 0} geotagged
|
||||
</span>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<div bind:this={mapEl} class="min-h-0 w-full flex-1"></div>
|
||||
|
||||
<style>
|
||||
/* PhotoPrism's marker / cluster styling, ported from
|
||||
frontend/src/css/places.css. `:global` because MapLibre appends
|
||||
markers outside Svelte's scoped CSS reach. */
|
||||
:global(.maplibregl-map .marker) {
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ffffff99;
|
||||
background-color: rgba(23, 23, 23, 0.23);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0px 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
|
||||
0px 1px 5px 0px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
:global(.maplibregl-map .cluster-marker) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 1px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
:global(.maplibregl-map .cluster-marker > div) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
:global(.maplibregl-map .badge) {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
background: #53478a;
|
||||
box-shadow:
|
||||
0px 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
|
||||
0px 1px 5px 0px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -30,7 +30,7 @@
|
||||
scanCrossFolderDuplicates,
|
||||
type CrossFolderScanResult
|
||||
} from '$lib/services/photoprism';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { isAuthenticated, userLibraryBase } from '$lib/stores/session.svelte';
|
||||
import { clearSelection, selection } from '$lib/stores/selection.svelte';
|
||||
import { filters, setSection, type Section } from '$lib/stores/filters.svelte';
|
||||
import {
|
||||
@@ -85,13 +85,16 @@
|
||||
// observes its cache (enabled:false) and DuplicatesView is what
|
||||
// triggers the actual scan when its tab is active.
|
||||
const stacksQuery = createQuery<DuplicateGroup[]>(() => ({
|
||||
queryKey: ['duplicates'],
|
||||
queryFn: listDuplicateGroups,
|
||||
queryKey: ['duplicates', userLibraryBase()],
|
||||
queryFn: () => listDuplicateGroups(userLibraryBase()),
|
||||
enabled: isAuthenticated(),
|
||||
staleTime: 30_000
|
||||
}));
|
||||
// Scope is enforced server-side (sidecar reads the caller's BasePath +
|
||||
// stored index sub-path), but key on userLibraryBase() so switching the
|
||||
// index folder doesn't show a stale, differently-scoped cached result.
|
||||
const crossFolderQuery = createQuery<CrossFolderScanResult>(() => ({
|
||||
queryKey: ['duplicates-cross-folder'],
|
||||
queryKey: ['duplicates-cross-folder', userLibraryBase()],
|
||||
queryFn: scanCrossFolderDuplicates,
|
||||
enabled: false,
|
||||
staleTime: 5 * 60_000
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
COLOR_SWATCHES,
|
||||
starLabel
|
||||
} from '$lib/utils/tagGroups';
|
||||
import { countryName } from '$lib/utils/countries';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
@@ -69,7 +70,10 @@
|
||||
// label badge of 157 could otherwise drill into 0 photos because the
|
||||
// session is scoped to a folder that has none of them).
|
||||
const useServer = $derived(
|
||||
category === 'labels' || category === 'keywords' || category === 'people'
|
||||
category === 'labels' ||
|
||||
category === 'keywords' ||
|
||||
category === 'people' ||
|
||||
category === 'countries'
|
||||
);
|
||||
const drillQ = $derived(
|
||||
useServer && selectedValue
|
||||
@@ -78,6 +82,10 @@
|
||||
heapUid: null,
|
||||
folderPath: null,
|
||||
search: '',
|
||||
sort: 'newest',
|
||||
mediaType: null,
|
||||
year: null,
|
||||
favorite: false,
|
||||
tagCategory: category,
|
||||
tagValue: selectedValue
|
||||
})
|
||||
@@ -161,6 +169,7 @@
|
||||
COLOR_SWATCHES.find((c) => c.key === selectedValue)?.title ?? selectedValue
|
||||
);
|
||||
}
|
||||
if (category === 'countries') return countryName(selectedValue);
|
||||
return selectedValue;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user