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:
@@ -44,6 +44,81 @@ async def get_folders(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
return {"folders": folders_list}
|
return {"folders": folders_list}
|
||||||
|
|
||||||
|
@router.get("/tree")
|
||||||
|
async def get_folder_tree(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Recursive folder tree, one root per active SourceRoot. The tree
|
||||||
|
starts at the Folder row matching the SourceRoot.path (the scanner
|
||||||
|
creates one for every walked directory), with the SourceRoot's
|
||||||
|
display name overlaid so the top-level entry reads as "Library"
|
||||||
|
instead of "/photos".
|
||||||
|
|
||||||
|
Returns a list of root nodes; each node has:
|
||||||
|
{ id, name, path, photo_count, children: [...] }
|
||||||
|
|
||||||
|
Sub-folders that physically belong to the same source root but
|
||||||
|
weren't created on disk (e.g. the / row the scanner sometimes
|
||||||
|
creates as a parent walk) are skipped via path-prefix filtering.
|
||||||
|
"""
|
||||||
|
sr_result = await db.execute(
|
||||||
|
select(SourceRoot).where(SourceRoot.is_active == True) # noqa: E712
|
||||||
|
)
|
||||||
|
source_roots = sr_result.scalars().all()
|
||||||
|
|
||||||
|
out = []
|
||||||
|
for sr in source_roots:
|
||||||
|
# Folders physically inside this source root, by path prefix.
|
||||||
|
prefix = os.path.normpath(sr.path).rstrip(os.sep)
|
||||||
|
f_result = await db.execute(
|
||||||
|
select(Folder).where(
|
||||||
|
Folder.source_root_id == sr.id,
|
||||||
|
# Either the folder IS the source root, or it sits beneath it.
|
||||||
|
(Folder.path == prefix) | (Folder.path.like(prefix + os.sep + '%'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
folders = f_result.scalars().all()
|
||||||
|
if not folders:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build a path → node map so we can attach children regardless of
|
||||||
|
# parent_id consistency.
|
||||||
|
nodes = {
|
||||||
|
f.path: {
|
||||||
|
"id": f.id,
|
||||||
|
"name": f.name or os.path.basename(f.path),
|
||||||
|
"path": f.path,
|
||||||
|
"photo_count": f.photo_count or 0,
|
||||||
|
"children": [],
|
||||||
|
}
|
||||||
|
for f in folders
|
||||||
|
}
|
||||||
|
|
||||||
|
root_node = None
|
||||||
|
for f in folders:
|
||||||
|
node = nodes[f.path]
|
||||||
|
if f.path == prefix:
|
||||||
|
root_node = node
|
||||||
|
# Override the display name with the source root's label.
|
||||||
|
node["name"] = sr.name or node["name"]
|
||||||
|
continue
|
||||||
|
parent_path = os.path.normpath(os.path.dirname(f.path))
|
||||||
|
parent = nodes.get(parent_path)
|
||||||
|
if parent is not None:
|
||||||
|
parent["children"].append(node)
|
||||||
|
# If parent isn't in the set (orphan from a partial scan), drop
|
||||||
|
# the node — it can't be rendered consistently.
|
||||||
|
|
||||||
|
if root_node is not None:
|
||||||
|
# Sort children alphabetically at every level.
|
||||||
|
def sort_recursive(n):
|
||||||
|
n["children"].sort(key=lambda c: c["name"].lower())
|
||||||
|
for c in n["children"]:
|
||||||
|
sort_recursive(c)
|
||||||
|
sort_recursive(root_node)
|
||||||
|
out.append(root_node)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{folder_id}")
|
@router.patch("/{folder_id}")
|
||||||
async def rename_folder(
|
async def rename_folder(
|
||||||
folder_id: str,
|
folder_id: str,
|
||||||
|
|||||||
@@ -71,15 +71,17 @@ async def list_photos(
|
|||||||
if date_to:
|
if date_to:
|
||||||
filters.append(Photo.taken_at <= date_to)
|
filters.append(Photo.taken_at <= date_to)
|
||||||
|
|
||||||
# Folder filter — the sidebar exposes "source roots" (top-level scan
|
# Folder filter. The sidebar can pass either a SourceRoot id or a
|
||||||
# paths) under the same UI affordance as folders, so the same param has
|
# Folder id; both should include descendants so clicking a parent
|
||||||
# to accept either a folder id or a source root id. If the value matches
|
# folder shows everything under it (Lightroom semantics).
|
||||||
# a source root, expand to every folder under that root and use IN.
|
|
||||||
if folder_id:
|
if folder_id:
|
||||||
sr_check = await db.execute(
|
sr_check = await db.execute(
|
||||||
select(SourceRoot.id).where(SourceRoot.id == folder_id)
|
select(SourceRoot).where(SourceRoot.id == folder_id)
|
||||||
)
|
)
|
||||||
if sr_check.scalar_one_or_none() is not None:
|
sr_row = sr_check.scalar_one_or_none()
|
||||||
|
|
||||||
|
if sr_row is not None:
|
||||||
|
# Source root → all folders under it (any depth).
|
||||||
child_folders = await db.execute(
|
child_folders = await db.execute(
|
||||||
select(Folder.id).where(Folder.source_root_id == folder_id)
|
select(Folder.id).where(Folder.source_root_id == folder_id)
|
||||||
)
|
)
|
||||||
@@ -87,11 +89,25 @@ async def list_photos(
|
|||||||
if child_ids:
|
if child_ids:
|
||||||
filters.append(Photo.folder_id.in_(child_ids))
|
filters.append(Photo.folder_id.in_(child_ids))
|
||||||
else:
|
else:
|
||||||
# Source root with no folder rows yet — match nothing rather
|
|
||||||
# than returning the entire library.
|
|
||||||
filters.append(Photo.id == '__no_match__')
|
filters.append(Photo.id == '__no_match__')
|
||||||
else:
|
else:
|
||||||
filters.append(Photo.folder_id == folder_id)
|
# Folder id → that folder + every descendant by path prefix.
|
||||||
|
target_check = await db.execute(
|
||||||
|
select(Folder).where(Folder.id == folder_id)
|
||||||
|
)
|
||||||
|
target = target_check.scalar_one_or_none()
|
||||||
|
if target is None:
|
||||||
|
filters.append(Photo.id == '__no_match__')
|
||||||
|
else:
|
||||||
|
target_path = os.path.normpath(target.path).rstrip(os.sep)
|
||||||
|
desc_result = await db.execute(
|
||||||
|
select(Folder.id).where(
|
||||||
|
(Folder.path == target_path)
|
||||||
|
| (Folder.path.like(target_path + os.sep + '%'))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
desc_ids = [row[0] for row in desc_result.all()]
|
||||||
|
filters.append(Photo.folder_id.in_(desc_ids))
|
||||||
|
|
||||||
# Media type filter
|
# Media type filter
|
||||||
if media_type:
|
if media_type:
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
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() {
|
export function ActiveFilterChips() {
|
||||||
const f = useFilterStore()
|
const f = useFilterStore()
|
||||||
|
|
||||||
// Look up names for id-based filters so the chips show something
|
// Look up names for id-based filters so the chips show something
|
||||||
// human-readable instead of opaque uuids.
|
// human-readable instead of opaque uuids. The folder tree handles
|
||||||
const { data: foldersData } = useQuery({
|
// both top-level source roots and nested subfolders.
|
||||||
queryKey: ['folders'],
|
const { data: folderTree } = useQuery<FolderTreeNode[]>({
|
||||||
queryFn: sourceFolders.list,
|
queryKey: ['folders', 'tree'],
|
||||||
|
queryFn: sourceFolders.tree,
|
||||||
enabled: f.folderId !== null,
|
enabled: f.folderId !== null,
|
||||||
})
|
})
|
||||||
const folder = f.folderId
|
const folder = f.folderId ? findFolderInTree(folderTree, f.folderId) : null
|
||||||
? (foldersData?.folders ?? []).find((x: any) => x.id === f.folderId)
|
|
||||||
: null
|
|
||||||
|
|
||||||
const { data: heaps = [] } = useQuery({
|
const { data: heaps = [] } = useQuery({
|
||||||
queryKey: ['heaps'],
|
queryKey: ['heaps'],
|
||||||
@@ -86,7 +86,7 @@ export function ActiveFilterChips() {
|
|||||||
if (f.folderId) {
|
if (f.folderId) {
|
||||||
chips.push({
|
chips.push({
|
||||||
key: 'folder',
|
key: 'folder',
|
||||||
label: `Folder: ${folder?.name || folder?.path?.split('/').pop() || f.folderId}`,
|
label: `Folder: ${folder?.name || f.folderId}`,
|
||||||
onRemove: () => f.setFolderId(null),
|
onRemove: () => f.setFolderId(null),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sourceFolders, library, photos as photosApi } from '../../services/api'
|
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from '../ToastContainer'
|
import { toast } from '../ToastContainer'
|
||||||
import { useFilterStore } from '../../store/filterStore'
|
import { useFilterStore } from '../../store/filterStore'
|
||||||
import { HeapsPanel } from '../heaps/HeapsPanel'
|
import { HeapsPanel } from '../heaps/HeapsPanel'
|
||||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||||
|
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||||
|
|
||||||
interface TreeItem {
|
interface TreeItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -148,17 +149,15 @@ export function LeftSidebar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch folders from API
|
// Fetch the recursive folder tree (one root per active source root).
|
||||||
const { data: foldersData } = useQuery({
|
const { data: folderTree = [] } = useFolderTreeQuery()
|
||||||
queryKey: ['folders'],
|
|
||||||
queryFn: sourceFolders.list,
|
|
||||||
})
|
|
||||||
|
|
||||||
const renameMutation = useMutation({
|
const renameMutation = useMutation({
|
||||||
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
mutationFn: ({ id, name }: { id: string; name: string }) =>
|
||||||
sourceFolders.rename(id, name),
|
sourceFolders.rename(id, name),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
||||||
},
|
},
|
||||||
onError: (e: any) =>
|
onError: (e: any) =>
|
||||||
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
toast.error('Rename failed', e?.response?.data?.detail || e.message || 'Unknown error'),
|
||||||
@@ -198,6 +197,18 @@ export function LeftSidebar() {
|
|||||||
setExpandedItems(newExpanded)
|
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[] = [
|
const libraryTree: TreeItem[] = [
|
||||||
{
|
{
|
||||||
id: 'library',
|
id: 'library',
|
||||||
@@ -214,13 +225,7 @@ export function LeftSidebar() {
|
|||||||
id: 'folders',
|
id: 'folders',
|
||||||
label: 'Folders',
|
label: 'Folders',
|
||||||
icon: <Folder className="h-4 w-4" />,
|
icon: <Folder className="h-4 w-4" />,
|
||||||
children: foldersData?.folders?.map((folder: any) => ({
|
children: folderTree.map(folderNodeToTreeItem),
|
||||||
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',
|
|
||||||
})) || [],
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -411,7 +416,7 @@ export function LeftSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Actions */}
|
{/* Bottom Actions */}
|
||||||
{foldersData?.folders?.length > 0 && (
|
{folderTree.length > 0 && (
|
||||||
<div className="border-t border-border p-3">
|
<div className="border-t border-border p-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleScanAll}
|
onClick={handleScanAll}
|
||||||
|
|||||||
26
frontend/src/hooks/useFolderTreeQuery.ts
Normal file
26
frontend/src/hooks/useFolderTreeQuery.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { sourceFolders, type FolderTreeNode } from '../services/api'
|
||||||
|
|
||||||
|
export const FOLDER_TREE_QUERY_KEY = ['folders', 'tree'] as const
|
||||||
|
|
||||||
|
export function useFolderTreeQuery() {
|
||||||
|
return useQuery<FolderTreeNode[]>({
|
||||||
|
queryKey: FOLDER_TREE_QUERY_KEY,
|
||||||
|
queryFn: sourceFolders.tree,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk the tree to find a node by id. Used for chip name lookups. */
|
||||||
|
export function findFolderInTree(
|
||||||
|
tree: FolderTreeNode[] | undefined,
|
||||||
|
id: string
|
||||||
|
): FolderTreeNode | null {
|
||||||
|
if (!tree) return null
|
||||||
|
for (const node of tree) {
|
||||||
|
if (node.id === id) return node
|
||||||
|
const child = findFolderInTree(node.children, id)
|
||||||
|
if (child) return child
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -12,12 +12,26 @@ const api = axios.create({
|
|||||||
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
|
// Source Folders API. Source roots are config-driven now (PHOTO_DIRS in
|
||||||
// .env → bootstrap on backend startup), so the UI only reads them and
|
// .env → bootstrap on backend startup), so the UI only reads them and
|
||||||
// optionally renames the display label.
|
// optionally renames the display label.
|
||||||
|
export interface FolderTreeNode {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
path: string
|
||||||
|
photo_count: number
|
||||||
|
children: FolderTreeNode[]
|
||||||
|
}
|
||||||
|
|
||||||
export const sourceFolders = {
|
export const sourceFolders = {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
const response = await api.get('/folders')
|
const response = await api.get('/folders')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Recursive folder tree, one root per active source root. */
|
||||||
|
tree: async (): Promise<FolderTreeNode[]> => {
|
||||||
|
const response = await api.get('/folders/tree')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
scan: async (folderId: string) => {
|
scan: async (folderId: string) => {
|
||||||
const response = await api.post(`/folders/${folderId}/scan`)
|
const response = await api.post(`/folders/${folderId}/scan`)
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
Reference in New Issue
Block a user