From b2b60608720c0d3e27e54f10100d2c04340b0039 Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 18 Jun 2026 00:10:19 +0200 Subject: [PATCH] feat(move): "move to folder" for grid selections, folders, and m shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the heap-only "move to folder" action to grid single/bulk selections, sidebar folders, and an `m` keyboard shortcut — all through one shared dialog driven by a moveDialog store. Backend (sidecar): - Extract the heap move/copy + reindex loop into a reusable movePhotoFiles helper plus resolveMoveTarget - POST /photos/move: move/copy an arbitrary UID list into a folder - POST /folders/:rel/move: reparent a folder dir (whole subtree) under a new parent, guarding against moving into itself/a descendant Frontend: - moveDialog store + generalized MoveToFolderDialog (heap | photos | folder subjects); mounted once in +layout.svelte. Replaces HeapConvertDialog - movePhotosToFolder / moveFolder service fns - Entry points: BulkActionBar button, gridKeyNav `m`, FolderTree kebab, heap kebab — all call openMove() Co-Authored-By: Claude Opus 4.8 --- sidecar/handlers_heap.go | 266 ++++++++-------- sidecar/handlers_move.go | 174 +++++++++++ sidecar/main.go | 2 + web/src/lib/actions/gridKeyNav.ts | 18 ++ .../lib/components/layout/FolderTree.svelte | 16 +- .../layout/HeapConvertDialog.svelte | 249 --------------- .../lib/components/layout/LeftSidebar.svelte | 9 +- .../layout/MoveToFolderDialog.svelte | 294 ++++++++++++++++++ .../components/timeline/BulkActionBar.svelte | 10 + web/src/lib/services/photoprism.ts | 37 +++ web/src/lib/stores/moveDialog.svelte.ts | 28 ++ web/src/routes/+layout.svelte | 5 + 12 files changed, 725 insertions(+), 383 deletions(-) create mode 100644 sidecar/handlers_move.go delete mode 100644 web/src/lib/components/layout/HeapConvertDialog.svelte create mode 100644 web/src/lib/components/layout/MoveToFolderDialog.svelte create mode 100644 web/src/lib/stores/moveDialog.svelte.ts diff --git a/sidecar/handlers_heap.go b/sidecar/handlers_heap.go index 653b4dd..1cc7127 100644 --- a/sidecar/handlers_heap.go +++ b/sidecar/handlers_heap.go @@ -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,133 @@ 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 { + // 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, statErr := os.Stat(srcAbs) + if statErr != 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 mvErr := os.Rename(srcAbs, dstAbs); mvErr != nil { + // Cross-device renames fail with EXDEV — fall back to + // copy+remove so a library that spans filesystems still + // works. + if err2 := copyFile(srcAbs, dstAbs); err2 != nil { + errs = append(errs, heapErr{UID: photo.UID, Reason: mvErr.Error()}) + continue + } + if err2 := os.Remove(srcAbs); err2 != nil { + errs = append(errs, heapErr{UID: photo.UID, Reason: "rename ok, source remove failed: " + err2.Error()}) + continue + } + } + moved++ + } else { + if cpErr := copyFile(srcAbs, dstAbs); cpErr != nil { + errs = append(errs, heapErr{UID: photo.UID, Reason: cpErr.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 — 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) +} diff --git a/sidecar/handlers_move.go b/sidecar/handlers_move.go new file mode 100644 index 0000000..02b6d44 --- /dev/null +++ b/sidecar/handlers_move.go @@ -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, + }) + } +} diff --git a/sidecar/main.go b/sidecar/main.go index 8852742..8ef86ab 100644 --- a/sidecar/main.go +++ b/sidecar/main.go @@ -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)) diff --git a/web/src/lib/actions/gridKeyNav.ts b/web/src/lib/actions/gridKeyNav.ts index 55eb4ed..09438c9 100644 --- a/web/src/lib/actions/gridKeyNav.ts +++ b/web/src/lib/actions/gridKeyNav.ts @@ -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, @@ -553,6 +554,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; diff --git a/web/src/lib/components/layout/FolderTree.svelte b/web/src/lib/components/layout/FolderTree.svelte index 15b7701..dae9b86 100644 --- a/web/src/lib/components/layout/FolderTree.svelte +++ b/web/src/lib/components/layout/FolderTree.svelte @@ -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 { 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 @@ -219,6 +223,13 @@ Rename + onMove?.(node.path)} + > + + Move to folder… + - - - { - if (!o) onClose(); - }} -> - - - -
- -
- - {mode === 'copy' ? 'Copy' : 'Move'} heap to folder - - - {heap?.Title ?? ''} · {heap?.PhotoCount ?? 0} photo{heap?.PhotoCount === 1 - ? '' - : 's'} - -
-
- - -
-
- Destination -
-
- {#if foldersQuery.isPending} - - {:else if (foldersQuery.data ?? []).length === 0} - - {:else} - - - (pickedPath = p)} - selectedPath={pickedPath} - readonly - /> - {/if} -
-
- - -
-
- - -
- - -
- -
- - -
-
-
-
diff --git a/web/src/lib/components/layout/LeftSidebar.svelte b/web/src/lib/components/layout/LeftSidebar.svelte index 5def018..7649558 100644 --- a/web/src/lib/components/layout/LeftSidebar.svelte +++ b/web/src/lib/components/layout/LeftSidebar.svelte @@ -43,9 +43,9 @@ type TagCategory } from '$lib/stores/filters.svelte'; import { isAuthenticated, session, userBasePath } from '$lib/stores/session.svelte'; + import { openMove } from '$lib/stores/moveDialog.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'; @@ -141,9 +141,6 @@ toast.error(err instanceof Error ? err.message : 'Could not duplicate heap') })); - // Heap currently being converted (move/copy to folder). Setting this - // mounts ; the dialog clears it on close. - let convertingHeap = $state(null); // Library/admin settings dialog visibility. let settingsOpen = $state(false); @@ -578,6 +575,7 @@ onRename={onRenameFolder} onDelete={onDeleteFolder} onCreateChild={(parent) => onCreateFolder(parent)} + onMove={(path) => openMove({ kind: 'folder', path })} /> {/if} @@ -653,7 +651,7 @@
(convertingHeap = heap)} + onSelect={() => openMove({ kind: 'heap', heap })} > Move to folder… @@ -875,7 +873,6 @@ - (convertingHeap = null)} /> (settingsOpen = false)} /> + + + { + if (!o) closeMove(); + }} +> + + + +
+ +
+ + {headerTitle} + + + {headerDesc} + +
+
+ + +
+
+ {kind === 'folder' ? 'Destination parent' : 'Destination'} +
+
+ {#if foldersQuery.isPending} + + {:else if (foldersQuery.data ?? []).length === 0} + + {:else} + + + (pickedPath = p)} + selectedPath={pickedPath} + readonly + /> + {/if} +
+
+ + + {#if showOptions} +
+
+ + +
+ + {#if showDeleteHeap} + + {/if} +
+ {/if} + +
+ + +
+
+
+
diff --git a/web/src/lib/components/timeline/BulkActionBar.svelte b/web/src/lib/components/timeline/BulkActionBar.svelte index 1df27b6..d758c2e 100644 --- a/web/src/lib/components/timeline/BulkActionBar.svelte +++ b/web/src/lib/components/timeline/BulkActionBar.svelte @@ -26,6 +26,7 @@ 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, @@ -455,6 +456,15 @@ Archive X + {/if}