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
+}