feat: heap convert can create a subfolder under the target
The previous /heaps/{id}/convert dropped photos directly into a chosen
source root, which is rarely what you want — Lightroom-style behaviour
is "make a folder named after the collection inside the library".
Now the dialog lets you do that.
Backend
- HeapConvertBody gains an optional subfolder_name field. Path
separators and dot-segments are rejected. When set, the handler
joins it onto the resolved parent_dir, mkdir's it if missing, and
uses the resulting path as the move/copy destination. Otherwise
the parent_dir itself is used (unchanged behaviour).
- The Folder DB row for the destination is created via the existing
scanner get_or_create_folder helper so dedupe + path normalization
stay consistent across the codebase.
- The target source root id is propagated through both the source-
root and folder branches so the new Folder row is correctly
parented when subfolder_name is set on a folder target too.
Frontend
- HeapConvertDialog grows a "Subfolder name" input that prefills
with the heap name when the dialog opens. Trimmed empty value
drops directly into the parent. A live hint below the input
shows exactly which path will be created (or that the parent
will be used).
- api.ts heaps.convert() signature accepts an optional
subfolder_name field; the dialog sends it via mutationFn.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
|
||||
const [targetId, setTargetId] = useState('')
|
||||
const [mode, setMode] = useState<'move' | 'copy'>('move')
|
||||
const [deleteHeap, setDeleteHeap] = useState(false)
|
||||
const [subfolderName, setSubfolderName] = useState('')
|
||||
|
||||
const { data: foldersData } = useQuery({
|
||||
queryKey: ['folders'],
|
||||
@@ -36,12 +37,15 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
|
||||
}
|
||||
}, [folders, targetId])
|
||||
|
||||
// Reset state on close.
|
||||
// Reset state on close, prefill subfolder name when opened.
|
||||
useEffect(() => {
|
||||
if (!heap) {
|
||||
if (heap) {
|
||||
setSubfolderName(heap.name)
|
||||
} else {
|
||||
setTargetId('')
|
||||
setMode('move')
|
||||
setDeleteHeap(false)
|
||||
setSubfolderName('')
|
||||
}
|
||||
}, [heap])
|
||||
|
||||
@@ -51,6 +55,9 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
|
||||
target_id: targetId,
|
||||
mode,
|
||||
delete_heap: deleteHeap,
|
||||
// Empty subfolder = drop directly into the parent. Trim and only
|
||||
// send if the user kept it populated.
|
||||
subfolder_name: subfolderName.trim() || null,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
const total = (data.moved ?? 0) + (data.copied ?? 0)
|
||||
@@ -118,6 +125,25 @@ export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subfolder name */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-1 block text-xs text-text-muted">
|
||||
Subfolder name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subfolderName}
|
||||
onChange={(e) => setSubfolderName(e.target.value)}
|
||||
placeholder="(none — use parent directly)"
|
||||
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text placeholder-text-faint focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-faint">
|
||||
{subfolderName.trim() && targetFolder
|
||||
? `Will create ${targetFolder.path}/${subfolderName.trim()} if missing.`
|
||||
: 'Photos go directly into the parent folder.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Mode toggle */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-1 block text-xs text-text-muted">Mode</label>
|
||||
|
||||
@@ -190,10 +190,16 @@ export const heaps = {
|
||||
},
|
||||
|
||||
/** Convert a heap into a folder by moving (or copying) every member
|
||||
* photo into the target directory. */
|
||||
* photo into the target directory. Optionally creates a subfolder
|
||||
* inside the target by name. */
|
||||
convert: async (
|
||||
heapId: string,
|
||||
body: { target_id: string; mode: 'move' | 'copy'; delete_heap: boolean }
|
||||
body: {
|
||||
target_id: string
|
||||
mode: 'move' | 'copy'
|
||||
delete_heap: boolean
|
||||
subfolder_name?: string | null
|
||||
}
|
||||
) => {
|
||||
const response = await api.post(`/heaps/${heapId}/convert`, body)
|
||||
return response.data as {
|
||||
|
||||
Reference in New Issue
Block a user