Merge pull request 'feat(move): "move to folder" for grid selections, folders, and m shortcut' (#3) from claude/infallible-sammet-3471fa into main
Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
@@ -83,32 +83,13 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
subfolder = s
|
subfolder = s
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve destination. resolveUnderRoot ensures the target lives
|
// Resolve destination under ORIGINALS_ROOT. Empty / "/" / "." mean
|
||||||
// inside ORIGINALS_ROOT and that its parent is a real directory.
|
// "drop these into originals/ itself" (the modal's "Root" option).
|
||||||
// Empty / "/" / "." are valid here — they mean "drop these into
|
targetAbs, err := resolveMoveTarget(cfg, body.TargetFolder)
|
||||||
// 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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid targetFolder"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
targetAbs = abs
|
|
||||||
}
|
|
||||||
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
|
// Pull the heap's photos via the q=album:UID query. count=1000 covers
|
||||||
// every realistic heap; merged=true expands stacked variants so we
|
// every realistic heap; merged=true expands stacked variants so we
|
||||||
// move the JPG/HEIC sibling alongside the primary.
|
// move the JPG/HEIC sibling alongside the primary.
|
||||||
@@ -129,9 +110,59 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
return
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
heapDeleted := false
|
||||||
|
if deleteHeap {
|
||||||
|
r, err := pp.call(context.Background(), http.MethodDelete, "/api/v1/albums/"+albumUID, token, nil)
|
||||||
|
if err == nil && r.OK {
|
||||||
|
heapDeleted = true
|
||||||
|
} else if err != nil {
|
||||||
|
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: " + err.Error()})
|
||||||
|
} else {
|
||||||
|
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: HTTP " + itoa(r.Status)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("heap.convert",
|
||||||
|
"album", albumUID,
|
||||||
|
"mode", mode,
|
||||||
|
"moved", moved,
|
||||||
|
"copied", copied,
|
||||||
|
"errors", len(errs),
|
||||||
|
"heap_deleted", heapDeleted,
|
||||||
|
)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"moved": moved,
|
||||||
|
"copied": copied,
|
||||||
|
"errors": errs,
|
||||||
|
"heap_deleted": heapDeleted,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{}{}
|
sourceParents := map[string]struct{}{}
|
||||||
errs := []heapErr{}
|
errs = []heapErr{}
|
||||||
moved, copied := 0, 0
|
|
||||||
|
|
||||||
for _, photo := range photos {
|
for _, photo := range photos {
|
||||||
// Pick the file to physically move. PhotoPrism's "primary" file
|
// Pick the file to physically move. PhotoPrism's "primary" file
|
||||||
@@ -167,8 +198,8 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "path escapes originals"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
st, err := os.Stat(srcAbs)
|
st, statErr := os.Stat(srcAbs)
|
||||||
if err != nil || !st.Mode().IsRegular() {
|
if statErr != nil || !st.Mode().IsRegular() {
|
||||||
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
|
errs = append(errs, heapErr{UID: photo.UID, Reason: "file missing on disk"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -183,12 +214,12 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
dstAbs := filepath.Join(destAbs, name)
|
dstAbs := filepath.Join(destAbs, name)
|
||||||
if mode == "move" {
|
if mode == "move" {
|
||||||
if err := os.Rename(srcAbs, dstAbs); err != nil {
|
if mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil {
|
||||||
// Cross-device renames fail with EXDEV — fall back to
|
// Cross-device renames fail with EXDEV — fall back to
|
||||||
// copy+remove so a library that spans filesystems still
|
// copy+remove so a library that spans filesystems still
|
||||||
// works.
|
// works.
|
||||||
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
if err2 := copyFile(srcAbs, dstAbs); err2 != nil {
|
||||||
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
|
errs = append(errs, heapErr{UID: photo.UID, Reason: mvErr.Error()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err2 := os.Remove(srcAbs); err2 != nil {
|
if err2 := os.Remove(srcAbs); err2 != nil {
|
||||||
@@ -198,8 +229,8 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
moved++
|
moved++
|
||||||
} else {
|
} else {
|
||||||
if err := copyFile(srcAbs, dstAbs); err != nil {
|
if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil {
|
||||||
errs = append(errs, heapErr{UID: photo.UID, Reason: err.Error()})
|
errs = append(errs, heapErr{UID: photo.UID, Reason: cpErr.Error()})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
copied++
|
copied++
|
||||||
@@ -207,14 +238,12 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
sourceParents[filepath.Dir(srcRel)] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reindex the destination + every source parent so PhotoPrism's
|
// Reindex the destination + every source parent so PhotoPrism's DB
|
||||||
// DB catches up. We block on these so the response only goes out
|
// catches up. We block on these so the response only goes out after the
|
||||||
// after the index reflects the move — callers (the frontend's
|
// index reflects the move — the frontend's invalidateQueries refetch
|
||||||
// invalidateQueries refetch in particular) need the next /photos
|
// needs the next /photos fetch to return the moved files, otherwise the
|
||||||
// fetch to return the moved files, otherwise the folder view
|
// folder view looks unchanged. PhotoPrism's index endpoint serialises
|
||||||
// looks unchanged. PhotoPrism's index endpoint serialises calls
|
// calls internally; running them sequentially matches that contract.
|
||||||
// internally; running them sequentially matches that contract
|
|
||||||
// without surprising the server.
|
|
||||||
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
destRel, _ := filepath.Rel(cfg.OriginalsRoot, destAbs)
|
||||||
paths := map[string]struct{}{destRel: {}}
|
paths := map[string]struct{}{destRel: {}}
|
||||||
for p := range sourceParents {
|
for p := range sourceParents {
|
||||||
@@ -232,31 +261,16 @@ func handleHeapConvert(cfg *Config, pp *ppClient) gin.HandlerFunc {
|
|||||||
fireReindex(cfg, pp, token, reindex)
|
fireReindex(cfg, pp, token, reindex)
|
||||||
}
|
}
|
||||||
|
|
||||||
heapDeleted := false
|
return moved, copied, errs, nil
|
||||||
if deleteHeap {
|
|
||||||
r, err := pp.call(context.Background(), http.MethodDelete, "/api/v1/albums/"+albumUID, token, nil)
|
|
||||||
if err == nil && r.OK {
|
|
||||||
heapDeleted = true
|
|
||||||
} else if err != nil {
|
|
||||||
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: " + err.Error()})
|
|
||||||
} else {
|
|
||||||
errs = append(errs, heapErr{UID: albumUID, Reason: "album delete: HTTP " + itoa(r.Status)})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("heap.convert",
|
// resolveMoveTarget translates a targetFolder (Originals-relative; ""/"/"/"."
|
||||||
"album", albumUID,
|
// mean the Originals root itself) into a validated absolute path under the
|
||||||
"mode", mode,
|
// root. Shared by the heap-convert and photos-move destination handling.
|
||||||
"moved", moved,
|
func resolveMoveTarget(cfg *Config, targetFolder string) (string, error) {
|
||||||
"copied", copied,
|
trimmed := strings.Trim(targetFolder, "/")
|
||||||
"errors", len(errs),
|
if trimmed == "" || trimmed == "." {
|
||||||
"heap_deleted", heapDeleted,
|
return cfg.OriginalsRoot, nil
|
||||||
)
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"moved": moved,
|
|
||||||
"copied": copied,
|
|
||||||
"errors": errs,
|
|
||||||
"heap_deleted": heapDeleted,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
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", handleFolderCreate(cfg, pp))
|
||||||
auth.POST("/folders/counts", handleFolderCounts(pp))
|
auth.POST("/folders/counts", handleFolderCounts(pp))
|
||||||
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
auth.POST("/folders/:rel/rename", handleFolderRename(cfg, pp))
|
||||||
|
auth.POST("/folders/:rel/move", handleFolderMove(cfg, pp))
|
||||||
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
auth.DELETE("/folders/:rel", handleFolderDelete(cfg, pp))
|
||||||
|
|
||||||
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
|
||||||
|
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
|
||||||
|
|
||||||
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
|
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
|
||||||
auth.POST("/duplicates/archive", handleDupArchive(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 { photoNameAndDir } from '$lib/types/photoprism';
|
||||||
import { queryClient } from '$lib/queryClient';
|
import { queryClient } from '$lib/queryClient';
|
||||||
import { filters } from '$lib/stores/filters.svelte';
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
|
import { openMove } from '$lib/stores/moveDialog.svelte';
|
||||||
import {
|
import {
|
||||||
clearBulkToFirst,
|
clearBulkToFirst,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
@@ -553,6 +554,23 @@ export function gridKeyNav(node: HTMLElement, params: GridKeyNavParams = {}) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void toggleArchive('restore');
|
void toggleArchive('restore');
|
||||||
return;
|
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':
|
||||||
case 'S':
|
case 'S':
|
||||||
if (meta || shift) return;
|
if (meta || shift) return;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
import { filters } from '$lib/stores/filters.svelte';
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
|
import { FolderInput, FolderPlus, Pencil, Trash2 } from 'lucide-svelte';
|
||||||
import Self from './FolderTree.svelte';
|
import Self from './FolderTree.svelte';
|
||||||
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
||||||
|
|
||||||
@@ -50,10 +50,13 @@
|
|||||||
depth?: number;
|
depth?: number;
|
||||||
onPick: (path: string) => void;
|
onPick: (path: string) => void;
|
||||||
/** Mutating callbacks are only required when readonly !== true. The
|
/** 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;
|
onRename?: (path: string) => void;
|
||||||
onDelete?: (path: string) => void;
|
onDelete?: (path: string) => void;
|
||||||
onCreateChild?: (parent: 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
|
/** Read-only mode: hides the kebab menu and disables double-click
|
||||||
* rename, so the tree can be reused as a folder picker. */
|
* rename, so the tree can be reused as a folder picker. */
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
@@ -74,6 +77,7 @@
|
|||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
onCreateChild,
|
onCreateChild,
|
||||||
|
onMove,
|
||||||
readonly = false,
|
readonly = false,
|
||||||
selectedPath,
|
selectedPath,
|
||||||
counts
|
counts
|
||||||
@@ -219,6 +223,13 @@
|
|||||||
<Pencil class="h-3.5 w-3.5 text-muted-foreground" />
|
<Pencil class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
Rename
|
Rename
|
||||||
</Item>
|
</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" />
|
<Separator class="my-1 h-px bg-border" />
|
||||||
<Item
|
<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"
|
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 +250,7 @@
|
|||||||
{onRename}
|
{onRename}
|
||||||
{onDelete}
|
{onDelete}
|
||||||
{onCreateChild}
|
{onCreateChild}
|
||||||
|
{onMove}
|
||||||
{readonly}
|
{readonly}
|
||||||
{selectedPath}
|
{selectedPath}
|
||||||
{counts}
|
{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>
|
|
||||||
@@ -43,9 +43,9 @@
|
|||||||
type TagCategory
|
type TagCategory
|
||||||
} from '$lib/stores/filters.svelte';
|
} from '$lib/stores/filters.svelte';
|
||||||
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
|
import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte';
|
||||||
|
import { openMove } from '$lib/stores/moveDialog.svelte';
|
||||||
import FolderTree, { buildTree } from './FolderTree.svelte';
|
import FolderTree, { buildTree } from './FolderTree.svelte';
|
||||||
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
|
import GeneralSettingsDialog from './GeneralSettingsDialog.svelte';
|
||||||
import HeapConvertDialog from './HeapConvertDialog.svelte';
|
|
||||||
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
import KebabMenu, { Item, Separator } from './KebabMenu.svelte';
|
||||||
import SettingsDialog from './SettingsDialog.svelte';
|
import SettingsDialog from './SettingsDialog.svelte';
|
||||||
import UsersDialog from './UsersDialog.svelte';
|
import UsersDialog from './UsersDialog.svelte';
|
||||||
@@ -141,9 +141,6 @@
|
|||||||
toast.error(err instanceof Error ? err.message : 'Could not duplicate heap')
|
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.
|
// Library/admin settings dialog visibility.
|
||||||
let settingsOpen = $state(false);
|
let settingsOpen = $state(false);
|
||||||
@@ -578,6 +575,7 @@
|
|||||||
onRename={onRenameFolder}
|
onRename={onRenameFolder}
|
||||||
onDelete={onDeleteFolder}
|
onDelete={onDeleteFolder}
|
||||||
onCreateChild={(parent) => onCreateFolder(parent)}
|
onCreateChild={(parent) => onCreateFolder(parent)}
|
||||||
|
onMove={(path) => openMove({ kind: 'folder', path })}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -653,7 +651,7 @@
|
|||||||
</Item>
|
</Item>
|
||||||
<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"
|
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" />
|
<FolderInput class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
Move to folder…
|
Move to folder…
|
||||||
@@ -875,7 +873,6 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HeapConvertDialog heap={convertingHeap} onClose={() => (convertingHeap = null)} />
|
|
||||||
<SettingsDialog open={settingsOpen} onClose={() => (settingsOpen = false)} />
|
<SettingsDialog open={settingsOpen} onClose={() => (settingsOpen = false)} />
|
||||||
<GeneralSettingsDialog
|
<GeneralSettingsDialog
|
||||||
open={generalSettingsOpen}
|
open={generalSettingsOpen}
|
||||||
|
|||||||
294
web/src/lib/components/layout/MoveToFolderDialog.svelte
Normal file
294
web/src/lib/components/layout/MoveToFolderDialog.svelte
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<!--
|
||||||
|
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;
|
||||||
|
try {
|
||||||
|
if (s.kind === 'heap') {
|
||||||
|
const r = await convertHeap(s.heap.UID, {
|
||||||
|
targetFolder: toOriginalsPath(pickedPath),
|
||||||
|
mode,
|
||||||
|
subfolder: subfolder.trim() || null,
|
||||||
|
deleteHeap: mode === 'move' && deleteHeap
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['heaps'] });
|
||||||
|
toast.success(
|
||||||
|
moveSummary(mode === 'copy' ? 'Copied' : 'Moved', mode === 'copy' ? r.copied : r.moved, r.errors.length)
|
||||||
|
);
|
||||||
|
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(pickedPath),
|
||||||
|
mode,
|
||||||
|
subfolder: subfolder.trim() || null
|
||||||
|
});
|
||||||
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
|
toast.success(
|
||||||
|
moveSummary(mode === 'copy' ? 'Copied' : 'Moved', mode === 'copy' ? r.copied : r.moved, r.errors.length)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Folder reparent (move only). Translate both the folder's own
|
||||||
|
// path and the destination parent to originals-relative for the
|
||||||
|
// sidecar, which moves real directories on disk.
|
||||||
|
await moveFolder(toOriginalsPath(s.path), toOriginalsPath(pickedPath));
|
||||||
|
qc.invalidateQueries({ queryKey: ['photos'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['folders'] });
|
||||||
|
const newUiPath = pickedPath === '' ? folderName : `${pickedPath}/${folderName}`;
|
||||||
|
toast.success(`Moved ${folderName} → ${pickedPath === '' ? '/' : pickedPath}`);
|
||||||
|
// If we just moved the folder the timeline is showing, follow it.
|
||||||
|
if (filters.folderPath === s.path) setFolderPath(newUiPath);
|
||||||
|
}
|
||||||
|
closeMove();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : 'Move failed');
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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,6 +26,7 @@
|
|||||||
import { filters } from '$lib/stores/filters.svelte';
|
import { filters } from '$lib/stores/filters.svelte';
|
||||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||||
import { isAuthenticated } from '$lib/stores/session.svelte';
|
import { isAuthenticated } from '$lib/stores/session.svelte';
|
||||||
|
import { openMove } from '$lib/stores/moveDialog.svelte';
|
||||||
import {
|
import {
|
||||||
startBulk,
|
startBulk,
|
||||||
setDetail,
|
setDetail,
|
||||||
@@ -455,6 +456,15 @@
|
|||||||
Archive
|
Archive
|
||||||
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
<kbd class="rounded bg-muted px-1 text-[9px] font-medium text-muted-foreground">X</kbd>
|
||||||
</button>
|
</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}
|
{/if}
|
||||||
<button
|
<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"
|
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
|||||||
@@ -1006,6 +1006,43 @@ export async function convertHeap(
|
|||||||
return callSidecar('POST', `/albums/${uid}/convert`, body) as Promise<HeapConvertResult>;
|
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) ─────────────────────────────────────────────
|
// ── Photo marks (rating + color) ─────────────────────────────────────────────
|
||||||
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
|
// PhotoPrism's PUT silently drops Rating and Color (they're auto-computed
|
||||||
// internal fields). We store them in mule-sidecar instead.
|
// internal fields). We store them in mule-sidecar instead.
|
||||||
|
|||||||
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 LeftSidebar from '$lib/components/layout/LeftSidebar.svelte';
|
||||||
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
import AnimatedMule from '$lib/components/mule/AnimatedMule.svelte';
|
||||||
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
import PreviewModal from '$lib/components/preview/PreviewModal.svelte';
|
||||||
|
import MoveToFolderDialog from '$lib/components/layout/MoveToFolderDialog.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
@@ -117,6 +118,10 @@
|
|||||||
helper (called by the timeline / PhotoGrid dblclick paths and
|
helper (called by the timeline / PhotoGrid dblclick paths and
|
||||||
by gridKeyNav's Space handler). -->
|
by gridKeyNav's Space handler). -->
|
||||||
<PreviewModal />
|
<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}
|
{:else}
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user