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 (
- 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}
-
- )
- })}
-
-
- {/* Min rating */}
-
- {[1, 2, 3, 4, 5].map((n) => (
- setRatingMin(ratingMin === n ? 0 : n)}
- className="p-0.5"
- title={`At least ${n} star${n > 1 ? 's' : ''}`}
- >
-
+ {/* Date */}
+ {
+ setDateFrom(null)
+ setDateTo(null)
+ }}
+ >
+
+
- {/* Flag */}
-
- {FLAG_OPTIONS.map(({ value, label }) => {
- const active = flag === value
- return (
- 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}
-
- )
- })}
-
-
- {/* 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 (
toggleTagId(tag.id)}
+ key={value}
+ onClick={() => toggleMediaType(value)}
className={clsx(
- 'rounded px-2 py-1 transition-colors',
+ 'rounded px-2 py-1 text-xs transition-colors',
active
? 'bg-primary text-white'
: 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
)}
>
- {tag.name}
+ {label}
)
})}
-
+
+
+
+ {/* Rating */}
+ setRatingMin(0)}
+ >
+
+
Minimum
+
+ {[1, 2, 3, 4, 5].map((n) => (
+ setRatingMin(ratingMin === n ? 0 : n)}
+ className="p-0.5"
+ title={`At least ${n} star${n > 1 ? 's' : ''}`}
+ >
+
+
+ ))}
+
+
+
+
+ {/* Color */}
+ setColorLabel(null)}
+ >
+
+ {COLOR_LABEL_OPTIONS.map(({ value, className }) => {
+ const active = colorLabel === value
+ return (
+ setColorLabel(active ? null : value)}
+ className={clsx(
+ 'h-5 w-5 rounded-full ring-offset-2 ring-offset-surface transition-all',
+ className,
+ active ? 'ring-2 ring-primary' : 'opacity-60 hover:opacity-100'
+ )}
+ title={value}
+ />
+ )
+ })}
+ {colorLabel && (
+ setColorLabel(null)}
+ className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
+ title="Clear color"
+ >
+
+
+ )}
+
+
+
+ {/* Flag — discarded toggle */}
+ setFlag('any')}
+ >
+
+ setFlag('any')}
+ className={clsx(
+ 'rounded px-2 py-1 text-left text-xs transition-colors',
+ flag === 'any'
+ ? 'bg-primary text-white'
+ : 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
+ )}
+ >
+ Any
+
+ setFlag('discarded')}
+ className={clsx(
+ 'rounded px-2 py-1 text-left text-xs transition-colors',
+ flag === 'discarded'
+ ? 'bg-primary text-white'
+ : 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
+ )}
+ >
+ Discarded
+
+
+
+
+ {/* Tags */}
+ {allTags.length > 0 && (
+ setTagIds([])}
+ >
+
+ {allTags.map((tag) => {
+ const active = tagIds.includes(tag.id)
+ return (
+ toggleTagId(tag.id)}
+ className={clsx(
+ 'rounded px-2 py-1 text-xs transition-colors',
+ active
+ ? 'bg-primary text-white'
+ : 'bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
+ )}
+ >
+ {tag.name}
+
+ )
+ })}
+
+
)}
- {/* Sort */}
-
- setSortBy(e.target.value as SortField)}
- className="rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none"
- >
- {SORT_OPTIONS.map((opt) => (
-
- {opt.label}
-
- ))}
-
+ {/* Sort — always present, never "active/inactive" since there's
+ always a value. */}
+
+
+
setSortBy(e.target.value as SortField)}
+ className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text focus:border-primary focus:outline-none"
+ >
+ {SORT_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ {sortOrder === 'desc' ? (
+ <>
+
+ Descending
+ >
+ ) : (
+ <>
+
+ Ascending
+ >
+ )}
+
+
+
+
+ {anyActive && (
- {sortOrder === 'desc' ? (
-
- ) : (
-
- )}
+ Clear all
-
-
-
- Clear all
-
-
- )
-}
-
-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 (
+
+
setOpen((v) => !v)}
+ className={clsx(
+ 'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors',
+ isActive
+ ? 'border-primary/40 bg-primary/15 text-primary'
+ : 'border-border bg-surface-2 text-text-muted hover:bg-surface-offset hover:text-text'
+ )}
+ >
+ {label}
+ {isActive && value && (
+ {value}
+ )}
+ {isActive && onClear ? (
+ {
+ e.stopPropagation()
+ onClear()
+ }}
+ className="ml-0.5 rounded-full p-0.5 hover:bg-primary/30"
+ title={`Clear ${label}`}
+ aria-label={`Clear ${label}`}
+ >
+
+
+ ) : (
+
+ )}
+
+
+ {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 */}
-