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

@@ -58,15 +58,26 @@ async def list_photos(
# Apply filters
filters = []
# Text search (would use FTS5 in production)
# Text search. Walks every metadata field a user might reasonably
# remember a photo by: basename, title, notes, raw EXIF blob, and
# tag names (via a subquery so photos with any matching tag come
# back even when the tag filter isn't set). Case-insensitive ILIKE
# across all fields — the frontend re-runs the same match logic to
# render a "matched on …" chip on each thumbnail.
if q:
search_pattern = f"%{q}%"
tag_subq = (
select(photo_tags.c.photo_id)
.select_from(photo_tags.join(Tag, photo_tags.c.tag_id == Tag.id))
.where(Tag.name.ilike(search_pattern))
)
filters.append(
or_(
Photo.filename.ilike(search_pattern),
Photo.user_title.ilike(search_pattern),
Photo.user_notes.ilike(search_pattern),
Photo.exif_json.ilike(search_pattern)
Photo.exif_json.ilike(search_pattern),
Photo.id.in_(tag_subq),
)
)

View File

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

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
}