import { useState } from 'react' import { ChevronRight, ChevronDown, Folder, FolderPlus, Image, Star, Trash2, HardDrive, Copy, Tag as TagIcon, Palette, MapPin, Layers2, MoreHorizontal, Pencil, Settings, Users, Eye, EyeOff, User as UserIcon, LogOut, Shield, Clock, Upload as UploadIcon, Download as DownloadIcon, } from 'lucide-react' import { DateRangePicker } from '../filter/DateRangePicker' import { Footer } from './Footer' import { cn } from '@/lib/utils' import { sourceFolders, photos as photosApi, downloads, 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' import { usePhotoStore } from '../../store/photoStore' import { stripPhotosFromCache } from '../../hooks/usePhotosQuery' import { useLibraryStatsQuery, LIBRARY_STATS_QUERY_KEY, } from '../../hooks/useLibraryStatsQuery' import { registerUndoable } from '../../store/undoStore' import { formatApiError } from '../../lib/apiError' import type { Photo } from '../../types/photo' import { DeleteFolderDialog } from '../dialogs/DeleteFolderDialog' import { ShareDialog } from '../sharing/ShareDialog' import { UploadModal } from '../upload/UploadModal' import { sharing as sharingApi } from '../../services/api' import { useSharedFoldersQuery, SHARED_FOLDERS_KEY, } from '../../hooks/useSharingQueries' import { NotificationBell } from '../sharing/NotificationBell' import { Avatar } from '../sharing/Avatar' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu' import { useAuth } from '../../contexts/AuthContext' import { useFeaturesQuery } from '../../hooks/useFeaturesQuery' import { useScanActivity } from '../../hooks/useScanActivity' import { Input } from '@/components/ui/input' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' interface TreeItem { id: string label: string icon?: React.ReactNode count?: number children?: TreeItem[] type?: 'folder' | 'heap' | 'special' /** For folder rows only: the user-set "hide from views" flag. Drives * the muted styling + eye-off badge + menu item label. */ isHidden?: boolean /** For folder rows only: filesystem path. Used to match against the * scan-status `current_folder` so we can show an inline spinner on * the row that's actively being scanned. */ path?: string } export function LeftSidebar() { const { user, isAdmin, logout } = useAuth() const scanActivity = useScanActivity() const [expandedItems, setExpandedItems] = useState>(new Set(['library', 'folders', 'heaps'])) // Library pane collapse. Its body holds Views, Folders, Shared, and // Heaps, so a single toggle hides the whole navigation area. // 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 { data: featuresMap } = useFeaturesQuery() const visionOn = featuresMap ? featuresMap['vision.enabled'] !== false : true const tagsOn = true const [dropTargetId, setDropTargetId] = useState(null) // Per-folder kebab menu open state. Stores the tree-item id ("folder-..." // or "folders" for the section header). Radix DropdownMenu handles // outside-click + Escape dismissal for us — we only track which row's // menu is open so the trigger stays visible while it's open (the // trigger is hover-hidden by default on non-hovered rows). const [openMenuId, setOpenMenuId] = useState(null) // "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) const [sharingFolder, setSharingFolder] = useState<{ id: string name: string } | null>(null) // Upload modal state. `uploadTarget` stores the pre-selected // destination Folder/SourceRoot id so "Upload here…" on a folder row // drops files straight into that folder; null means the general // Library-level button (defaults to the first source root). const [uploadTarget, setUploadTarget] = useState<{ open: boolean; folderId: string | null }>({ open: false, folderId: null, }) const { data: sharedFolders = [] } = useSharedFoldersQuery() // Bulk discard mutation for the drag-onto-Discarded interaction. const discardDropMutation = useMutation({ mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds), // Optimistically pull the dropped photos out of the timeline so the // grid reflows the moment the drop lands, instead of waiting for // the network round-trip + invalidation refetch. onMutate: (photoIds) => { usePhotoStore.getState().removePhotosFromTimeline(photoIds) stripPhotosFromCache(queryClient, 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', formatApiError(e)), }) // 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', formatApiError(e)), }) // 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', formatApiError(e)), }) // 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 'needs-review': navigateToSection('needs-review', { needsReview: true }) break case 'colors': navigateToSection('colors', { groupBy: 'color' }) break case 'map': navigateToSection('map', {}) break case 'memories': navigateToSection('memories', {}) 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', formatApiError(e)), }) 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', formatApiError(e)), }) // Toggle folder hide-from-views. Invalidates every query that could // include photos from the affected folder subtree — the sidebar // tree (for the counts + badge), the photos timeline, library // stats (sidebar badges), tags (count subquery), and duplicates // (source for dup groups). All of these respect the new flag on // the server side; the invalidation is just cache bust. const toggleHiddenMutation = useMutation({ mutationFn: ({ id, hidden }: { id: string; hidden: boolean }) => sourceFolders.setHidden(id, hidden), onSuccess: (data) => { toast.success( data.is_hidden ? 'Folder hidden' : 'Folder visible', data.is_hidden ? `${data.name} is now excluded from cross-cutting views` : `${data.name} is back in cross-cutting views` ) queryClient.invalidateQueries({ queryKey: ['folders'] }) queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] }) queryClient.invalidateQueries({ queryKey: ['photos'] }) queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY }) queryClient.invalidateQueries({ queryKey: ['tags'] }) }, onError: (e: any) => toast.error('Toggle failed', formatApiError(e)), }) 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', formatApiError(e)), }) 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', isHidden: node.is_hidden, path: node.path, children: node.children.length > 0 ? node.children.map(folderNodeToTreeItem) : undefined, }) // Total tag count for the badge on the Tags entry (user tags only). const userTags = allTags.filter((t) => t.kind === 'user') const tagsTotalCount = userTags.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 }, ...(tagsOn ? [{ id: 'tags', label: 'Tags', icon: , count: tagsTotalCount }] : []), ...(visionOn ? [{ id: 'needs-review', label: 'Needs Review', icon: , count: stats?.needs_review ?? 0 }] : []), { id: 'colors', label: 'Colors', icon: , count: stats?.colored ?? 0 }, { id: 'map', label: 'Map', icon: , count: stats?.with_gps ?? 0 }, { id: 'memories', label: 'Memories', icon: }, { 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. Hidden folders swap the folder * icon for an EyeOff so the user sees the state at a glance * without hunting through the kebab menu. */} {item.icon && !isSectionHeader && ( {item.isHidden ? : 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="h-6 flex-1 bg-bg px-1 text-[13px]" /> ) : ( )} {/* Activity spinner — shown on the FOLDERS section header * whenever any background scan/processing is happening, and * on a folder row whose path is the one currently being * scanned. Replaces the old bottom-right ScanProgress popup. */} {(() => { const showOnFolders = isSectionHeader && item.id === 'folders' && scanActivity.active const showOnFolderRow = scanActivity.isScanning && !!item.path && !!scanActivity.currentFolder && scanActivity.currentFolder.startsWith(item.path) if (!showOnFolders && !showOnFolderRow) return null return ( ) })()} {/* 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} ) : (
{/* Inline "create new sub-folder" input. Renders just below the * parent row when its create state is active. */} {item.id.startsWith('folder-') && creatingUnder === item.id.slice('folder-'.length) && (
setCreateDraft(e.target.value)} onClick={(e) => e.stopPropagation()} onKeyDown={(e) => { if (e.key === 'Enter') { const name = createDraft.trim() if (name) { createFolderMutation.mutate({ parentId: item.id.slice('folder-'.length), name, }) } } else if (e.key === 'Escape') { setCreatingUnder(null) setCreateDraft('') } }} onBlur={() => { // Don't auto-commit on blur — empty/escaped renames // close the input but don't fire the request. if (!createFolderMutation.isPending) { setCreatingUnder(null) setCreateDraft('') } }} className="flex-1 rounded border border-border bg-bg px-1 py-0 text-[13px] text-text focus:border-primary focus:outline-none" />
)} {/* Render Children */} {hasChildren && isExpanded && (
{item.children!.map((child) => renderTreeItem(child, depth + 1))}
)}
) } return (
{/* Date range calendar — always visible, collapsible. The * from/to range inputs live on the top filter bar; this block * just hosts the calendar visualisation and click-to-jump. */} {/* Library — the sole navigation section. Contains Views, * Folders, Shared-with-me, and Heaps in a single scroll area. */}
Library
{libraryTree.map((item) => renderTreeItem(item))} {/* Shared with me — folders shared by other users. Each row * shows the owner's avatar bubble (same hash-tinted palette * as ShareDialog + the notification bell) and an Eye/Pencil * icon for permission, so the vocabulary stays consistent * across every sharing surface. */} {sharedFolders.length > 0 && (
Shared with me
{sharedFolders.map((sf) => { const isSelected = currentSection === `folder-${sf.id}` const PermissionIcon = sf.permission === 'write' ? Pencil : Eye return (
navigateToSection(`folder-${sf.id}`, { folderId: sf.id }) } > {sf.name} {sf.photo_count > 0 && ( {sf.photo_count} )}
navigateToSection(`folder-${sf.id}`, { folderId: sf.id }) } > Open { try { await sharingApi.revokeFolderShare(sf.id, sf.share_id) queryClient.invalidateQueries({ queryKey: SHARED_FOLDERS_KEY }) toast.success(`Left ${sf.name}`) } catch (err) { toast.error('Could not leave', formatApiError(err)) } }} > Leave
) })}
)} {/* Heaps — nested inside the Library section. */}
{/* Bottom panel — user identity + settings, pinned below the tree. */}
{/* User row */}
{user?.username} {isAdmin && ( )} {/* Share-invite bell — click opens a popover listing any * pending invites this user has. Sits here (rather than the * TopBar) per the user's preference to keep the identity * controls grouped. */}
{/* Settings — admin only, navigates to the settings section */} {isAdmin && ( )}
setDeletingFolder(null)} onConfirm={(mode) => { if (deletingFolder) { deleteFolderMutation.mutate({ id: deletingFolder.id, mode }) } }} /> setSharingFolder(null)} /> setUploadTarget({ open: false, folderId: null })} />
) } /** Always-visible calendar block at the top of the left sidebar. * Drives the same global dateFrom/dateTo filter-store fields as the * topbar Date pill, and visualises photo density per day. */ function DateFilterSection() { const dateFrom = useFilterStore((s) => s.dateFrom) const dateTo = useFilterStore((s) => s.dateTo) const setDateFrom = useFilterStore((s) => s.setDateFrom) const setDateTo = useFilterStore((s) => s.setDateTo) return (
) }