feat(move): "move to folder" for grid selections, folders, and m shortcut

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 00:10:19 +02:00
parent 5be6fd9047
commit b2b6060872
12 changed files with 725 additions and 383 deletions

View File

@@ -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 @@
<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 +250,7 @@
{onRename}
{onDelete}
{onCreateChild}
{onMove}
{readonly}
{selectedPath}
{counts}