refactor: compact pill-based filter toolbar

Merge the toggleable multi-line FilterBar and the separate ActiveFilterChips
strip into a single always-visible row of pills. Each filter category is a
pill that opens a small popover with its underlying control; when active, the
pill shows its current value inline (so the chips strip is redundant).

- New FilterPill primitive: outside-click + Escape to close, optional inline
  X to clear without opening the popover.
- FilterBar rebuilt out of pills for Date/Type/Rating/Color/Flag/Tags/Sort,
  with a Clear-all pill on the right when any filter is active.
- Drop filterBarOpen from filterStore, the SlidersHorizontal toggle from
  TopBar, the \\ shortcut from useKeyboardShortcuts, and the matching hint
  from KeyboardHints — the bar is always visible now.
- Delete ActiveFilterChips; its information lives inside the pills.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 12:49:14 +02:00
parent 522228fb79
commit 2f1e9033ae
8 changed files with 374 additions and 346 deletions

View File

@@ -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() {
<div className="flex flex-col h-screen bg-bg text-text">
<TopBar />
<FilterBar />
<ActiveFilterChips />
<DiscardActionBar />
<KeyboardHints />

View File

@@ -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' },
]

View File

@@ -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<FolderTreeNode[]>({
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 (
<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>
)
}

View File

@@ -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 (
<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'
)}
<div className="flex items-center gap-1.5 overflow-x-auto border-b border-border bg-surface px-3 py-1.5">
{/* Date */}
<FilterPill
label="Date"
value={dateValue}
isActive={dateActive}
onClear={() => {
setDateFrom(null)
setDateTo(null)
}}
>
<div className="space-y-2">
<div>
<label className="mb-1 block text-[11px] text-text-muted">From</label>
<input
type="date"
value={dateFrom ?? ''}
onChange={(e) => setDateFrom(e.target.value || null)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
</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}
</div>
<div>
<label className="mb-1 block text-[11px] text-text-muted">To</label>
<input
type="date"
value={dateTo ?? ''}
onChange={(e) => setDateTo(e.target.value || null)}
className="w-full rounded border border-border bg-bg px-2 py-1 text-xs text-text"
/>
)
})}
{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>
</div>
</div>
</FilterPill>
{/* 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>
{/* Tags */}
{allTags.length > 0 && (
<Group label="Tags">
{allTags.map((tag) => {
const active = tagIds.includes(tag.id)
{/* Type */}
<FilterPill
label="Type"
value={typeValue}
isActive={typeActive}
onClear={() => mediaTypes.forEach((t) => toggleMediaType(t))}
>
<div className="flex flex-wrap gap-1">
{MEDIA_TYPES.map(({ value, label }) => {
const active = mediaTypes.includes(value)
return (
<button
key={tag.id}
onClick={() => 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}
</button>
)
})}
</Group>
</div>
</FilterPill>
{/* Rating */}
<FilterPill
label="Rating"
value={ratingValue}
isActive={ratingActive}
onClear={() => setRatingMin(0)}
>
<div>
<p className="mb-1 text-[11px] text-text-muted">Minimum</p>
<div className="flex gap-1">
{[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-5 w-5 transition-colors',
n <= ratingMin
? 'fill-star text-star'
: 'text-text-muted hover:text-star'
)}
/>
</button>
))}
</div>
</div>
</FilterPill>
{/* Color */}
<FilterPill
label="Color"
value={colorValue}
isActive={colorActive}
onClear={() => setColorLabel(null)}
>
<div className="flex items-center gap-1.5">
{COLOR_LABEL_OPTIONS.map(({ value, className }) => {
const active = colorLabel === value
return (
<button
key={value}
onClick={() => 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 && (
<button
onClick={() => setColorLabel(null)}
className="ml-1 rounded p-0.5 text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear color"
>
<X className="h-3 w-3" />
</button>
)}
</div>
</FilterPill>
{/* Flag — discarded toggle */}
<FilterPill
label="Flag"
value={flagValue}
isActive={flagActive}
onClear={() => setFlag('any')}
>
<div className="flex flex-col gap-1">
<button
onClick={() => 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
</button>
<button
onClick={() => 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
</button>
</div>
</FilterPill>
{/* Tags */}
{allTags.length > 0 && (
<FilterPill
label="Tags"
value={tagValue}
isActive={tagActive}
onClear={() => setTagIds([])}
>
<div className="flex max-h-60 flex-wrap gap-1 overflow-y-auto">
{allTags.map((tag) => {
const active = tagIds.includes(tag.id)
return (
<button
key={tag.id}
onClick={() => 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}
</button>
)
})}
</div>
</FilterPill>
)}
{/* Sort */}
<Group label="Sort">
<select
value={sortBy}
onChange={(e) => 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) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{/* Sort — always present, never "active/inactive" since there's
always a value. */}
<FilterPill label="Sort" value={sortValue} isActive>
<div className="space-y-2">
<select
value={sortBy}
onChange={(e) => 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) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
onClick={toggleSortOrder}
className="flex w-full items-center justify-center gap-1 rounded bg-surface-2 px-2 py-1 text-xs text-text-muted hover:bg-surface-offset hover:text-text"
>
{sortOrder === 'desc' ? (
<>
<ArrowDown className="h-3.5 w-3.5" />
Descending
</>
) : (
<>
<ArrowUp className="h-3.5 w-3.5" />
Ascending
</>
)}
</button>
</div>
</FilterPill>
{anyActive && (
<button
onClick={toggleSortOrder}
className="rounded bg-surface-2 p-1 text-text-muted hover:bg-surface-offset hover:text-text"
title={sortOrder === 'desc' ? 'Descending (click for ascending)' : 'Ascending (click for descending)'}
onClick={clearAll}
className="ml-auto whitespace-nowrap rounded-full border border-border px-2.5 py-1 text-xs text-text-muted hover:bg-surface-2 hover:text-text"
title="Clear all filters in this section"
>
{sortOrder === 'desc' ? (
<ArrowDown className="h-3.5 w-3.5" />
) : (
<ArrowUp className="h-3.5 w-3.5" />
)}
Clear all
</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>
)
}

View File

@@ -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<HTMLDivElement>(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 (
<div ref={wrapperRef} className="relative">
<button
onClick={() => 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'
)}
>
<span className={clsx(isActive && 'font-medium')}>{label}</span>
{isActive && value && (
<span className="font-mono text-[11px] opacity-90">{value}</span>
)}
{isActive && onClear ? (
<button
onClick={(e) => {
e.stopPropagation()
onClear()
}}
className="ml-0.5 rounded-full p-0.5 hover:bg-primary/30"
title={`Clear ${label}`}
aria-label={`Clear ${label}`}
>
<X className="h-3 w-3" />
</button>
) : (
<ChevronDown className="h-3 w-3 opacity-60" />
)}
</button>
{open && (
<div
className={clsx(
'absolute top-full z-30 mt-1 min-w-[220px] rounded-lg border border-border bg-surface p-3 shadow-xl',
alignRight ? 'right-0' : 'left-0'
)}
>
{children}
</div>
)}
</div>
)
}

View File

@@ -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() {
</div>
</div>
{/* Right — filter toggle */}
<div className="flex items-center gap-2">
<button
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">
\
</kbd>
</button>
</div>
{/* Right — reserved for future actions */}
<div className="flex items-center gap-2" />
</header>
)
}

View File

@@ -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()

View File

@@ -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<FilterStore>((set) => ({
...INITIAL_FILTERS,
filterBarOpen: false,
currentSection: ALL_PHOTOS_SECTION,
sectionFilters: {},
sectionPresets: { [ALL_PHOTOS_SECTION]: {} },
@@ -165,9 +159,6 @@ export const useFilterStore = create<FilterStore>((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.