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
@@ -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;
|
||||
|
||||
@@ -12,6 +12,7 @@ const INCREMENTAL_MIGRATIONS = [
|
||||
'migrate-auth.sql',
|
||||
'migrate-portrait-thumbs.sql',
|
||||
'migrate-perf-indexes.sql',
|
||||
'migrate-search.sql',
|
||||
];
|
||||
|
||||
async function bootstrapCurator() {
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user