feat: folder navigation from sidebar
Folders in the LeftSidebar were decorative — clicking one did
nothing. Now they actually filter the timeline.
filterStore: new folderId field, setFolderId, hasActiveFilters check,
filtersToParams sends folder_id to the backend (the param was already
declared and applied server-side, just nothing was setting it).
useFilterUrlSync round-trips ?folder_id= so the filter persists in
the URL. usePhotosQuery threads it through.
LeftSidebar:
- Clicking a folder row calls clearAllFilters() then setFolderId(id)
so the user lands cleanly on that folder.
- Library virtual nodes (All Photos, Rated, Discarded) clear the
folder filter as part of their normal action.
- The active-row visual highlight is now derived from the filter
store: a folder row is selected when filterStore.folderId matches
it, "All Photos" is selected when no folder is set. Keeps the
sidebar in sync if filters change externally (URL hydrate, the
ActiveFilterChips X button, FilterBar Clear all).
ActiveFilterChips: shows "Folder: {name}" and "Heap: {name}" chips,
looking up the names from the folders / heaps queries (lazy-enabled
only when the corresponding filter is set). Clicking the X clears
the filter.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,29 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
||||
import { sourceFolders, heaps as heapsApi } from '../../services/api'
|
||||
|
||||
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,
|
||||
enabled: f.folderId !== null,
|
||||
})
|
||||
const folder = f.folderId
|
||||
? (foldersData?.folders ?? []).find((x: any) => x.id === f.folderId)
|
||||
: null
|
||||
|
||||
const { data: heaps = [] } = useQuery({
|
||||
queryKey: ['heaps'],
|
||||
queryFn: heapsApi.list,
|
||||
enabled: f.heapId !== null,
|
||||
})
|
||||
const heap = f.heapId ? heaps.find((h) => h.id === f.heapId) : null
|
||||
|
||||
if (!hasActiveFilters(f)) return null
|
||||
|
||||
const chips: { key: string; label: string; onRemove: () => void }[] = []
|
||||
@@ -57,6 +77,20 @@ export function ActiveFilterChips() {
|
||||
onRemove: () => f.setFlag('any'),
|
||||
})
|
||||
}
|
||||
if (f.folderId) {
|
||||
chips.push({
|
||||
key: 'folder',
|
||||
label: `Folder: ${folder?.name || folder?.path?.split('/').pop() || f.folderId}`,
|
||||
onRemove: () => f.setFolderId(null),
|
||||
})
|
||||
}
|
||||
if (f.heapId) {
|
||||
chips.push({
|
||||
key: 'heap',
|
||||
label: `Heap: ${heap?.name ?? f.heapId}`,
|
||||
onRemove: () => f.setHeapId(null),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-surface-2 px-4 py-2 text-xs">
|
||||
|
||||
@@ -39,6 +39,8 @@ export function LeftSidebar() {
|
||||
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 filterFolderId = useFilterStore((s) => s.folderId)
|
||||
|
||||
// Map a library tree id to a filter-store mutation. Each "virtual node" in
|
||||
// the library tree is just a saved filter preset.
|
||||
@@ -55,7 +57,16 @@ export function LeftSidebar() {
|
||||
clearAllFilters()
|
||||
setFlag('discarded')
|
||||
break
|
||||
// 'by-date' is purely visual until we add a date-grouping UI
|
||||
default:
|
||||
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)
|
||||
}
|
||||
// 'by-date' is still visual-only.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,10 +161,24 @@ export function LeftSidebar() {
|
||||
},
|
||||
]
|
||||
|
||||
// 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 === 'all-photos') {
|
||||
return filterFolderId === null && selectedItem === 'all-photos'
|
||||
}
|
||||
return selectedItem === id
|
||||
}
|
||||
|
||||
const renderTreeItem = (item: TreeItem, depth: number = 0) => {
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const isExpanded = expandedItems.has(item.id)
|
||||
const isSelected = selectedItem === item.id
|
||||
const isSelected = isItemActive(item.id)
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
|
||||
@@ -68,6 +68,9 @@ function parseUrl(): Partial<FilterState> {
|
||||
const heapId = sp.get('heap_id')
|
||||
if (heapId) out.heapId = heapId
|
||||
|
||||
const folderId = sp.get('folder_id')
|
||||
if (folderId) out.folderId = folderId
|
||||
|
||||
const sortBy = sp.get('sort')
|
||||
if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) {
|
||||
out.sortBy = sortBy as SortField
|
||||
@@ -91,6 +94,7 @@ function writeUrl(f: FilterState) {
|
||||
if (f.colorLabel) sp.set('color_label', f.colorLabel)
|
||||
if (f.flag !== 'any') sp.set('flag', f.flag)
|
||||
if (f.heapId) sp.set('heap_id', f.heapId)
|
||||
if (f.folderId) sp.set('folder_id', f.folderId)
|
||||
if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy)
|
||||
if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder)
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export function usePhotosQuery() {
|
||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
const heapId = useFilterStore((s) => s.heapId)
|
||||
const folderId = useFilterStore((s) => s.folderId)
|
||||
const sortBy = useFilterStore((s) => s.sortBy)
|
||||
const sortOrder = useFilterStore((s) => s.sortOrder)
|
||||
|
||||
@@ -33,10 +34,11 @@ export function usePhotosQuery() {
|
||||
colorLabel,
|
||||
flag,
|
||||
heapId,
|
||||
folderId,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}),
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, sortBy, sortOrder]
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, sortBy, sortOrder]
|
||||
)
|
||||
|
||||
return useQuery({
|
||||
|
||||
@@ -22,6 +22,8 @@ export interface FilterState {
|
||||
/** When set, restrict to photos in this heap. Independent of `activeHeapId`
|
||||
* on the heap store — that's the target for the T shortcut. */
|
||||
heapId: string | null
|
||||
/** When set, restrict to photos in this folder. */
|
||||
folderId: string | null
|
||||
sortBy: SortField
|
||||
sortOrder: SortOrder
|
||||
}
|
||||
@@ -37,6 +39,7 @@ interface FilterStore extends FilterState {
|
||||
setColorLabel: (label: ColorLabel | null) => void
|
||||
setFlag: (flag: FlagFilter) => void
|
||||
setHeapId: (id: string | null) => void
|
||||
setFolderId: (id: string | null) => void
|
||||
setSortBy: (field: SortField) => void
|
||||
setSortOrder: (order: SortOrder) => void
|
||||
toggleSortOrder: () => void
|
||||
@@ -57,6 +60,7 @@ export const INITIAL_FILTERS: FilterState = {
|
||||
colorLabel: null,
|
||||
flag: 'any',
|
||||
heapId: null,
|
||||
folderId: null,
|
||||
sortBy: 'taken_at',
|
||||
sortOrder: 'desc',
|
||||
}
|
||||
@@ -78,6 +82,7 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
||||
setColorLabel: (colorLabel) => set({ colorLabel }),
|
||||
setFlag: (flag) => set({ flag }),
|
||||
setHeapId: (heapId) => set({ heapId }),
|
||||
setFolderId: (folderId) => set({ folderId }),
|
||||
setSortBy: (sortBy) => set({ sortBy }),
|
||||
setSortOrder: (sortOrder) => set({ sortOrder }),
|
||||
toggleSortOrder: () =>
|
||||
@@ -102,6 +107,7 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
|
||||
if (f.colorLabel) params.color_label = f.colorLabel
|
||||
if (f.flag === 'discarded') params.is_discarded = 'true'
|
||||
if (f.heapId) params.heap_id = f.heapId
|
||||
if (f.folderId) params.folder_id = f.folderId
|
||||
params.sort = f.sortBy
|
||||
params.order = f.sortOrder
|
||||
return params
|
||||
@@ -117,6 +123,7 @@ export function hasActiveFilters(f: FilterState): boolean {
|
||||
f.ratingMin > 0 ||
|
||||
f.colorLabel !== null ||
|
||||
f.flag !== 'any' ||
|
||||
f.heapId !== null
|
||||
f.heapId !== null ||
|
||||
f.folderId !== null
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user