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>
222 lines
8.0 KiB
TypeScript
222 lines
8.0 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { X, Folder, AlertCircle } from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import { sourceFolders, heaps as heapsApi, type Heap } from '../../services/api'
|
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
|
import { toast } from '../ToastContainer'
|
|
|
|
interface HeapConvertDialogProps {
|
|
heap: Heap | null
|
|
onClose: () => void
|
|
}
|
|
|
|
/**
|
|
* Modal that converts a heap into a folder. The user picks a target folder
|
|
* (any source root, today — sub-folder picking is a follow-up), chooses
|
|
* move vs copy semantics, and optionally has the heap deleted on success.
|
|
*/
|
|
export function HeapConvertDialog({ heap, onClose }: HeapConvertDialogProps) {
|
|
const queryClient = useQueryClient()
|
|
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'],
|
|
queryFn: sourceFolders.list,
|
|
enabled: !!heap,
|
|
})
|
|
const folders = foldersData?.folders ?? []
|
|
|
|
// Default to the first folder when the dialog opens or folders load.
|
|
useEffect(() => {
|
|
if (!targetId && folders.length > 0) {
|
|
setTargetId(folders[0].id)
|
|
}
|
|
}, [folders, targetId])
|
|
|
|
// Reset state on close, prefill subfolder name when opened.
|
|
useEffect(() => {
|
|
if (heap) {
|
|
setSubfolderName(heap.name)
|
|
} else {
|
|
setTargetId('')
|
|
setMode('move')
|
|
setDeleteHeap(false)
|
|
setSubfolderName('')
|
|
}
|
|
}, [heap])
|
|
|
|
const convertMutation = useMutation({
|
|
mutationFn: () =>
|
|
heapsApi.convert(heap!.id, {
|
|
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)
|
|
const verb = data.mode === 'move' ? 'Moved' : 'Copied'
|
|
toast.success(
|
|
`${verb} ${total} photo${total === 1 ? '' : 's'}`,
|
|
data.heap_deleted ? `Heap "${heap?.name}" deleted` : undefined
|
|
)
|
|
queryClient.invalidateQueries({ queryKey: HEAPS_QUERY_KEY })
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
|
onClose()
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Convert failed', e?.response?.data?.detail || e.message),
|
|
})
|
|
|
|
if (!heap) return null
|
|
|
|
const targetFolder = folders.find((f: any) => f.id === targetId)
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
|
|
|
<div className="relative z-10 w-full max-w-md rounded-lg border border-border bg-surface p-6 shadow-xl">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-lg font-semibold text-text">
|
|
Convert "{heap.name}" to folder
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
disabled={convertMutation.isPending}
|
|
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Target picker */}
|
|
<div className="mb-4">
|
|
<label className="mb-1 block text-xs text-text-muted">Target folder</label>
|
|
{folders.length === 0 ? (
|
|
<div className="rounded border border-border bg-bg px-3 py-2 text-xs text-text-muted">
|
|
No folders available
|
|
</div>
|
|
) : (
|
|
<select
|
|
value={targetId}
|
|
onChange={(e) => setTargetId(e.target.value)}
|
|
className="w-full rounded border border-border bg-bg px-2 py-1.5 text-sm text-text focus:border-primary focus:outline-none"
|
|
>
|
|
{folders.map((f: any) => (
|
|
<option key={f.id} value={f.id}>
|
|
{f.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
{targetFolder && (
|
|
<p className="mt-1 flex items-center gap-1 text-xs text-text-faint">
|
|
<Folder className="h-3 w-3" />
|
|
{targetFolder.path}
|
|
</p>
|
|
)}
|
|
</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>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => setMode('move')}
|
|
className={clsx(
|
|
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
|
|
mode === 'move'
|
|
? 'bg-primary text-white'
|
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
|
)}
|
|
>
|
|
Move
|
|
</button>
|
|
<button
|
|
onClick={() => setMode('copy')}
|
|
className={clsx(
|
|
'flex-1 rounded px-3 py-1.5 text-sm transition-colors',
|
|
mode === 'copy'
|
|
? 'bg-primary text-white'
|
|
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
|
)}
|
|
>
|
|
Copy
|
|
</button>
|
|
</div>
|
|
<p className="mt-1 text-xs text-text-faint">
|
|
{mode === 'move'
|
|
? 'Files are moved on disk; original photos update their folder.'
|
|
: 'Files are copied on disk; new photo records are created.'}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Delete heap toggle */}
|
|
<div className="mb-4 flex items-center gap-2">
|
|
<input
|
|
id="delete-heap"
|
|
type="checkbox"
|
|
checked={deleteHeap}
|
|
onChange={(e) => setDeleteHeap(e.target.checked)}
|
|
className="h-4 w-4 rounded border-border bg-bg text-primary focus:ring-2 focus:ring-primary focus:ring-offset-0"
|
|
/>
|
|
<label htmlFor="delete-heap" className="text-sm text-text">
|
|
Delete heap after conversion
|
|
</label>
|
|
</div>
|
|
|
|
{convertMutation.isError && (
|
|
<div className="mb-3 flex items-center gap-2 rounded bg-reject/10 p-3 text-sm text-reject">
|
|
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
|
<span>{(convertMutation.error as any)?.message || 'Conversion failed'}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={onClose}
|
|
disabled={convertMutation.isPending}
|
|
className="rounded bg-surface-2 px-4 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={() => convertMutation.mutate()}
|
|
disabled={!targetId || convertMutation.isPending}
|
|
className="rounded bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
|
|
>
|
|
{convertMutation.isPending ? 'Converting…' : 'Convert'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|