Files
Art-gallery/server/search-service.js
T
Danila KhodjaefandCursor acc4a91a08 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>
2026-07-15 10:34:29 +03:00

136 lines
4.3 KiB
JavaScript

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 };