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:
2026-04-08 11:31:14 +02:00
parent bed817d274
commit bb7c2b12d6
3 changed files with 77 additions and 12 deletions

View File

@@ -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>