feat: per-section filter memory
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<sectionId, FilterState> — in-memory snapshots
- sectionPresets: Record<sectionId, Partial<FilterState>> — 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -33,7 +33,6 @@ interface TreeItem {
|
||||
|
||||
export function LeftSidebar() {
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||
const [selectedItem, setSelectedItem] = useState<string | null>('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<string | null>(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) {
|
||||
|
||||
@@ -82,6 +82,9 @@ function parseUrl(): Partial<FilterState> {
|
||||
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<FilterState> {
|
||||
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)
|
||||
|
||||
|
||||
@@ -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<string, FilterState>
|
||||
/** 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<string, Partial<FilterState>>
|
||||
|
||||
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<FilterState>) => 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<FilterState>
|
||||
) => void
|
||||
|
||||
hydrate: (partial: Partial<FilterState> & { 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<FilterStore>((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<FilterStore>((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.
|
||||
|
||||
Reference in New Issue
Block a user