Files
mule-image/frontend/src/components/layout/LeftSidebar.tsx
dtoro 2d37fba211 feat: Tags entry in the Library sidebar
Adds an expandable Tags node alongside All Photos / Rated /
Duplicates / Discarded. The children are populated dynamically from
useTagsQuery — one row per tag, showing the tag name and its photo
count badge. Click a tag row to filter the timeline to just that
tag (single-tag), with the active highlight following the filter
store.

Multi-tag filtering still lives in the FilterBar; the sidebar entry
is the quick "show me everything in this tag" affordance.

Implementation
- New 'tags' library tree node with children: allTags.map(...)
- 'tag-{id}' click handler in applyLibraryNode → clearAll() +
  setTagIds([id])
- isItemActive recognises a tag row as selected only when the
  filter store has exactly that single tag id, so combining it with
  multi-tag filter mode in the FilterBar doesn't leave a stale
  highlight.
- Tags section is collapsed by default like other library nodes; no
  effect when there are no tags yet (children list is empty).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 11:39:33 +02:00

461 lines
16 KiB
TypeScript

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<Set<string>>(new Set(['library', 'folders', 'heaps']))
const [selectedItem, setSelectedItem] = useState<string | null>('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<string | null>(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<string | null>(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: <Folder className="h-4 w-4" />,
count: node.photo_count,
type: 'folder',
children: node.children.length > 0
? node.children.map(folderNodeToTreeItem)
: undefined,
})
const libraryTree: TreeItem[] = [
{
id: 'library',
label: 'Library',
icon: <HardDrive className="h-4 w-4" />,
children: [
{ id: 'all-photos', label: 'All Photos', icon: <Image className="h-4 w-4" />, count: 0 },
{ id: 'rated', label: 'Rated', icon: <Star className="h-4 w-4" />, count: 0 },
{ id: 'duplicates', label: 'Duplicates', icon: <Copy className="h-4 w-4" />, count: 0 },
{ id: 'discarded', label: 'Discarded', icon: <Trash2 className="h-4 w-4" />, count: 0 },
{
id: 'tags',
label: 'Tags',
icon: <TagIcon className="h-4 w-4" />,
children: allTags.map((tag) => ({
id: `tag-${tag.id}`,
label: tag.name,
icon: <TagIcon className="h-4 w-4" />,
count: tag.photo_count,
})),
},
],
},
{
id: 'folders',
label: 'Folders',
icon: <Folder 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.
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 (
<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
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 ? (
<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 */}
{item.count !== undefined && item.count > 0 && (
<span className="rounded bg-surface-offset px-1.5 py-0.5 text-xs text-text-muted">
{item.count}
</span>
)}
</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">
{/* Sidebar Header */}
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<h2 className="text-sm font-semibold text-text">Library</h2>
<button className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text">
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
{/* 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>
)}
</div>
)
}