Bulk-move via drag-and-drop. Drop a photo (or multi-selection) on
any folder row in the LeftSidebar and the files move on disk +
photo.folder_id updates atomically.
Backend
- New POST /photos/move accepting { photo_ids, target_id }. The
target_id can be either a Folder id OR a SourceRoot id (the
sidebar exposes source roots today, so the same drag target
needs to resolve either).
- Resolves source roots to their on-disk path and looks up / creates
the canonical Folder row via the existing scan get_or_create_folder
helper, so dedupe + path normalization stay consistent with the
scanner.
- Per-photo loop with shutil.move; per-file failures (target name
collision, missing source, OS error) are collected into a
structured `errors` array and don't abort the batch.
- Skips photos that are already in the target folder so re-drops
are a no-op.
Frontend
- New photos.move(ids, targetId) helper in api.ts.
- LeftSidebar grows a moveDropMutation alongside the existing
discard one. handleDrop dispatches by id prefix:
'discarded' → discard, 'folder-{id}' → move.
- Folder rows now report acceptsDrop and get the same drag-over
highlight as heap drops, in primary tint instead of reject.
- onSuccess invalidates both the photos query and the folders
query so the new folder counts in the sidebar refresh.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
394 lines
13 KiB
TypeScript
394 lines
13 KiB
TypeScript
import { useState } from 'react'
|
|
import {
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Folder,
|
|
Image,
|
|
Calendar,
|
|
Star,
|
|
Trash2,
|
|
Plus,
|
|
MoreHorizontal,
|
|
HardDrive,
|
|
RefreshCw,
|
|
} from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
import { AddSourceFolderDialog } from '../dialogs/AddSourceFolderDialog'
|
|
import { sourceFolders, library, photos as photosApi } from '../../services/api'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { toast } from '../ToastContainer'
|
|
import { useFilterStore } from '../../store/filterStore'
|
|
import { HeapsPanel } from '../heaps/HeapsPanel'
|
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
|
|
|
interface TreeItem {
|
|
id: string
|
|
label: string
|
|
icon?: React.ReactNode
|
|
count?: number
|
|
children?: TreeItem[]
|
|
type?: 'folder' | 'heap' | 'special'
|
|
}
|
|
|
|
export function LeftSidebar() {
|
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
|
const [selectedItem, setSelectedItem] = useState<string | null>('all-photos')
|
|
const [showAddFolderDialog, setShowAddFolderDialog] = useState(false)
|
|
const [isScanning, setIsScanning] = useState(false)
|
|
|
|
const queryClient = useQueryClient()
|
|
const clearAllFilters = useFilterStore((s) => s.clearAll)
|
|
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
|
const setFlag = useFilterStore((s) => s.setFlag)
|
|
const setFolderId = useFilterStore((s) => s.setFolderId)
|
|
const filterFolderId = useFilterStore((s) => s.folderId)
|
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null)
|
|
|
|
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
|
const discardDropMutation = useMutation({
|
|
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
|
|
onSuccess: (_data, photoIds) => {
|
|
toast.success(
|
|
'Discarded',
|
|
`${photoIds.length} photo${photoIds.length > 1 ? 's' : ''}`
|
|
)
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Discard failed', e?.message || 'Unknown error'),
|
|
})
|
|
|
|
// Bulk move mutation for the drag-onto-folder interaction.
|
|
const moveDropMutation = useMutation({
|
|
mutationFn: ({ targetId, photoIds }: { targetId: string; photoIds: string[] }) =>
|
|
photosApi.move(photoIds, targetId),
|
|
onSuccess: (data) => {
|
|
const moved = data?.moved ?? 0
|
|
const errCount = (data?.errors?.length ?? 0)
|
|
if (moved > 0) {
|
|
toast.success(
|
|
'Moved',
|
|
`${moved} photo${moved > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}`
|
|
)
|
|
} else if (errCount > 0) {
|
|
toast.error('Move failed', `${errCount} file${errCount > 1 ? 's' : ''} could not be moved`)
|
|
}
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
|
},
|
|
onError: (e: any) =>
|
|
toast.error('Move failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
|
})
|
|
|
|
// Reads the dragged ids out of a drop event payload.
|
|
const readDragIds = (e: React.DragEvent): string[] | null => {
|
|
const raw = e.dataTransfer.getData(PHOTO_DRAG_MIME)
|
|
if (!raw) return null
|
|
try {
|
|
const parsed = JSON.parse(raw) as string[]
|
|
return Array.isArray(parsed) && parsed.length > 0 ? parsed : null
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Map a library tree id to a filter-store mutation. Each "virtual node" in
|
|
// the library tree is just a saved filter preset.
|
|
const applyLibraryNode = (id: string) => {
|
|
switch (id) {
|
|
case 'all-photos':
|
|
clearAllFilters()
|
|
break
|
|
case 'rated':
|
|
clearAllFilters()
|
|
setRatingMin(1)
|
|
break
|
|
case 'discarded':
|
|
clearAllFilters()
|
|
setFlag('discarded')
|
|
break
|
|
default:
|
|
if (id.startsWith('folder-')) {
|
|
// Folder rows: filter to that folder, clear other filters that
|
|
// would compete (heap, discarded, etc.) so the user sees what they
|
|
// expect when they click a folder.
|
|
const folderId = id.slice('folder-'.length)
|
|
clearAllFilters()
|
|
setFolderId(folderId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fetch folders from API
|
|
const { data: foldersData, refetch: refetchFolders } = useQuery({
|
|
queryKey: ['folders'],
|
|
queryFn: sourceFolders.list,
|
|
})
|
|
|
|
// Mutation for adding folders
|
|
const addFolderMutation = useMutation({
|
|
mutationFn: async ({ path, recursive }: { path: string; recursive: boolean }) => {
|
|
// Add the folder
|
|
const folder = await sourceFolders.add(path, recursive)
|
|
// Trigger scan for the new folder
|
|
await sourceFolders.scan(folder.id)
|
|
return folder
|
|
},
|
|
onSuccess: (folder) => {
|
|
toast.success('Folder Added', `Scanning ${folder.name || folder.path}...`)
|
|
// Refetch folders list
|
|
refetchFolders()
|
|
// Refetch photos to show new ones
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error('Failed to Add Folder', error.message || 'An error occurred')
|
|
},
|
|
})
|
|
|
|
// Mutation for scanning all folders
|
|
const scanLibraryMutation = useMutation({
|
|
mutationFn: library.scan,
|
|
onMutate: () => {
|
|
setIsScanning(true)
|
|
toast.info('Scan Started', 'Scanning all folders for new photos...')
|
|
},
|
|
onSuccess: () => {
|
|
toast.success('Scan Complete', 'All folders have been scanned')
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error('Scan Failed', error.message || 'Failed to scan folders')
|
|
},
|
|
onSettled: () => {
|
|
setIsScanning(false)
|
|
// Refetch photos after scan
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
},
|
|
})
|
|
|
|
const handleAddFolder = async (path: string, recursive: boolean) => {
|
|
await addFolderMutation.mutateAsync({ path, recursive })
|
|
}
|
|
|
|
const handleScanAll = () => {
|
|
scanLibraryMutation.mutate()
|
|
}
|
|
|
|
const toggleExpanded = (id: string) => {
|
|
const newExpanded = new Set(expandedItems)
|
|
if (newExpanded.has(id)) {
|
|
newExpanded.delete(id)
|
|
} else {
|
|
newExpanded.add(id)
|
|
}
|
|
setExpandedItems(newExpanded)
|
|
}
|
|
|
|
const libraryTree: TreeItem[] = [
|
|
{
|
|
id: 'library',
|
|
label: 'Library',
|
|
icon: <HardDrive className="h-4 w-4" />,
|
|
children: [
|
|
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
|
|
{ id: 'by-date', label: 'By Date', icon: <Calendar className="h-4 w-4" /> },
|
|
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
|
|
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
|
|
],
|
|
},
|
|
{
|
|
id: 'folders',
|
|
label: 'Folders',
|
|
icon: <Folder className="h-4 w-4" />,
|
|
children: foldersData?.folders?.map((folder: any) => ({
|
|
id: `folder-${folder.id}`,
|
|
label: folder.name || folder.path.split('/').pop() || folder.path,
|
|
icon: <Folder className="h-4 w-4" />,
|
|
count: folder.photo_count,
|
|
type: 'folder',
|
|
})) || [],
|
|
},
|
|
]
|
|
|
|
// Derive whether a tree item is currently the "active" filter target.
|
|
// Folder rows are selected when the filter store's folderId matches; the
|
|
// library "All Photos" virtual node is selected when no folder/heap filter
|
|
// is set.
|
|
const isItemActive = (id: string): boolean => {
|
|
if (id.startsWith('folder-')) {
|
|
return filterFolderId === id.slice('folder-'.length)
|
|
}
|
|
if (id === 'all-photos') {
|
|
return filterFolderId === null && selectedItem === 'all-photos'
|
|
}
|
|
return selectedItem === id
|
|
}
|
|
|
|
// Which tree items accept photo drops, and what each does on drop.
|
|
const isDropTarget = (id: string): boolean => {
|
|
return id === 'discarded' || id.startsWith('folder-')
|
|
}
|
|
|
|
const handleDrop = (id: string, ids: string[]) => {
|
|
if (id === 'discarded') {
|
|
discardDropMutation.mutate(ids)
|
|
return
|
|
}
|
|
if (id.startsWith('folder-')) {
|
|
const targetId = id.slice('folder-'.length)
|
|
moveDropMutation.mutate({ targetId, photoIds: ids })
|
|
}
|
|
}
|
|
|
|
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
|
const hasChildren = item.children && item.children.length > 0
|
|
const isExpanded = expandedItems.has(item.id)
|
|
const isSelected = isItemActive(item.id)
|
|
const acceptsDrop = isDropTarget(item.id)
|
|
const isDropHover = dropTargetId === item.id
|
|
|
|
return (
|
|
<div key={item.id}>
|
|
<div
|
|
className={clsx(
|
|
'group flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-sm',
|
|
isSelected ? 'bg-primary/20 text-primary' : 'text-text hover:bg-surface-2',
|
|
isDropHover && (item.id === 'discarded'
|
|
? 'ring-2 ring-reject bg-reject/10'
|
|
: 'ring-2 ring-primary bg-primary/10'),
|
|
depth > 0 && 'text-[13px]'
|
|
)}
|
|
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
|
onClick={() => {
|
|
setSelectedItem(item.id)
|
|
if (hasChildren) {
|
|
toggleExpanded(item.id)
|
|
} else {
|
|
applyLibraryNode(item.id)
|
|
}
|
|
}}
|
|
onDragOver={acceptsDrop ? (e) => {
|
|
if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) {
|
|
e.preventDefault()
|
|
e.dataTransfer.dropEffect = item.id === 'discarded' ? 'move' : 'move'
|
|
if (dropTargetId !== item.id) setDropTargetId(item.id)
|
|
}
|
|
} : undefined}
|
|
onDragLeave={acceptsDrop ? (e) => {
|
|
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
|
|
if (dropTargetId === item.id) setDropTargetId(null)
|
|
}
|
|
} : undefined}
|
|
onDrop={acceptsDrop ? (e) => {
|
|
e.preventDefault()
|
|
setDropTargetId(null)
|
|
const ids = readDragIds(e)
|
|
if (ids) handleDrop(item.id, ids)
|
|
} : undefined}
|
|
>
|
|
{/* Expand/Collapse Icon */}
|
|
{hasChildren ? (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
toggleExpanded(item.id)
|
|
}}
|
|
className="rounded p-0.5 hover:bg-surface-offset"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-3 w-3" />
|
|
) : (
|
|
<ChevronRight className="h-3 w-3" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<div className="w-4" />
|
|
)}
|
|
|
|
{/* Item Icon */}
|
|
{item.icon && (
|
|
<span className={clsx('flex-shrink-0', isSelected ? 'text-primary' : 'text-text-muted')}>
|
|
{item.icon}
|
|
</span>
|
|
)}
|
|
|
|
{/* Label */}
|
|
<span className="flex-1 truncate">{item.label}</span>
|
|
|
|
{/* Count Badge */}
|
|
{item.count !== undefined && item.count > 0 && (
|
|
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
|
|
{item.count}
|
|
</span>
|
|
)}
|
|
|
|
{/* Actions (shown on hover) */}
|
|
{(item.id === 'folders' || item.id === 'heaps') && (
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
// Handle add folder/heap
|
|
}}
|
|
className="invisible rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text group-hover:visible"
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Render Children */}
|
|
{hasChildren && isExpanded && (
|
|
<div>
|
|
{item.children!.map((child) => renderTreeItem(child, depth + 1))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col bg-surface">
|
|
{/* Sidebar Header */}
|
|
<div className="flex items-center justify-between border-b border-border px-3 py-2">
|
|
<h2 className="text-sm font-semibold text-text">Library</h2>
|
|
<button className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text">
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tree View */}
|
|
<div className="flex-1 overflow-y-auto py-2">
|
|
{libraryTree.map((item) => renderTreeItem(item))}
|
|
<HeapsPanel />
|
|
</div>
|
|
|
|
{/* Bottom Actions */}
|
|
<div className="border-t border-border p-3 space-y-2">
|
|
<button
|
|
onClick={() => setShowAddFolderDialog(true)}
|
|
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Source Folder
|
|
</button>
|
|
{foldersData?.folders?.length > 0 && (
|
|
<button
|
|
onClick={handleScanAll}
|
|
disabled={isScanning}
|
|
className="flex w-full items-center gap-2 rounded bg-surface-2 px-3 py-2 text-sm text-text hover:bg-surface-offset disabled:opacity-50"
|
|
>
|
|
<RefreshCw className={clsx("h-4 w-4", isScanning && "animate-spin")} />
|
|
{isScanning ? 'Scanning...' : 'Scan All Folders'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add Source Folder Dialog */}
|
|
<AddSourceFolderDialog
|
|
isOpen={showAddFolderDialog}
|
|
onClose={() => setShowAddFolderDialog(false)}
|
|
onAdd={handleAddFolder}
|
|
/>
|
|
</div>
|
|
)
|
|
} |