feat: highlight matched metadata on thumbnails during search

Renders a thin top-of-cell banner on each photo while a text search
is active, labelling which metadata field matched (filename, title,
note, tag, or EXIF key name) and showing a short excerpt with the
exact matched substring highlighted in amber. Extends the list
endpoint's search to also match photos whose tag names contain the
query so tags show up alongside filename/title/notes/EXIF hits.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 21:38:27 +02:00
parent 64e4ea8083
commit 38d975e354
3 changed files with 236 additions and 2 deletions

View File

@@ -0,0 +1,175 @@
/**
* 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
}