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