feat: filter bar with search, URL sync, and active-filter chips
Adds the spec §6.7 filter bar to the top of the timeline with: - Date range (native date inputs) - Media type chips (Photo / Video / RAW / HEIC, multi-select) - Min star rating (click to set, click again to clear) - Color label dots (red/orange/yellow/green/blue/purple, single) - Flag (any / picked / rejected / unflagged) - Clear-all button Active filters surface as removable chips below the bar so they're visible whether the bar is collapsed or open. The TopBar search input is now wired to the same filter store with a 300ms debounce, and shows a clear button when populated. Filter state is the source of truth in a Zustand store and round-trips through the URL via history.replaceState — bookmarkable and shareable per spec §6.7. Hydrate happens once on mount; subsequent store changes write back to ?q=&date_from=&… without navigation. Timeline reads filter state, builds the backend params via filtersToParams, and includes them in the React Query key so the cache invalidates on every filter change. Also fixes a latent bug: Timeline was sending limit=1000&offset=0, which the backend silently ignores — swapped to page=1&per_page=500 with explicit sort=taken_at&order=desc. New keyboard shortcuts: - \\ toggles the filter bar - / focuses the TopBar search input - Cmd/Ctrl+F same as / Filter button in the TopBar now lights up when filters are active or the bar is open. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
102
frontend/src/hooks/useFilterUrlSync.ts
Normal file
102
frontend/src/hooks/useFilterUrlSync.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
useFilterStore,
|
||||
type FilterState,
|
||||
type MediaType,
|
||||
type ColorLabel,
|
||||
type FlagFilter,
|
||||
} from '../store/filterStore'
|
||||
|
||||
const ALLOWED_MEDIA: MediaType[] = ['photo', 'video', 'raw', 'heic']
|
||||
const ALLOWED_COLORS: ColorLabel[] = [
|
||||
'red',
|
||||
'orange',
|
||||
'yellow',
|
||||
'green',
|
||||
'blue',
|
||||
'purple',
|
||||
]
|
||||
const ALLOWED_FLAGS: FlagFilter[] = ['any', 'picked', 'rejected', 'unflagged']
|
||||
|
||||
function parseUrl(): Partial<FilterState> {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const out: Partial<FilterState> = {}
|
||||
|
||||
const q = sp.get('q')
|
||||
if (q) out.q = q
|
||||
|
||||
const df = sp.get('date_from')
|
||||
if (df) out.dateFrom = df
|
||||
|
||||
const dt = sp.get('date_to')
|
||||
if (dt) out.dateTo = dt
|
||||
|
||||
const mt = sp.get('media_type')
|
||||
if (mt) {
|
||||
const types = mt
|
||||
.split(',')
|
||||
.filter((t): t is MediaType => ALLOWED_MEDIA.includes(t as MediaType))
|
||||
if (types.length > 0) out.mediaTypes = types
|
||||
}
|
||||
|
||||
const rm = sp.get('rating_min')
|
||||
if (rm) {
|
||||
const n = parseInt(rm, 10)
|
||||
if (Number.isFinite(n) && n >= 0 && n <= 5) out.ratingMin = n
|
||||
}
|
||||
|
||||
const cl = sp.get('color_label')
|
||||
if (cl && ALLOWED_COLORS.includes(cl as ColorLabel)) {
|
||||
out.colorLabel = cl as ColorLabel
|
||||
}
|
||||
|
||||
const flag = sp.get('flag')
|
||||
if (flag && ALLOWED_FLAGS.includes(flag as FlagFilter)) {
|
||||
out.flag = flag as FlagFilter
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function writeUrl(f: FilterState) {
|
||||
const sp = new URLSearchParams()
|
||||
if (f.q.trim()) sp.set('q', f.q.trim())
|
||||
if (f.dateFrom) sp.set('date_from', f.dateFrom)
|
||||
if (f.dateTo) sp.set('date_to', f.dateTo)
|
||||
if (f.mediaTypes.length > 0) sp.set('media_type', f.mediaTypes.join(','))
|
||||
if (f.ratingMin > 0) sp.set('rating_min', String(f.ratingMin))
|
||||
if (f.colorLabel) sp.set('color_label', f.colorLabel)
|
||||
if (f.flag !== 'any') sp.set('flag', f.flag)
|
||||
|
||||
const search = sp.toString()
|
||||
const next = search ? `?${search}` : window.location.pathname
|
||||
if (next !== window.location.search && next !== window.location.pathname + window.location.search) {
|
||||
window.history.replaceState(null, '', next)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bidirectional URL <-> filterStore sync. Hydrates the store from the URL on
|
||||
* mount, then writes any subsequent store changes back to the URL via
|
||||
* history.replaceState (no navigation).
|
||||
*/
|
||||
export function useFilterUrlSync() {
|
||||
const hydrated = useRef(false)
|
||||
|
||||
// Hydrate once on mount.
|
||||
useEffect(() => {
|
||||
const fromUrl = parseUrl()
|
||||
if (Object.keys(fromUrl).length > 0) {
|
||||
useFilterStore.getState().hydrate(fromUrl)
|
||||
}
|
||||
hydrated.current = true
|
||||
}, [])
|
||||
|
||||
// Mirror store -> URL on every change (after hydration).
|
||||
useEffect(() => {
|
||||
return useFilterStore.subscribe((state) => {
|
||||
if (!hydrated.current) return
|
||||
writeUrl(state)
|
||||
})
|
||||
}, [])
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePhotoStore } from '../store/photoStore'
|
||||
import { useFilterStore } from '../store/filterStore'
|
||||
import { photos as photosApi } from '../services/api'
|
||||
|
||||
interface KeyboardShortcutsProps {
|
||||
@@ -66,6 +67,21 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
onToggleRightSidebar()
|
||||
})
|
||||
|
||||
// Filter bar toggle (\) and search focus (/ or Cmd/Ctrl+F).
|
||||
useHotkeys('\\', (e) => {
|
||||
e.preventDefault()
|
||||
useFilterStore.getState().toggleFilterBar()
|
||||
})
|
||||
|
||||
const focusSearch = (e: KeyboardEvent) => {
|
||||
e.preventDefault()
|
||||
const el = document.getElementById('topbar-search') as HTMLInputElement | null
|
||||
el?.focus()
|
||||
el?.select()
|
||||
}
|
||||
useHotkeys('/', focusSearch)
|
||||
useHotkeys('mod+f', focusSearch)
|
||||
|
||||
// G always returns to grid (closes loupe if open).
|
||||
useHotkeys('g', () => {
|
||||
closeLoupe()
|
||||
|
||||
Reference in New Issue
Block a user