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
@@ -7,6 +7,7 @@ import type {
|
||||
MovementGalleryDetail,
|
||||
PaintingDetail,
|
||||
ArtistNavigation,
|
||||
CatalogSearchResponse,
|
||||
} from '../types';
|
||||
|
||||
const API = '/api';
|
||||
@@ -299,6 +300,13 @@ export const api = {
|
||||
getTimeline: (start: number, end: number) =>
|
||||
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`),
|
||||
|
||||
search: (q: string, options?: { limit?: number; types?: string }) => {
|
||||
const params = new URLSearchParams({ q });
|
||||
if (options?.limit != null) params.set('limit', String(options.limit));
|
||||
if (options?.types) params.set('types', options.types);
|
||||
return fetchJson<CatalogSearchResponse>(`${API}/search?${params}`);
|
||||
},
|
||||
|
||||
getArtists: (start?: number, end?: number, movementId?: number) => {
|
||||
const params = new URLSearchParams();
|
||||
if (start != null) params.set('start', String(start));
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
.catalog-search {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(520px, 100%);
|
||||
margin: 16px auto 0;
|
||||
}
|
||||
|
||||
.catalog-search-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.catalog-search-field {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.catalog-search-input {
|
||||
width: 100%;
|
||||
padding: 10px 40px 10px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.45);
|
||||
background: rgba(15, 15, 26, 0.92);
|
||||
color: #e8d5b5;
|
||||
font-size: 15px;
|
||||
font-family: Georgia, serif;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.catalog-search-input::placeholder {
|
||||
color: rgba(232, 213, 181, 0.45);
|
||||
}
|
||||
|
||||
.catalog-search-input:focus {
|
||||
border-color: #c9a96e;
|
||||
box-shadow: 0 0 0 2px rgba(201, 169, 110, 0.18);
|
||||
}
|
||||
|
||||
.catalog-search-spinner {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(201, 169, 110, 0.22);
|
||||
border-top-color: #c9a96e;
|
||||
animation: catalog-search-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.catalog-search-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 120;
|
||||
max-height: min(420px, 60vh);
|
||||
overflow: auto;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(201, 169, 110, 0.35);
|
||||
background: rgba(12, 12, 22, 0.98);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.catalog-search-message {
|
||||
margin: 0;
|
||||
padding: 14px 16px;
|
||||
color: rgba(232, 213, 181, 0.75);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.catalog-search-error {
|
||||
color: #e8a0a0;
|
||||
}
|
||||
|
||||
.catalog-search-group + .catalog-search-group {
|
||||
border-top: 1px solid rgba(201, 169, 110, 0.12);
|
||||
}
|
||||
|
||||
.catalog-search-group-label {
|
||||
margin: 0;
|
||||
padding: 10px 14px 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(201, 169, 110, 0.65);
|
||||
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
.catalog-search-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 6px 8px;
|
||||
}
|
||||
|
||||
.catalog-search-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #e8d5b5;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.catalog-search-option:hover,
|
||||
.catalog-search-option-active {
|
||||
background: rgba(201, 169, 110, 0.12);
|
||||
}
|
||||
|
||||
.catalog-search-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.catalog-search-movement-swatch {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.catalog-search-option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.catalog-search-option-title {
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.catalog-search-option-meta {
|
||||
font-size: 12px;
|
||||
color: rgba(232, 213, 181, 0.6);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@keyframes catalog-search-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import './GalleryLoadingMarker.css';
|
||||
interface Props {
|
||||
data: PaintingDetail;
|
||||
artistPaintings?: Painting[];
|
||||
backLabel?: string;
|
||||
onBack: () => void;
|
||||
onPaintingClick: (paintingId: number) => void;
|
||||
onCatalogNavigate: (paintingId: number) => void;
|
||||
@@ -191,6 +192,7 @@ function InfluenceCard({
|
||||
export default function PaintingDetailView({
|
||||
data,
|
||||
artistPaintings = [],
|
||||
backLabel = '← Back to Gallery',
|
||||
onBack,
|
||||
onPaintingClick,
|
||||
onCatalogNavigate,
|
||||
@@ -457,7 +459,7 @@ export default function PaintingDetailView({
|
||||
/>
|
||||
)}
|
||||
<header className="painting-header">
|
||||
<button className="back-btn" onClick={onBack}>← Back to Gallery</button>
|
||||
<button className="back-btn" onClick={onBack}>{backLabel}</button>
|
||||
<div className="painting-title-block">
|
||||
<h1>{painting.title}</h1>
|
||||
<p className="painting-meta">
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
padding: 24px 16px 8px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 110;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.site-dev-tools {
|
||||
|
||||
@@ -7,7 +7,9 @@ import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
import CheckupPage from '../pages/CheckupPage';
|
||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||
import CatalogSearchBar from '../components/CatalogSearchBar';
|
||||
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
||||
import '../components/CatalogSearchBar.css';
|
||||
import '../components/CuratorLoginModal.css';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
@@ -183,6 +185,17 @@ export default function HomePage() {
|
||||
viewChangeScheduler.current.schedule(start, end);
|
||||
}, []);
|
||||
|
||||
const goToTimelineHome = useCallback(() => {
|
||||
detailReturnToRef.current = { type: 'timeline' };
|
||||
setGallerySession(null);
|
||||
setHoveredLifespan(null);
|
||||
setGalleryEntryLoading(null);
|
||||
setViewStart(bounds.min);
|
||||
setViewEnd(bounds.max);
|
||||
setGalleryRevision((revision) => revision + 1);
|
||||
setView({ type: 'timeline' });
|
||||
}, [bounds.min, bounds.max]);
|
||||
|
||||
const toggleDebugMode = () => {
|
||||
setDebugMode((prev) => {
|
||||
const next = !prev;
|
||||
@@ -215,7 +228,7 @@ export default function HomePage() {
|
||||
writeDebugMode(false);
|
||||
setDebugMode(false);
|
||||
if (view.type === 'checkup') {
|
||||
setView({ type: 'timeline' });
|
||||
goToTimelineHome();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -621,7 +634,7 @@ export default function HomePage() {
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onNavigateArtist={handleArtistClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onBack={goToTimelineHome}
|
||||
onBioClick={() =>
|
||||
handleBioClick(displayGallery.data, {
|
||||
type: 'gallery',
|
||||
@@ -638,7 +651,7 @@ export default function HomePage() {
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onBack={goToTimelineHome}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -650,7 +663,12 @@ export default function HomePage() {
|
||||
key={view.paintingId}
|
||||
data={view.data}
|
||||
artistPaintings={sortedDetailArtistPaintings}
|
||||
backLabel={view.returnTo.type === 'timeline' ? '← Back to Timeline' : '← Back to Gallery'}
|
||||
onBack={() => {
|
||||
if (view.returnTo.type === 'timeline') {
|
||||
goToTimelineHome();
|
||||
return;
|
||||
}
|
||||
const returnTo = view.returnTo;
|
||||
if (
|
||||
returnTo.type === 'gallery' &&
|
||||
@@ -708,7 +726,7 @@ export default function HomePage() {
|
||||
{view.type === 'checkup' && (
|
||||
isCurator ? (
|
||||
<CheckupPage
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onBack={goToTimelineHome}
|
||||
onOpenPainting={handlePaintingClick}
|
||||
/>
|
||||
) : (
|
||||
@@ -719,7 +737,7 @@ export default function HomePage() {
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('checkup')}>
|
||||
Curator login
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={() => setView({ type: 'timeline' })}>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
Back to gallery
|
||||
</button>
|
||||
</div>
|
||||
@@ -794,6 +812,11 @@ export default function HomePage() {
|
||||
</div>
|
||||
<h1>Virtual Art Gallery</h1>
|
||||
<p className="site-subtitle">Watch art movements branch forward through time — each flowing from what came before</p>
|
||||
<CatalogSearchBar
|
||||
onSelectArtist={handleArtistClick}
|
||||
onSelectMovement={handleMovementClick}
|
||||
onSelectPainting={handlePaintingClick}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="home-timeline-stack">
|
||||
|
||||
@@ -143,6 +143,49 @@ export interface CatalogBootstrap extends TimelineData {
|
||||
artists: Artist[];
|
||||
}
|
||||
|
||||
export interface CatalogSearchArtistResult {
|
||||
type: 'artist';
|
||||
id: number;
|
||||
name: string;
|
||||
birth_year: number | null;
|
||||
death_year: number | null;
|
||||
movement_name: string | null;
|
||||
portrait_path: string | null;
|
||||
portrait_thumb_path: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogSearchMovementResult {
|
||||
type: 'movement';
|
||||
id: number;
|
||||
name: string;
|
||||
color: string;
|
||||
start_year: number;
|
||||
end_year: number;
|
||||
era_name: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogSearchPaintingResult {
|
||||
type: 'painting';
|
||||
id: number;
|
||||
title: string;
|
||||
year: number | null;
|
||||
artist_id: number;
|
||||
artist_name: string;
|
||||
movement_name: string | null;
|
||||
thumbnail_path: string | null;
|
||||
image_path: string | null;
|
||||
}
|
||||
|
||||
export type CatalogSearchResult =
|
||||
| CatalogSearchArtistResult
|
||||
| CatalogSearchMovementResult
|
||||
| CatalogSearchPaintingResult;
|
||||
|
||||
export interface CatalogSearchResponse {
|
||||
q: string;
|
||||
results: CatalogSearchResult[];
|
||||
}
|
||||
|
||||
export interface YearBounds {
|
||||
min_year: number;
|
||||
max_year: number;
|
||||
|
||||
Reference in New Issue
Block a user