From b4a2241bd932f2931cd4be4092918d3e863a7756 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 12:07:16 +0200 Subject: [PATCH] feat: per-section filter memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filters were global — switching from "Discarded" to a folder kept the discarded flag, switching from a heap to All Photos kept the heap filter, etc. Confusing because the user couldn't tell what state any section would be in until they got there. Now each "section" remembers its own filter state independently. The in-memory map is keyed by section id ('all-photos', 'rated', 'discarded', 'duplicates', 'tags', 'folder-{id}', 'heap-{id}'), and navigating saves the current section's state under its id and restores the destination's. Sections you've never visited start with their intrinsic preset on top of INITIAL_FILTERS. filterStore additions - currentSection: string (default 'all-photos') - sectionFilters: Record — in-memory snapshots - sectionPresets: Record> — the intrinsic filter that defines each section, used by clearAll - navigateToSection(id, presetOverrides): 1. snapshot the current FilterState slice into sectionFilters[ currentSection] 2. record presetOverrides in sectionPresets[id] 3. set currentSection = id 4. load sectionFilters[id] if a saved snapshot exists, otherwise apply presetOverrides on top of INITIAL_FILTERS - clearAll: now resets the CURRENT section to its preset rather than jumping to all-photos. The user explicitly clicks All Photos to navigate. - snapshotFilters() helper extracts the FilterState slice cleanly so control fields (filterBarOpen, the maps themselves) don't leak into per-section state. URL sync - writeUrl serialises currentSection as ?section=… (omitted for the default 'all-photos'). - parseUrl reads it back into currentSection on hydrate. Per-section memory is in-memory only; reload restores the current view but not the other sections' saved states (acceptable for MVP). LeftSidebar - applyLibraryNode now dispatches navigateToSection per node, with the appropriate preset: all-photos → {} rated → { ratingMin: 1 } discarded → { flag: 'discarded' } duplicates → { duplicates: true } tags → { groupBy: 'tag' } folder-X → { folderId: X } - isItemActive collapses to a single check against currentSection for both library nodes and folder rows. Dropped the old selectedItem local state and the per-field active probes; they were doing the same job in a more fragile way. HeapsPanel - Heap row click → navigateToSection(`heap-${id}`, { heapId: id }) - isFiltered uses currentSection instead of filterStore.heapId - Deleting the currently-viewed heap navigates back to all-photos via navigateToSection (was setFilterHeapId(null), which now lives in the section model). User flow: 1. Click Discarded → seeing discarded photos. 2. Open FilterBar, set Rating ≥ 3 — discarded section now has rating. 3. Click Library "Library" folder → no rating filter, just library contents. 4. Open FilterBar, set media type Photo only — folder section now has that. 5. Click Discarded again → restored to discarded + rating ≥ 3. 6. Click Library folder again → restored to library + photo only. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/heaps/HeapsPanel.tsx | 16 ++-- .../src/components/layout/LeftSidebar.tsx | 58 ++++-------- frontend/src/hooks/useFilterUrlSync.ts | 7 +- frontend/src/store/filterStore.ts | 89 ++++++++++++++++++- 4 files changed, 121 insertions(+), 49 deletions(-) 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.