Compare commits
8 Commits
claude/str
...
a52f171946
| Author | SHA1 | Date | |
|---|---|---|---|
| a52f171946 | |||
| e124809ad5 | |||
| ad6e733622 | |||
| 6d9b236ef6 | |||
| 669e5fde33 | |||
| b2b6060872 | |||
| 277fdc5a53 | |||
| 5be6fd9047 |
@@ -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 {
|
||||
|
||||
@@ -83,32 +83,13 @@ 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.
|
||||
@@ -129,107 +110,10 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
||||
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{}{}
|
||||
}
|
||||
|
||||
// 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{}{}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
heapDeleted := false
|
||||
@@ -260,3 +144,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)
|
||||
}
|
||||
|
||||
174
sidecar/handlers_move.go
Normal file
174
sidecar/handlers_move.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"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 the photos via a single q=uid:a|b|c query. PhotoPrism's
|
||||
// search treats `|` as OR within a filter value, so one round-trip
|
||||
// covers the whole selection; merged=true pulls stacked variants so
|
||||
// the JPG/HEIC sibling travels with its primary.
|
||||
q := url.QueryEscape("uid:" + strings.Join(body.UIDs, "|"))
|
||||
listURL := "/api/v1/photos?q=" + q + "&count=" + itoa(len(body.UIDs)) + "&merged=true"
|
||||
resp, err := pp.call(c.Request.Context(), http.MethodGet, listURL, token, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !resp.OK {
|
||||
c.JSON(resp.Status, gin.H{"error": "list photos failed"})
|
||||
return
|
||||
}
|
||||
var photos []heapPhoto
|
||||
if err := json.Unmarshal(resp.Body, &photos); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo list"})
|
||||
return
|
||||
}
|
||||
|
||||
moved, copied, errs, err := movePhotoFiles(cfg, pp, token, photos, targetAbs, subfolder, mode)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -91,9 +91,11 @@ 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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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>
|
||||
@@ -19,6 +19,7 @@
|
||||
renameFolder,
|
||||
renameHeap,
|
||||
scanCrossFolderDuplicates,
|
||||
startIndex,
|
||||
triggerDownload,
|
||||
type CrossFolderScanResult,
|
||||
type PpAlbum,
|
||||
@@ -42,14 +43,22 @@
|
||||
type Section,
|
||||
type TagCategory
|
||||
} from '$lib/stores/filters.svelte';
|
||||
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
|
||||
import {
|
||||
isAuthenticated,
|
||||
session,
|
||||
userBasePath,
|
||||
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 +68,7 @@
|
||||
LogOut,
|
||||
Moon,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Sun,
|
||||
Trash2,
|
||||
@@ -141,9 +151,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);
|
||||
@@ -284,10 +291,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 +307,57 @@
|
||||
|
||||
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 {
|
||||
await startIndex({ path: '/', 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;
|
||||
@@ -487,6 +520,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,18 +565,20 @@
|
||||
{#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"
|
||||
@@ -578,6 +624,7 @@
|
||||
onRename={onRenameFolder}
|
||||
onDelete={onDeleteFolder}
|
||||
onCreateChild={(parent) => onCreateFolder(parent)}
|
||||
onMove={(path) => openMove({ kind: 'folder', path })}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -653,7 +700,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…
|
||||
@@ -700,9 +747,11 @@
|
||||
aria-expanded={tagsExpanded}
|
||||
>
|
||||
<span
|
||||
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
|
||||
class="flex h-[18px] w-5 items-center justify-center text-muted-foreground"
|
||||
>
|
||||
{tagsExpanded ? '▾' : '▸'}
|
||||
<ChevronRight
|
||||
class="h-4 w-4 transition-transform duration-150 {tagsExpanded ? 'rotate-90' : ''}"
|
||||
/>
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||
<span class="truncate">Tags</span>
|
||||
@@ -768,9 +817,11 @@
|
||||
aria-expanded={reviewExpanded}
|
||||
>
|
||||
<span
|
||||
class="flex h-[18px] w-4 items-center justify-center text-[10px] text-muted-foreground"
|
||||
class="flex h-[18px] w-5 items-center justify-center text-muted-foreground"
|
||||
>
|
||||
{reviewExpanded ? '▾' : '▸'}
|
||||
<ChevronRight
|
||||
class="h-4 w-4 transition-transform duration-150 {reviewExpanded ? 'rotate-90' : ''}"
|
||||
/>
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center pl-1">
|
||||
<span class="truncate">Review</span>
|
||||
@@ -875,7 +926,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 } 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'],
|
||||
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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -1006,6 +1006,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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user