UI chrome via react-i18next, catalog text in entity_translations with ru.wikipedia seeding, locale-aware search, and Translations page for publish workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
279 lines
10 KiB
TypeScript
279 lines
10 KiB
TypeScript
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<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 { t } = useTranslation('search');
|
||
const typeLabels: Record<CatalogSearchResult['type'], string> = {
|
||
artist: t('groupArtist'),
|
||
movement: t('groupMovement'),
|
||
painting: t('groupPainting'),
|
||
};
|
||
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(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<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`}>
|
||
{t('label')}
|
||
</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={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 && <span className="catalog-search-spinner" aria-hidden />}
|
||
</div>
|
||
|
||
{showPanel && (
|
||
<div
|
||
id={`${listboxId}-listbox`}
|
||
className="catalog-search-panel"
|
||
role="listbox"
|
||
aria-label={t('resultsLabel')}
|
||
>
|
||
{error && <p className="catalog-search-message catalog-search-error">{error}</p>}
|
||
{!error && !loading && flatResults.length === 0 && (
|
||
<p className="catalog-search-message">{t('noMatches')}</p>
|
||
)}
|
||
{grouped.map((group) => (
|
||
<div key={group.type} className="catalog-search-group">
|
||
<p className="catalog-search-group-label">{typeLabels[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);
|
||
}
|