diff --git a/frontend/src/components/heaps/HeapsPanel.tsx b/frontend/src/components/heaps/HeapsPanel.tsx index 8e280e1..bf67ab6 100644 --- a/frontend/src/components/heaps/HeapsPanel.tsx +++ b/frontend/src/components/heaps/HeapsPanel.tsx @@ -29,8 +29,8 @@ import { HeapConvertDialog } from './HeapConvertDialog' */ export function HeapsPanel() { const { data: heaps = [] } = useHeapsQuery() - const filterHeapId = useFilterStore((s) => s.heapId) - const setFilterHeapId = useFilterStore((s) => s.setHeapId) + const navigateToSection = useFilterStore((s) => s.navigateToSection) + const currentSection = useFilterStore((s) => s.currentSection) const queryClient = useQueryClient() const [expanded, setExpanded] = useState(true) @@ -71,8 +71,10 @@ export function HeapsPanel() { mutationFn: (heapId: string) => heapsApi.delete(heapId), onSuccess: (_, heapId) => { invalidate() - // If we were filtering by this heap, clear the filter - if (filterHeapId === heapId) setFilterHeapId(null) + // If we were viewing this heap, snap back to all-photos. + if (currentSection === `heap-${heapId}`) { + navigateToSection('all-photos', {}) + } }, onError: (e: any) => toast.error('Failed to delete heap', e.message || 'Unknown error'), @@ -197,7 +199,7 @@ export function HeapsPanel() { )} {heaps.map((heap) => { - const isFiltered = filterHeapId === heap.id + const isFiltered = currentSection === `heap-${heap.id}` const isActive = heap.is_active const isDropTarget = dropTargetId === heap.id return ( @@ -209,7 +211,9 @@ export function HeapsPanel() { isDropTarget && 'ring-2 ring-primary bg-primary/10' )} style={{ paddingLeft: '32px' }} - onClick={() => setFilterHeapId(heap.id)} + onClick={() => + navigateToSection(`heap-${heap.id}`, { heapId: heap.id }) + } onDragOver={(e) => { if (e.dataTransfer.types.includes(PHOTO_DRAG_MIME)) { e.preventDefault() diff --git a/frontend/src/components/layout/LeftSidebar.tsx b/frontend/src/components/layout/LeftSidebar.tsx index ab24f15..4cf93b5 100644 --- a/frontend/src/components/layout/LeftSidebar.tsx +++ b/frontend/src/components/layout/LeftSidebar.tsx @@ -33,7 +33,6 @@ interface TreeItem { export function LeftSidebar() { const [expandedItems, setExpandedItems] = useState>(new Set(['library', 'folders', 'heaps'])) - const [selectedItem, setSelectedItem] = useState('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. @@ -41,15 +40,8 @@ export function LeftSidebar() { 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 setGroupBy = useFilterStore((s) => s.setGroupBy) - const filterFolderId = useFilterStore((s) => s.folderId) - const filterDuplicates = useFilterStore((s) => s.duplicates) - const filterGroupBy = useFilterStore((s) => s.groupBy) + const navigateToSection = useFilterStore((s) => s.navigateToSection) + const currentSection = useFilterStore((s) => s.currentSection) const { data: allTags = [] } = useTagsQuery() const [dropTargetId, setDropTargetId] = useState(null) @@ -123,38 +115,32 @@ export function LeftSidebar() { } } - // Map a library tree id to a filter-store mutation. Each "virtual node" in - // the library tree is just a saved filter preset. + // 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': - clearAllFilters() + navigateToSection('all-photos', {}) break case 'rated': - clearAllFilters() - setRatingMin(1) + navigateToSection('rated', { ratingMin: 1 }) break case 'discarded': - clearAllFilters() - setFlag('discarded') + navigateToSection('discarded', { flag: 'discarded' }) break case 'duplicates': - clearAllFilters() - setDuplicates(true) + navigateToSection('duplicates', { duplicates: true }) break case 'tags': - // Tags is a leaf entry, not expandable. Clicking switches the - // timeline to grouped-by-tag mode without touching other filters. - setGroupBy('tag') + navigateToSection('tags', { groupBy: 'tag' }) break 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) + navigateToSection(`folder-${folderId}`, { folderId }) } } } @@ -247,20 +233,13 @@ export function LeftSidebar() { // 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 filterFolderId === id.slice('folder-'.length) + return currentSection === id } - if (id === 'tags') { - return filterGroupBy === 'tag' - } - if (id === 'all-photos') { - return filterFolderId === null && selectedItem === 'all-photos' - } - if (id === 'duplicates') { - return filterDuplicates - } - return selectedItem === id + return currentSection === id } // Which tree items accept photo drops, and what each does on drop. @@ -304,12 +283,11 @@ export function LeftSidebar() { style={{ paddingLeft: `${8 + depth * 16}px` }} onClick={() => { if (renamingId === item.id) return - setSelectedItem(item.id) // 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 filter. + // they have no associated section. if (item.id.startsWith('folder-')) { applyLibraryNode(item.id) } else if (hasChildren) { diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts index d4766e0..fe27541 100644 --- a/frontend/src/hooks/useFilterUrlSync.ts +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -82,6 +82,9 @@ function parseUrl(): Partial { const groupBy = sp.get('group') if (groupBy === 'date' || groupBy === 'tag') out.groupBy = groupBy + const section = sp.get('section') + if (section) (out as any).currentSection = section + const sortBy = sp.get('sort') if (sortBy && ALLOWED_SORT_FIELDS.includes(sortBy as SortField)) { out.sortBy = sortBy as SortField @@ -95,7 +98,7 @@ function parseUrl(): Partial { return out } -function writeUrl(f: FilterState) { +function writeUrl(f: FilterState & { currentSection?: string }) { const sp = new URLSearchParams() if (f.q.trim()) sp.set('q', f.q.trim()) if (f.dateFrom) sp.set('date_from', f.dateFrom) @@ -109,6 +112,8 @@ function writeUrl(f: FilterState) { if (f.tagIds.length > 0) sp.set('tag_ids', f.tagIds.join(',')) if (f.duplicates) sp.set('duplicates', 'true') if (f.groupBy !== 'date') sp.set('group', f.groupBy) + if (f.currentSection && f.currentSection !== 'all-photos') + sp.set('section', f.currentSection) if (f.sortBy !== 'taken_at') sp.set('sort', f.sortBy) if (f.sortOrder !== 'desc') sp.set('order', f.sortOrder) diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index 4a7f2a4..93386d8 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -36,9 +36,25 @@ export interface FilterState { sortOrder: SortOrder } +/** Identifies which "section" of the app the user is currently viewing. + * Sections each carry their own filter state — switching to one restores + * whatever filters were active there last time, switching away saves the + * current state under the section being left. */ +export const ALL_PHOTOS_SECTION = 'all-photos' + interface FilterStore extends FilterState { filterBarOpen: boolean + /** The active section id. Changes via navigateToSection. */ + currentSection: string + /** Per-section snapshot of filter state, in-memory. Restored on return. */ + sectionFilters: Record + /** Per-section "intrinsic" filters — the preset that defines what makes + * a section that section (e.g. flag=discarded for the discarded + * section). Used by clearAll to reset within a section without + * navigating away. */ + sectionPresets: Record> + setQ: (q: string) => void setDateFrom: (date: string | null) => void setDateTo: (date: string | null) => void @@ -59,7 +75,19 @@ interface FilterStore extends FilterState { setFilterBarOpen: (open: boolean) => void toggleFilterBar: () => void - hydrate: (partial: Partial) => void + /** Navigate to a section. Saves the current section's filter state into + * the in-memory map under the OLD section id, then loads the saved + * state for the destination — or, if none exists, applies the preset + * overrides on top of INITIAL_FILTERS. The preset is also stored so + * clearAll inside the section resets correctly. */ + navigateToSection: ( + sectionId: string, + presetOverrides?: Partial + ) => void + + hydrate: (partial: Partial & { currentSection?: string }) => void + /** Reset filters within the CURRENT section back to its preset. Doesn't + * navigate. For an explicit "go to all photos" use navigateToSection. */ clearAll: () => void } @@ -80,9 +108,34 @@ export const INITIAL_FILTERS: FilterState = { sortOrder: 'desc', } +/** Pull the FilterState slice out of the full store, dropping the + * control fields. Used when snapshotting current filters into the + * per-section map. */ +function snapshotFilters(s: FilterState): FilterState { + return { + q: s.q, + dateFrom: s.dateFrom, + dateTo: s.dateTo, + mediaTypes: [...s.mediaTypes], + ratingMin: s.ratingMin, + colorLabel: s.colorLabel, + flag: s.flag, + heapId: s.heapId, + folderId: s.folderId, + tagIds: [...s.tagIds], + duplicates: s.duplicates, + groupBy: s.groupBy, + sortBy: s.sortBy, + sortOrder: s.sortOrder, + } +} + export const useFilterStore = create((set) => ({ ...INITIAL_FILTERS, filterBarOpen: false, + currentSection: ALL_PHOTOS_SECTION, + sectionFilters: {}, + sectionPresets: { [ALL_PHOTOS_SECTION]: {} }, setQ: (q) => set({ q }), setDateFrom: (dateFrom) => set({ dateFrom }), @@ -115,8 +168,40 @@ export const useFilterStore = create((set) => ({ setFilterBarOpen: (filterBarOpen) => set({ filterBarOpen }), toggleFilterBar: () => set((s) => ({ filterBarOpen: !s.filterBarOpen })), + navigateToSection: (sectionId, presetOverrides = {}) => + set((s) => { + // Snapshot the current section's filters before switching. + const updatedSectionFilters = { + ...s.sectionFilters, + [s.currentSection]: snapshotFilters(s), + } + // Remember this section's intrinsic preset (last write wins, which + // is fine — sections are uniquely identified by id). + const updatedSectionPresets = { + ...s.sectionPresets, + [sectionId]: presetOverrides, + } + // Restore the destination section's saved state, or apply the + // preset on top of fresh defaults if it's never been visited. + const saved = updatedSectionFilters[sectionId] + const next: FilterState = saved + ? saved + : { ...INITIAL_FILTERS, ...presetOverrides } + + return { + ...next, + currentSection: sectionId, + sectionFilters: updatedSectionFilters, + sectionPresets: updatedSectionPresets, + } + }), + hydrate: (partial) => set(partial), - clearAll: () => set({ ...INITIAL_FILTERS }), + clearAll: () => + set((s) => { + const preset = s.sectionPresets[s.currentSection] ?? {} + return { ...INITIAL_FILTERS, ...preset } + }), })) /** Convert filter state to the query params the backend list endpoint expects.