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:
Danila Khodjaef
2026-07-15 10:34:29 +03:00
co-authored by Cursor
parent 50fc253ab2
commit acc4a91a08
17 changed files with 827 additions and 12 deletions
+276
View File
@@ -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);
}