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:
@@ -12,7 +12,9 @@ import clsx from 'clsx'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
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'
|
||||
@@ -107,6 +109,16 @@ export function PhotoThumbnail({
|
||||
// thumbnail badge all read from one source of truth.
|
||||
const dateWarning = photo.has_date_warning === true
|
||||
|
||||
// Active search query — subscribed with a focused selector so typing
|
||||
// in the search box only re-renders thumbnails, not every consumer
|
||||
// of the filter store. 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.
|
||||
const searchQuery = useFilterStore((s) => s.q)
|
||||
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
|
||||
@@ -265,6 +277,42 @@ export function PhotoThumbnail({
|
||||
* 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 — selection */}
|
||||
{isSelected && (
|
||||
<div
|
||||
|
||||
175
frontend/src/lib/searchMatch.ts
Normal file
175
frontend/src/lib/searchMatch.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user