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:
2026-04-07 21:50:58 +02:00
parent 892e8e1da4
commit d2155d9dd2
8 changed files with 602 additions and 20 deletions

View File

@@ -0,0 +1,99 @@
import { create } from 'zustand'
export type MediaType = 'photo' | 'video' | 'raw' | 'heic'
export type ColorLabel = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple'
export type FlagFilter = 'any' | 'picked' | 'rejected' | 'unflagged'
export interface FilterState {
q: string
dateFrom: string | null // ISO yyyy-mm-dd
dateTo: string | null
mediaTypes: MediaType[]
ratingMin: number // 0-5; 0 means no filter
colorLabel: ColorLabel | null
flag: FlagFilter
}
interface FilterStore extends FilterState {
filterBarOpen: boolean
setQ: (q: string) => void
setDateFrom: (date: string | null) => void
setDateTo: (date: string | null) => void
toggleMediaType: (t: MediaType) => void
setRatingMin: (rating: number) => void
setColorLabel: (label: ColorLabel | null) => void
setFlag: (flag: FlagFilter) => void
setFilterBarOpen: (open: boolean) => void
toggleFilterBar: () => void
hydrate: (partial: Partial<FilterState>) => void
clearAll: () => void
}
export const INITIAL_FILTERS: FilterState = {
q: '',
dateFrom: null,
dateTo: null,
mediaTypes: [],
ratingMin: 0,
colorLabel: null,
flag: 'any',
}
export const useFilterStore = create<FilterStore>((set) => ({
...INITIAL_FILTERS,
filterBarOpen: false,
setQ: (q) => set({ q }),
setDateFrom: (dateFrom) => set({ dateFrom }),
setDateTo: (dateTo) => set({ dateTo }),
toggleMediaType: (t) =>
set((s) => ({
mediaTypes: s.mediaTypes.includes(t)
? s.mediaTypes.filter((x) => x !== t)
: [...s.mediaTypes, t],
})),
setRatingMin: (ratingMin) => set({ ratingMin }),
setColorLabel: (colorLabel) => set({ colorLabel }),
setFlag: (flag) => set({ flag }),
setFilterBarOpen: (filterBarOpen) => set({ filterBarOpen }),
toggleFilterBar: () => set((s) => ({ filterBarOpen: !s.filterBarOpen })),
hydrate: (partial) => set(partial),
clearAll: () => set({ ...INITIAL_FILTERS }),
}))
/** Convert filter state to the query params the backend list endpoint expects.
* Empty / default values are omitted so the cache key is stable. */
export function filtersToParams(f: FilterState): Record<string, string | number> {
const params: Record<string, string | number> = {}
if (f.q.trim()) params.q = f.q.trim()
if (f.dateFrom) params.date_from = f.dateFrom
if (f.dateTo) params.date_to = f.dateTo
if (f.mediaTypes.length > 0) params.media_type = f.mediaTypes.join(',')
if (f.ratingMin > 0) params.rating_min = f.ratingMin
if (f.colorLabel) params.color_label = f.colorLabel
if (f.flag === 'picked') params.is_picked = 'true'
else if (f.flag === 'rejected') params.is_rejected = 'true'
else if (f.flag === 'unflagged') {
params.is_picked = 'false'
params.is_rejected = 'false'
}
return params
}
/** True if any filter (other than the search box) is active. */
export function hasActiveFilters(f: FilterState): boolean {
return (
f.q.trim() !== '' ||
f.dateFrom !== null ||
f.dateTo !== null ||
f.mediaTypes.length > 0 ||
f.ratingMin > 0 ||
f.colorLabel !== null ||
f.flag !== 'any'
)
}