feat: real recursive folder tree in the sidebar
The Folders section in the LeftSidebar previously rendered the flat
list of source roots — actual subdirectories were invisible. Now it
shows the full nested tree, click any node to filter, drop targets
work at every depth.
Backend
- New GET /folders/tree returning a list of root nodes (one per
active SourceRoot). Each node is { id, name, path, photo_count,
children: [...] } with children sorted alphabetically at every
level. Walks Folder rows whose source_root_id matches and whose
path is at or beneath the source root, then attaches them by
parent path so partial scans don't break the tree.
- The source root's display label is overlaid on the root folder
node so the top-level entry reads as "Library" instead of
"/photos".
- list_photos folder_id filter now does descendant matching: when
a Folder id is given, it includes the folder itself and every
Folder whose path is a sep-prefixed descendant. Matches the
Lightroom mental model: clicking "Library" or any parent folder
shows everything beneath it. The existing source-root-id branch
is unchanged.
Frontend
- New types/api.ts FolderTreeNode interface and sourceFolders.tree()
helper.
- New hooks/useFolderTreeQuery.ts with a 30s staleTime and a
findFolderInTree() walker for id-based name lookups.
- LeftSidebar drops the flat foldersData list and uses the tree
query. folderNodeToTreeItem recursively maps backend nodes into
the existing TreeItem shape; renderTreeItem already knew how to
recurse into children, so the tree just works at any depth.
Drop targets, drag-to-move, drag-to-copy, double-click rename,
and active-state highlighting all carry over to nested folders.
- The renameMutation now also invalidates ['folders', 'tree'] so a
source-root rename refreshes the tree label immediately.
- ActiveFilterChips switches to the tree query and uses the new
findFolderInTree walker so the chip label resolves correctly for
sub-folder filters too — not just top-level source roots.
- The "Scan all folders" button visibility now keys off the tree
length instead of the flat folders length.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,21 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
||||
import { sourceFolders, heaps as heapsApi, tags as tagsApi } from '../../services/api'
|
||||
import { sourceFolders, heaps as heapsApi, tags as tagsApi, type FolderTreeNode } from '../../services/api'
|
||||
import { findFolderInTree } from '../../hooks/useFolderTreeQuery'
|
||||
|
||||
export function ActiveFilterChips() {
|
||||
const f = useFilterStore()
|
||||
|
||||
// Look up names for id-based filters so the chips show something
|
||||
// human-readable instead of opaque uuids.
|
||||
const { data: foldersData } = useQuery({
|
||||
queryKey: ['folders'],
|
||||
queryFn: sourceFolders.list,
|
||||
// human-readable instead of opaque uuids. The folder tree handles
|
||||
// both top-level source roots and nested subfolders.
|
||||
const { data: folderTree } = useQuery<FolderTreeNode[]>({
|
||||
queryKey: ['folders', 'tree'],
|
||||
queryFn: sourceFolders.tree,
|
||||
enabled: f.folderId !== null,
|
||||
})
|
||||
const folder = f.folderId
|
||||
? (foldersData?.folders ?? []).find((x: any) => x.id === f.folderId)
|
||||
: null
|
||||
const folder = f.folderId ? findFolderInTree(folderTree, f.folderId) : null
|
||||
|
||||
const { data: heaps = [] } = useQuery({
|
||||
queryKey: ['heaps'],
|
||||
@@ -86,7 +86,7 @@ export function ActiveFilterChips() {
|
||||
if (f.folderId) {
|
||||
chips.push({
|
||||
key: 'folder',
|
||||
label: `Folder: ${folder?.name || folder?.path?.split('/').pop() || f.folderId}`,
|
||||
label: `Folder: ${folder?.name || f.folderId}`,
|
||||
onRemove: () => f.setFolderId(null),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,12 +12,13 @@ import {
|
||||
Copy,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { sourceFolders, library, photos as photosApi } from '../../services/api'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
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'
|
||||
|
||||
interface TreeItem {
|
||||
id: string
|
||||
@@ -148,17 +149,15 @@ export function LeftSidebar() {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch folders from API
|
||||
const { data: foldersData } = useQuery({
|
||||
queryKey: ['folders'],
|
||||
queryFn: sourceFolders.list,
|
||||
})
|
||||
// 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'),
|
||||
@@ -198,6 +197,18 @@ export function LeftSidebar() {
|
||||
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',
|
||||
@@ -214,13 +225,7 @@ export function LeftSidebar() {
|
||||
id: 'folders',
|
||||
label: 'Folders',
|
||||
icon: <Folder className="h-4 w-4" />,
|
||||
children: foldersData?.folders?.map((folder: any) => ({
|
||||
id: `folder-${folder.id}`,
|
||||
label: folder.name || folder.path.split('/').pop() || folder.path,
|
||||
icon: <Folder className="h-4 w-4" />,
|
||||
count: folder.photo_count,
|
||||
type: 'folder',
|
||||
})) || [],
|
||||
children: folderTree.map(folderNodeToTreeItem),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -411,7 +416,7 @@ export function LeftSidebar() {
|
||||
</div>
|
||||
|
||||
{/* Bottom Actions */}
|
||||
{foldersData?.folders?.length > 0 && (
|
||||
{folderTree.length > 0 && (
|
||||
<div className="border-t border-border p-3">
|
||||
<button
|
||||
onClick={handleScanAll}
|
||||
|
||||
Reference in New Issue
Block a user