import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { CatalogSearchResult } from '../types'; import { api, imageUrl, portraitThumbUrl } from '../api/client'; import './CatalogSearchBar.css'; interface Props { onSelectArtist: (artistId: number) => void; onSelectMovement: (movementId: number) => void; onSelectPainting: (paintingId: number) => void; } const TYPE_ORDER: CatalogSearchResult['type'][] = ['artist', 'movement', 'painting']; function resultKey(item: CatalogSearchResult): string { return `${item.type}-${item.id}`; } function paintingThumbSrc(item: Extract): string { const path = item.thumbnail_path || item.image_path; return path ? imageUrl(path) : '/placeholder-art.svg'; } export default function CatalogSearchBar({ onSelectArtist, onSelectMovement, onSelectPainting, }: Props) { const { t } = useTranslation('search'); const typeLabels: Record = { artist: t('groupArtist'), movement: t('groupMovement'), painting: t('groupPainting'), }; const listboxId = useId(); const rootRef = useRef(null); const inputRef = useRef(null); const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const grouped = useMemo(() => { const map = new Map(); for (const type of TYPE_ORDER) map.set(type, []); for (const item of results) { map.get(item.type)?.push(item); } return TYPE_ORDER.map((type) => ({ type, items: map.get(type) ?? [] })).filter((g) => g.items.length > 0); }, [results]); const flatResults = useMemo(() => grouped.flatMap((g) => g.items), [grouped]); const activate = useCallback( (item: CatalogSearchResult) => { setOpen(false); setQuery(''); setResults([]); setActiveIndex(-1); inputRef.current?.blur(); if (item.type === 'artist') onSelectArtist(item.id); else if (item.type === 'movement') onSelectMovement(item.id); else onSelectPainting(item.id); }, [onSelectArtist, onSelectMovement, onSelectPainting] ); useEffect(() => { const trimmed = query.trim(); if (trimmed.length < 2) { setResults([]); setLoading(false); setError(null); setActiveIndex(-1); return; } let cancelled = false; setLoading(true); setError(null); const timer = window.setTimeout(() => { api .search(trimmed) .then((data) => { if (cancelled) return; setResults(data.results); setOpen(true); setActiveIndex(data.results.length > 0 ? 0 : -1); }) .catch(() => { if (cancelled) return; setResults([]); setError(t('searchFailed')); setOpen(true); setActiveIndex(-1); }) .finally(() => { if (!cancelled) setLoading(false); }); }, 300); return () => { cancelled = true; window.clearTimeout(timer); }; }, [query]); useEffect(() => { const onPointerDown = (e: MouseEvent) => { if (!rootRef.current?.contains(e.target as Node)) { setOpen(false); } }; document.addEventListener('mousedown', onPointerDown); return () => document.removeEventListener('mousedown', onPointerDown); }, []); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { setOpen(false); setActiveIndex(-1); return; } if (!open || flatResults.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex((i) => (i + 1) % flatResults.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex((i) => (i <= 0 ? flatResults.length - 1 : i - 1)); } else if (e.key === 'Enter' && activeIndex >= 0) { e.preventDefault(); activate(flatResults[activeIndex]); } }; const showPanel = open && query.trim().length >= 2; return (
= 0 ? `${listboxId}-opt-${activeIndex}` : undefined } placeholder={t('placeholder')} value={query} autoComplete="off" spellCheck={false} onChange={(e) => { setQuery(e.target.value); setOpen(true); }} onFocus={() => { if (query.trim().length >= 2) setOpen(true); }} onKeyDown={handleKeyDown} /> {loading && }
{showPanel && (
{error &&

{error}

} {!error && !loading && flatResults.length === 0 && (

{t('noMatches')}

)} {grouped.map((group) => (

{typeLabels[group.type]}

    {group.items.map((item) => { const flatIndex = flatResults.findIndex((r) => resultKey(r) === resultKey(item)); const active = flatIndex === activeIndex; return (
  • ); })}
))}
)}
); } function formatYears(start: number | null | undefined, end: number | null | undefined): string | null { if (start == null && end == null) return null; if (start != null && end != null) return `${start}–${end}`; if (start != null) return String(start); return String(end); }