Restore React client source, add hall-to-hall navigation via painting influences grouped by movement, and update documentation. Co-authored-by: Cursor <cursoragent@cursor.com>
323 lines
10 KiB
JavaScript
323 lines
10 KiB
JavaScript
const express = require('express');
|
|
const cors = require('cors');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
require('dotenv').config();
|
|
|
|
const pool = require('./db');
|
|
const { ensurePaintingImages, preloadArtistImagesLocal, IMAGE_DIR } = require('./image-service');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3001;
|
|
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
app.use('/images', express.static(IMAGE_DIR));
|
|
|
|
// Timeline: eras + movements for a year range
|
|
app.get('/api/timeline', async (req, res) => {
|
|
try {
|
|
const { start, end } = req.query;
|
|
const startYear = parseInt(start) || -3000;
|
|
const endYear = parseInt(end) || 2100;
|
|
|
|
const [eras, movements] = await Promise.all([
|
|
pool.query(
|
|
`SELECT * FROM historical_eras
|
|
WHERE end_year >= $1 AND start_year <= $2
|
|
ORDER BY sort_order, start_year`,
|
|
[startYear, endYear]
|
|
),
|
|
pool.query(
|
|
`SELECT DISTINCT m.*, e.name as era_name
|
|
FROM art_movements m
|
|
LEFT JOIN historical_eras e ON m.era_id = e.id
|
|
INNER JOIN artists a ON a.movement_id = m.id
|
|
WHERE m.end_year >= $1 AND m.start_year <= $2
|
|
AND (a.death_year IS NULL OR a.death_year >= $1)
|
|
AND (a.birth_year IS NULL OR a.birth_year <= $2)
|
|
ORDER BY m.start_year`,
|
|
[startYear, endYear]
|
|
),
|
|
]);
|
|
|
|
res.json({ eras: eras.rows, movements: movements.rows });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Failed to fetch timeline' });
|
|
}
|
|
});
|
|
|
|
// Artists for a movement in a time range
|
|
app.get('/api/movements/:id/artists', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const result = await pool.query(
|
|
`SELECT id, name, birth_year, death_year, portrait_path, bio_short
|
|
FROM artists WHERE movement_id = $1
|
|
ORDER BY birth_year`,
|
|
[id]
|
|
);
|
|
res.json(result.rows);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Failed to fetch artists' });
|
|
}
|
|
});
|
|
|
|
// All artists in a year range (for timeline portrait placement)
|
|
app.get('/api/artists', async (req, res) => {
|
|
try {
|
|
const { start, end, movement_id } = req.query;
|
|
let query = `
|
|
SELECT a.*, m.name as movement_name, m.color as movement_color
|
|
FROM artists a
|
|
LEFT JOIN art_movements m ON a.movement_id = m.id
|
|
WHERE 1=1`;
|
|
const params = [];
|
|
|
|
if (start) {
|
|
params.push(parseInt(start));
|
|
query += ` AND (a.death_year IS NULL OR a.death_year >= $${params.length})`;
|
|
}
|
|
if (end) {
|
|
params.push(parseInt(end));
|
|
query += ` AND (a.birth_year IS NULL OR a.birth_year <= $${params.length})`;
|
|
}
|
|
if (movement_id) {
|
|
params.push(parseInt(movement_id));
|
|
query += ` AND a.movement_id = $${params.length}`;
|
|
}
|
|
|
|
query += ' ORDER BY a.birth_year';
|
|
const result = await pool.query(query, params);
|
|
res.json(result.rows);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Failed to fetch artists' });
|
|
}
|
|
});
|
|
|
|
// Artist detail with periods and paintings
|
|
app.get('/api/artists/:id/navigation', async (req, res) => {
|
|
try {
|
|
const artistId = parseInt(req.params.id, 10);
|
|
|
|
const groupByMovement = (rows) => {
|
|
const map = new Map();
|
|
for (const row of rows) {
|
|
const key = row.movement_id ?? 0;
|
|
if (!map.has(key)) {
|
|
map.set(key, {
|
|
movement_id: row.movement_id,
|
|
movement_name: row.movement_name || 'Other',
|
|
movement_color: row.movement_color || '#8B7355',
|
|
artists: [],
|
|
});
|
|
}
|
|
const group = map.get(key);
|
|
if (!group.artists.some((a) => a.id === row.id)) {
|
|
group.artists.push({
|
|
id: row.id,
|
|
name: row.name,
|
|
birth_year: row.birth_year,
|
|
death_year: row.death_year,
|
|
portrait_path: row.portrait_path,
|
|
});
|
|
}
|
|
}
|
|
return Array.from(map.values()).sort((a, b) =>
|
|
a.movement_name.localeCompare(b.movement_name)
|
|
);
|
|
};
|
|
|
|
const [predecessors, successors] = await Promise.all([
|
|
pool.query(
|
|
`SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path,
|
|
m.id AS movement_id, m.name AS movement_name, m.color AS movement_color
|
|
FROM painting_influences pi
|
|
JOIN paintings p_work ON pi.painting_id = p_work.id
|
|
JOIN paintings p_pred ON pi.influenced_by_painting_id = p_pred.id
|
|
JOIN artists a ON p_pred.artist_id = a.id
|
|
LEFT JOIN art_movements m ON a.movement_id = m.id
|
|
WHERE p_work.artist_id = $1 AND a.id <> $1
|
|
ORDER BY m.name NULLS LAST, a.birth_year NULLS LAST, a.name`,
|
|
[artistId]
|
|
),
|
|
pool.query(
|
|
`SELECT DISTINCT a.id, a.name, a.birth_year, a.death_year, a.portrait_path,
|
|
m.id AS movement_id, m.name AS movement_name, m.color AS movement_color
|
|
FROM painting_influences pi
|
|
JOIN paintings p_src ON pi.influenced_by_painting_id = p_src.id
|
|
JOIN paintings p_work ON pi.painting_id = p_work.id
|
|
JOIN artists a ON p_work.artist_id = a.id
|
|
LEFT JOIN art_movements m ON a.movement_id = m.id
|
|
WHERE p_src.artist_id = $1 AND a.id <> $1
|
|
ORDER BY m.name NULLS LAST, a.birth_year NULLS LAST, a.name`,
|
|
[artistId]
|
|
),
|
|
]);
|
|
|
|
res.json({
|
|
predecessors: groupByMovement(predecessors.rows),
|
|
successors: groupByMovement(successors.rows),
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Failed to fetch artist navigation' });
|
|
}
|
|
});
|
|
|
|
// Artist detail with periods and paintings
|
|
app.get('/api/artists/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const [artist, periods, paintings] = await Promise.all([
|
|
pool.query(
|
|
`SELECT a.*, m.name as movement_name
|
|
FROM artists a
|
|
LEFT JOIN art_movements m ON a.movement_id = m.id
|
|
WHERE a.id = $1`,
|
|
[id]
|
|
),
|
|
pool.query(
|
|
`SELECT * FROM artist_periods WHERE artist_id = $1 ORDER BY sort_order, start_year`,
|
|
[id]
|
|
),
|
|
pool.query(
|
|
`SELECT * FROM paintings WHERE artist_id = $1 ORDER BY sort_order, year`,
|
|
[id]
|
|
),
|
|
]);
|
|
|
|
if (artist.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Artist not found' });
|
|
}
|
|
|
|
res.json({
|
|
artist: artist.rows[0],
|
|
periods: periods.rows,
|
|
paintings: paintings.rows,
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Failed to fetch artist' });
|
|
}
|
|
});
|
|
|
|
// Painting detail with influences
|
|
app.get('/api/paintings/:id', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
const painting = await pool.query(
|
|
`SELECT p.*, a.name as artist_name, a.id as artist_id, a.portrait_path as artist_portrait
|
|
FROM paintings p
|
|
JOIN artists a ON p.artist_id = a.id
|
|
WHERE p.id = $1`,
|
|
[id]
|
|
);
|
|
|
|
if (painting.rows.length === 0) {
|
|
return res.status(404).json({ error: 'Painting not found' });
|
|
}
|
|
|
|
const [influencedBy, influenced] = await Promise.all([
|
|
pool.query(
|
|
`SELECT pi.notes, pi.source, pi.aspects, pi.quote, pi.source_author, pi.source_url,
|
|
p.id, p.title, p.year, p.image_path, a.name as artist_name, a.id as artist_id
|
|
FROM painting_influences pi
|
|
JOIN paintings p ON pi.influenced_by_painting_id = p.id
|
|
JOIN artists a ON p.artist_id = a.id
|
|
WHERE pi.painting_id = $1`,
|
|
[id]
|
|
),
|
|
pool.query(
|
|
`SELECT pi.notes, pi.source, pi.aspects, pi.quote, pi.source_author, pi.source_url,
|
|
p.id, p.title, p.year, p.image_path, a.name as artist_name, a.id as artist_id
|
|
FROM painting_influences pi
|
|
JOIN paintings p ON pi.painting_id = p.id
|
|
JOIN artists a ON p.artist_id = a.id
|
|
WHERE pi.influenced_by_painting_id = $1`,
|
|
[id]
|
|
),
|
|
]);
|
|
|
|
res.json({
|
|
painting: painting.rows[0],
|
|
influencedBy: influencedBy.rows,
|
|
influenced: influenced.rows,
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Failed to fetch painting' });
|
|
}
|
|
});
|
|
|
|
// Fast preload: link local image files only (no external downloads)
|
|
app.post('/api/artists/:id/preload-images', async (req, res) => {
|
|
try {
|
|
const artistId = parseInt(req.params.id, 10);
|
|
const result = await preloadArtistImagesLocal(artistId);
|
|
res.json(result);
|
|
} catch (err) {
|
|
console.error('Preload error:', err.message);
|
|
res.status(500).json({ error: 'Preload failed' });
|
|
}
|
|
});
|
|
|
|
// On-demand painting image (resolves, caches, serves thumb or full)
|
|
app.get('/api/paintings/:id/image', async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const size = req.query.size === 'full' ? 'full' : 'thumb';
|
|
const relPath = await ensurePaintingImages(parseInt(id, 10), size);
|
|
|
|
if (!relPath) {
|
|
return res.status(404).json({ error: 'Image not found' });
|
|
}
|
|
|
|
const absPath = path.join(IMAGE_DIR, relPath);
|
|
if (!fs.existsSync(absPath)) {
|
|
return res.status(404).json({ error: 'Image file missing' });
|
|
}
|
|
|
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
res.sendFile(absPath);
|
|
} catch (err) {
|
|
console.error('Image fetch error:', err.message);
|
|
res.status(500).json({ error: 'Image fetch failed' });
|
|
}
|
|
});
|
|
|
|
// Year range bounds
|
|
app.get('/api/bounds', async (req, res) => {
|
|
try {
|
|
const result = await pool.query(`
|
|
SELECT
|
|
(SELECT MIN(start_year) FROM art_movements) as min_year,
|
|
GREATEST(
|
|
(SELECT MAX(end_year) FROM historical_eras),
|
|
(SELECT MAX(end_year) FROM art_movements),
|
|
(SELECT MAX(death_year) FROM artists WHERE death_year IS NOT NULL)
|
|
) as max_year
|
|
`);
|
|
res.json(result.rows[0]);
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Failed to fetch bounds' });
|
|
}
|
|
});
|
|
|
|
const CLIENT_DIST = path.join(__dirname, '..', 'client', 'dist');
|
|
if (fs.existsSync(CLIENT_DIST)) {
|
|
app.use(express.static(CLIENT_DIST));
|
|
app.get(/^(?!\/api|\/images).*/, (_req, res) => {
|
|
res.sendFile(path.join(CLIENT_DIST, 'index.html'));
|
|
});
|
|
}
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`Gallery running on http://localhost:${PORT}`);
|
|
});
|