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', 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}`); });