- HeapConvertDialog: switch the target picker from sourceFolders.list (top-level source roots only) to useFolderTreeQuery, flattened depth-first into a list with depth info. Each option is indented with non-breaking spaces so nested subfolders read as a tree in the native dropdown. Backend already accepts any Folder id, so no server change needed. - photoStore.setVisiblePhotoIds: short-circuit when the new id list matches the existing one element-for-element. Avoids feedback loops if a publisher fires from an effect on a render where the contents haven't actually changed (which was triggering React error #185). - Timeline: pull setVisiblePhotoIds via a focused selector instead of the wholesale destructure so the publisher subscription doesn't re-render Timeline on unrelated photo store changes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
248 lines
8.9 KiB
TypeScript
248 lines
8.9 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react'
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { X, Folder, AlertCircle } from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import {
|
|
heaps as heapsApi,
|
|
type Heap,
|
|
type FolderTreeNode,
|
|
} from '../../services/api'
|
|
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
|
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
|
import { toast } from '../ToastContainer'
|
|
|
|
interface FlatFolder {
|
|
id: string
|
|
name: string
|
|
path: string
|
|
depth: number
|
|
}
|
|
|
|
/** Walk the folder tree depth-first into a flat list with depth info so
|
|
* the picker can render every node — including nested subfolders — as
|
|
* one indented option. */
|
|
function flattenTree(nodes: FolderTreeNode[], depth = 0): FlatFolder[] {
|
|
const out: FlatFolder[] = []
|
|
for (const n of nodes) {
|
|
out.push({ id: n.id, name: n.name, path: n.path, depth })
|
|
if (n.children.length > 0) {
|
|
out.push(...flattenTree(n.children, depth + 1))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
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('')
|
|
|
|
// Use the recursive folder tree, not the flat source-root list, so the
|
|
// user can pick a sub-folder at any depth as the target.
|
|
const { data: tree = [] } = useFolderTreeQuery()
|
|
const folders = useMemo<FlatFolder[]>(() => flattenTree(tree), [tree])
|
|
|
|
// 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) => 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) => (
|
|
<option key={f.id} value={f.id}>
|
|
{/* Two non-breaking spaces per depth so nested
|
|
* subfolders read as a tree in the native dropdown. */}
|
|
{'\u00A0\u00A0'.repeat(f.depth) + 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>
|
|
)
|
|
}
|