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

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