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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user