import { useState } from 'react' import { ChevronRight, ChevronDown, Folder, Image, Star, Trash2, MoreHorizontal, HardDrive, RefreshCw, Copy, Tag as TagIcon, } from 'lucide-react' import clsx from 'clsx' import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api' import { 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' import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' import { useTagsQuery } from '../../hooks/useTagsQuery' 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 [isScanning, setIsScanning] = useState(false) // Inline rename state for source-root rows. Stores the id being edited // and the draft name. Double-click a folder row to start. const [renamingId, setRenamingId] = useState(null) const [renameDraft, setRenameDraft] = useState('') 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 setDuplicates = useFilterStore((s) => s.setDuplicates) const setTagIds = useFilterStore((s) => s.setTagIds) const filterFolderId = useFilterStore((s) => s.folderId) const filterDuplicates = useFilterStore((s) => s.duplicates) const filterTagIds = useFilterStore((s) => s.tagIds) const { data: allTags = [] } = useTagsQuery() 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'), }) // Bulk copy mutation — Alt-drag uses this instead of move. const copyDropMutation = useMutation({ mutationFn: ({ targetId, photoIds }: { targetId: string; photoIds: string[] }) => photosApi.copy(photoIds, targetId), onSuccess: (data) => { const copied = data?.copied ?? 0 const errCount = data?.errors?.length ?? 0 if (copied > 0) { toast.success( 'Copied', `${copied} photo${copied > 1 ? 's' : ''}${errCount ? ` (${errCount} skipped)` : ''}` ) } else if (errCount > 0) { toast.error('Copy failed', `${errCount} file${errCount > 1 ? 's' : ''} could not be copied`) } queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['folders'] }) }, onError: (e: any) => toast.error('Copy 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 case 'duplicates': clearAllFilters() setDuplicates(true) break default: if (id.startsWith('tag-')) { // Tag rows: filter to that single tag. Multi-tag filtering is // available via the FilterBar. const tagId = id.slice('tag-'.length) clearAllFilters() setTagIds([tagId]) return } 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 the recursive folder tree (one root per active source root). const { data: folderTree = [] } = useFolderTreeQuery() const renameMutation = useMutation({ mutationFn: ({ id, name }: { id: string; name: string }) => sourceFolders.rename(id, name), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['folders'] }) queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] }) }, onError: (e: any) => toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'), }) // 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 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) } // Recursively map a backend FolderTreeNode into our generic TreeItem. const folderNodeToTreeItem = (node: FolderTreeNode): TreeItem => ({ id: `folder-${node.id}`, label: node.name, icon: , count: node.photo_count, type: 'folder', children: node.children.length > 0 ? node.children.map(folderNodeToTreeItem) : undefined, }) const libraryTree: TreeItem[] = [ { id: 'library', label: 'Library', icon: , children: [ { id: 'all-photos', label: 'All Photos', icon: , count: 0 }, { id: 'rated', label: 'Rated', icon: , count: 0 }, { id: 'duplicates', label: 'Duplicates', icon: , count: 0 }, { id: 'discarded', label: 'Discarded', icon: , count: 0 }, { id: 'tags', label: 'Tags', icon: , children: allTags.map((tag) => ({ id: `tag-${tag.id}`, label: tag.name, icon: , count: tag.photo_count, })), }, ], }, { id: 'folders', label: 'Folders', icon: , children: folderTree.map(folderNodeToTreeItem), }, ] // 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.startsWith('tag-')) { const tagId = id.slice('tag-'.length) return filterTagIds.length === 1 && filterTagIds[0] === tagId } if (id === 'all-photos') { return filterFolderId === null && selectedItem === 'all-photos' } if (id === 'duplicates') { return filterDuplicates } 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[], copy: boolean) => { if (id === 'discarded') { discardDropMutation.mutate(ids) return } if (id.startsWith('folder-')) { const targetId = id.slice('folder-'.length) if (copy) { copyDropMutation.mutate({ targetId, photoIds: ids }) } else { 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={() => { if (renamingId === item.id) return setSelectedItem(item.id) if (hasChildren) { toggleExpanded(item.id) } else { applyLibraryNode(item.id) } }} onDoubleClick={ item.id.startsWith('folder-') ? (e) => { e.stopPropagation() setRenamingId(item.id) setRenameDraft(item.label) } : undefined } onDragOver={acceptsDrop ? (e) => { if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) { e.preventDefault() // Alt held → copy (only meaningful for folder targets; // discarding doesn't copy). const wantCopy = e.altKey && item.id.startsWith('folder-') e.dataTransfer.dropEffect = wantCopy ? 'copy' : '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, e.altKey) } : undefined} > {/* Expand/Collapse Icon */} {hasChildren ? ( ) : (
)} {/* Item Icon */} {item.icon && ( {item.icon} )} {/* Label (or inline rename input for folder rows) */} {renamingId === item.id ? ( setRenameDraft(e.target.value)} onClick={(e) => e.stopPropagation()} onBlur={() => { const next = renameDraft.trim() const id = item.id.slice('folder-'.length) if (next && next !== item.label) { renameMutation.mutate({ id, name: next }) } setRenamingId(null) }} onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur() } else if (e.key === 'Escape') { setRenamingId(null) } }} className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none" /> ) : ( {item.label} )} {/* Count Badge */} {item.count !== undefined && item.count > 0 && ( {item.count} )}
{/* 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 */} {folderTree.length > 0 && (
)}
) }