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
+71
View File
@@ -171,6 +171,76 @@ Movements are filtered to those with at least one artist active in the requested
---
## `GET /api/search`
Public catalog search over **artists**, **paintings**, and **art movements**. Used by the timeline header search bar (`CatalogSearchBar.tsx`).
**Query**
| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `q` | string | — | Search text (min **2** characters after trim; shorter returns `{ q, results: [] }`) |
| `limit` | int | 20 | Max results total (capped at **50**) |
| `types` | string | all | Optional comma list: `artist`, `painting`, `movement` |
**Matching (case-insensitive `ILIKE`):**
| Entity | Fields |
|--------|--------|
| Artist | `name`, `wikipedia_title`, movement name |
| Movement | movement `name`, era name |
| Painting | `title`, `wikipedia_title`, `year` (as text), artist name, movement name |
Prefix matches on primary labels (`name` / `title`) rank before substring matches.
**Response**
```json
{
"q": "monet",
"results": [
{
"type": "artist",
"id": 19,
"name": "Claude Monet",
"birth_year": 1840,
"death_year": 1926,
"movement_name": "Impressionism",
"portrait_path": "portraits/Claude_Monet.jpg",
"portrait_thumb_path": "portraits/thumbs/Claude_Monet_thumb.jpg"
},
{
"type": "movement",
"id": 12,
"name": "Impressionism",
"color": "#87CEEB",
"start_year": 1860,
"end_year": 1890,
"era_name": "Modern"
},
{
"type": "painting",
"id": 241,
"title": "Water Lilies",
"year": 1919,
"artist_id": 19,
"artist_name": "Claude Monet",
"movement_name": "Impressionism",
"thumbnail_path": "paintings/thumbs/Claude_Monet_Water_Lilies_thumb.jpg",
"image_path": "paintings/Claude_Monet_Water_Lilies.jpg"
}
]
}
```
**Indexes:** applied by `npm run dev:migrate` (`db/migrate-search.sql`) or standalone `npm run dev:migrate:search`.
**Client:** `api.search(q, { limit?, types? })`.
**Navigation from search:** choosing a **painting** opens detail with `returnTo: timeline`; the client shows **← Back to Timeline** and calls `goToTimelineHome()` (clears gallery session, resets timeline zoom). Choosing an **artist** or **movement** uses the normal gallery entry handlers.
---
## `GET /api/artists`
Artists for timeline portraits and the movement flow diagram.
@@ -720,6 +790,7 @@ The React client wraps these endpoints in `client/src/api/client.ts`. All reques
| `logoutCurator()` | `POST /api/auth/logout` |
| `api.getBounds()` | `GET /api/bounds` |
| `api.getTimeline(start, end)` | `GET /api/timeline` |
| `api.search(q, options?)` | `GET /api/search` |
| `api.getArtists(...)` | `GET /api/artists` |
| `api.getTimelineArtists()` | `GET /api/artists?timeline=1` |
| `api.getArtist(id)` | `GET /api/artists/:id` |
+2
View File
@@ -5,4 +5,6 @@ this file contains draft for future releases and features
3. tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ?
4. tool to sync prod /env resources (both ways), db structure, db data, images, users etc
5. curator_audit_log should contain log of actions like fixit, checked, upload etc with details for which entity it was made and details what was the action and outcome
6. ~~create search by entity (painting, artist, movement)~~ — done: timeline header + `GET /api/search`
7. create guided tours (with text/extra infor, set of entities)
+43 -6
View File
@@ -12,6 +12,8 @@ The app is organised as a **drill-down hierarchy**:
4. **Painting detail** — full work in the centre, *Influenced By* on the left, *Influenced* on the right (paintings, artists, or movements), optional **art-history notes** with image markers, prev/next catalog browsing, optional fullscreen, link to artist biography.
5. **Artist biography** — portrait, lifespan, movement, and Wikipedia-sourced intro text (`bio_short` / `bio_full`). With debug mode on, the same image-audit panel as painting detail (portrait search, **Checked** / **Fix it** / **More** / **Clear** / **Upload**).
**Catalog search** — on the timeline home page, the header search bar (`CatalogSearchBar.tsx`) finds artists, paintings, and movements by name and metadata (year, movement, Wikipedia title). Type at least **2 characters** (300 ms debounce); results group into **Artists**, **Movements**, and **Paintings** with thumbnails. Keyboard: `↑`/`↓` to move, `Enter` to open, `Escape` to close. Choosing a result opens the artist gallery, movement gallery, or painting detail. Paintings opened from search show **← Back to Timeline** and return to the home timeline (full year range), not the previous view.
All artwork images are stored locally under `data/images/` — the UI never hot-links to Wikipedia or Commons at runtime (except optional on-demand fetch when a file is missing).
## Stack
@@ -45,6 +47,7 @@ Gallery/
│ │ ├── components/Timeline.tsx # Era bar, year ticks, event markers
│ │ ├── components/TimelineEventGuides.tsx # Event vertical guides into movement flow
│ │ ├── components/MovementBands.tsx # Movement flow (SVG streams + branches)
│ │ ├── components/CatalogSearchBar.tsx # Timeline header catalog search
│ │ ├── components/PaintingAnnotations.tsx # Art-history notes on painting detail
│ │ ├── pages/CheckupPage.tsx # Image audit table
│ │ ├── data/historical-events.ts # Timeline event markers (UI)
@@ -117,9 +120,13 @@ Use for fast frontend iteration without Keenetic. Legacy nginx config in [`deplo
```mermaid
flowchart TD
A[Home — timeline + movement flow] -->|scroll / drag / zoom| A
A -->|catalog search| S[Search results dropdown]
S -->|artist| C
S -->|movement| G
S -->|painting| D
A -->|click movement name| G[Movement gallery — 3D wings]
G -->|click painting| D
D -->|Back| G
D -->|Back to Gallery| G
G -->|back door / Wings / Exit| H[Wing navigator]
H -->|pick wing| G
H -->|Exit to Timeline| A
@@ -131,16 +138,28 @@ flowchart TD
D -->|prev / next| D
D -->|click centre image| F[Fullscreen lightbox]
F -->|close| D
D -->|Back| C
D -->|Back to Gallery| C
C -->|exit doorway / E key| E[Path picker]
E -->|predecessors| C
E -->|successors| C
D -->|influence thumbnail| D
D -->|artist link| B
B -->|Back| A
C -->|Back| A
B -->|Back| C
C -->|Back to Timeline| A
D -->|Back to Timeline when opened from search| A
```
### Back navigation
| Control | Behaviour |
|---------|-----------|
| **← Back to Timeline** (3D gallery header, movement **Exit to Timeline**) | Always returns to the **home timeline**: unmounts the hall, clears the gallery session, resets timeline zoom to the full catalog year range |
| **← Back to Gallery** (painting detail from a hall) | Returns to the **same hall session** — camera position and wing are preserved |
| **← Back to Timeline** (painting detail opened from catalog search) | Returns to the home timeline (same as gallery **Back to Timeline**) |
| **← Back** (artist bio) | Returns to wherever you opened bio from (usually the artist hall) |
Implementation: `goToTimelineHome()` in `HomePage.tsx` — do not use the browser **Back** button; it is not wired to app navigation.
## Timeline and movement flow
The home page shows two linked views over the **same year window** (`viewStart` / `viewEnd` in `HomePage.tsx`):
@@ -152,6 +171,22 @@ The home page shows two linked views over the **same year window** (`viewStart`
Both views share zoom/pan behaviour via `client/src/utils/timelineView.ts` (`zoomTimelineView`, `panTimelineView`, `chooseTimelineTickInterval`, `createViewChangeScheduler`). The home page uses a **fixed viewport** (`100vh`): timeline + movement flow sit in a shared `home-timeline-stack` so event guide lines can extend from the era bar down through the movement canvas. The movement flow compresses vertically so all movements in the visible year range fit without page scrolling.
### Catalog search (timeline header)
`CatalogSearchBar.tsx` calls `GET /api/search?q=…` (public, no login). The dropdown is stacked above the timeline (`z-index` on `.site-header`) so results are not hidden by movement bands.
| UX | Detail |
|----|--------|
| Minimum query | 2 characters after trim |
| Debounce | 300 ms |
| Result groups | Artists, Movements, Paintings (with thumb or movement colour swatch) |
| Open artist | Preload images → artist 3D hall |
| Open movement | Movement gallery (wing 1) |
| Open painting | Painting detail with `returnTo: timeline`**Back to Timeline** |
| Keyboard | `↑`/`↓` highlight, `Enter` open, `Escape` close |
Run `npm run dev:migrate:search` once on existing databases before first use, or rely on `npm run dev:migrate` (includes `migrate-search.sql`). See [API.md — GET /api/search](API.md#get-apsearch).
### Timeline data loading
On first visit, `HomePage.tsx` fetches the full catalog once:
@@ -299,7 +334,7 @@ Enter from the home page by clicking a **movement name** on the movement flow (`
| Front wall | Open **“Next wing →”** archway when a later wing exists; walk through or press `E` when near |
| Influence lamps | Same golden lamps as artist halls when `has_influence_links` is true |
| Missing images | Draped canvas cover in frame |
| Detail return | Hall stays mounted; camera preserved on **Back to Timeline** / **Back to Gallery** |
| Detail return | **Back to Gallery** from painting detail returns to the same wing with camera preserved; **Back to Timeline** exits the hall entirely |
**Controls (movement gallery):**
@@ -337,7 +372,8 @@ Opened from the 3D hall (artist or movement wing — click a frame) or from infl
| Click centre image | Open fullscreen lightbox |
| Click influence thumbnail | Open that works detail (different artist allowed) |
| Click influence artist portrait | Open that artists 3D gallery hall |
| **← Back to Gallery** / **← Back to Timeline** | Return to the hall or movement wing you entered from — **3D camera position is preserved** |
| **← Back to Gallery** | Return to the hall or movement wing you entered from — **3D camera position is preserved** |
| **← Back to Timeline** | Return to the home timeline (from search result, or from the 3D gallery header / movement **Exit to Timeline**) — hall unmounts, timeline zoom resets |
| **About {artist}** | Open artist biography |
**Navigation rules:**
@@ -388,6 +424,7 @@ Curator-only workflow for reviewing and fixing local image files — not part of
| Feature | Where | Purpose |
|---------|--------|---------|
| **Catalog search** | Timeline header (all visitors) | Find artists, paintings, movements; `GET /api/search`; navigate to gallery or detail |
| **Curator login** | Home header (guests) | Username + password modal; unlocks debug tools |
| **Debug mode** | Home header toggle (curators only) | Persists in `localStorage`; enables debug panel on painting detail and artist bio |
| **Show more** | Home header checkbox (curators, when debug on) | Auto-opens the **More** modal on each painting / bio page load |
+5
View File
@@ -88,6 +88,7 @@ npm run dev:expand-catalog # famous works for artists below MIN_PAIN
npm run dev:update-influences # painting influence graph for detail view + hall exits
npm run dev:migrate:checkup-flags # optional: review/fixed flags for Checkup page (paintings)
npm run dev:migrate:artist-checkup-flags # optional: same flags for artist portraits (bio debug)
npm run dev:migrate:search # optional on very old DBs — also applied by dev:migrate / prod Step 4
npm run dev:migrate:painting-annotations # optional: art-history notes table
npm run dev:update-painting-annotations # optional: load curated notes (+ --wikipedia for Wikipedia intros)
npm run dev:fetch-images -- --limit=50 # random sample; 10s max per painting (default)
@@ -177,6 +178,7 @@ Node on the dev PC at `:3520` with nginx → Vite `:5173` is superseded by TrueN
| `npm run dev:migrate:influence-sources` | `scripts/migrate-influence-sources.js` | Create `painting_influence_sources` + backfill legacy edges |
| `npm run dev:migrate:checkup-flags` | `scripts/migrate-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `paintings` |
| `npm run dev:migrate:artist-checkup-flags` | `scripts/migrate-artist-checkup-flags.js` | Add `checkup_checked` / `checkup_fixed` on `artists` (bio debug) |
| `npm run dev:migrate:search` | `scripts/migrate-search.js` | Same indexes as `migrate-search.sql` (also run by `dev:migrate`) |
| `npm run dev:migrate:painting-annotations` | `scripts/migrate-painting-annotations.js` | Create `painting_annotations` table |
| `npm run dev:migrate:artist-palette` | `scripts/migrate-artist-palette.js` | Add `palette_metadata` JSONB on `artists` |
| `npm run dev:import-painter-palette` | `scripts/import-painter-palette.js` | Enrich artists + influence links from `Inputs/PainterPalette.csv` |
@@ -263,6 +265,9 @@ After clone: copy `.env.example` → `.env`, install dependencies, run [one-time
| No art-history notes on painting detail | Annotations not migrated or loaded | `npm run dev:migrate:painting-annotations` then `npm run dev:update-painting-annotations` |
| **Fix it** fails with `read ECONNRESET` | Remote host dropped connection | Restart server; client sends `searchUrl` / `source`; retry or use Commons URL in overrides |
| Fixed image not shown in 3D gallery | Stale gallery session or cached texture | Rebuild client; fix/upload updates session + `?v=` from `image_cache_key` — use **Back to Gallery** (not browser back) |
| **Back to Timeline** returns to gallery / previous wing | Stale client build | Pull latest client — `goToTimelineHome()` unmounts the hall and resets timeline zoom |
| Catalog search dropdown hidden under timeline | Stale client CSS | Rebuild client — `.site-header` uses `z-index: 110` above the sticky timeline bar |
| Catalog search returns empty / 500 | Search indexes missing | Run `npm run dev:migrate` (includes `migrate-search.sql`) or `npm run dev:migrate:search`; restart API |
| Frame still black after **Checked** | Gallery session not synced | Re-enter hall or toggle debug **Checked** from detail with gallery open behind overlay |
| Duplicate works in gallery / timeline | Double import or variant Wikipedia titles | `npm run dev:find-duplicates`; merge or delete spare rows manually |
| **Failed to load movement gallery** / `Cannot GET /api/movements/:id/gallery` | Stale server process missing route | Restart `npm run dev:web` or `npm run dev:server` after pulling API changes |
+8
View File
@@ -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));
+168
View File
@@ -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);
}
}
+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);
}
+3 -1
View File
@@ -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">
+2
View File
@@ -54,6 +54,8 @@
padding: 24px 16px 8px;
flex-shrink: 0;
position: relative;
z-index: 110;
overflow: visible;
}
.site-dev-tools {
+28 -5
View File
@@ -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">
+43
View File
@@ -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;
+6
View File
@@ -0,0 +1,6 @@
-- Indexes for catalog search (ILIKE on name/title)
CREATE INDEX IF NOT EXISTS artists_name_lower_idx ON artists (lower(name));
CREATE INDEX IF NOT EXISTS artists_wikipedia_title_lower_idx ON artists (lower(wikipedia_title));
CREATE INDEX IF NOT EXISTS paintings_title_lower_idx ON paintings (lower(title));
CREATE INDEX IF NOT EXISTS paintings_wikipedia_title_lower_idx ON paintings (lower(wikipedia_title));
CREATE INDEX IF NOT EXISTS art_movements_name_lower_idx ON art_movements (lower(name));
+1
View File
@@ -28,6 +28,7 @@
"dev:migrate:artist-checkup-flags": "node scripts/migrate-artist-checkup-flags.js",
"dev:migrate:painting-annotations": "node scripts/migrate-painting-annotations.js",
"dev:migrate:artist-palette": "node scripts/migrate-artist-palette.js",
"dev:migrate:search": "node scripts/migrate-search.js",
"dev:import-painter-palette": "node scripts/import-painter-palette.js",
"dev:analyze-painter-palette": "node scripts/analyze-painter-palette.js",
"dev:export-paintings": "node scripts/export-paintings-csv.js",
+17
View File
@@ -0,0 +1,17 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const pool = require('../server/db');
async function main() {
const sqlPath = path.join(__dirname, '../db/migrate-search.sql');
const sql = fs.readFileSync(sqlPath, 'utf8');
await pool.query(sql);
console.log('Catalog search indexes ready');
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+18
View File
@@ -13,6 +13,7 @@ const { logCuratorAction } = require('./audit-log');
const authRoutes = require('./routes/auth');
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
const { getVersionInfo } = require('./version-info');
const { searchCatalog } = require('./search-service');
const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
const app = express();
@@ -181,6 +182,23 @@ const INFLUENCED_SQL = `
WHERE pis.source_painting_id = $1 AND pis.source_type = 'painting'
ORDER BY p.year NULLS LAST, p.title`;
app.get('/api/search', async (req, res) => {
try {
const q = typeof req.query.q === 'string' ? req.query.q : '';
const limit = parseInt(req.query.limit, 10);
const types = typeof req.query.types === 'string' ? req.query.types : undefined;
const result = await searchCatalog(q, {
limit: Number.isFinite(limit) ? limit : 20,
types,
});
res.setHeader('Cache-Control', 'no-store');
res.json(result);
} catch (err) {
console.error('Search error:', err.message);
res.status(500).json({ error: 'Search failed' });
}
});
app.get('/api/timeline', async (req, res) => {
try {
const { start, end } = req.query;
+1
View File
@@ -12,6 +12,7 @@ const INCREMENTAL_MIGRATIONS = [
'migrate-auth.sql',
'migrate-portrait-thumbs.sql',
'migrate-perf-indexes.sql',
'migrate-search.sql',
];
async function bootstrapCurator() {
+135
View File
@@ -0,0 +1,135 @@
const pool = require('./db');
const VALID_TYPES = new Set(['artist', 'painting', 'movement']);
function parseTypes(typesParam) {
if (!typesParam || typeof typesParam !== 'string') {
return ['artist', 'movement', 'painting'];
}
const parsed = typesParam
.split(',')
.map((t) => t.trim().toLowerCase())
.filter((t) => VALID_TYPES.has(t));
return parsed.length > 0 ? parsed : ['artist', 'movement', 'painting'];
}
async function searchArtists(pattern, prefixPattern, perTypeLimit) {
const { rows } = await pool.query(
`SELECT a.id, a.name, a.birth_year, a.death_year,
a.portrait_path, a.portrait_thumb_path,
m.name AS movement_name,
CASE WHEN a.name ILIKE $2 THEN 0 ELSE 1 END AS rank
FROM artists a
LEFT JOIN art_movements m ON a.movement_id = m.id
WHERE a.name ILIKE $1
OR a.wikipedia_title ILIKE $1
OR m.name ILIKE $1
ORDER BY rank, a.name
LIMIT $3`,
[pattern, prefixPattern, perTypeLimit]
);
return rows.map((row) => ({
type: 'artist',
id: row.id,
name: row.name,
birth_year: row.birth_year,
death_year: row.death_year,
movement_name: row.movement_name,
portrait_path: row.portrait_path,
portrait_thumb_path: row.portrait_thumb_path,
rank: row.rank,
}));
}
async function searchMovements(pattern, prefixPattern, perTypeLimit) {
const { rows } = await pool.query(
`SELECT m.id, m.name, m.color, m.start_year, m.end_year,
e.name AS era_name,
CASE WHEN m.name ILIKE $2 THEN 0 ELSE 1 END AS rank
FROM art_movements m
LEFT JOIN historical_eras e ON m.era_id = e.id
WHERE m.name ILIKE $1
OR e.name ILIKE $1
ORDER BY rank, m.name
LIMIT $3`,
[pattern, prefixPattern, perTypeLimit]
);
return rows.map((row) => ({
type: 'movement',
id: row.id,
name: row.name,
color: row.color,
start_year: row.start_year,
end_year: row.end_year,
era_name: row.era_name,
rank: row.rank,
}));
}
async function searchPaintings(pattern, prefixPattern, perTypeLimit) {
const { rows } = await pool.query(
`SELECT p.id, p.title, p.year, p.thumbnail_path, p.image_path,
a.id AS artist_id, a.name AS artist_name,
m.name AS movement_name,
CASE WHEN p.title ILIKE $2 THEN 0 ELSE 1 END AS rank
FROM paintings p
JOIN artists a ON p.artist_id = a.id
LEFT JOIN art_movements m ON a.movement_id = m.id
WHERE p.title ILIKE $1
OR p.wikipedia_title ILIKE $1
OR a.name ILIKE $1
OR m.name ILIKE $1
OR CAST(p.year AS TEXT) ILIKE $1
ORDER BY rank, p.year NULLS LAST, p.title
LIMIT $3`,
[pattern, prefixPattern, perTypeLimit]
);
return rows.map((row) => ({
type: 'painting',
id: row.id,
title: row.title,
year: row.year,
artist_id: row.artist_id,
artist_name: row.artist_name,
movement_name: row.movement_name,
thumbnail_path: row.thumbnail_path,
image_path: row.image_path,
rank: row.rank,
}));
}
async function searchCatalog(query, options = {}) {
const q = String(query || '').trim();
if (q.length < 2) {
return { q, results: [] };
}
const limit = Math.min(50, Math.max(1, Number(options.limit) || 20));
const types = parseTypes(options.types);
const perTypeLimit = Math.max(1, Math.ceil(limit / types.length));
const pattern = `%${q}%`;
const prefixPattern = `${q}%`;
const tasks = [];
if (types.includes('artist')) tasks.push(searchArtists(pattern, prefixPattern, perTypeLimit));
if (types.includes('movement')) tasks.push(searchMovements(pattern, prefixPattern, perTypeLimit));
if (types.includes('painting')) tasks.push(searchPaintings(pattern, prefixPattern, perTypeLimit));
const groups = await Promise.all(tasks);
const merged = groups
.flat()
.sort((a, b) => {
if (a.rank !== b.rank) return a.rank - b.rank;
const typeOrder = { artist: 0, movement: 1, painting: 2 };
if (typeOrder[a.type] !== typeOrder[b.type]) return typeOrder[a.type] - typeOrder[b.type];
const labelA = a.type === 'painting' ? a.title : a.name;
const labelB = b.type === 'painting' ? b.title : b.name;
return String(labelA).localeCompare(String(labelB));
})
.slice(0, limit)
.map(({ rank: _rank, ...rest }) => rest);
return { q, results: merged };
}
module.exports = { searchCatalog };