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:
@@ -9,8 +9,11 @@ import { ToastContainer } from './components/ToastContainer'
|
||||
import { KeyboardShortcuts } from './components/KeyboardShortcuts'
|
||||
import { KeyboardHints } from './components/KeyboardHints'
|
||||
import { LoupeView } from './components/loupe/LoupeView'
|
||||
import { FilterBar } from './components/filter/FilterBar'
|
||||
import { ActiveFilterChips } from './components/filter/ActiveFilterChips'
|
||||
import { usePhotoStore } from './store/photoStore'
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
|
||||
import { useFilterUrlSync } from './hooks/useFilterUrlSync'
|
||||
import type { Photo } from './types/photo'
|
||||
|
||||
function App() {
|
||||
@@ -20,6 +23,9 @@ function App() {
|
||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Bidirectional sync of filter store with URL query params.
|
||||
useFilterUrlSync()
|
||||
|
||||
// Set up global keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
|
||||
@@ -45,6 +51,8 @@ function App() {
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg text-text">
|
||||
<TopBar />
|
||||
<FilterBar />
|
||||
<ActiveFilterChips />
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left Sidebar */}
|
||||
|
||||
81
frontend/src/components/filter/ActiveFilterChips.tsx
Normal file
81
frontend/src/components/filter/ActiveFilterChips.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
||||
|
||||
export function ActiveFilterChips() {
|
||||
const f = useFilterStore()
|
||||
|
||||
if (!hasActiveFilters(f)) return null
|
||||
|
||||
const chips: { key: string; label: string; onRemove: () => void }[] = []
|
||||
|
||||
if (f.q.trim()) {
|
||||
chips.push({
|
||||
key: 'q',
|
||||
label: `Search: "${f.q.trim()}"`,
|
||||
onRemove: () => f.setQ(''),
|
||||
})
|
||||
}
|
||||
if (f.dateFrom) {
|
||||
chips.push({
|
||||
key: 'dateFrom',
|
||||
label: `From: ${f.dateFrom}`,
|
||||
onRemove: () => f.setDateFrom(null),
|
||||
})
|
||||
}
|
||||
if (f.dateTo) {
|
||||
chips.push({
|
||||
key: 'dateTo',
|
||||
label: `To: ${f.dateTo}`,
|
||||
onRemove: () => f.setDateTo(null),
|
||||
})
|
||||
}
|
||||
for (const t of f.mediaTypes) {
|
||||
chips.push({
|
||||
key: `mt-${t}`,
|
||||
label: t.toUpperCase(),
|
||||
onRemove: () => f.toggleMediaType(t),
|
||||
})
|
||||
}
|
||||
if (f.ratingMin > 0) {
|
||||
chips.push({
|
||||
key: 'rating',
|
||||
label: `Rating ≥ ${f.ratingMin}★`,
|
||||
onRemove: () => f.setRatingMin(0),
|
||||
})
|
||||
}
|
||||
if (f.colorLabel) {
|
||||
chips.push({
|
||||
key: 'color',
|
||||
label: f.colorLabel,
|
||||
onRemove: () => f.setColorLabel(null),
|
||||
})
|
||||
}
|
||||
if (f.flag !== 'any') {
|
||||
chips.push({
|
||||
key: 'flag',
|
||||
label: f.flag,
|
||||
onRemove: () => f.setFlag('any'),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-surface-2 px-4 py-2 text-xs">
|
||||
<span className="text-text-muted">Active filters:</span>
|
||||
{chips.map((chip) => (
|
||||
<span
|
||||
key={chip.key}
|
||||
className="flex items-center gap-1 rounded bg-primary/20 px-2 py-0.5 text-primary"
|
||||
>
|
||||
{chip.label}
|
||||
<button
|
||||
onClick={chip.onRemove}
|
||||
className="rounded p-0.5 hover:bg-primary/30"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
185
frontend/src/components/filter/FilterBar.tsx
Normal file
185
frontend/src/components/filter/FilterBar.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Star, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
useFilterStore,
|
||||
type MediaType,
|
||||
type ColorLabel,
|
||||
type FlagFilter,
|
||||
} from '../../store/filterStore'
|
||||
|
||||
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
||||
{ value: 'photo', label: 'Photo' },
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'raw', label: 'RAW' },
|
||||
{ value: 'heic', label: 'HEIC' },
|
||||
]
|
||||
|
||||
const COLOR_LABELS: { value: ColorLabel; className: string }[] = [
|
||||
{ value: 'red', className: 'bg-red-500' },
|
||||
{ value: 'orange', className: 'bg-orange-500' },
|
||||
{ value: 'yellow', className: 'bg-yellow-400' },
|
||||
{ value: 'green', className: 'bg-green-500' },
|
||||
{ value: 'blue', className: 'bg-blue-500' },
|
||||
{ value: 'purple', className: 'bg-purple-500' },
|
||||
]
|
||||
|
||||
const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [
|
||||
{ value: 'any', label: 'Any' },
|
||||
{ value: 'picked', label: 'Picked' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
{ value: 'unflagged', label: 'Unflagged' },
|
||||
]
|
||||
|
||||
export function FilterBar() {
|
||||
const filterBarOpen = useFilterStore((s) => s.filterBarOpen)
|
||||
const dateFrom = useFilterStore((s) => s.dateFrom)
|
||||
const dateTo = useFilterStore((s) => s.dateTo)
|
||||
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
|
||||
const setDateFrom = useFilterStore((s) => s.setDateFrom)
|
||||
const setDateTo = useFilterStore((s) => s.setDateTo)
|
||||
const toggleMediaType = useFilterStore((s) => s.toggleMediaType)
|
||||
const setRatingMin = useFilterStore((s) => s.setRatingMin)
|
||||
const setColorLabel = useFilterStore((s) => s.setColorLabel)
|
||||
const setFlag = useFilterStore((s) => s.setFlag)
|
||||
const clearAll = useFilterStore((s) => s.clearAll)
|
||||
|
||||
if (!filterBarOpen) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 border-b border-border bg-surface px-4 py-3 text-xs">
|
||||
{/* Date range */}
|
||||
<Group label="Date">
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom ?? ''}
|
||||
onChange={(e) => setDateFrom(e.target.value || null)}
|
||||
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text"
|
||||
/>
|
||||
<span className="text-text-muted">→</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo ?? ''}
|
||||
onChange={(e) => setDateTo(e.target.value || null)}
|
||||
className="rounded border border-border bg-bg px-2 py-1 text-xs text-text"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Media type chips */}
|
||||
<Group label="Type">
|
||||
{MEDIA_TYPES.map(({ value, label }) => {
|
||||
const active = mediaTypes.includes(value)
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => toggleMediaType(value)}
|
||||
className={clsx(
|
||||
'rounded px-2 py-1 transition-colors',
|
||||
active
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</Group>
|
||||
|
||||
{/* Min rating */}
|
||||
<Group label="Rating ≥">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => setRatingMin(ratingMin === n ? 0 : n)}
|
||||
className="p-0.5"
|
||||
title={`At least ${n} star${n > 1 ? 's' : ''}`}
|
||||
>
|
||||
<Star
|
||||
className={clsx(
|
||||
'h-4 w-4 transition-colors',
|
||||
n <= ratingMin
|
||||
? 'fill-star text-star'
|
||||
: 'text-text-muted hover:text-star'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
{/* Color label dots */}
|
||||
<Group label="Color">
|
||||
{COLOR_LABELS.map(({ value, className }) => {
|
||||
const active = colorLabel === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setColorLabel(active ? null : value)}
|
||||
className={clsx(
|
||||
'h-4 w-4 rounded-full ring-offset-1 ring-offset-surface transition-all',
|
||||
className,
|
||||
active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
|
||||
)}
|
||||
title={value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{colorLabel && (
|
||||
<button
|
||||
onClick={() => setColorLabel(null)}
|
||||
className="ml-1 text-text-muted hover:text-text"
|
||||
title="Clear color"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Flag */}
|
||||
<Group label="Flag">
|
||||
{FLAG_OPTIONS.map(({ value, label }) => {
|
||||
const active = flag === value
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setFlag(value)}
|
||||
className={clsx(
|
||||
'rounded px-2 py-1 transition-colors',
|
||||
active
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</Group>
|
||||
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="ml-auto rounded border border-border px-2 py-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Group({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-text-muted">{label}:</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Grid,
|
||||
@@ -8,17 +8,50 @@ import {
|
||||
Upload,
|
||||
Settings,
|
||||
Menu,
|
||||
Trash2
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
|
||||
import { photos } from '../../services/api'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import muliLogo from '../../assets/muli-logo.png'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
export function TopBar() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
// Filter store is the source of truth for search; the input has a local
|
||||
// mirror so typing stays responsive while we debounce store updates.
|
||||
const storeQ = useFilterStore((s) => s.q)
|
||||
const setStoreQ = useFilterStore((s) => s.setQ)
|
||||
const filterBarOpen = useFilterStore((s) => s.filterBarOpen)
|
||||
const toggleFilterBar = useFilterStore((s) => s.toggleFilterBar)
|
||||
const filterState = useFilterStore()
|
||||
const filtersActive = hasActiveFilters(filterState) || filterBarOpen
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(storeQ)
|
||||
|
||||
// Keep local input in sync if the store is changed externally (URL hydrate,
|
||||
// active-chip removal, clear-all).
|
||||
useEffect(() => {
|
||||
setSearchQuery(storeQ)
|
||||
}, [storeQ])
|
||||
|
||||
// Debounce local input -> store.
|
||||
const debounceRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (searchQuery === storeQ) return
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
setStoreQ(searchQuery)
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return () => {
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [searchQuery, storeQ, setStoreQ])
|
||||
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
|
||||
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
||||
const clearSelection = usePhotoStore((state) => state.clearSelection)
|
||||
@@ -80,12 +113,32 @@ export function TopBar() {
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
id="topbar-search"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchQuery('')
|
||||
setStoreQ('')
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder="Search photos..."
|
||||
className="w-full rounded-md border border-border bg-bg py-1.5 pl-9 pr-3 text-sm text-text placeholder-text-muted focus:border-primary focus:outline-none"
|
||||
className="w-full rounded-md border border-border bg-bg py-1.5 pl-9 pr-9 text-sm text-text placeholder-text-muted focus:border-primary focus:outline-none"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setStoreQ('')
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,12 +174,18 @@ export function TopBar() {
|
||||
|
||||
{/* Filter Button */}
|
||||
<button
|
||||
className="group relative rounded p-1.5 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||
title="Filter photos (Ctrl+F)"
|
||||
onClick={toggleFilterBar}
|
||||
className={clsx(
|
||||
'group relative rounded p-1.5 transition-colors',
|
||||
filtersActive
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'text-text-muted hover:bg-surface-2 hover:text-text'
|
||||
)}
|
||||
title="Toggle filters (\\)"
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
<kbd className="absolute -bottom-5 left-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-surface-offset px-1 py-0.5 text-[9px] font-medium text-text opacity-0 group-hover:opacity-100">
|
||||
Ctrl+F
|
||||
\
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useRef, useEffect, useMemo, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore, filtersToParams } from '../../store/filterStore'
|
||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import axios from 'axios'
|
||||
@@ -45,16 +46,47 @@ export function Timeline() {
|
||||
return Math.floor((containerWidth - padding * 2) / (thumbnailSize + gap))
|
||||
}, [containerWidth, thumbnailSize, gap, padding])
|
||||
|
||||
// Fetch photos from backend
|
||||
// Filter state — included in the query key so the cache invalidates when
|
||||
// any filter changes. Subscribing field-by-field keeps re-renders cheap.
|
||||
const q = useFilterStore((s) => s.q)
|
||||
const dateFrom = useFilterStore((s) => s.dateFrom)
|
||||
const dateTo = useFilterStore((s) => s.dateTo)
|
||||
const mediaTypes = useFilterStore((s) => s.mediaTypes)
|
||||
const ratingMin = useFilterStore((s) => s.ratingMin)
|
||||
const colorLabel = useFilterStore((s) => s.colorLabel)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
|
||||
const filterParams = useMemo(
|
||||
() =>
|
||||
filtersToParams({
|
||||
q,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
mediaTypes,
|
||||
ratingMin,
|
||||
colorLabel,
|
||||
flag,
|
||||
}),
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag]
|
||||
)
|
||||
|
||||
// Fetch photos from backend. Note: backend uses page/per_page (max 500),
|
||||
// not limit/offset — sending limit/offset previously was a silent no-op.
|
||||
const { data: photos = [], isLoading } = useQuery({
|
||||
queryKey: ['photos'],
|
||||
queryKey: ['photos', filterParams],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get<{photos: Photo[], total: number}>('http://localhost:8001/api/v1/photos', {
|
||||
params: {
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
const response = await axios.get<{ photos: Photo[]; total: number }>(
|
||||
'http://localhost:8001/api/v1/photos',
|
||||
{
|
||||
params: {
|
||||
page: 1,
|
||||
per_page: 500,
|
||||
sort: 'taken_at',
|
||||
order: 'desc',
|
||||
...filterParams,
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data.photos || []
|
||||
},
|
||||
staleTime: 30000,
|
||||
|
||||
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()
|
||||
|
||||
99
frontend/src/store/filterStore.ts
Normal file
99
frontend/src/store/filterStore.ts
Normal 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'
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user