Compare commits
14 Commits
claude/str
...
claude/ser
| Author | SHA1 | Date | |
|---|---|---|---|
| 634abc2a95 | |||
| ba5684d120 | |||
| 74bae78270 | |||
| 52ab3b6840 | |||
| 400b215036 | |||
| e1e508671e | |||
| a52f171946 | |||
| e124809ad5 | |||
| ad6e733622 | |||
| 6d9b236ef6 | |||
| 669e5fde33 | |||
| b2b6060872 | |||
| 277fdc5a53 | |||
| 5be6fd9047 |
@@ -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"
|
||||
@@ -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).
|
||||
|
||||
@@ -83,35 +83,17 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
subfolder = s
|
||||
}
|
||||
|
||||
// Resolve destination. resolveUnderRoot ensures the target lives
|
||||
// inside ORIGINALS_ROOT and that its parent is a real directory.
|
||||
// Empty / "/" / "." are valid here — they mean "drop these into
|
||||
// originals/ itself" (the modal's "Root" option). resolveUnderRoot
|
||||
// rejects those for safety, so handle the root case explicitly.
|
||||
var targetAbs string
|
||||
trimmed := strings.Trim(body.TargetFolder, "/")
|
||||
if trimmed == "" || trimmed == "." {
|
||||
targetAbs = cfg.OriginalsRoot
|
||||
} else {
|
||||
abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.TargetFolder, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||
return
|
||||
}
|
||||
targetAbs = abs
|
||||
// Resolve destination under ORIGINALS_ROOT. Empty / "/" / "." mean
|
||||
// "drop these into originals/ itself" (the modal's "Root" option).
|
||||
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||
return
|
||||
}
|
||||
destAbs := targetAbs
|
||||
if subfolder != "" {
|
||||
destAbs = filepath.Join(targetAbs, subfolder)
|
||||
if err := os.MkdirAll(destAbs, 0o755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
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.
|
||||
// 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)
|
||||
@@ -123,114 +105,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
|
||||
}
|
||||
|
||||
sourceParents := map[string]struct{}{}
|
||||
errs := []heapErr{}
|
||||
moved, copied := 0, 0
|
||||
|
||||
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
|
||||
for _, f := range photo.Files {
|
||||
if f.Root == "/" && f.Primary {
|
||||
file, found = f, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
for _, f := range photo.Files {
|
||||
if f.Root == "/" {
|
||||
file, found = f, true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
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
|
||||
}
|
||||
st, err := os.Stat(srcAbs)
|
||||
if err != nil || !st.Mode().IsRegular() {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
|
||||
continue
|
||||
}
|
||||
if filepath.Dir(srcAbs) == destAbs {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "already in target"})
|
||||
continue
|
||||
}
|
||||
_, name, ok := uniqueName(destAbs, filepath.Base(srcAbs))
|
||||
if !ok {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
|
||||
continue
|
||||
}
|
||||
dstAbs := filepath.Join(destAbs, name)
|
||||
if mode == "move" {
|
||||
if err := os.Rename(srcAbs, dstAbs); err != 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: err.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
|
||||
}
|
||||
}
|
||||
moved++
|
||||
} else {
|
||||
if err := copyFile(srcAbs, dstAbs); err != nil {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
|
||||
continue
|
||||
}
|
||||
copied++
|
||||
}
|
||||
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
||||
uids := make([]string, 0, len(listed))
|
||||
for _, p := range listed {
|
||||
uids = append(uids, p.UID)
|
||||
}
|
||||
|
||||
// Reindex the destination + every source parent so PhotoPrism's
|
||||
// DB catches up. We block on these so the response only goes out
|
||||
// after the index reflects the move — callers (the frontend's
|
||||
// invalidateQueries refetch in particular) need the next /photos
|
||||
// fetch to return the moved files, otherwise the folder view
|
||||
// looks unchanged. PhotoPrism's index endpoint serialises calls
|
||||
// internally; running them sequentially matches that contract
|
||||
// without surprising the server.
|
||||
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
||||
paths := map[string]struct{}{destRel: {}}
|
||||
for p := range sourceParents {
|
||||
paths[p] = struct{}{}
|
||||
// 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
|
||||
}
|
||||
if subfolder != "" {
|
||||
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
|
||||
paths[parent] = struct{}{}
|
||||
}
|
||||
for p := range paths {
|
||||
reindex := "/"
|
||||
if p != "" && p != "." {
|
||||
reindex = "/" + p
|
||||
}
|
||||
fireReindex(cfg, pp, token, reindex)
|
||||
|
||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
errs = append(resolveErrs, errs...)
|
||||
|
||||
heapDeleted := false
|
||||
if deleteHeap {
|
||||
@@ -260,3 +159,180 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// movePhotoFiles moves (or copies) each photo's originals-rooted primary file
|
||||
// into targetAbs — optionally into `subfolder` under it — then blocks on a
|
||||
// PhotoPrism reindex of the destination plus every source parent so the next
|
||||
// /photos fetch reflects the move. Shared by handleHeapConvert (album-scoped)
|
||||
// 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) {
|
||||
destAbs := targetAbs
|
||||
if subfolder != "" {
|
||||
destAbs = filepath.Join(targetAbs, subfolder)
|
||||
if e := os.MkdirAll(destAbs, 0o755); e != nil {
|
||||
return 0, 0, nil, e
|
||||
}
|
||||
}
|
||||
|
||||
sourceParents := map[string]struct{}{}
|
||||
errs = []heapErr{}
|
||||
|
||||
for _, photo := range photos {
|
||||
// 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 != "/" {
|
||||
continue
|
||||
}
|
||||
group = append(group, f)
|
||||
if f.Primary && !havePrimary {
|
||||
primary, havePrimary = f, true
|
||||
}
|
||||
}
|
||||
if len(group) == 0 {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "no originals-rooted file"})
|
||||
continue
|
||||
}
|
||||
if !havePrimary {
|
||||
primary = group[0]
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
stem, ok := uniqueStem(destAbs, filepath.Base(primary.Name), exts)
|
||||
if !ok {
|
||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "too many collisions"})
|
||||
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, cfg.OriginalsRoot) {
|
||||
failure = "path escapes originals"
|
||||
break
|
||||
}
|
||||
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++
|
||||
}
|
||||
}
|
||||
|
||||
// Reindex the destination + every source parent so PhotoPrism's DB
|
||||
// catches up. We block on these so the response only goes out after the
|
||||
// index reflects the move — the frontend's invalidateQueries refetch
|
||||
// needs the next /photos fetch to return the moved files, otherwise the
|
||||
// folder view looks unchanged. PhotoPrism's index endpoint serialises
|
||||
// calls internally; running them sequentially matches that contract.
|
||||
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
||||
paths := map[string]struct{}{destRel: {}}
|
||||
for p := range sourceParents {
|
||||
paths[p] = struct{}{}
|
||||
}
|
||||
if subfolder != "" {
|
||||
parent, _ := filepath.Rel(cfg.OriginalsRoot, targetAbs)
|
||||
paths[parent] = struct{}{}
|
||||
}
|
||||
for p := range paths {
|
||||
reindex := "/"
|
||||
if p != "" && p != "." {
|
||||
reindex = "/" + p
|
||||
}
|
||||
fireReindex(cfg, pp, token, reindex)
|
||||
}
|
||||
|
||||
return moved, copied, errs, nil
|
||||
}
|
||||
|
||||
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"."
|
||||
// mean the Originals root itself) into a validated absolute path under the
|
||||
// root. Shared by the heap-convert and photos-move destination handling.
|
||||
func resolveMoveTarget(cfg *Config, targetFolder string) (string, error) {
|
||||
trimmed := strings.Trim(targetFolder, "/")
|
||||
if trimmed == "" || trimmed == "." {
|
||||
return cfg.OriginalsRoot, nil
|
||||
}
|
||||
return resolveUnderRoot(cfg.OriginalsRoot, targetFolder, true)
|
||||
}
|
||||
|
||||
196
sidecar/handlers_move.go
Normal file
196
sidecar/handlers_move.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type photosMoveBody struct {
|
||||
UIDs []string `json:"uids"`
|
||||
TargetFolder string `json:"targetFolder"`
|
||||
Mode string `json:"mode"` // "move" or "copy"
|
||||
Subfolder string `json:"subfolder"` // optional, sanitized to a single segment
|
||||
}
|
||||
|
||||
// handlePhotosMove moves/copies an arbitrary list of photos (by UID) into a
|
||||
// folder under originals/. Mirrors handleHeapConvert but resolves the photos
|
||||
// from a UID list instead of an album query, then shares movePhotoFiles for
|
||||
// the on-disk work + reindex. Backs the grid's "Move to folder" action.
|
||||
func handlePhotosMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
|
||||
var body photosMoveBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
if len(body.UIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no uids"})
|
||||
return
|
||||
}
|
||||
mode := body.Mode
|
||||
if mode != "copy" {
|
||||
mode = "move"
|
||||
}
|
||||
|
||||
var subfolder string
|
||||
if body.Subfolder != "" {
|
||||
s, ok := sanitizeFilename(body.Subfolder)
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subfolder name"})
|
||||
return
|
||||
}
|
||||
subfolder = s
|
||||
}
|
||||
|
||||
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||
return
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
|
||||
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),
|
||||
"mode", mode,
|
||||
"moved", moved,
|
||||
"copied", copied,
|
||||
"errors", len(errs),
|
||||
)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"moved": moved,
|
||||
"copied": copied,
|
||||
"errors": errs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePhotosFull fetches each photo's complete file list via the
|
||||
// single-photo endpoint (GET /photos/:uid). Use this instead of the /photos
|
||||
// search whenever you need every file of a photo: the search — even with
|
||||
// merged=true — can return a trimmed Files array that omits the photo's video
|
||||
// file, and it applies PhotoPrism's default quality/review/archive filters.
|
||||
// Both silently drop videos (which PhotoPrism routinely files under review)
|
||||
// from a move. The per-UID lookup returns every file and ignores those
|
||||
// filters. UIDs PhotoPrism can't resolve are returned in `errs` so the batch
|
||||
// continues; a transport-level failure aborts with a fatal error. Mirrors
|
||||
// handleRename's single-photo resolution.
|
||||
func resolvePhotosFull(ctx context.Context, pp *ppClient, token string, uids []string) (photos []heapPhoto, errs []heapErr, err error) {
|
||||
photos = make([]heapPhoto, 0, len(uids))
|
||||
for _, uid := range uids {
|
||||
resp, e := pp.call(ctx, http.MethodGet, "/api/v1/photos/"+url.PathEscape(uid), token, nil)
|
||||
if e != nil {
|
||||
return nil, nil, e
|
||||
}
|
||||
if !resp.OK {
|
||||
errs = append(errs, heapErr{UID: uid, Reason: "photo not found"})
|
||||
continue
|
||||
}
|
||||
var p heapPhoto
|
||||
if e := json.Unmarshal(resp.Body, &p); e != nil {
|
||||
return nil, nil, e
|
||||
}
|
||||
photos = append(photos, p)
|
||||
}
|
||||
return photos, errs, nil
|
||||
}
|
||||
|
||||
type folderMoveBody struct {
|
||||
// Originals-relative destination parent. ""/"/"/"." mean the root.
|
||||
TargetParent string `json:"targetParent"`
|
||||
}
|
||||
|
||||
// handleFolderMove reparents a folder: moves the directory (and everything in
|
||||
// it) under a different parent, keeping its own name. Mirrors
|
||||
// handleFolderRename but the destination is a parent folder rather than a new
|
||||
// name. A whole-tree os.Rename preserves subfolder structure.
|
||||
func handleFolderMove(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := ctxToken(c)
|
||||
rel, ok := pathParam(c, "rel")
|
||||
if !ok {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
var body folderMoveBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(oldAbs)
|
||||
if err != nil || !st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
|
||||
return
|
||||
}
|
||||
targetParentAbs, err := resolveMoveTarget(cfg, body.TargetParent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetParent"})
|
||||
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"})
|
||||
return
|
||||
}
|
||||
newAbs := filepath.Join(targetParentAbs, filepath.Base(oldAbs))
|
||||
if newAbs == oldAbs {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "already in that folder"})
|
||||
return
|
||||
}
|
||||
if !sameOrUnder(newAbs, cfg.OriginalsRoot) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"})
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(newAbs); err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "target already exists"})
|
||||
return
|
||||
}
|
||||
if err := os.Rename(oldAbs, newAbs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs)
|
||||
newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs)
|
||||
slog.Info("folder.move", "from", oldRel, "to", newRel)
|
||||
// Reindex both the old and new parents so PhotoPrism drops the moved
|
||||
// rows from the source view and picks them up under the destination.
|
||||
fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel))
|
||||
fireReindex(cfg, pp, token, "/"+filepath.Dir(newRel))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"oldPath": oldRel,
|
||||
"newPath": newRel,
|
||||
})
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -81,6 +81,9 @@ func main() {
|
||||
// under one group keeps the middleware wiring obvious.
|
||||
auth := r.Group("/api/sidecar", requireSession(pp))
|
||||
{
|
||||
auth.GET("/prefs", handlePrefsGet(db))
|
||||
auth.PUT("/prefs", handlePrefsPut(cfg, db))
|
||||
|
||||
auth.GET("/photos/marks", handleMarksAll(db))
|
||||
auth.GET("/photos/:uid/marks", handleMarkGet(db))
|
||||
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
|
||||
@@ -91,17 +94,20 @@ func main() {
|
||||
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("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
|
||||
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
|
||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
|
||||
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
|
||||
|
||||
// User-scoped proxies — require PpDSN connection.
|
||||
if ppDb != nil {
|
||||
auth.GET("/labels", handleLabels(pp, ppDb))
|
||||
auth.GET("/counts", handleScopedCounts(ppDb))
|
||||
auth.GET("/countries", handleCountries(ppDb))
|
||||
}
|
||||
|
||||
// User-scoped photos — post-filters by BasePath so review/archive
|
||||
|
||||
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",
|
||||
|
||||
@@ -15,6 +15,7 @@ import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
import { photoNameAndDir } from '$lib/types/photoprism';
|
||||
import { queryClient } from '$lib/queryClient';
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { openMove } from '$lib/stores/moveDialog.svelte';
|
||||
import {
|
||||
clearBulkToFirst,
|
||||
clearSelection,
|
||||
@@ -27,7 +28,14 @@ import {
|
||||
toggle
|
||||
} from '$lib/stores/selection.svelte';
|
||||
import { popAndRun, push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { startBulk, doneBulk, failBulk, setDetail } from '$lib/stores/bulkAction.svelte';
|
||||
import {
|
||||
startBulk,
|
||||
doneBulk,
|
||||
removedBulk,
|
||||
failBulk,
|
||||
setDetail,
|
||||
markRemoved
|
||||
} from '$lib/stores/bulkAction.svelte';
|
||||
import { openPreview, toggleLeftSidebar, toggleRightSidebar, view } from '$lib/stores/view.svelte';
|
||||
|
||||
/**
|
||||
@@ -163,6 +171,8 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
async function toggleArchive(direction: 'archive' | 'restore' | 'toggle') {
|
||||
const ids = cullTargets();
|
||||
if (ids.length === 0) {
|
||||
@@ -193,11 +203,29 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive/restore failed', { id: tid });
|
||||
return;
|
||||
}
|
||||
doneBulk(doneLabel, ids);
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
invalidatePhotos(ids);
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
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. 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);
|
||||
void queryClient.invalidateQueries({ queryKey: ['photos'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
} else {
|
||||
doneBulk(doneLabel, ids);
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
invalidatePhotos(ids);
|
||||
void queryClient.invalidateQueries({ queryKey: ['marks'] });
|
||||
}
|
||||
toast.success(doneLabel, { id: tid });
|
||||
pushUndo(doneLabel, async () => {
|
||||
if (target) await batchRestore(ids);
|
||||
@@ -233,10 +261,16 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
|
||||
return;
|
||||
}
|
||||
doneBulk(`Deleted ${ids.length}`, ids);
|
||||
// Destructive removal — same red-cross flash then immediate hide as archive.
|
||||
removedBulk(`Deleted ${ids.length}`, ids);
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
await delay(500);
|
||||
markRemoved(ids);
|
||||
invalidatePhotos(ids);
|
||||
// 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'] });
|
||||
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||||
}
|
||||
@@ -522,6 +556,23 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
||||
e.preventDefault();
|
||||
void toggleArchive('restore');
|
||||
return;
|
||||
case 'm':
|
||||
case 'M': {
|
||||
if (meta || shift) return;
|
||||
e.preventDefault();
|
||||
// Move the cull targets to a folder — opens the shared
|
||||
// move-to-folder dialog (same one the bar button and the
|
||||
// heap/folder kebabs use).
|
||||
const moveIds = cullTargets();
|
||||
if (moveIds.length === 0) {
|
||||
toast.message('Nothing to move', {
|
||||
description: 'Click a photo or select some first'
|
||||
});
|
||||
return;
|
||||
}
|
||||
openMove({ kind: 'photos', uids: moveIds });
|
||||
return;
|
||||
}
|
||||
case 's':
|
||||
case 'S':
|
||||
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(() => {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { untrack } from 'svelte';
|
||||
import { 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';
|
||||
|
||||
@@ -50,10 +50,13 @@
|
||||
depth?: number;
|
||||
onPick: (path: string) => void;
|
||||
/** Mutating callbacks are only required when readonly !== true. The
|
||||
* picker (HeapConvertDialog) reuses the tree just for `onPick`. */
|
||||
* picker (MoveToFolderDialog) reuses the tree just for `onPick`. */
|
||||
onRename?: (path: string) => void;
|
||||
onDelete?: (path: string) => void;
|
||||
onCreateChild?: (parent: string) => void;
|
||||
/** Reparent this folder under a chosen destination (opens the shared
|
||||
* move-to-folder dialog). Sidebar only; the readonly picker omits it. */
|
||||
onMove?: (path: string) => void;
|
||||
/** Read-only mode: hides the kebab menu and disables double-click
|
||||
* rename, so the tree can be reused as a folder picker. */
|
||||
readonly?: boolean;
|
||||
@@ -74,6 +77,7 @@
|
||||
onRename,
|
||||
onDelete,
|
||||
onCreateChild,
|
||||
onMove,
|
||||
readonly = false,
|
||||
selectedPath,
|
||||
counts
|
||||
@@ -160,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
|
||||
@@ -219,6 +225,13 @@
|
||||
<Pencil class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Rename
|
||||
</Item>
|
||||
<Item
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
||||
onSelect={() => onMove?.(node.path)}
|
||||
>
|
||||
<FolderInput class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Move to folder…
|
||||
</Item>
|
||||
<Separator class="my-1 h-px bg-border" />
|
||||
<Item
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] text-destructive outline-none hover:bg-destructive/10 focus:bg-destructive/10"
|
||||
@@ -239,6 +252,7 @@
|
||||
{onRename}
|
||||
{onDelete}
|
||||
{onCreateChild}
|
||||
{onMove}
|
||||
{readonly}
|
||||
{selectedPath}
|
||||
{counts}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
<!--
|
||||
Move/copy every photo in a heap into a folder under originals/.
|
||||
|
||||
Picker reuses the existing FolderTree in readonly mode; the dialog owns
|
||||
the selection (`pickedPath`) so it doesn't conflict with the global
|
||||
folderPath filter the sidebar drives.
|
||||
|
||||
Submit goes to the sidecar's POST /albums/:uid/convert. On success we
|
||||
invalidate the photos / folders / heaps queries so the timeline and
|
||||
sidebar refresh; if the heap was deleted and was active, route home.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Dialog } from 'bits-ui';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import {
|
||||
convertHeap,
|
||||
listFolders,
|
||||
type HeapConvertBody,
|
||||
type HeapConvertResult,
|
||||
type PpAlbum,
|
||||
type PpFolder
|
||||
} from '$lib/services/photoprism';
|
||||
import { filters, setSection } from '$lib/stores/filters.svelte';
|
||||
import { isAuthenticated, toOriginalsPath } from '$lib/stores/session.svelte';
|
||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||
|
||||
interface Props {
|
||||
heap: PpAlbum | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
let { heap, onClose }: Props = $props();
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
// 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'],
|
||||
queryFn: listFolders,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
const folderTree = $derived(
|
||||
buildTree((foldersQuery.data ?? []).map((f) => f.Path))
|
||||
);
|
||||
|
||||
let pickedPath = $state<string | null>(null);
|
||||
let mode = $state<'move' | 'copy'>('move');
|
||||
let subfolder = $state('');
|
||||
let deleteHeap = $state(false);
|
||||
|
||||
// Reset draft state whenever a new heap is picked (or the dialog closes
|
||||
// and reopens). $effect runs after the prop change, so the form is
|
||||
// blank on every fresh open.
|
||||
$effect(() => {
|
||||
void heap;
|
||||
pickedPath = null;
|
||||
mode = 'move';
|
||||
subfolder = '';
|
||||
deleteHeap = false;
|
||||
});
|
||||
|
||||
const convertMut = createMutation(() => ({
|
||||
mutationFn: (args: { uid: string; body: HeapConvertBody }) =>
|
||||
convertHeap(args.uid, args.body),
|
||||
onSuccess: (result: HeapConvertResult, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||
const verb = mode === 'copy' ? 'Copied' : 'Moved';
|
||||
const count = mode === 'copy' ? result.copied : result.moved;
|
||||
const tail =
|
||||
result.errors.length > 0
|
||||
? ` · ${result.errors.length} skipped`
|
||||
: '';
|
||||
toast.success(`${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`);
|
||||
// If the heap got deleted and we were viewing it, fall back home.
|
||||
if (
|
||||
result.heap_deleted &&
|
||||
filters.section === 'heap' &&
|
||||
filters.heapUid === vars.uid
|
||||
) {
|
||||
setSection('all-photos');
|
||||
void goto('/', { keepFocus: true, noScroll: true });
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
onError: (err) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Convert failed')
|
||||
}));
|
||||
|
||||
function submit() {
|
||||
// pickedPath === '' is the root selection; falsy check would
|
||||
// wrongly block it. Distinguish `null` (nothing picked) from `''`.
|
||||
if (!heap || pickedPath === null) return;
|
||||
// pickedPath is user-relative (listFolders strips BasePath). The
|
||||
// sidecar moves files on disk so it needs a server-absolute path —
|
||||
// translate before submitting.
|
||||
convertMut.mutate({
|
||||
uid: heap.UID,
|
||||
body: {
|
||||
targetFolder: toOriginalsPath(pickedPath),
|
||||
mode,
|
||||
subfolder: subfolder.trim() || null,
|
||||
deleteHeap: mode === 'move' && deleteHeap
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Copy mode doesn't change membership, so "delete heap after" is
|
||||
// meaningless. Force-clear it when the user flips back to copy.
|
||||
$effect(() => {
|
||||
if (mode === 'copy' && deleteHeap) deleteHeap = false;
|
||||
});
|
||||
|
||||
const open = $derived(heap !== null);
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
{open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) onClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<Dialog.Content
|
||||
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[520px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<Dialog.Title class="text-sm font-semibold leading-tight">
|
||||
{mode === 'copy' ? 'Copy' : 'Move'} heap to folder
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
||||
{heap?.Title ?? ''} · {heap?.PhotoCount ?? 0} photo{heap?.PhotoCount === 1
|
||||
? ''
|
||||
: 's'}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder picker. Readonly FolderTree so the user can't kebab/
|
||||
rename their way out of the picker mid-flow. -->
|
||||
<div class="rounded-md border border-border bg-background p-2">
|
||||
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
Destination
|
||||
</div>
|
||||
<div class="max-h-[200px] overflow-y-auto">
|
||||
{#if foldersQuery.isPending}
|
||||
<InlineLoader size="sm" label="Loading folders…" />
|
||||
{:else if (foldersQuery.data ?? []).length === 0}
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={FolderOpen}
|
||||
title="No folders"
|
||||
description="Create one from the sidebar first."
|
||||
/>
|
||||
{:else}
|
||||
<!-- Root row: lets the user drop the heap directly into
|
||||
originals/ without picking a subfolder. The empty
|
||||
string is the sidecar's "root" sentinel — matches
|
||||
resolveUnderRoot's special case in handlers_heap. -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
|
||||
class:bg-primary={pickedPath === ''}
|
||||
class:text-primary-foreground={pickedPath === ''}
|
||||
class:hover:bg-primary={pickedPath === ''}
|
||||
onclick={() => (pickedPath = '')}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
<FolderTree
|
||||
nodes={folderTree}
|
||||
onPick={(p) => (pickedPath = p)}
|
||||
selectedPath={pickedPath}
|
||||
readonly
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode + options. Plain radio + checkbox; bits-ui has dedicated
|
||||
primitives but inline form controls keep the dialog small. -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-4 text-[12px]">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input type="radio" bind:group={mode} value="move" />
|
||||
Move
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input type="radio" bind:group={mode} value="copy" />
|
||||
Copy
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1 text-[12px]">
|
||||
<span class="text-muted-foreground">
|
||||
New subfolder (optional)
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. {heap?.Title ?? 'My heap'}"
|
||||
bind:value={subfolder}
|
||||
class="rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={deleteHeap}
|
||||
disabled={mode === 'copy'}
|
||||
/>
|
||||
<span class:text-muted-foreground={mode === 'copy'}>
|
||||
Delete heap after move
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
|
||||
onclick={onClose}
|
||||
disabled={convertMut.isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
onclick={submit}
|
||||
disabled={pickedPath === null || convertMut.isPending}
|
||||
>
|
||||
{#if convertMut.isPending}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
{mode === 'copy' ? 'Copy' : 'Move'}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -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 HeapConvertDialog from './HeapConvertDialog.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
|
||||
@@ -141,9 +178,6 @@
|
||||
toast.error(err instanceof Error ? err.message : 'Could not duplicate heap')
|
||||
}));
|
||||
|
||||
// Heap currently being converted (move/copy to folder). Setting this
|
||||
// mounts <HeapConvertDialog>; the dialog clears it on close.
|
||||
let convertingHeap = $state<PpAlbum | null>(null);
|
||||
|
||||
// Library/admin settings dialog visibility.
|
||||
let settingsOpen = $state(false);
|
||||
@@ -171,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
|
||||
}));
|
||||
|
||||
@@ -239,7 +241,8 @@
|
||||
keywords: 'Keywords',
|
||||
people: 'People',
|
||||
colors: 'Colors',
|
||||
ratings: 'Ratings'
|
||||
ratings: 'Ratings',
|
||||
countries: 'Countries'
|
||||
};
|
||||
|
||||
function isTagCategoryActive(cat: TagCategory): boolean {
|
||||
@@ -268,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 });
|
||||
@@ -284,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')
|
||||
@@ -295,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;
|
||||
@@ -380,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
|
||||
@@ -395,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.
|
||||
];
|
||||
@@ -487,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)}
|
||||
@@ -521,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
|
||||
@@ -558,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." />
|
||||
@@ -578,6 +637,7 @@
|
||||
onRename={onRenameFolder}
|
||||
onDelete={onDeleteFolder}
|
||||
onCreateChild={(parent) => onCreateFolder(parent)}
|
||||
onMove={(path) => openMove({ kind: 'folder', path })}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -653,7 +713,7 @@
|
||||
</Item>
|
||||
<Item
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[12px] outline-none hover:bg-accent focus:bg-accent"
|
||||
onSelect={() => (convertingHeap = heap)}
|
||||
onSelect={() => openMove({ kind: 'heap', heap })}
|
||||
>
|
||||
<FolderInput class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Move to folder…
|
||||
@@ -685,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
|
||||
@@ -753,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>
|
||||
@@ -875,7 +877,6 @@
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<HeapConvertDialog heap={convertingHeap} onClose={() => (convertingHeap = null)} />
|
||||
<SettingsDialog open={settingsOpen} onClose={() => (settingsOpen = false)} />
|
||||
<GeneralSettingsDialog
|
||||
open={generalSettingsOpen}
|
||||
|
||||
311
web/src/lib/components/layout/MoveToFolderDialog.svelte
Normal file
311
web/src/lib/components/layout/MoveToFolderDialog.svelte
Normal file
@@ -0,0 +1,311 @@
|
||||
<!--
|
||||
Move/copy photos into a folder under originals/ — the single dialog behind
|
||||
every "move to folder" entry point (heap kebab, folder kebab, the grid's
|
||||
BulkActionBar button, and the `m` shortcut). Driven by the moveDialog store
|
||||
so the picker UI and the move/copy wiring live in exactly one place.
|
||||
|
||||
Three subjects:
|
||||
• heap — move/copy an album's photos into a folder (optional subfolder,
|
||||
optional delete-heap-after). The original behaviour.
|
||||
• photos — move/copy a UID selection from the grid. Same options minus
|
||||
delete-heap.
|
||||
• folder — reparent a folder: move the directory (and its subfolders)
|
||||
under a chosen destination parent. Move-only, no subfolder; the
|
||||
folder keeps its own name. The picker excludes the folder
|
||||
itself and its descendants.
|
||||
|
||||
Picker reuses the readonly FolderTree; the dialog owns the selection
|
||||
(`pickedPath`) so it never fights the global folderPath filter.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Dialog } from 'bits-ui';
|
||||
import { createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { FolderInput, FolderOpen, Loader2 } from 'lucide-svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import {
|
||||
convertHeap,
|
||||
movePhotosToFolder,
|
||||
moveFolder,
|
||||
listFolders,
|
||||
type PpFolder
|
||||
} from '$lib/services/photoprism';
|
||||
import { filters, setSection, setFolderPath } from '$lib/stores/filters.svelte';
|
||||
import { isAuthenticated, toOriginalsPath, userLibraryBase } from '$lib/stores/session.svelte';
|
||||
import { moveDialog, closeMove } from '$lib/stores/moveDialog.svelte';
|
||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
// 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', userLibraryBase()],
|
||||
queryFn: listFolders,
|
||||
enabled: isAuthenticated()
|
||||
}));
|
||||
|
||||
const subject = $derived(moveDialog.subject);
|
||||
const kind = $derived(subject?.kind);
|
||||
const open = $derived(subject !== null);
|
||||
|
||||
// For folder reparent, exclude the folder itself and everything under it —
|
||||
// you can't move a directory into its own subtree.
|
||||
const folderTree = $derived.by(() => {
|
||||
const paths = (foldersQuery.data ?? []).map((f) => f.Path);
|
||||
if (subject?.kind === 'folder') {
|
||||
const self = subject.path;
|
||||
return buildTree(paths.filter((p) => p !== self && !p.startsWith(self + '/')));
|
||||
}
|
||||
return buildTree(paths);
|
||||
});
|
||||
|
||||
const showOptions = $derived(kind === 'heap' || kind === 'photos');
|
||||
const showDeleteHeap = $derived(kind === 'heap');
|
||||
|
||||
const folderName = $derived(
|
||||
subject?.kind === 'folder' ? (subject.path.split('/').pop() ?? subject.path) : ''
|
||||
);
|
||||
const headerTitle = $derived.by(() => {
|
||||
if (subject?.kind === 'folder') return 'Move folder';
|
||||
const verb = mode === 'copy' ? 'Copy' : 'Move';
|
||||
if (subject?.kind === 'heap') return `${verb} heap to folder`;
|
||||
return `${verb} photos to folder`;
|
||||
});
|
||||
const headerDesc = $derived.by(() => {
|
||||
if (subject?.kind === 'heap') {
|
||||
const n = subject.heap.PhotoCount ?? 0;
|
||||
return `${subject.heap.Title ?? ''} · ${n} photo${n === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (subject?.kind === 'photos') {
|
||||
const n = subject.uids.length;
|
||||
return `${n} photo${n === 1 ? '' : 's'} selected`;
|
||||
}
|
||||
if (subject?.kind === 'folder') return `${folderName} → pick a destination`;
|
||||
return '';
|
||||
});
|
||||
|
||||
let pickedPath = $state<string | null>(null);
|
||||
let mode = $state<'move' | 'copy'>('move');
|
||||
let subfolder = $state('');
|
||||
let deleteHeap = $state(false);
|
||||
let submitting = $state(false);
|
||||
|
||||
// Reset draft state whenever a new subject is picked (or the dialog closes
|
||||
// and reopens), so the form is blank on every fresh open.
|
||||
$effect(() => {
|
||||
void subject;
|
||||
pickedPath = null;
|
||||
mode = 'move';
|
||||
subfolder = '';
|
||||
deleteHeap = false;
|
||||
submitting = false;
|
||||
});
|
||||
|
||||
// Copy mode doesn't change membership, so "delete heap after" is
|
||||
// meaningless. Force-clear it when the user flips back to copy.
|
||||
$effect(() => {
|
||||
if (mode === 'copy' && deleteHeap) deleteHeap = false;
|
||||
});
|
||||
|
||||
function moveSummary(verb: string, count: number, errors: number): string {
|
||||
const tail = errors > 0 ? ` · ${errors} skipped` : '';
|
||||
return `${verb} ${count} photo${count === 1 ? '' : 's'}${tail}`;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const s = moveDialog.subject;
|
||||
// pickedPath === '' is the root selection; distinguish it from `null`
|
||||
// (nothing picked) so a falsy check doesn't wrongly block root.
|
||||
if (!s || pickedPath === null || submitting) return;
|
||||
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(dest),
|
||||
mode: opMode,
|
||||
subfolder: sub,
|
||||
deleteHeap: delHeap
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||
toast.success(
|
||||
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');
|
||||
void goto('/', { keepFocus: true, noScroll: true });
|
||||
}
|
||||
} else if (s.kind === 'photos') {
|
||||
const r = await movePhotosToFolder({
|
||||
uids: s.uids,
|
||||
targetFolder: toOriginalsPath(dest),
|
||||
mode: opMode,
|
||||
subfolder: sub
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
toast.success(
|
||||
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(dest));
|
||||
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||
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);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Move failed', { id: tid });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
{open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) closeMove();
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
class="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<Dialog.Content
|
||||
class="fixed left-1/2 top-1/2 z-50 grid w-full max-w-[520px] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border border-border bg-card p-5 text-card-foreground shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<FolderInput class="mt-0.5 h-4 w-4 text-muted-foreground" />
|
||||
<div class="flex-1">
|
||||
<Dialog.Title class="text-sm font-semibold leading-tight">
|
||||
{headerTitle}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="mt-1 text-xs text-muted-foreground">
|
||||
{headerDesc}
|
||||
</Dialog.Description>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder picker. Readonly FolderTree so the user can't kebab/rename
|
||||
their way out of the picker mid-flow. -->
|
||||
<div class="rounded-md border border-border bg-background p-2">
|
||||
<div class="mb-1 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{kind === 'folder' ? 'Destination parent' : 'Destination'}
|
||||
</div>
|
||||
<div class="max-h-[200px] overflow-y-auto">
|
||||
{#if foldersQuery.isPending}
|
||||
<InlineLoader size="sm" label="Loading folders…" />
|
||||
{:else if (foldersQuery.data ?? []).length === 0}
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={FolderOpen}
|
||||
title="No folders"
|
||||
description="Create one from the sidebar first."
|
||||
/>
|
||||
{:else}
|
||||
<!-- Root row: drop straight into originals/ (the user's root)
|
||||
without picking a subfolder. Empty string is the
|
||||
sidecar's "root" sentinel. -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center rounded px-2 py-1 text-left text-[12px] hover:bg-accent"
|
||||
class:bg-primary={pickedPath === ''}
|
||||
class:text-primary-foreground={pickedPath === ''}
|
||||
class:hover:bg-primary={pickedPath === ''}
|
||||
onclick={() => (pickedPath = '')}
|
||||
>
|
||||
/
|
||||
</button>
|
||||
<FolderTree
|
||||
nodes={folderTree}
|
||||
onPick={(p) => (pickedPath = p)}
|
||||
selectedPath={pickedPath}
|
||||
readonly
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Move/copy + subfolder, hidden for folder reparent (always a move
|
||||
that keeps the folder's own name). -->
|
||||
{#if showOptions}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-4 text-[12px]">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input type="radio" bind:group={mode} value="move" />
|
||||
Move
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5">
|
||||
<input type="radio" bind:group={mode} value="copy" />
|
||||
Copy
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex flex-col gap-1 text-[12px]">
|
||||
<span class="text-muted-foreground">New subfolder (optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. 2024-summer"
|
||||
bind:value={subfolder}
|
||||
class="rounded border border-input bg-background px-2 py-1 text-[12px] focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
{#if showDeleteHeap}
|
||||
<label class="flex items-center gap-1.5 text-[12px]">
|
||||
<input type="checkbox" bind:checked={deleteHeap} disabled={mode === 'copy'} />
|
||||
<span class:text-muted-foreground={mode === 'copy'}>Delete heap after move</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-3 py-1 text-[12px] hover:bg-accent"
|
||||
onclick={closeMove}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 rounded bg-primary px-3 py-1 text-[12px] text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
onclick={submit}
|
||||
disabled={pickedPath === null || submitting}
|
||||
>
|
||||
{#if submitting}
|
||||
<Loader2 class="h-3 w-3 animate-spin" />
|
||||
{/if}
|
||||
{kind === 'folder' ? 'Move' : mode === 'copy' ? 'Copy' : 'Move'}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
@@ -16,10 +15,10 @@
|
||||
Calendar,
|
||||
File,
|
||||
Folder,
|
||||
Globe,
|
||||
HardDrive,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Star,
|
||||
Tag,
|
||||
@@ -43,8 +42,9 @@
|
||||
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 } from '$lib/stores/filters.svelte';
|
||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||
import { countryName } from '$lib/utils/countries';
|
||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
|
||||
interface Props {
|
||||
@@ -432,28 +432,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -26,13 +26,14 @@
|
||||
import { filters } from '$lib/stores/filters.svelte';
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||
import { openMove } from '$lib/stores/moveDialog.svelte';
|
||||
import {
|
||||
startBulk,
|
||||
setDetail,
|
||||
doneBulk,
|
||||
removedBulk,
|
||||
failBulk,
|
||||
markRemoved,
|
||||
clearRemoved
|
||||
markRemoved
|
||||
} from '$lib/stores/bulkAction.svelte';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import { Layers } from 'lucide-svelte';
|
||||
@@ -127,6 +128,9 @@
|
||||
ids: string[];
|
||||
label: string;
|
||||
doneLabel: string;
|
||||
/** Destructive removal (archive / delete): flash a red cross, then hide
|
||||
* the tiles via markRemoved after the flash instead of green check. */
|
||||
removing?: boolean;
|
||||
}
|
||||
|
||||
async function withBusy<T>(fn: () => Promise<T>, bulk?: BulkConfig): Promise<T> {
|
||||
@@ -135,8 +139,15 @@
|
||||
try {
|
||||
const result = await fn();
|
||||
if (bulk) {
|
||||
doneBulk(bulk.doneLabel, bulk.ids);
|
||||
await delay(1000);
|
||||
if (bulk.removing) {
|
||||
// Destructive: red-cross flash, then pull tiles from the grid.
|
||||
removedBulk(bulk.doneLabel, bulk.ids);
|
||||
await delay(500);
|
||||
markRemoved(bulk.ids);
|
||||
} else {
|
||||
doneBulk(bulk.doneLabel, bulk.ids);
|
||||
await delay(1000);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (e) {
|
||||
@@ -144,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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +206,6 @@
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchArchive(ids);
|
||||
markRemoved(ids);
|
||||
pushUndo(`Archived ${ids.length}`, async () => {
|
||||
await batchRestore(ids);
|
||||
void qc.invalidateQueries({ queryKey: ['photos'] });
|
||||
@@ -207,7 +216,7 @@
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Archive failed', { id: tid });
|
||||
}
|
||||
}, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}` });
|
||||
}, { ids, label: 'Archiving', doneLabel: `Archived ${ids.length}`, removing: true });
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
@@ -222,14 +231,13 @@
|
||||
await withBusy(async () => {
|
||||
try {
|
||||
await batchDelete(ids);
|
||||
markRemoved(ids);
|
||||
focusAfter(ids);
|
||||
clearSelection();
|
||||
toast.success(`Deleted ${ids.length}`, { id: tid });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Delete failed', { id: tid });
|
||||
}
|
||||
}, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}` });
|
||||
}, { ids, label: 'Deleting', doneLabel: `Deleted ${ids.length}`, removing: true });
|
||||
}
|
||||
|
||||
async function onRestore() {
|
||||
@@ -446,6 +454,15 @@
|
||||
Archive
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded border border-border bg-background px-2 py-0.5 text-[11px] hover:bg-accent disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onclick={() => openMove({ kind: 'photos', uids: snapshotIds() })}
|
||||
title="Move selected photos to a folder"
|
||||
>
|
||||
Move to folder
|
||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">M</kbd>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
|
||||
@@ -166,6 +166,13 @@
|
||||
>
|
||||
<Check class="h-7 w-7 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{:else if bulkState === 'removed'}
|
||||
<div
|
||||
transition:fade={{ duration: 200 }}
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-500/70"
|
||||
>
|
||||
<X class="h-7 w-7 text-white drop-shadow-md" />
|
||||
</div>
|
||||
{:else if bulkState === 'error'}
|
||||
<div class="pointer-events-none absolute inset-0 flex items-center justify-center bg-red-500/60">
|
||||
<X class="h-7 w-7 text-white drop-shadow-md" />
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1006,6 +1013,43 @@ export async function convertHeap(
|
||||
return callSidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
|
||||
}
|
||||
|
||||
// ── Move arbitrary photos (by UID) to a folder ──────────────────────────────
|
||||
// Same on-disk move/copy + reindex as convertHeap, but the sidecar resolves the
|
||||
// photos from a UID list instead of an album. Backs the grid's move-to-folder.
|
||||
|
||||
export interface PhotosMoveBody {
|
||||
uids: string[];
|
||||
/** Originals-relative target folder. Empty string = originals root. */
|
||||
targetFolder: string;
|
||||
mode: 'move' | 'copy';
|
||||
/** Optional subfolder to create under `targetFolder` and place files into. */
|
||||
subfolder?: string | null;
|
||||
}
|
||||
|
||||
export interface PhotosMoveResult {
|
||||
moved: number;
|
||||
copied: number;
|
||||
errors: { uid: string; reason: string }[];
|
||||
}
|
||||
|
||||
export async function movePhotosToFolder(body: PhotosMoveBody): Promise<PhotosMoveResult> {
|
||||
return callSidecar('POST', '/photos/move', body) as Promise<PhotosMoveResult>;
|
||||
}
|
||||
|
||||
// ── Reparent a folder (move the directory under a different parent) ──────────
|
||||
|
||||
export interface FolderMoveResult {
|
||||
ok: boolean;
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
}
|
||||
|
||||
export async function moveFolder(rel: string, targetParent: string): Promise<FolderMoveResult> {
|
||||
return callSidecar('POST', `/folders/${encodeURIComponent(rel)}/move`, {
|
||||
targetParent
|
||||
}) as Promise<FolderMoveResult>;
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
@@ -1056,26 +1100,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;
|
||||
@@ -1085,7 +1120,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;
|
||||
@@ -1096,42 +1130,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;
|
||||
@@ -1162,26 +1181,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;
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* startBulk → pill spins, all target tiles go "pending"
|
||||
* setDetail → pill shows the filename currently being processed (fan-out ops)
|
||||
* doneBulk → pill shows completion label, tiles flash green, auto-clears after 3 s
|
||||
* removedBulk→ destructive completion (archive / delete): tiles flash a red cross,
|
||||
* then the caller hides them via markRemoved; map auto-clears after 3 s
|
||||
* failBulk → tiles flash red, auto-clears after 2 s
|
||||
*/
|
||||
|
||||
@@ -21,7 +23,7 @@ export const bulkAction = $state<BulkActionState>({ active: false, label: '' });
|
||||
// SvelteMap (not `$state(new Map())`) so a `.get(uid)` read in a PhotoTile
|
||||
// reliably re-runs when the entry flips — the plain-Map proxy form wasn't
|
||||
// re-rendering the timeline tiles' overlay.
|
||||
export const bulkPhotoStates = new SvelteMap<string, 'pending' | 'done' | 'error'>();
|
||||
export const bulkPhotoStates = new SvelteMap<string, 'pending' | 'done' | 'error' | 'removed'>();
|
||||
|
||||
/**
|
||||
* UIDs hidden from the timeline grid the instant a removing action (archive /
|
||||
@@ -71,6 +73,24 @@ export function doneBulk(label: string, ids: string[]): void {
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destructive completion (archive / permanent delete): flash a red cross on the
|
||||
* target tiles instead of the green check. The caller hides the tiles via
|
||||
* markRemoved shortly after the flash; this timer only cleans up the state map.
|
||||
*/
|
||||
export function removedBulk(label: string, ids: string[]): void {
|
||||
for (const id of ids) bulkPhotoStates.set(id, 'removed');
|
||||
bulkAction.active = false;
|
||||
bulkAction.label = label;
|
||||
bulkAction.detail = undefined;
|
||||
if (doneTimer !== null) clearTimeout(doneTimer);
|
||||
doneTimer = setTimeout(() => {
|
||||
bulkAction.label = '';
|
||||
bulkPhotoStates.clear();
|
||||
doneTimer = null;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
export function failBulk(ids: string[]): void {
|
||||
for (const id of ids) bulkPhotoStates.set(id, 'error');
|
||||
bulkAction.active = false;
|
||||
|
||||
@@ -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 {
|
||||
@@ -239,6 +246,8 @@ 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));
|
||||
|
||||
@@ -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:
|
||||
|
||||
28
web/src/lib/stores/moveDialog.svelte.ts
Normal file
28
web/src/lib/stores/moveDialog.svelte.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Global "move to folder" dialog state. A single MoveToFolderDialog (mounted
|
||||
* once in the root layout) renders whenever `subject` is non-null. Every entry
|
||||
* point — heap kebab, folder kebab, the grid's BulkActionBar button, and the
|
||||
* `m` keyboard shortcut — opens it through openMove(), so the picker UI and
|
||||
* the move/copy logic live in exactly one place.
|
||||
*/
|
||||
|
||||
import type { PpAlbum } from '$lib/services/photoprism';
|
||||
|
||||
export type MoveSubject =
|
||||
| { kind: 'heap'; heap: PpAlbum }
|
||||
| { kind: 'photos'; uids: string[] }
|
||||
| { kind: 'folder'; path: string };
|
||||
|
||||
interface MoveDialogState {
|
||||
subject: MoveSubject | null;
|
||||
}
|
||||
|
||||
export const moveDialog = $state<MoveDialogState>({ subject: null });
|
||||
|
||||
export function openMove(subject: MoveSubject): void {
|
||||
moveDialog.subject = subject;
|
||||
}
|
||||
|
||||
export function closeMove(): void {
|
||||
moveDialog.subject = null;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
import LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
||||
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -117,6 +118,10 @@
|
||||
helper (called by the timeline / PhotoGrid dblclick paths and
|
||||
by gridKeyNav's Space handler). -->
|
||||
<PreviewModal />
|
||||
<!-- Single shared move-to-folder dialog, driven by the moveDialog
|
||||
store. Opened from the heap/folder kebabs, the BulkActionBar
|
||||
button, and the `m` shortcut — all through openMove(). -->
|
||||
<MoveToFolderDialog />
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
|
||||
@@ -37,7 +37,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,
|
||||
@@ -283,6 +283,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));
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -161,6 +165,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