Add catalog search on timeline header and fix Back to Timeline navigation.
Public GET /api/search over artists, movements, and paintings with a debounced header bar on the timeline; Back to Timeline resets zoom and gallery session. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
50fc253ab2
commit
acc4a91a08
@@ -0,0 +1,276 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
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'];
|
||||
const TYPE_LABELS: Record<CatalogSearchResult['type'], string> = {
|
||||
artist: 'Artists',
|
||||
movement: 'Movements',
|
||||
painting: 'Paintings',
|
||||
};
|
||||
|
||||
function resultKey(item: CatalogSearchResult): string {
|
||||
return `${item.type}-${item.id}`;
|
||||
}
|
||||
|
||||
function paintingThumbSrc(item: Extract<CatalogSearchResult, { type: 'painting' }>): 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 listboxId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [results, setResults] = useState<CatalogSearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<CatalogSearchResult['type'], CatalogSearchResult[]>();
|
||||
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('Search failed.');
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="catalog-search" ref={rootRef}>
|
||||
<label className="catalog-search-label" htmlFor={`${listboxId}-input`}>
|
||||
Search
|
||||
</label>
|
||||
<div className="catalog-search-field">
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={`${listboxId}-input`}
|
||||
className="catalog-search-input"
|
||||
type="search"
|
||||
role="combobox"
|
||||
aria-expanded={showPanel}
|
||||
aria-controls={showPanel ? `${listboxId}-listbox` : undefined}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={
|
||||
showPanel && activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined
|
||||
}
|
||||
placeholder="Search artists, paintings, movements…"
|
||||
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 && <span className="catalog-search-spinner" aria-hidden />}
|
||||
</div>
|
||||
|
||||
{showPanel && (
|
||||
<div
|
||||
id={`${listboxId}-listbox`}
|
||||
className="catalog-search-panel"
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
>
|
||||
{error && <p className="catalog-search-message catalog-search-error">{error}</p>}
|
||||
{!error && !loading && flatResults.length === 0 && (
|
||||
<p className="catalog-search-message">No matches found.</p>
|
||||
)}
|
||||
{grouped.map((group) => (
|
||||
<div key={group.type} className="catalog-search-group">
|
||||
<p className="catalog-search-group-label">{TYPE_LABELS[group.type]}</p>
|
||||
<ul className="catalog-search-list">
|
||||
{group.items.map((item) => {
|
||||
const flatIndex = flatResults.findIndex((r) => resultKey(r) === resultKey(item));
|
||||
const active = flatIndex === activeIndex;
|
||||
return (
|
||||
<li key={resultKey(item)}>
|
||||
<button
|
||||
type="button"
|
||||
id={`${listboxId}-opt-${flatIndex}`}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`catalog-search-option${active ? ' catalog-search-option-active' : ''}`}
|
||||
onMouseEnter={() => setActiveIndex(flatIndex)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => activate(item)}
|
||||
>
|
||||
{item.type === 'artist' && (
|
||||
<>
|
||||
<img
|
||||
className="catalog-search-thumb"
|
||||
src={portraitThumbUrl(item)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="catalog-search-option-text">
|
||||
<span className="catalog-search-option-title">{item.name}</span>
|
||||
<span className="catalog-search-option-meta">
|
||||
{[item.movement_name, formatYears(item.birth_year, item.death_year)]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.type === 'movement' && (
|
||||
<>
|
||||
<span
|
||||
className="catalog-search-movement-swatch"
|
||||
style={{ background: item.color || '#c9a96e' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="catalog-search-option-text">
|
||||
<span className="catalog-search-option-title">{item.name}</span>
|
||||
<span className="catalog-search-option-meta">
|
||||
{[item.era_name, formatYears(item.start_year, item.end_year)]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.type === 'painting' && (
|
||||
<>
|
||||
<img
|
||||
className="catalog-search-thumb"
|
||||
src={paintingThumbSrc(item)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="catalog-search-option-text">
|
||||
<span className="catalog-search-option-title">{item.title}</span>
|
||||
<span className="catalog-search-option-meta">
|
||||
{[item.artist_name, item.year != null ? String(item.year) : null, item.movement_name]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user