diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9aaeeda..6c77038 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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,7 +51,9 @@ function App() { return (
- + + +
{/* Left Sidebar */}
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 ( +
+ Active filters: + {chips.map((chip) => ( + + {chip.label} + + + ))} +
+ ) +} diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx new file mode 100644 index 0000000..0d1eb75 --- /dev/null +++ b/frontend/src/components/filter/FilterBar.tsx @@ -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 ( +
+ {/* Date range */} + + setDateFrom(e.target.value || null)} + className="rounded border border-border bg-bg px-2 py-1 text-xs text-text" + /> + + setDateTo(e.target.value || null)} + className="rounded border border-border bg-bg px-2 py-1 text-xs text-text" + /> + + + {/* Media type chips */} + + {MEDIA_TYPES.map(({ value, label }) => { + const active = mediaTypes.includes(value) + return ( + + ) + })} + + + {/* Min rating */} + + {[1, 2, 3, 4, 5].map((n) => ( + + ))} + + + {/* Color label dots */} + + {COLOR_LABELS.map(({ value, className }) => { + const active = colorLabel === value + return ( + + )} + + + {/* Flag */} + + {FLAG_OPTIONS.map(({ value, label }) => { + const active = flag === value + return ( + + ) + })} + + + +
+ ) +} + +function Group({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( +
+ {label}: + {children} +
+ ) +} diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index ee58f55..4831b05 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -1,24 +1,57 @@ -import { useState } from 'react' -import { - Search, - Grid, - List, +import { useState, useEffect, useRef } from 'react' +import { + Search, + Grid, + List, SlidersHorizontal, FolderOpen, 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(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() {
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 && ( + + )}
@@ -121,12 +174,18 @@ export function TopBar() { {/* Filter Button */} diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index d9fa60f..38b7c86 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -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, diff --git a/frontend/src/hooks/useFilterUrlSync.ts b/frontend/src/hooks/useFilterUrlSync.ts new file mode 100644 index 0000000..a099342 --- /dev/null +++ b/frontend/src/hooks/useFilterUrlSync.ts @@ -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 { + const sp = new URLSearchParams(window.location.search) + const out: Partial = {} + + 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) + }) + }, []) +} diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index 39c9e69..90adf82 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -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() diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts new file mode 100644 index 0000000..6d58aab --- /dev/null +++ b/frontend/src/store/filterStore.ts @@ -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) => void + clearAll: () => void +} + +export const INITIAL_FILTERS: FilterState = { + q: '', + dateFrom: null, + dateTo: null, + mediaTypes: [], + ratingMin: 0, + colorLabel: null, + flag: 'any', +} + +export const useFilterStore = create((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 { + const params: Record = {} + 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' + ) +}