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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
} 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 <HeapConvertDialog>; the dialog clears it on close.
|
||||
let convertingHeap = $state<PpAlbum | null>(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}
|
||||
</div>
|
||||
@@ -653,7 +651,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…
|
||||
@@ -875,7 +873,6 @@
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<HeapConvertDialog heap={convertingHeap} onClose={() => (convertingHeap = null)} />
|
||||
<SettingsDialog open={settingsOpen} onClose={() => (settingsOpen = false)} />
|
||||
<GeneralSettingsDialog
|
||||
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 { 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
|
||||
<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"
|
||||
|
||||
Reference in New Issue
Block a user