diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a1f3dc6..c6d15d6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,7 +8,6 @@ import { ToastContainer } from './components/ToastContainer' import { KeyboardHints } from './components/KeyboardHints' import { PreviewView } from './components/preview/PreviewView' import { FilterBar } from './components/filter/FilterBar' -import { ActiveFilterChips } from './components/filter/ActiveFilterChips' import { DiscardActionBar } from './components/discard/DiscardActionBar' import { usePhotoStore } from './store/photoStore' import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' @@ -52,7 +51,6 @@ function App() {
- diff --git a/frontend/src/components/KeyboardHints.tsx b/frontend/src/components/KeyboardHints.tsx index aac27e6..2dbe667 100644 --- a/frontend/src/components/KeyboardHints.tsx +++ b/frontend/src/components/KeyboardHints.tsx @@ -21,7 +21,6 @@ export function KeyboardHints() { { key: 'Click', action: 'Select' }, { key: 'Shift+Click', action: 'Range' }, { key: 'Space', action: 'Preview' }, - { key: '\\', action: 'Filters' }, { key: '/', action: 'Search' }, ] diff --git a/frontend/src/components/filter/ActiveFilterChips.tsx b/frontend/src/components/filter/ActiveFilterChips.tsx deleted file mode 100644 index c1b2439..0000000 --- a/frontend/src/components/filter/ActiveFilterChips.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { X } from 'lucide-react' -import { useQuery } from '@tanstack/react-query' -import { useFilterStore, hasActiveFilters } from '../../store/filterStore' -import { sourceFolders, heaps as heapsApi, tags as tagsApi, type FolderTreeNode } from '../../services/api' -import { findFolderInTree } from '../../hooks/useFolderTreeQuery' - -export function ActiveFilterChips() { - const f = useFilterStore() - - // Look up names for id-based filters so the chips show something - // human-readable instead of opaque uuids. The folder tree handles - // both top-level source roots and nested subfolders. - const { data: folderTree } = useQuery({ - queryKey: ['folders', 'tree'], - queryFn: sourceFolders.tree, - enabled: f.folderId !== null, - }) - const folder = f.folderId ? findFolderInTree(folderTree, f.folderId) : null - - const { data: heaps = [] } = useQuery({ - queryKey: ['heaps'], - queryFn: heapsApi.list, - enabled: f.heapId !== null, - }) - const heap = f.heapId ? heaps.find((h) => h.id === f.heapId) : null - - const { data: allTags = [] } = useQuery({ - queryKey: ['tags'], - queryFn: tagsApi.list, - enabled: f.tagIds.length > 0, - }) - - 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'), - }) - } - if (f.folderId) { - chips.push({ - key: 'folder', - label: `Folder: ${folder?.name || f.folderId}`, - onRemove: () => f.setFolderId(null), - }) - } - if (f.heapId) { - chips.push({ - key: 'heap', - label: `Heap: ${heap?.name ?? f.heapId}`, - onRemove: () => f.setHeapId(null), - }) - } - for (const tagId of f.tagIds) { - const tag = allTags.find((t) => t.id === tagId) - chips.push({ - key: `tag-${tagId}`, - label: `Tag: ${tag?.name ?? tagId}`, - onRemove: () => f.toggleTagId(tagId), - }) - } - - return ( -
- Active filters: - {chips.map((chip) => ( - - {chip.label} - - - ))} -
- ) -} diff --git a/frontend/src/components/filter/FilterBar.tsx b/frontend/src/components/filter/FilterBar.tsx index 5cd011f..cafb23f 100644 --- a/frontend/src/components/filter/FilterBar.tsx +++ b/frontend/src/components/filter/FilterBar.tsx @@ -2,12 +2,13 @@ import { Star, X, ArrowDown, ArrowUp } from 'lucide-react' import clsx from 'clsx' import { useFilterStore, + hasActiveFilters, type MediaType, type ColorLabel, - type FlagFilter, type SortField, } from '../../store/filterStore' import { useTagsQuery } from '../../hooks/useTagsQuery' +import { FilterPill } from './FilterPill' const MEDIA_TYPES: { value: MediaType; label: string }[] = [ { value: 'photo', label: 'Photo' }, @@ -16,7 +17,7 @@ const MEDIA_TYPES: { value: MediaType; label: string }[] = [ { value: 'heic', label: 'HEIC' }, ] -const COLOR_LABELS: { value: ColorLabel; className: string }[] = [ +const COLOR_LABEL_OPTIONS: { value: ColorLabel; className: string }[] = [ { value: 'red', className: 'bg-red-500' }, { value: 'orange', className: 'bg-orange-500' }, { value: 'yellow', className: 'bg-yellow-400' }, @@ -25,11 +26,6 @@ const COLOR_LABELS: { value: ColorLabel; className: string }[] = [ { value: 'purple', className: 'bg-purple-500' }, ] -const FLAG_OPTIONS: { value: FlagFilter; label: string }[] = [ - { value: 'any', label: 'Any' }, - { value: 'discarded', label: 'Discarded' }, -] - const SORT_OPTIONS: { value: SortField; label: string }[] = [ { value: 'taken_at', label: 'Date taken' }, { value: 'added_at', label: 'Date added' }, @@ -38,8 +34,14 @@ const SORT_OPTIONS: { value: SortField; label: string }[] = [ { value: 'rating', label: 'Rating' }, ] +/** + * Compact, always-visible filter toolbar built out of FilterPill primitives. + * Each pill represents a filter category, opens a popover with the + * underlying control, and shows a short value summary inline when active. + * Replaces the old expandable FilterBar + ActiveFilterChips combo. + */ export function FilterBar() { - const filterBarOpen = useFilterStore((s) => s.filterBarOpen) + const filterState = useFilterStore() const dateFrom = useFilterStore((s) => s.dateFrom) const dateTo = useFilterStore((s) => s.dateTo) const mediaTypes = useFilterStore((s) => s.mediaTypes) @@ -49,8 +51,6 @@ export function FilterBar() { const sortBy = useFilterStore((s) => s.sortBy) const sortOrder = useFilterStore((s) => s.sortOrder) const tagIds = useFilterStore((s) => s.tagIds) - const toggleTagId = useFilterStore((s) => s.toggleTagId) - const { data: allTags = [] } = useTagsQuery() const setDateFrom = useFilterStore((s) => s.setDateFrom) const setDateTo = useFilterStore((s) => s.setDateTo) @@ -58,192 +58,283 @@ export function FilterBar() { const setRatingMin = useFilterStore((s) => s.setRatingMin) const setColorLabel = useFilterStore((s) => s.setColorLabel) const setFlag = useFilterStore((s) => s.setFlag) + const setTagIds = useFilterStore((s) => s.setTagIds) + const toggleTagId = useFilterStore((s) => s.toggleTagId) const setSortBy = useFilterStore((s) => s.setSortBy) const toggleSortOrder = useFilterStore((s) => s.toggleSortOrder) const clearAll = useFilterStore((s) => s.clearAll) - if (!filterBarOpen) return null + const { data: allTags = [] } = useTagsQuery() + + // Pre-compute pill values + active flags so the JSX stays terse. + const dateActive = dateFrom !== null || dateTo !== null + const dateValue = dateActive + ? `${dateFrom ?? '…'} → ${dateTo ?? '…'}` + : null + + const typeActive = mediaTypes.length > 0 + const typeValue = typeActive + ? mediaTypes.map((t) => t.toUpperCase()).join(', ') + : null + + const ratingActive = ratingMin > 0 + const ratingValue = ratingActive ? `≥ ${ratingMin}★` : null + + const colorActive = colorLabel !== null + const colorValue = colorActive ? colorLabel : null + + const flagActive = flag !== 'any' + const flagValue = flagActive ? flag : null + + const tagActive = tagIds.length > 0 + const activeTagNames = allTags + .filter((t) => tagIds.includes(t.id)) + .map((t) => t.name) + const tagValue = tagActive + ? activeTagNames.length <= 2 + ? activeTagNames.join(', ') + : `${activeTagNames.slice(0, 2).join(', ')} +${activeTagNames.length - 2}` + : null + + const sortLabel = SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? sortBy + const sortValue = `${sortLabel} ${sortOrder === 'desc' ? '↓' : '↑'}` + + const anyActive = hasActiveFilters(filterState) 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 ( -
+
+ + setDateTo(e.target.value || null)} + className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text" /> - ) - })} - {colorLabel && ( - - )} - +
+
+ - {/* Flag */} - - {FLAG_OPTIONS.map(({ value, label }) => { - const active = flag === value - return ( - - ) - })} - - - {/* Tags */} - {allTags.length > 0 && ( - - {allTags.map((tag) => { - const active = tagIds.includes(tag.id) + {/* Type */} + mediaTypes.forEach((t) => toggleMediaType(t))} + > +
+ {MEDIA_TYPES.map(({ value, label }) => { + const active = mediaTypes.includes(value) return ( ) })} - +
+
+ + {/* Rating */} + setRatingMin(0)} + > +
+

Minimum

+
+ {[1, 2, 3, 4, 5].map((n) => ( + + ))} +
+
+
+ + {/* Color */} + setColorLabel(null)} + > +
+ {COLOR_LABEL_OPTIONS.map(({ value, className }) => { + const active = colorLabel === value + return ( + + )} +
+
+ + {/* Flag — discarded toggle */} + setFlag('any')} + > +
+ + +
+
+ + {/* Tags */} + {allTags.length > 0 && ( + setTagIds([])} + > +
+ {allTags.map((tag) => { + const active = tagIds.includes(tag.id) + return ( + + ) + })} +
+
)} - {/* Sort */} - - + {/* Sort — always present, never "active/inactive" since there's + always a value. */} + +
+ + +
+
+ + {anyActive && ( -
- - - - ) -} - -function Group({ - label, - children, -}: { - label: string - children: React.ReactNode -}) { - return ( -
- {label}: - {children} + )}
) } diff --git a/frontend/src/components/filter/FilterPill.tsx b/frontend/src/components/filter/FilterPill.tsx new file mode 100644 index 0000000..a9f71bd --- /dev/null +++ b/frontend/src/components/filter/FilterPill.tsx @@ -0,0 +1,107 @@ +import { useEffect, useRef, useState } from 'react' +import { ChevronDown, X } from 'lucide-react' +import clsx from 'clsx' + +interface FilterPillProps { + /** Category label, always shown ("Date", "Type", etc.). */ + label: string + /** When the filter is active, a short summary of its current value + * ("≥ 3★", "RAW + Photo", "Mar 2024 → Apr 2026"). Renders inside the + * pill so the user sees the state without opening the popover. */ + value?: string | null + isActive?: boolean + /** When provided + isActive, an X appears inside the pill that clears + * this filter without opening the popover. */ + onClear?: () => void + /** Popover contents — usually the existing control for this filter. */ + children: React.ReactNode + /** Force the popover open programmatically (rare). */ + defaultOpen?: boolean + /** Right-align the popover instead of left (for pills near the right + * edge so they don't overflow the viewport). */ + alignRight?: boolean +} + +/** + * A toolbar pill that hosts a filter category. Click the pill to open a + * small popover with the actual control; the popover closes on outside + * click or Escape. Active filters tint the pill primary and show their + * current value inline. + */ +export function FilterPill({ + label, + value, + isActive = false, + onClear, + children, + defaultOpen = false, + alignRight = false, +}: FilterPillProps) { + const [open, setOpen] = useState(defaultOpen) + const wrapperRef = useRef(null) + + // Close on outside click + Escape. + useEffect(() => { + if (!open) return + const onDocMouseDown = (e: MouseEvent) => { + if (!wrapperRef.current) return + if (!wrapperRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', onDocMouseDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDocMouseDown) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + return ( +
+ + ) : ( + + )} + + + {open && ( +
+ {children} +
+ )} +
+ ) +} diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index 83ce6d8..dd1f86a 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -1,12 +1,6 @@ import { useState, useEffect, useRef } from 'react' -import { - Search, - SlidersHorizontal, - X, - ShoppingBasket, -} from 'lucide-react' -import clsx from 'clsx' -import { useFilterStore, hasActiveFilters } from '../../store/filterStore' +import { Search, X, ShoppingBasket } from 'lucide-react' +import { useFilterStore } from '../../store/filterStore' import { useHeapsQuery } from '../../hooks/useHeapsQuery' import muliLogo from '../../assets/muli-logo.png' @@ -17,10 +11,6 @@ export function TopBar() { // 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) @@ -101,24 +91,8 @@ export function TopBar() { - {/* Right — filter toggle */} -
- -
+ {/* Right — reserved for future actions */} +
) } diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index cc5467f..4d675c2 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -1,7 +1,6 @@ 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, heaps as heapsApi, type Heap } from '../services/api' import { HEAPS_QUERY_KEY } from './useHeapsQuery' import { toast } from '../components/ToastContainer' @@ -222,9 +221,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) { useHotkeys('tab', onToggleLeftSidebar, HK_OPTS) useHotkeys('i', onToggleRightSidebar, HK_OPTS) - // Filter bar toggle (\) and search focus (/ or Cmd/Ctrl+F). - useHotkeys('\\', () => useFilterStore.getState().toggleFilterBar(), HK_OPTS) - + // Search focus (/ or Cmd/Ctrl+F). const focusSearch = () => { const el = document.getElementById('topbar-search') as HTMLInputElement | null el?.focus() diff --git a/frontend/src/store/filterStore.ts b/frontend/src/store/filterStore.ts index 93386d8..b53ad5b 100644 --- a/frontend/src/store/filterStore.ts +++ b/frontend/src/store/filterStore.ts @@ -43,8 +43,6 @@ export interface FilterState { export const ALL_PHOTOS_SECTION = 'all-photos' interface FilterStore extends FilterState { - filterBarOpen: boolean - /** The active section id. Changes via navigateToSection. */ currentSection: string /** Per-section snapshot of filter state, in-memory. Restored on return. */ @@ -72,9 +70,6 @@ interface FilterStore extends FilterState { setSortOrder: (order: SortOrder) => void toggleSortOrder: () => void - setFilterBarOpen: (open: boolean) => void - toggleFilterBar: () => void - /** Navigate to a section. Saves the current section's filter state into * the in-memory map under the OLD section id, then loads the saved * state for the destination — or, if none exists, applies the preset @@ -132,7 +127,6 @@ function snapshotFilters(s: FilterState): FilterState { export const useFilterStore = create((set) => ({ ...INITIAL_FILTERS, - filterBarOpen: false, currentSection: ALL_PHOTOS_SECTION, sectionFilters: {}, sectionPresets: { [ALL_PHOTOS_SECTION]: {} }, @@ -165,9 +159,6 @@ export const useFilterStore = create((set) => ({ toggleSortOrder: () => set((s) => ({ sortOrder: s.sortOrder === 'desc' ? 'asc' : 'desc' })), - setFilterBarOpen: (filterBarOpen) => set({ filterBarOpen }), - toggleFilterBar: () => set((s) => ({ filterBarOpen: !s.filterBarOpen })), - navigateToSection: (sectionId, presetOverrides = {}) => set((s) => { // Snapshot the current section's filters before switching.