feat(topbar): drop search box to reclaim filter-bar space

The search box on the right edge of the filter bar wasn't pulling its
weight — kills it entirely along with the supporting plumbing:

- FilterBar: remove input + Search icon import + local/debounced state
- filterStore: drop `q`, `setQ`, plus all references in INITIAL_FILTERS,
  filtersToParams, hasActiveFilters, snapshotFilters
- usePhotosQuery: stop passing q through filtersToParams
- useFilterUrlSync: drop the `q` URL param read/write
- PhotoThumbnail + PreviewView: remove the search-match banner/chip and
  findSearchMatch helper imports
- Timeline + MemoriesView: stop subscribing to / forwarding the prop
- useKeyboardShortcuts: drop the `/` and Cmd+F focus hotkeys
- KeyboardHints: drop the `/` hint and the now-stale `?` collision note
- delete hooks/useSearchQuery.ts (no callers) and lib/searchMatch.ts

Backend /photos/search endpoint left untouched — no UI reaches it now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-05-12 09:53:32 +02:00
parent c30b387dc3
commit ab3c55dd96
12 changed files with 2 additions and 395 deletions

View File

@@ -65,7 +65,6 @@ function getHints(opts: {
{ key: 'Space', action: 'Preview' },
{ key: 'Tab', action: 'Library panel' },
{ key: 'I', action: 'Info panel' },
{ key: '/', action: 'Search' },
]
}
@@ -81,8 +80,7 @@ export function KeyboardHints() {
localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0')
}, [collapsed])
// `H` toggles the panel. `?` (shift+/) collides with the global `/`
// search shortcut, so we use a plain letter instead.
// `H` toggles the panel.
useHotkeys('h', () => setCollapsed((c) => !c), { preventDefault: true })
const hints = getHints({ selectedCount, currentSection, viewMode })

View File

@@ -1,10 +1,8 @@
import { useEffect, useRef, useState } from 'react'
import {
Star,
X,
ArrowDown,
ArrowUp,
Search,
PanelLeftOpen,
PanelLeftClose,
PanelRightOpen,
@@ -26,7 +24,6 @@ import {
import { useTagsQuery } from '../../hooks/useTagsQuery'
import { FilterPill } from './FilterPill'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import {
Select,
@@ -37,8 +34,6 @@ import {
} from '@/components/ui/select'
import { MultiSelect } from '@/components/ui/multi-select'
const SEARCH_DEBOUNCE_MS = 300
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
{ value: 'photo', label: 'Photo' },
{ value: 'video', label: 'Video' },
@@ -112,26 +107,6 @@ export function FilterBar({
const { data: allTags = [] } = useTagsQuery()
// Search box. Local state mirrors the store so typing stays responsive
// while we debounce store writes (each store write triggers a re-fetch).
const storeQ = useFilterStore((s) => s.q)
const setStoreQ = useFilterStore((s) => s.setQ)
const [searchQuery, setSearchQuery] = useState(storeQ)
useEffect(() => {
setSearchQuery(storeQ)
}, [storeQ])
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])
// Pre-compute pill values + active flags so the JSX stays terse.
const typeActive = mediaTypes.length > 0
const typeValue = typeActive
@@ -527,40 +502,6 @@ export function FilterBar({
)}
</div>
{/* Search — pinned to the right edge of the bar. Same id as before
* so the global "/" focus shortcut still finds it. */}
<div className="relative w-56 flex-shrink-0">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -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="h-7 w-full rounded-full bg-surface-2 pl-8 pr-7 text-xs"
/>
{searchQuery && (
<button
onClick={() => {
setSearchQuery('')
setStoreQ('')
}}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
title="Clear search (Esc)"
aria-label="Clear search"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Right sidebar toggle — pinned to the far-right edge. */}
<button
onClick={onToggleRightSidebar}

View File

@@ -8,7 +8,6 @@ import {
} from '../../services/api'
import type { Photo } from '../../types/photo'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { useViewSettingsStore } from '../../store/viewSettingsStore'
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
@@ -53,7 +52,6 @@ export function MemoriesView() {
const selectRange = usePhotoStore((s) => s.selectRange)
const openPreview = usePhotoStore((s) => s.openPreview)
const viewMode = usePhotoStore((s) => s.viewMode)
const searchQuery = useFilterStore((s) => s.q)
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
const visibleSequenceRef = useRef<string[]>([])
@@ -216,7 +214,6 @@ export function MemoriesView() {
fill
isSelected={selectedPhotos.includes(m.id)}
isInActiveHeap={activeHeapMembers.has(m.id)}
searchQuery={searchQuery}
onClick={handleCellClick}
onDoubleClick={handleCellDoubleClick}
/>

View File

@@ -3,10 +3,8 @@ import { useQuery } from '@tanstack/react-query'
import { useHotkeys } from 'react-hotkeys-hook'
import { X, Info } from 'lucide-react'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { photos as photosApi } from '../../services/api'
import { findSearchMatch } from '../../lib/searchMatch'
import type { Photo } from '../../types/photo'
import { PreviewImage } from './PreviewImage'
import { PreviewFilmstrip } from './PreviewFilmstrip'
@@ -86,15 +84,6 @@ export function PreviewView() {
const currentPhoto: Photo | undefined =
photoInListById ?? photos[safeIndex] ?? standalonePhoto
// Carry the timeline's search-match chip into preview so the user
// doesn't lose the "why did this photo come back" context when they
// zoom in. Pure recompute — same helper the thumbnail uses.
const searchQuery = useFilterStore((s) => s.q)
const searchMatch =
currentPhoto && searchQuery.trim()
? findSearchMatch(currentPhoto, searchQuery)
: null
// Keep the latest photos array + active id in a ref so the keyboard
// handlers ALWAYS read the freshest state. Without this, react-hotkeys-
// hook can fire a closure that captured an older photos array (e.g.
@@ -237,34 +226,6 @@ export function PreviewView() {
<div className="text-text-muted">
{safeIndex + 1} / {photos.length}
</div>
{searchMatch && (
<div
className="mt-1 flex items-center gap-1.5 text-[11px]"
title={`Matched on ${searchMatch.label.toLowerCase()}: ${searchMatch.excerpt}`}
>
<span className="shrink-0 rounded-sm bg-primary/80 px-1 text-[9px] font-semibold uppercase tracking-wider">
{searchMatch.label}
</span>
<span className="truncate">
{searchMatch.matchLength > 0 ? (
<>
{searchMatch.excerpt.slice(0, searchMatch.matchStart)}
<mark className="rounded-sm bg-amber-400/90 px-0.5 font-semibold text-black">
{searchMatch.excerpt.slice(
searchMatch.matchStart,
searchMatch.matchStart + searchMatch.matchLength,
)}
</mark>
{searchMatch.excerpt.slice(
searchMatch.matchStart + searchMatch.matchLength,
)}
</>
) : (
searchMatch.excerpt
)}
</span>
</div>
)}
</div>
{/* Top-right action buttons */}

View File

@@ -13,7 +13,6 @@ import { photos as photosApi } from '../../services/api'
import type { Photo } from '../../types/photo'
import { usePhotoStore } from '../../store/photoStore'
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
import { findSearchMatch } from '../../lib/searchMatch'
/** Custom MIME used by HeapsPanel to recognise our drag payload. */
export const PHOTO_DRAG_MIME = 'application/x-mulita-photos'
@@ -82,10 +81,6 @@ interface PhotoThumbnailProps {
* view is filtered to the active heap, where every cell would
* otherwise be wash-green. */
hideActiveHeapTint?: boolean
/** Active text-search query, passed in from Timeline rather than
* subscribed-to here so we don't have N thumbnails each running a
* per-keystroke selector. Empty string disables the highlight. */
searchQuery?: string
/** Called with the photo + the native event. Pass a stable handler
* (useCallback with store-action deps) so React.memo can actually
* elide re-renders on unrelated store updates. */
@@ -101,7 +96,6 @@ function PhotoThumbnailImpl({
isInActiveHeap = false,
hideDiscardedTint = false,
hideActiveHeapTint = false,
searchQuery = '',
onClick,
onDoubleClick,
}: PhotoThumbnailProps) {
@@ -127,16 +121,6 @@ function PhotoThumbnailImpl({
// thumbnail badge all read from one source of truth.
const dateWarning = photo.has_date_warning === true
// When a query is active we compute which field on this photo
// matched so the user can see *why* the photo came back from the
// search instead of guessing. The query itself is passed down as a
// prop (subscribed once at the Timeline level) — having every
// thumbnail subscribe individually multiplied keystroke renders by
// the row count.
const searchMatch = searchQuery.trim()
? findSearchMatch(photo, searchQuery)
: null
// Square cells (Lightroom-style grid). Variable-aspect cells previously
// overflowed their row because TanStack Virtual estimates row height as a
// single fixed value — portraits in a landscape row would overlap the row
@@ -347,42 +331,6 @@ function PhotoThumbnailImpl({
* Corner ownership is fixed: TL=selection, TR=file-type,
* BL=rating, BR=flags. This keeps badges from stacking or colliding. */}
{/* Top banner — shown only when a text search is active, explains
* which metadata field of this photo matched the user's query so
* they don't have to guess why the photo came back. The matched
* substring is wrapped in an amber highlight span so the user
* can see *exactly* what hit — e.g. "**Mul**ti-segment" when
* searching "mul". Sits above the TL/TR corner badges (lower
* z-index) so they still read on top of long excerpts. */}
{searchMatch && (
<div
className="pointer-events-none absolute inset-x-0 top-0 z-[1] flex items-center gap-1 bg-black/75 px-1.5 py-0.5 text-[10px] font-medium text-white backdrop-blur-sm"
title={`Matched on ${searchMatch.label.toLowerCase()}: ${searchMatch.excerpt}`}
>
<span className="shrink-0 rounded-sm bg-primary/80 px-1 text-[9px] font-semibold uppercase tracking-wider">
{searchMatch.label}
</span>
<span className="truncate">
{searchMatch.matchLength > 0 ? (
<>
{searchMatch.excerpt.slice(0, searchMatch.matchStart)}
<mark className="rounded-sm bg-amber-400/90 px-0.5 font-semibold text-black">
{searchMatch.excerpt.slice(
searchMatch.matchStart,
searchMatch.matchStart + searchMatch.matchLength
)}
</mark>
{searchMatch.excerpt.slice(
searchMatch.matchStart + searchMatch.matchLength
)}
</>
) : (
searchMatch.excerpt
)}
</span>
</div>
)}
{/* TL — owner badge for shared photos. Selection itself is
* conveyed by the ring/outline on the wrapper, no badge needed. */}
{photo.owner_username && (

View File

@@ -177,10 +177,6 @@ export function Timeline() {
const currentSection = useFilterStore((s) => s.currentSection)
const flag = useFilterStore((s) => s.flag)
const filterHeapId = useFilterStore((s) => s.heapId)
// Subscribed once at this level and passed down to each
// PhotoThumbnail as a prop. Previously every thumbnail had its own
// subscription, multiplying keystroke renders by the row count.
const searchQuery = useFilterStore((s) => s.q)
const viewMode = usePhotoStore((s) => s.viewMode)
const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize)
@@ -856,7 +852,6 @@ export function Timeline() {
isInActiveHeap={activeHeapMembers.has(photo.id)}
hideDiscardedTint={hideDiscardedTint}
hideActiveHeapTint={hideActiveHeapTint}
searchQuery={searchQuery}
onClick={handleCellClick}
onDoubleClick={handleCellDoubleClick}
/>