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>(new Set(['library', 'folders', 'heaps'])) const [selectedItem, setSelectedItem] = useState('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(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: , children: [ { id: 'all-photos', label: 'All Photos', icon: , count: 0 }, { id: 'by-date', label: 'By Date', icon: }, { id: 'rated', label: 'Rated', icon: , count: 0 }, { id: 'discarded', label: 'Discarded', icon: , count: 0 }, ], }, { id: 'folders', label: 'Folders', icon: , children: foldersData?.folders?.map((folder: any) => ({ id: `folder-${folder.id}`, label: folder.name || folder.path.split('/').pop() || folder.path, icon: , 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 (
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 ? ( ) : (
)} {/* Item Icon */} {item.icon && ( {item.icon} )} {/* Label */} {item.label} {/* Count Badge */} {item.count !== undefined && item.count > 0 && ( {item.count} )} {/* Actions (shown on hover) */} {(item.id === 'folders' || item.id === 'heaps') && ( )}
{/* Render Children */} {hasChildren && isExpanded && (
{item.children!.map((child) => renderTreeItem(child, depth + 1))}
)}
) } return (
{/* Sidebar Header */}

Library

{/* Tree View */}
{libraryTree.map((item) => renderTreeItem(item))}
{/* Bottom Actions */}
{foldersData?.folders?.length > 0 && ( )}
{/* Add Source Folder Dialog */} setShowAddFolderDialog(false)} onAdd={handleAddFolder} />
) }