feat(topbar): drop search box to reclaim filter-bar space
The search box on the right edge of the filter bar wasn't pulling its weight — kills it entirely along with the supporting plumbing: - FilterBar: remove input + Search icon import + local/debounced state - filterStore: drop `q`, `setQ`, plus all references in INITIAL_FILTERS, filtersToParams, hasActiveFilters, snapshotFilters - usePhotosQuery: stop passing q through filtersToParams - useFilterUrlSync: drop the `q` URL param read/write - PhotoThumbnail + PreviewView: remove the search-match banner/chip and findSearchMatch helper imports - Timeline + MemoriesView: stop subscribing to / forwarding the prop - useKeyboardShortcuts: drop the `/` and Cmd+F focus hotkeys - KeyboardHints: drop the `/` hint and the now-stale `?` collision note - delete hooks/useSearchQuery.ts (no callers) and lib/searchMatch.ts Backend /photos/search endpoint left untouched — no UI reaches it now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,6 @@ function getHints(opts: {
|
||||
{ key: 'Space', action: 'Preview' },
|
||||
{ key: 'Tab', action: 'Library panel' },
|
||||
{ key: 'I', action: 'Info panel' },
|
||||
{ key: '/', action: 'Search' },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -81,8 +80,7 @@ export function KeyboardHints() {
|
||||
localStorage.setItem(STORAGE_KEY, collapsed ? '1' : '0')
|
||||
}, [collapsed])
|
||||
|
||||
// `H` toggles the panel. `?` (shift+/) collides with the global `/`
|
||||
// search shortcut, so we use a plain letter instead.
|
||||
// `H` toggles the panel.
|
||||
useHotkeys('h', () => setCollapsed((c) => !c), { preventDefault: true })
|
||||
|
||||
const hints = getHints({ selectedCount, currentSection, viewMode })
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Star,
|
||||
X,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Search,
|
||||
PanelLeftOpen,
|
||||
PanelLeftClose,
|
||||
PanelRightOpen,
|
||||
@@ -26,7 +24,6 @@ import {
|
||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||
import { FilterPill } from './FilterPill'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
@@ -37,8 +34,6 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { MultiSelect } from '@/components/ui/multi-select'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
const MEDIA_TYPES: { value: MediaType; label: string }[] = [
|
||||
{ value: 'photo', label: 'Photo' },
|
||||
{ value: 'video', label: 'Video' },
|
||||
@@ -112,26 +107,6 @@ export function FilterBar({
|
||||
|
||||
const { data: allTags = [] } = useTagsQuery()
|
||||
|
||||
// Search box. Local state mirrors the store so typing stays responsive
|
||||
// while we debounce store writes (each store write triggers a re-fetch).
|
||||
const storeQ = useFilterStore((s) => s.q)
|
||||
const setStoreQ = useFilterStore((s) => s.setQ)
|
||||
const [searchQuery, setSearchQuery] = useState(storeQ)
|
||||
useEffect(() => {
|
||||
setSearchQuery(storeQ)
|
||||
}, [storeQ])
|
||||
const debounceRef = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (searchQuery === storeQ) return
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
||||
debounceRef.current = window.setTimeout(() => {
|
||||
setStoreQ(searchQuery)
|
||||
}, SEARCH_DEBOUNCE_MS)
|
||||
return () => {
|
||||
if (debounceRef.current) window.clearTimeout(debounceRef.current)
|
||||
}
|
||||
}, [searchQuery, storeQ, setStoreQ])
|
||||
|
||||
// Pre-compute pill values + active flags so the JSX stays terse.
|
||||
const typeActive = mediaTypes.length > 0
|
||||
const typeValue = typeActive
|
||||
@@ -527,40 +502,6 @@ export function FilterBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search — pinned to the right edge of the bar. Same id as before
|
||||
* so the global "/" focus shortcut still finds it. */}
|
||||
<div className="relative w-56 flex-shrink-0">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
|
||||
<Input
|
||||
id="topbar-search"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setSearchQuery('')
|
||||
setStoreQ('')
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
placeholder="Search photos…"
|
||||
className="h-7 w-full rounded-full bg-surface-2 pl-8 pr-7 text-xs"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('')
|
||||
setStoreQ('')
|
||||
}}
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-full p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
|
||||
title="Clear search (Esc)"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right sidebar toggle — pinned to the far-right edge. */}
|
||||
<button
|
||||
onClick={onToggleRightSidebar}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { useViewSettingsStore } from '../../store/viewSettingsStore'
|
||||
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
@@ -53,7 +52,6 @@ export function MemoriesView() {
|
||||
const selectRange = usePhotoStore((s) => s.selectRange)
|
||||
const openPreview = usePhotoStore((s) => s.openPreview)
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
const searchQuery = useFilterStore((s) => s.q)
|
||||
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
||||
|
||||
const visibleSequenceRef = useRef<string[]>([])
|
||||
@@ -216,7 +214,6 @@ export function MemoriesView() {
|
||||
fill
|
||||
isSelected={selectedPhotos.includes(m.id)}
|
||||
isInActiveHeap={activeHeapMembers.has(m.id)}
|
||||
searchQuery={searchQuery}
|
||||
onClick={handleCellClick}
|
||||
onDoubleClick={handleCellDoubleClick}
|
||||
/>
|
||||
|
||||
@@ -3,10 +3,8 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { X, Info } from 'lucide-react'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import { photos as photosApi } from '../../services/api'
|
||||
import { findSearchMatch } from '../../lib/searchMatch'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { PreviewImage } from './PreviewImage'
|
||||
import { PreviewFilmstrip } from './PreviewFilmstrip'
|
||||
@@ -86,15 +84,6 @@ export function PreviewView() {
|
||||
const currentPhoto: Photo | undefined =
|
||||
photoInListById ?? photos[safeIndex] ?? standalonePhoto
|
||||
|
||||
// Carry the timeline's search-match chip into preview so the user
|
||||
// doesn't lose the "why did this photo come back" context when they
|
||||
// zoom in. Pure recompute — same helper the thumbnail uses.
|
||||
const searchQuery = useFilterStore((s) => s.q)
|
||||
const searchMatch =
|
||||
currentPhoto && searchQuery.trim()
|
||||
? findSearchMatch(currentPhoto, searchQuery)
|
||||
: null
|
||||
|
||||
// Keep the latest photos array + active id in a ref so the keyboard
|
||||
// handlers ALWAYS read the freshest state. Without this, react-hotkeys-
|
||||
// hook can fire a closure that captured an older photos array (e.g.
|
||||
@@ -237,34 +226,6 @@ export function PreviewView() {
|
||||
<div className="text-text-muted">
|
||||
{safeIndex + 1} / {photos.length}
|
||||
</div>
|
||||
{searchMatch && (
|
||||
<div
|
||||
className="mt-1 flex items-center gap-1.5 text-[11px]"
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top-right action buttons */}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { photos as photosApi } from '../../services/api'
|
||||
import type { Photo } from '../../types/photo'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
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'
|
||||
@@ -82,10 +81,6 @@ interface PhotoThumbnailProps {
|
||||
* view is filtered to the active heap, where every cell would
|
||||
* otherwise be wash-green. */
|
||||
hideActiveHeapTint?: boolean
|
||||
/** Active text-search query, passed in from Timeline rather than
|
||||
* subscribed-to here so we don't have N thumbnails each running a
|
||||
* per-keystroke selector. Empty string disables the highlight. */
|
||||
searchQuery?: string
|
||||
/** Called with the photo + the native event. Pass a stable handler
|
||||
* (useCallback with store-action deps) so React.memo can actually
|
||||
* elide re-renders on unrelated store updates. */
|
||||
@@ -101,7 +96,6 @@ function PhotoThumbnailImpl({
|
||||
isInActiveHeap = false,
|
||||
hideDiscardedTint = false,
|
||||
hideActiveHeapTint = false,
|
||||
searchQuery = '',
|
||||
onClick,
|
||||
onDoubleClick,
|
||||
}: PhotoThumbnailProps) {
|
||||
@@ -127,16 +121,6 @@ function PhotoThumbnailImpl({
|
||||
// thumbnail badge all read from one source of truth.
|
||||
const dateWarning = photo.has_date_warning === true
|
||||
|
||||
// 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. The query itself is passed down as a
|
||||
// prop (subscribed once at the Timeline level) — having every
|
||||
// thumbnail subscribe individually multiplied keystroke renders by
|
||||
// the row count.
|
||||
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
|
||||
@@ -347,42 +331,6 @@ function PhotoThumbnailImpl({
|
||||
* 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 — owner badge for shared photos. Selection itself is
|
||||
* conveyed by the ring/outline on the wrapper, no badge needed. */}
|
||||
{photo.owner_username && (
|
||||
|
||||
@@ -177,10 +177,6 @@ export function Timeline() {
|
||||
const currentSection = useFilterStore((s) => s.currentSection)
|
||||
const flag = useFilterStore((s) => s.flag)
|
||||
const filterHeapId = useFilterStore((s) => s.heapId)
|
||||
// Subscribed once at this level and passed down to each
|
||||
// PhotoThumbnail as a prop. Previously every thumbnail had its own
|
||||
// subscription, multiplying keystroke renders by the row count.
|
||||
const searchQuery = useFilterStore((s) => s.q)
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
const thumbnailSize = useViewSettingsStore((s) => s.thumbnailSize)
|
||||
|
||||
@@ -856,7 +852,6 @@ export function Timeline() {
|
||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||
hideDiscardedTint={hideDiscardedTint}
|
||||
hideActiveHeapTint={hideActiveHeapTint}
|
||||
searchQuery={searchQuery}
|
||||
onClick={handleCellClick}
|
||||
onDoubleClick={handleCellDoubleClick}
|
||||
/>
|
||||
|
||||
@@ -37,9 +37,6 @@ function parseUrl(): HydratePayload {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
const out: HydratePayload = {}
|
||||
|
||||
const q = sp.get('q')
|
||||
if (q) out.q = q
|
||||
|
||||
const df = sp.get('date_from')
|
||||
if (df) out.dateFrom = df
|
||||
|
||||
@@ -112,7 +109,6 @@ function parseUrl(): HydratePayload {
|
||||
|
||||
function writeUrl(f: FilterState & { currentSection?: string }) {
|
||||
const sp = new URLSearchParams()
|
||||
if (f.q.trim()) sp.set('q', f.q.trim())
|
||||
if (f.dateFrom) sp.set('date_from', f.dateFrom)
|
||||
if (f.dateTo) sp.set('date_to', f.dateTo)
|
||||
if (f.mediaTypes.length > 0) sp.set('media_type', f.mediaTypes.join(','))
|
||||
|
||||
@@ -403,15 +403,6 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
HK_OPTS
|
||||
)
|
||||
|
||||
// Search focus (/ or Cmd/Ctrl+F).
|
||||
const focusSearch = () => {
|
||||
const el = document.getElementById('topbar-search') as HTMLInputElement | null
|
||||
el?.focus()
|
||||
el?.select()
|
||||
}
|
||||
useHotkeys('/', focusSearch, HK_OPTS)
|
||||
useHotkeys('mod+f', focusSearch, HK_OPTS)
|
||||
|
||||
// Space toggles the preview view (open from grid, close from preview).
|
||||
// Double-click on a thumbnail does the same.
|
||||
const openPreviewFromGrid = () => {
|
||||
|
||||
@@ -49,7 +49,6 @@ export function usePhotosQuery() {
|
||||
const filterParams = useFilterStore(
|
||||
useShallow((s) =>
|
||||
filtersToParams({
|
||||
q: s.q,
|
||||
dateFrom: s.dateFrom,
|
||||
dateTo: s.dateTo,
|
||||
mediaTypes: s.mediaTypes,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { search, type SearchResult } from '../services/api'
|
||||
import { useFilterStore } from '../store/filterStore'
|
||||
|
||||
/**
|
||||
* Hybrid search hook — fires POST /photos/search when the user has a
|
||||
* non-empty search query. Returns results ranked by RRF (FTS + semantic).
|
||||
*
|
||||
* When `q` is empty, this hook is disabled and returns no data — the
|
||||
* normal usePhotosQuery takes over for browse mode.
|
||||
*/
|
||||
export function useSearchQuery() {
|
||||
const q = useFilterStore((s) => s.q)
|
||||
const tagIds = useFilterStore((s) => s.tagIds)
|
||||
const dateFrom = useFilterStore((s) => s.dateFrom)
|
||||
const dateTo = useFilterStore((s) => s.dateTo)
|
||||
|
||||
const hasQuery = q.trim().length > 0
|
||||
|
||||
return useQuery<SearchResult[]>({
|
||||
queryKey: ['search', q, tagIds, dateFrom, dateTo],
|
||||
queryFn: async () => {
|
||||
const resp = await search.query({
|
||||
q: q.trim(),
|
||||
filters: {
|
||||
tag_ids: tagIds.length > 0 ? tagIds : undefined,
|
||||
date_from: dateFrom ?? undefined,
|
||||
date_to: dateTo ?? undefined,
|
||||
},
|
||||
limit: 200,
|
||||
})
|
||||
return resp.results
|
||||
},
|
||||
enabled: hasQuery,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -14,7 +14,6 @@ export type SortOrder = 'asc' | 'desc'
|
||||
export type GroupBy = 'date' | 'tag' | 'rating' | 'color'
|
||||
|
||||
export interface FilterState {
|
||||
q: string
|
||||
dateFrom: string | null // ISO yyyy-mm-dd
|
||||
dateTo: string | null
|
||||
mediaTypes: MediaType[]
|
||||
@@ -57,7 +56,6 @@ interface FilterStore extends FilterState {
|
||||
* navigating away. */
|
||||
sectionPresets: Record<string, Partial<FilterState>>
|
||||
|
||||
setQ: (q: string) => void
|
||||
setDateFrom: (date: string | null) => void
|
||||
setDateTo: (date: string | null) => void
|
||||
toggleMediaType: (t: MediaType) => void
|
||||
@@ -93,7 +91,6 @@ interface FilterStore extends FilterState {
|
||||
}
|
||||
|
||||
export const INITIAL_FILTERS: FilterState = {
|
||||
q: '',
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
mediaTypes: [],
|
||||
@@ -116,7 +113,6 @@ export const INITIAL_FILTERS: FilterState = {
|
||||
* per-section map. */
|
||||
function snapshotFilters(s: FilterState): FilterState {
|
||||
return {
|
||||
q: s.q,
|
||||
dateFrom: s.dateFrom,
|
||||
dateTo: s.dateTo,
|
||||
mediaTypes: [...s.mediaTypes],
|
||||
@@ -141,7 +137,6 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
||||
sectionFilters: {},
|
||||
sectionPresets: { [ALL_PHOTOS_SECTION]: {} },
|
||||
|
||||
setQ: (q) => set({ q }),
|
||||
setDateFrom: (dateFrom) => set({ dateFrom }),
|
||||
setDateTo: (dateTo) => set({ dateTo }),
|
||||
toggleMediaType: (t) =>
|
||||
@@ -211,7 +206,6 @@ export const useFilterStore = create<FilterStore>((set) => ({
|
||||
* Empty / default values are omitted so the cache key is stable. */
|
||||
export function filtersToParams(f: FilterState): Record<string, string | number> {
|
||||
const params: Record<string, string | number> = {}
|
||||
if (f.q.trim()) params.q = f.q.trim()
|
||||
// Both bounds need an explicit time component because pydantic v2
|
||||
// rejects bare date strings ("2026-04-10") for datetime params with
|
||||
// 422. The from is start-of-day; the to is inclusive end-of-day so
|
||||
@@ -235,10 +229,9 @@ export function filtersToParams(f: FilterState): Record<string, string | number>
|
||||
return params
|
||||
}
|
||||
|
||||
/** True if any filter (other than the search box) is active. */
|
||||
/** True if any filter is active. */
|
||||
export function hasActiveFilters(f: FilterState): boolean {
|
||||
return (
|
||||
f.q.trim() !== '' ||
|
||||
f.dateFrom !== null ||
|
||||
f.dateTo !== null ||
|
||||
f.mediaTypes.length > 0 ||
|
||||
|
||||
Reference in New Issue
Block a user