From 38d975e3541cb83c202c9f3a3f0d2d7d19a9a063 Mon Sep 17 00:00:00 2001 From: dtoro Date: Sat, 11 Apr 2026 21:38:27 +0200 Subject: [PATCH] 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) --- backend/app/routers/photos.py | 15 +- .../components/timeline/PhotoThumbnail.tsx | 48 +++++ frontend/src/lib/searchMatch.ts | 175 ++++++++++++++++++ 3 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 frontend/src/lib/searchMatch.ts diff --git a/backend/app/routers/photos.py b/backend/app/routers/photos.py index 93bf6b0..2a63256 100644 --- a/backend/app/routers/photos.py +++ b/backend/app/routers/photos.py @@ -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), ) ) diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx index ed4da6c..f9d6093 100644 --- a/frontend/src/components/timeline/PhotoThumbnail.tsx +++ b/frontend/src/components/timeline/PhotoThumbnail.tsx @@ -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 && ( +
+ + {searchMatch.label} + + + {searchMatch.matchLength > 0 ? ( + <> + {searchMatch.excerpt.slice(0, searchMatch.matchStart)} + + {searchMatch.excerpt.slice( + searchMatch.matchStart, + searchMatch.matchStart + searchMatch.matchLength + )} + + {searchMatch.excerpt.slice( + searchMatch.matchStart + searchMatch.matchLength + )} + + ) : ( + searchMatch.excerpt + )} + +
+ )} + {/* TL — selection */} {isSelected && (
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)) { + 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 + 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 +}