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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user