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}
/>

View File

@@ -37,9 +37,6 @@ function parseUrl(): HydratePayload {
const sp = new URLSearchParams(window.location.search)
const out: HydratePayload = {}
const q = sp.get('q')
if (q) out.q = q
const df = sp.get('date_from')
if (df) out.dateFrom = df
@@ -112,7 +109,6 @@ function parseUrl(): HydratePayload {
function writeUrl(f: FilterState & { currentSection?: string }) {
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(','))

View File

@@ -403,15 +403,6 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
HK_OPTS
)
// Search focus (/ or Cmd/Ctrl+F).
const focusSearch = () => {
const el = document.getElementById('topbar-search') as HTMLInputElement | null
el?.focus()
el?.select()
}
useHotkeys('/', focusSearch, HK_OPTS)
useHotkeys('mod+f', focusSearch, HK_OPTS)
// Space toggles the preview view (open from grid, close from preview).
// Double-click on a thumbnail does the same.
const openPreviewFromGrid = () => {

View File

@@ -49,7 +49,6 @@ export function usePhotosQuery() {
const filterParams = useFilterStore(
useShallow((s) =>
filtersToParams({
q: s.q,
dateFrom: s.dateFrom,
dateTo: s.dateTo,
mediaTypes: s.mediaTypes,

View File

@@ -1,37 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import { search, type SearchResult } from '../services/api'
import { useFilterStore } from '../store/filterStore'
/**
* Hybrid search hook — fires POST /photos/search when the user has a
* non-empty search query. Returns results ranked by RRF (FTS + semantic).
*
* When `q` is empty, this hook is disabled and returns no data — the
* normal usePhotosQuery takes over for browse mode.
*/
export function useSearchQuery() {
const q = useFilterStore((s) => s.q)
const tagIds = useFilterStore((s) => s.tagIds)
const dateFrom = useFilterStore((s) => s.dateFrom)
const dateTo = useFilterStore((s) => s.dateTo)
const hasQuery = q.trim().length > 0
return useQuery<SearchResult[]>({
queryKey: ['search', q, tagIds, dateFrom, dateTo],
queryFn: async () => {
const resp = await search.query({
q: q.trim(),
filters: {
tag_ids: tagIds.length > 0 ? tagIds : undefined,
date_from: dateFrom ?? undefined,
date_to: dateTo ?? undefined,
},
limit: 200,
})
return resp.results
},
enabled: hasQuery,
staleTime: 30_000,
})
}

View File

@@ -1,175 +0,0 @@
/**
* Client-side re-implementation of the backend list-endpoint text search,
* used to explain on each thumbnail *which* metadata field actually
* matched the user's query. The backend search at
* `backend/app/routers/photos.py` walks filename, user_title,
* user_notes, exif_json, and tag names with case-insensitive ILIKE; we
* mirror that here over the PhotoResponse fields that the list endpoint
* already returns on the wire.
*
* Pure, deterministic, no I/O. Returns `null` when the query is empty
* or no field matches (the latter shouldn't happen when the backend
* and frontend logic agree, but we'd rather render nothing than lie).
*/
import type { Photo } from '../types/photo'
export type SearchMatchField =
| 'filename'
| 'title'
| 'notes'
| 'tag'
| 'exif'
export interface SearchMatch {
field: SearchMatchField
/** Human-friendly label shown before the excerpt, e.g. "Tag" or
* "EXIF: Make". The backend doesn't store per-field breakdowns for
* exif so we parse the blob and name the key whose value matched. */
label: string
/** Short excerpt centred on the matched substring — up to ~40 chars
* of context on either side, ellipsed when trimmed. */
excerpt: string
/** Char offset of the match WITHIN ``excerpt`` (not the original
* value), so the UI can wrap the matched run in a highlight span
* without redoing the search. */
matchStart: number
/** Length of the matched run inside ``excerpt``. Together with
* ``matchStart`` this fully describes where to highlight. */
matchLength: number
}
const MAX_EXCERPT = 48
interface Excerpt {
text: string
/** Offset of the matched substring inside ``text``. */
matchStart: number
matchLength: number
}
/** Build a compact excerpt of ``value`` centred on the first occurrence
* of ``needle`` (case-insensitive) and return it alongside the exact
* offset of the match inside the excerpt, so the caller can highlight
* it without re-searching. Preserves the original casing so the user
* reads what the metadata actually contains. */
function excerpt(value: string, needleLower: string): Excerpt {
const lower = value.toLowerCase()
const idx = lower.indexOf(needleLower)
if (idx === -1) {
return {
text: value.slice(0, MAX_EXCERPT),
matchStart: 0,
matchLength: 0,
}
}
const half = Math.floor((MAX_EXCERPT - needleLower.length) / 2)
const start = Math.max(0, idx - half)
const end = Math.min(value.length, idx + needleLower.length + half)
const prefix = start > 0 ? '…' : ''
const suffix = end < value.length ? '…' : ''
const text = prefix + value.slice(start, end) + suffix
return {
text,
matchStart: prefix.length + (idx - start),
matchLength: needleLower.length,
}
}
function wrap(
field: SearchMatchField,
label: string,
ex: Excerpt,
): SearchMatch {
return {
field,
label,
excerpt: ex.text,
matchStart: ex.matchStart,
matchLength: ex.matchLength,
}
}
/** Walk a parsed EXIF object looking for the first (key, value) pair
* whose stringified value contains the needle. Keys are stripped of
* their `Group:` prefix for display (EXIF output from exiftool looks
* like `EXIF:Make` / `IFD0:Model`). Returns null when nothing inside
* the blob matches. */
function findExifMatch(
exifJson: string | null | undefined,
needleLower: string,
): { key: string; value: string } | null {
if (!exifJson) return null
let parsed: unknown
try {
parsed = JSON.parse(exifJson)
} catch {
// Not JSON? Fall back to substring — the whole blob still
// counts as "EXIF" in the chip.
const lower = exifJson.toLowerCase()
if (lower.includes(needleLower)) {
return { key: 'metadata', value: exifJson }
}
return null
}
if (!parsed || typeof parsed !== 'object') return null
for (const [rawKey, rawVal] of Object.entries(parsed as Record<string, unknown>)) {
if (rawVal == null) continue
const str = typeof rawVal === 'string' ? rawVal : JSON.stringify(rawVal)
if (str.toLowerCase().includes(needleLower)) {
// Drop the `Group:` prefix some exiftool outputs carry.
const label = rawKey.includes(':') ? rawKey.split(':').slice(-1)[0] : rawKey
return { key: label, value: str }
}
}
return null
}
/**
* Figure out which field of ``photo`` the user's ``query`` matches and
* return a compact chip-ready description. Field priority mirrors what
* a human would look at first: filename → title → notes → tag → EXIF.
* Returns `null` for an empty query or (rarely) a photo that somehow
* came back from the backend without any client-visible match.
*/
export function findSearchMatch(
photo: Photo,
query: string,
): SearchMatch | null {
const trimmed = query.trim()
if (!trimmed) return null
const needle = trimmed.toLowerCase()
if (photo.filename && photo.filename.toLowerCase().includes(needle)) {
return wrap('filename', 'Filename', excerpt(photo.filename, needle))
}
// PhotoResponse's user_title/user_notes aren't on the Photo type used
// by the timeline (that's the lighter list shape), but any extra
// props are still carried through React Query's cache — cast through
// `unknown` so we can read them opportunistically without widening
// the Photo type for every consumer.
const anyPhoto = photo as unknown as Record<string, unknown>
const title = typeof anyPhoto.user_title === 'string' ? anyPhoto.user_title : null
if (title && title.toLowerCase().includes(needle)) {
return wrap('title', 'Title', excerpt(title, needle))
}
const notes = typeof anyPhoto.user_notes === 'string' ? anyPhoto.user_notes : null
if (notes && notes.toLowerCase().includes(needle)) {
return wrap('notes', 'Note', excerpt(notes, needle))
}
if (photo.tags && photo.tags.length > 0) {
const hit = photo.tags.find((t) => t.name.toLowerCase().includes(needle))
if (hit) {
return wrap('tag', 'Tag', excerpt(hit.name, needle))
}
}
const exifJson = typeof anyPhoto.exif_json === 'string' ? anyPhoto.exif_json : null
const exifHit = findExifMatch(exifJson, needle)
if (exifHit) {
return wrap('exif', `EXIF: ${exifHit.key}`, excerpt(exifHit.value, needle))
}
return null
}

View File

@@ -14,7 +14,6 @@ export type SortOrder = 'asc' | 'desc'
export type GroupBy = 'date' | 'tag' | 'rating' | 'color'
export interface FilterState {
q: string
dateFrom: string | null // ISO yyyy-mm-dd
dateTo: string | null
mediaTypes: MediaType[]
@@ -57,7 +56,6 @@ interface FilterStore extends FilterState {
* navigating away. */
sectionPresets: Record<string, Partial<FilterState>>
setQ: (q: string) => void
setDateFrom: (date: string | null) => void
setDateTo: (date: string | null) => void
toggleMediaType: (t: MediaType) => void
@@ -93,7 +91,6 @@ interface FilterStore extends FilterState {
}
export const INITIAL_FILTERS: FilterState = {
q: '',
dateFrom: null,
dateTo: null,
mediaTypes: [],
@@ -116,7 +113,6 @@ export const INITIAL_FILTERS: FilterState = {
* per-section map. */
function snapshotFilters(s: FilterState): FilterState {
return {
q: s.q,
dateFrom: s.dateFrom,
dateTo: s.dateTo,
mediaTypes: [...s.mediaTypes],
@@ -141,7 +137,6 @@ export const useFilterStore = create<FilterStore>((set) => ({
sectionFilters: {},
sectionPresets: { [ALL_PHOTOS_SECTION]: {} },
setQ: (q) => set({ q }),
setDateFrom: (dateFrom) => set({ dateFrom }),
setDateTo: (dateTo) => set({ dateTo }),
toggleMediaType: (t) =>
@@ -211,7 +206,6 @@ export const useFilterStore = create<FilterStore>((set) => ({
* Empty / default values are omitted so the cache key is stable. */
export function filtersToParams(f: FilterState): Record<string, string | number> {
const params: Record<string, string | number> = {}
if (f.q.trim()) params.q = f.q.trim()
// Both bounds need an explicit time component because pydantic v2
// rejects bare date strings ("2026-04-10") for datetime params with
// 422. The from is start-of-day; the to is inclusive end-of-day so
@@ -235,10 +229,9 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
return params
}
/** True if any filter (other than the search box) is active. */
/** True if any filter is active. */
export function hasActiveFilters(f: FilterState): boolean {
return (
f.q.trim() !== '' ||
f.dateFrom !== null ||
f.dateTo !== null ||
f.mediaTypes.length > 0 ||