The previous count-alignment fix used invisible group-hover:visible for
hover-only buttons, but invisible still reserves layout space. Folder
rows had a permanent kebab slot that non-folder rows didn't, and active
heap rows had a Target indicator before the count — both shifted their
counts left of the rest. The result was visually misaligned counts.
- LeftSidebar folder kebab + HeapsPanel kebab/set-active: switch to
hidden group-hover:block so the slot occupies zero width in the
resting state. Counts now sit at the same right edge across folder,
non-folder, and heap rows.
- HeapsPanel: drop the standalone Target indicator from active heap
rows. Active state is signaled by the bold name (font-semibold)
already, and removing the indicator lets the heap count column line
up with everything else.
- Both kebab wrappers also use hidden group-hover:block on the wrapper
div so the menu trigger truly takes 0 width when not hovered.
On hover the kebab appears to the right of the count and pushes it
slightly left, as the user requested ("on hover we can push them to
make space for the burger").
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
738 lines
27 KiB
TypeScript
738 lines
27 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import {
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Folder,
|
|
FolderPlus,
|
|
Image,
|
|
Star,
|
|
Trash2,
|
|
HardDrive,
|
|
RefreshCw,
|
|
Copy,
|
|
Tag as TagIcon,
|
|
Layers2,
|
|
MoreHorizontal,
|
|
Pencil,
|
|
} 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'
|
|
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'
|
|
}
|
|
|
|
export function LeftSidebar() {
|
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
|
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<string | null>(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<string | null>(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<string | null>(null)
|
|
const menuRef = useRef<HTMLDivElement>(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<string | null>(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<string, string>()
|
|
const photoCaches = queryClient.getQueriesData<Photo[]>({ 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<string, string[]>()
|
|
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 })
|
|
break
|
|
case 'discarded':
|
|
navigateToSection('discarded', { flag: 'discarded' })
|
|
break
|
|
case 'duplicates':
|
|
navigateToSection('duplicates', { duplicates: true })
|
|
break
|
|
case 'tags':
|
|
navigateToSection('tags', { groupBy: 'tag' })
|
|
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'),
|
|
})
|
|
|
|
// 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: <Folder className="h-4 w-4" />,
|
|
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: <Layers2 className="h-4 w-4" />,
|
|
children: [
|
|
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: stats?.all_photos ?? 0 },
|
|
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: stats?.rated ?? 0 },
|
|
{ id: 'tags', label: 'Tags', icon: <TagIcon className="h-4 w-4" />, count: tagsTotalCount },
|
|
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: stats?.duplicates ?? 0 },
|
|
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: stats?.discarded ?? 0 },
|
|
],
|
|
},
|
|
{
|
|
id: 'folders',
|
|
label: 'Folders',
|
|
icon: <HardDrive className="h-4 w-4" />,
|
|
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
|
|
|
|
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={() => {
|
|
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 ? (
|
|
<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 (or inline rename input for folder rows) */}
|
|
{renamingId === item.id ? (
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
value={renameDraft}
|
|
onChange={(e) => 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"
|
|
/>
|
|
) : (
|
|
<span className="flex-1 truncate">{item.label}</span>
|
|
)}
|
|
|
|
{/* Count Badge — fixed-width slot so counts line up in a column
|
|
* across rows regardless of digit count. */}
|
|
{item.count !== undefined && item.count > 0 ? (
|
|
<span className="flex h-5 min-w-[24px] flex-shrink-0 items-center justify-center rounded bg-surface-offset px-1.5 text-xs tabular-nums text-text-muted">
|
|
{item.count}
|
|
</span>
|
|
) : (
|
|
<span className="h-5 min-w-[24px] flex-shrink-0" aria-hidden="true" />
|
|
)}
|
|
|
|
{/* Folder kebab menu — only on folder rows. Hidden (display:none)
|
|
* until hover so it reserves NO width in the resting state and
|
|
* the count column stays aligned across folder + non-folder
|
|
* rows. On hover it appears to the right, pushing the count
|
|
* left to make room. */}
|
|
{item.id.startsWith('folder-') &&
|
|
(() => {
|
|
const folderId = item.id.slice('folder-'.length)
|
|
const isMenuOpen = openMenuId === item.id
|
|
return (
|
|
<div
|
|
className={clsx(
|
|
'relative flex-shrink-0',
|
|
isMenuOpen ? 'block' : 'hidden group-hover:block'
|
|
)}
|
|
>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setOpenMenuId(isMenuOpen ? null : item.id)
|
|
}}
|
|
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
|
title="More actions"
|
|
aria-label="More folder actions"
|
|
aria-haspopup="menu"
|
|
aria-expanded={isMenuOpen}
|
|
>
|
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
|
</button>
|
|
|
|
{isMenuOpen && (
|
|
<div
|
|
ref={menuRef}
|
|
role="menu"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="absolute right-0 top-full z-30 mt-1 min-w-[180px] overflow-hidden rounded-lg border border-border bg-surface py-1 text-sm shadow-xl"
|
|
>
|
|
<FolderMenuItem
|
|
icon={<FolderPlus className="h-3.5 w-3.5" />}
|
|
label="New sub-folder"
|
|
onClick={() => {
|
|
setOpenMenuId(null)
|
|
setCreatingUnder(folderId)
|
|
setCreateDraft('')
|
|
// Make sure the parent is expanded so the new
|
|
// input is visible.
|
|
if (!expandedItems.has(item.id)) {
|
|
toggleExpanded(item.id)
|
|
}
|
|
}}
|
|
/>
|
|
<FolderMenuItem
|
|
icon={<Pencil className="h-3.5 w-3.5" />}
|
|
label="Rename"
|
|
onClick={() => {
|
|
setOpenMenuId(null)
|
|
setRenamingId(item.id)
|
|
setRenameDraft(item.label)
|
|
}}
|
|
/>
|
|
<div className="my-1 h-px bg-border" />
|
|
<FolderMenuItem
|
|
icon={<Trash2 className="h-3.5 w-3.5" />}
|
|
label="Delete folder…"
|
|
destructive
|
|
onClick={() => {
|
|
setOpenMenuId(null)
|
|
setDeletingFolder({
|
|
id: folderId,
|
|
name: item.label,
|
|
photoCount: item.count,
|
|
})
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
</div>
|
|
|
|
{/* 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) && (
|
|
<div
|
|
className="flex items-center gap-1 px-2 py-1"
|
|
style={{ paddingLeft: `${8 + (depth + 1) * 16 + 4}px` }}
|
|
>
|
|
<FolderPlus className="h-3 w-3 flex-shrink-0 text-text-muted" />
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
value={createDraft}
|
|
placeholder="New folder name"
|
|
onChange={(e) => 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"
|
|
/>
|
|
</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">
|
|
{/* Tree View */}
|
|
<div className="flex-1 overflow-y-auto py-2">
|
|
{libraryTree.map((item) => renderTreeItem(item))}
|
|
<HeapsPanel />
|
|
</div>
|
|
|
|
{/* Bottom Actions */}
|
|
{folderTree.length > 0 && (
|
|
<div className="border-t border-border p-3">
|
|
<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>
|
|
)}
|
|
|
|
<DeleteFolderDialog
|
|
isOpen={!!deletingFolder}
|
|
folderName={deletingFolder?.name ?? ''}
|
|
photoCount={deletingFolder?.photoCount}
|
|
onClose={() => setDeletingFolder(null)}
|
|
onConfirm={(mode) => {
|
|
if (deletingFolder) {
|
|
deleteFolderMutation.mutate({ id: deletingFolder.id, mode })
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FolderMenuItem({
|
|
icon,
|
|
label,
|
|
onClick,
|
|
destructive = false,
|
|
}: {
|
|
icon: React.ReactNode
|
|
label: string
|
|
onClick: () => void
|
|
destructive?: boolean
|
|
}) {
|
|
return (
|
|
<button
|
|
role="menuitem"
|
|
onClick={onClick}
|
|
className={clsx(
|
|
'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors',
|
|
destructive
|
|
? 'text-reject hover:bg-reject/10'
|
|
: 'text-text hover:bg-surface-2'
|
|
)}
|
|
>
|
|
<span className="text-text-muted">{icon}</span>
|
|
{label}
|
|
</button>
|
|
)
|
|
} |