import { useEffect, useRef, useState } from 'react' import { ChevronRight, ChevronDown, Folder, FolderPlus, Image, Star, Trash2, HardDrive, Copy, Tag as TagIcon, Palette, Layers2, MoreHorizontal, Pencil, PanelLeftClose, Settings, } from 'lucide-react' import clsx from 'clsx' import { sourceFolders, 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 { ActiveHeapCard } from '../heaps/ActiveHeapCard' import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail' import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery' import { useTagsQuery } from '../../hooks/useTagsQuery' import { useLibraryStatsQuery, LIBRARY_STATS_QUERY_KEY, } from '../../hooks/useLibraryStatsQuery' import { registerUndoable } from '../../store/undoStore' import type { Photo } from '../../types/photo' import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog' interface TreeItem { id: string label: string icon?: React.ReactNode count?: number children?: TreeItem[] type?: 'folder' | 'heap' | 'special' } interface LeftSidebarProps { onCollapse: () => void onOpenSettings: () => void } export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) { const [expandedItems, setExpandedItems] = useState>(new Set(['library', 'folders', 'heaps'])) // 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 navigateToSection = useFilterStore((s) => s.navigateToSection) const currentSection = useFilterStore((s) => s.currentSection) const { data: allTags = [] } = useTagsQuery() const { data: stats } = useLibraryStatsQuery() const [dropTargetId, setDropTargetId] = useState(null) // Per-folder kebab menu open state. Stores the tree-item id ("folder-..." // or "folders" for the section header). Outside-click + Escape close. const [openMenuId, setOpenMenuId] = useState(null) const menuRef = useRef(null) useEffect(() => { if (!openMenuId) return const onDown = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { setOpenMenuId(null) } } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpenMenuId(null) } document.addEventListener('mousedown', onDown) document.addEventListener('keydown', onKey) return () => { document.removeEventListener('mousedown', onDown) document.removeEventListener('keydown', onKey) } }, [openMenuId]) // "Create new folder under {parent}" inline state. parentId is the // Folder.id (no "folder-" prefix). const [creatingUnder, setCreatingUnder] = useState(null) const [createDraft, setCreateDraft] = useState('') // Folder being deleted, drives the DeleteFolderDialog mounted below. const [deletingFolder, setDeletingFolder] = useState<{ id: string name: string photoCount?: number } | null>(null) // Bulk discard mutation for the drag-onto-Discarded interaction. const discardDropMutation = useMutation({ mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds), onSuccess: (_data, photoIds) => { registerUndoable( `Discarded ${photoIds.length} photo${photoIds.length === 1 ? '' : 's'}`, async () => { await photosApi.bulkRestore(photoIds) queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) } ) queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) }, onError: (e: any) => toast.error('Discard failed', e?.message || 'Unknown error'), }) // Bulk move mutation for the drag-onto-folder interaction. The mutation // captures each photo's source folder before issuing the move so the // undo path can put them back exactly where they came from (different // sources end up in different undo subgroups). const moveDropMutation = useMutation({ mutationFn: async ({ targetId, photoIds, }: { targetId: string photoIds: string[] }) => { // Snapshot per-photo source folder ids from the photos cache. We // walk every cached ['photos', ...] entry because the user could // be in any section / filter combination, and we don't know the // exact key offhand. const sourceMap = new Map() const photoCaches = queryClient.getQueriesData({ queryKey: ['photos'] }) for (const [, list] of photoCaches) { if (!list) continue for (const p of list) { if (photoIds.includes(p.id) && p.folder_id && !sourceMap.has(p.id)) { sourceMap.set(p.id, p.folder_id) } } } const result = await photosApi.move(photoIds, targetId) return { result, sourceMap } }, onSuccess: ({ result, sourceMap }) => { const moved = result?.moved ?? 0 const errCount = result?.errors?.length ?? 0 if (moved > 0) { // Group photos by their source folder so we can issue one move // call per group when undoing. Photos whose source folder we // couldn't recover get dropped from the undo (they'll just stay // where the move put them). const groups = new Map() for (const [photoId, src] of sourceMap.entries()) { const arr = groups.get(src) ?? [] arr.push(photoId) groups.set(src, arr) } if (groups.size > 0) { registerUndoable( `Moved ${moved} photo${moved === 1 ? '' : 's'}`, async () => { for (const [src, ids] of groups.entries()) { await photosApi.move(ids, src) } queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: ['folders'] }) } ) } else { 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 section navigation. Each "virtual node" in // the library tree is its own section, with its own remembered filter // state. The preset is the section's intrinsic filter (the thing that // makes it that section); user-added filters from the FilterBar layer // on top and are saved when the user navigates away. const applyLibraryNode = (id: string) => { switch (id) { case 'all-photos': navigateToSection('all-photos', {}) break case 'rated': navigateToSection('rated', { ratingMin: 1, groupBy: 'rating' }) break case 'discarded': navigateToSection('discarded', { flag: 'discarded' }) break case 'duplicates': navigateToSection('duplicates', { duplicates: true }) break case 'tags': navigateToSection('tags', { groupBy: 'tag' }) break case 'colors': navigateToSection('colors', { groupBy: 'color' }) break default: if (id.startsWith('folder-')) { const folderId = id.slice('folder-'.length) navigateToSection(`folder-${folderId}`, { 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'] }) queryClient.invalidateQueries({ queryKey: ['photos'] }) }, onError: (e: any) => toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'), }) const createFolderMutation = useMutation({ mutationFn: ({ parentId, name }: { parentId: string; name: string }) => sourceFolders.create(parentId, name), onSuccess: (data) => { toast.success('Folder created', data.name) queryClient.invalidateQueries({ queryKey: ['folders'] }) queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] }) setCreatingUnder(null) setCreateDraft('') }, onError: (e: any) => toast.error('Create failed', e?.response?.data?.detail || e.message || 'Unknown error'), }) const deleteFolderMutation = useMutation({ mutationFn: ({ id, mode }: { id: string; mode: 'discard' | 'permanent' }) => sourceFolders.delete(id, mode), onSuccess: (data) => { if (data.mode === 'discard') { toast.success( 'Folder photos discarded', `${data.discarded ?? 0} moved to discard pile` ) } else { toast.success( 'Folder deleted', `${data.deleted_photos ?? 0} photos removed from disk` ) } queryClient.invalidateQueries({ queryKey: ['folders'] }) queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] }) queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) // If we were viewing the deleted folder, snap back to all-photos. if (deletingFolder && currentSection === `folder-${deletingFolder.id}`) { navigateToSection('all-photos', {}) } setDeletingFolder(null) }, onError: (e: any) => toast.error('Delete failed', e?.response?.data?.detail || e.message || 'Unknown error'), }) 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, }) // Total tag count for the badge on the Tags entry. const tagsTotalCount = allTags.reduce((sum, t) => sum + (t.photo_count || 0), 0) const libraryTree: TreeItem[] = [ { id: 'library', label: 'Views', icon: , children: [ { id: 'all-photos', label: 'All Photos', icon: , count: stats?.all_photos ?? 0 }, { id: 'rated', label: 'Rated', icon: , count: stats?.rated ?? 0 }, { id: 'tags', label: 'Tags', icon: , count: tagsTotalCount }, { id: 'colors', label: 'Colors', icon: , count: stats?.colored ?? 0 }, { id: 'duplicates', label: 'Duplicates', icon: , count: stats?.duplicates ?? 0 }, { id: 'discarded', label: 'Discarded', icon: , count: stats?.discarded ?? 0 }, ], }, { 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. // Active highlight is now driven entirely by currentSection. Each // library node and folder row maps 1:1 to a section id. const isItemActive = (id: string): boolean => { if (id.startsWith('folder-')) { return currentSection === id } return currentSection === 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 // Top-level entries (Views, Folders) render as small uppercase // section eyebrows rather than another tree row, so the panel reads // as distinct sections with the actual items beneath them. const isSectionHeader = depth === 0 return (
{ if (renamingId === item.id) return // Folder rows are always filterable, parent or leaf — clicking // anywhere on the row applies the filter and the chevron // (separate button below) handles expansion. Other group // headers (Library, Folders) just toggle expansion since // they have no associated section. if (item.id.startsWith('folder-')) { applyLibraryNode(item.id) } else 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 ? ( ) : ( !isSectionHeader &&
)} {/* Item Icon — section headers drop their icon in favor of the * uppercase eyebrow label. */} {item.icon && !isSectionHeader && ( {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 — fixed-width slot so counts line up in a column * across rows regardless of digit count. Section headers skip * the badge entirely (they're labels, not navigable rows). */} {!isSectionHeader && ( item.count !== undefined && item.count > 0 ? ( {item.count} ) : (
) } return (
{/* Header with collapse button. Matches the right sidebar header * so both panels have symmetric affordances. */}

Library

{/* Active heap card — pinned just below the Library header so * toasts (bottom-left fixed) can't cover it. Returns null when * no heap is active, so the layout collapses cleanly. */} {/* Tree View */}
{libraryTree.map((item) => renderTreeItem(item))}
{/* Settings entry point — pinned to the bottom of the panel so it * sits out of the way of the library tree but is always reachable. */}
setDeletingFolder(null)} onConfirm={(mode) => { if (deletingFolder) { deleteFolderMutation.mutate({ id: deletingFolder.id, mode }) } }} />
) } function FolderMenuItem({ icon, label, onClick, destructive = false, }: { icon: React.ReactNode label: string onClick: () => void destructive?: boolean }) { return ( ) }