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, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, IMAGE_DIR } = require('./image-service'); const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher'); const app = express(); const HOST = process.env.HOST || '0.0.0.0'; const PORT = Number(process.env.PORT) || 3001; const PUBLIC_URL = process.env.PUBLIC_URL || ''; if (process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true') { app.set('trust proxy', 1); } app.use(cors()); app.use(express.json({ limit: '20mb' })); app.use('/images', express.static(IMAGE_DIR)); const INFLUENCE_LINKS_EXISTS = ` EXISTS ( SELECT 1 FROM painting_influence_sources pis WHERE pis.painting_id = p.id OR pis.source_painting_id = p.id )`; const INFLUENCED_BY_SQL = ` SELECT pis.source_type, pis.period_note, pis.period_start_year, pis.period_end_year, pis.notes, pis.source, pis.aspects, pis.quote, pis.source_author, pis.source_url, pis.confidence, pis.discovered_via, p.id, p.title, p.year, p.image_path, pa.name AS artist_name, pa.id AS artist_id, sa.id AS source_artist_id, sa.name AS source_artist_name, sa.portrait_path AS artist_portrait, m.id AS movement_id, m.name AS movement_name, m.color AS movement_color FROM painting_influence_sources pis LEFT JOIN paintings p ON pis.source_painting_id = p.id LEFT JOIN artists pa ON p.artist_id = pa.id LEFT JOIN artists sa ON pis.source_artist_id = sa.id LEFT JOIN art_movements m ON pis.source_movement_id = m.id WHERE pis.painting_id = $1 ORDER BY CASE pis.source_type WHEN 'painting' THEN 1 WHEN 'artist' THEN 2 ELSE 3 END, COALESCE(p.year, pis.period_start_year) NULLS LAST`; const INFLUENCED_SQL = ` SELECT pis.notes, pis.source, pis.aspects, pis.quote, pis.source_author, pis.source_url, 'painting' AS source_type, p.id, p.title, p.year, p.image_path, a.name AS artist_name, a.id AS artist_id FROM painting_influence_sources pis JOIN paintings p ON pis.painting_id = p.id JOIN artists a ON p.artist_id = a.id WHERE pis.source_painting_id = $1 AND pis.source_type = 'painting' ORDER BY p.year NULLS LAST, p.title`; 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' }); } }); // Movement gallery — all paintings by artists in the movement, chronological app.get('/api/movements/:id/gallery', async (req, res) => { try { const { id } = req.params; const movement = await pool.query( `SELECT m.*, e.name AS era_name FROM art_movements m LEFT JOIN historical_eras e ON m.era_id = e.id WHERE m.id = $1`, [id] ); if (movement.rows.length === 0) { return res.status(404).json({ error: 'Movement not found' }); } const paintings = await pool.query( `SELECT p.*, a.name AS artist_name, p.checkup_checked, p.checkup_fixed, (${INFLUENCE_LINKS_EXISTS}) AS has_influence_links FROM paintings p INNER JOIN artists a ON p.artist_id = a.id WHERE a.movement_id = $1 ORDER BY p.year NULLS LAST, p.sort_order, a.birth_year NULLS LAST, p.title`, [id] ); res.json({ movement: movement.rows[0], paintings: paintings.rows, }); } catch (err) { console.error(err); res.status(500).json({ error: 'Failed to fetch movement gallery' }); } }); // 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 predecessorSql = ` 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 paintings p_work JOIN painting_influence_sources pis ON pis.painting_id = p_work.id LEFT JOIN paintings p_pred ON pis.source_painting_id = p_pred.id LEFT JOIN artists a ON a.id = COALESCE(p_pred.artist_id, pis.source_artist_id) LEFT JOIN art_movements m ON a.movement_id = m.id WHERE p_work.artist_id = $1 AND a.id IS NOT NULL AND a.id <> $1`; const successorSql = ` 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_influence_sources pis JOIN paintings p_src ON pis.source_painting_id = p_src.id JOIN paintings p_work ON pis.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 UNION 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_influence_sources pis JOIN paintings p_work ON pis.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 pis.source_artist_id = $1 AND a.id <> $1`; const [predecessors, successors] = await Promise.all([ pool.query( `SELECT * FROM (${predecessorSql}) u ORDER BY movement_name NULLS LAST, birth_year NULLS LAST, name`, [artistId] ), pool.query( `SELECT * FROM (${successorSql}) u ORDER BY movement_name NULLS LAST, birth_year NULLS LAST, 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' }); } }); // Update artist portrait checkup flags (checked / fixed) app.patch('/api/artists/:id/checkup-flags', async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const { checked, fixed } = req.body ?? {}; if (checked === undefined && fixed === undefined) { return res.status(400).json({ error: 'Provide checked and/or fixed boolean' }); } if (checked !== undefined && typeof checked !== 'boolean') { return res.status(400).json({ error: 'checked must be a boolean' }); } if (fixed !== undefined && typeof fixed !== 'boolean') { return res.status(400).json({ error: 'fixed must be a boolean' }); } const current = await pool.query( `SELECT checkup_checked, checkup_fixed FROM artists WHERE id = $1`, [artistId] ); if (current.rows.length === 0) { return res.status(404).json({ error: 'Artist not found' }); } const willBeFixed = fixed !== undefined ? fixed : !!current.rows[0].checkup_fixed; let nextChecked = checked; if (willBeFixed) { nextChecked = true; } const sets = []; const params = []; if (fixed !== undefined) { params.push(fixed); sets.push(`checkup_fixed = $${params.length}`); } if (nextChecked !== undefined) { params.push(nextChecked); sets.push(`checkup_checked = $${params.length}`); } params.push(artistId); const result = await pool.query( `UPDATE artists SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING checkup_checked AS checked, checkup_fixed AS fixed`, params ); res.json({ checked: !!result.rows[0].checked, fixed: !!result.rows[0].fixed, }); } catch (err) { console.error('Artist checkup flags error:', err.message); res.status(500).json({ error: 'Failed to update checkup flags' }); } }); // Developer debug: portrait image search for artist bio app.get('/api/artists/:id/debug-portrait-search/more', async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20)); const result = await pool.query(`SELECT name FROM artists WHERE id = $1`, [artistId]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Artist not found' }); } const { name } = result.rows[0]; const search = await searchArtistPortraitMany(name, limit); res.json(search); } catch (err) { console.error('Debug portrait search (more) error:', err.message); res.status(500).json({ error: 'Portrait search failed' }); } }); app.get('/api/artists/:id/debug-portrait-search', async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const result = await pool.query(`SELECT name FROM artists WHERE id = $1`, [artistId]); if (result.rows.length === 0) { return res.status(404).json({ error: 'Artist not found' }); } const { name } = result.rows[0]; const search = await searchArtistPortraitFirst(name); res.json(search); } catch (err) { console.error('Debug portrait search error:', err.message); res.status(500).json({ error: 'Portrait search failed' }); } }); // Developer debug: replace artist portrait with a search result URL app.post('/api/artists/:id/fix-portrait', async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {}; if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) { return res.status(400).json({ error: 'Valid imageUrl required' }); } const updated = await replaceArtistPortraitFromUrl(artistId, imageUrl, { searchUrl: typeof searchUrl === 'string' ? searchUrl : undefined, source: typeof source === 'string' ? source : undefined, pageUrl: typeof pageUrl === 'string' ? pageUrl : undefined, thumbUrl: typeof thumbUrl === 'string' ? thumbUrl : undefined, }); await pool.query( `UPDATE artists SET checkup_fixed = true, checkup_checked = true WHERE id = $1`, [artistId] ); res.json({ ...updated, fixed: true, checked: true }); } catch (err) { console.error('Fix portrait error:', err.message); res.status(500).json({ error: friendlyImageFetchError(err) }); } }); app.post('/api/artists/:id/clear-portrait', async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const updated = await clearArtistPortrait(artistId); await pool.query( `UPDATE artists SET checkup_fixed = true, checkup_checked = true WHERE id = $1`, [artistId] ); res.json({ ...updated, fixed: true, checked: true }); } catch (err) { console.error('Clear portrait error:', err.message); res.status(500).json({ error: err.message || 'Could not clear portrait' }); } }); app.post('/api/artists/:id/upload-portrait', async (req, res) => { try { const artistId = parseInt(req.params.id, 10); const { imageData, mimeType } = req.body ?? {}; if (!imageData || typeof imageData !== 'string') { return res.status(400).json({ error: 'imageData required' }); } const buffer = Buffer.from(imageData, 'base64'); if (!buffer.length) { return res.status(400).json({ error: 'Empty image data' }); } if (buffer.length > 15 * 1024 * 1024) { return res.status(400).json({ error: 'Image too large (max 15 MB)' }); } const updated = await replaceArtistPortraitFromBuffer( artistId, buffer, typeof mimeType === 'string' ? mimeType : 'image/jpeg' ); await pool.query( `UPDATE artists SET checkup_fixed = true, checkup_checked = true WHERE id = $1`, [artistId] ); res.json({ ...updated, fixed: true, checked: true }); } catch (err) { console.error('Upload portrait error:', err.message); res.status(500).json({ error: err.message || 'Could not upload portrait' }); } }); // 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, m.color as movement_color 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 p.*, p.checkup_checked, p.checkup_fixed, (${INFLUENCE_LINKS_EXISTS}) AS has_influence_links FROM paintings p WHERE p.artist_id = $1 ORDER BY p.sort_order, p.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 image checkup (developer audit table) — must be before /api/paintings/:id app.get('/api/paintings/checkup', async (_req, res) => { try { const { rows } = await pool.query( `SELECT p.id, p.title, p.year, p.image_path, p.thumbnail_path, p.checkup_checked AS checked, p.checkup_fixed AS fixed, a.name AS artist_name FROM paintings p JOIN artists a ON a.id = p.artist_id ORDER BY a.name, p.year NULLS LAST, p.title` ); const paintings = rows.map((row) => { const galleryFile = row.image_path || row.thumbnail_path || null; const detailFile = row.image_path || row.thumbnail_path || null; const detailOnDemand = !detailFile; const galleryFileExists = galleryFile ? fs.existsSync(path.join(IMAGE_DIR, galleryFile)) : false; const detailFileExists = detailFile ? fs.existsSync(path.join(IMAGE_DIR, detailFile)) : false; return { id: row.id, title: row.title, artist_name: row.artist_name, year: row.year, gallery_file: galleryFile, gallery_preview: row.thumbnail_path || galleryFile, detail_file: detailOnDemand ? `/api/paintings/${row.id}/image?size=full` : detailFile, detail_preview: row.thumbnail_path || (detailOnDemand ? null : detailFile), detail_on_demand: detailOnDemand, gallery_file_exists: galleryFileExists, detail_file_exists: detailOnDemand ? null : detailFileExists, checked: !!row.checked, fixed: !!row.fixed, }; }); res.json({ paintings, total: paintings.length }); } catch (err) { console.error('Checkup error:', err.message); res.status(500).json({ error: 'Failed to load checkup data' }); } }); // Update checkup workflow flags (checked / fixed) app.patch('/api/paintings/:id/checkup-flags', async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const { checked, fixed } = req.body ?? {}; if (checked === undefined && fixed === undefined) { return res.status(400).json({ error: 'Provide checked and/or fixed boolean' }); } if (checked !== undefined && typeof checked !== 'boolean') { return res.status(400).json({ error: 'checked must be a boolean' }); } if (fixed !== undefined && typeof fixed !== 'boolean') { return res.status(400).json({ error: 'fixed must be a boolean' }); } const current = await pool.query( `SELECT checkup_checked, checkup_fixed FROM paintings WHERE id = $1`, [paintingId] ); if (current.rows.length === 0) { return res.status(404).json({ error: 'Painting not found' }); } const willBeFixed = fixed !== undefined ? fixed : !!current.rows[0].checkup_fixed; let nextChecked = checked; if (willBeFixed) { nextChecked = true; } const sets = []; const params = []; if (fixed !== undefined) { params.push(fixed); sets.push(`checkup_fixed = $${params.length}`); } if (nextChecked !== undefined) { params.push(nextChecked); sets.push(`checkup_checked = $${params.length}`); } params.push(paintingId); const result = await pool.query( `UPDATE paintings SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING checkup_checked AS checked, checkup_fixed AS fixed`, params ); res.json({ checked: !!result.rows[0].checked, fixed: !!result.rows[0].fixed, }); } catch (err) { console.error('Checkup flags error:', err.message); res.status(500).json({ error: 'Failed to update checkup flags' }); } }); // 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, (${INFLUENCE_LINKS_EXISTS}) AS has_influence_links 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, annotations] = await Promise.all([ pool.query(INFLUENCED_BY_SQL, [id]), pool.query(INFLUENCED_SQL, [id]), pool.query( `SELECT id, label, body, category, pos_x, pos_y, source_author, source, source_url, sort_order, confidence FROM painting_annotations WHERE painting_id = $1 ORDER BY sort_order, id`, [id] ), ]); res.json({ painting: painting.rows[0], influencedBy: influencedBy.rows, influenced: influenced.rows, annotations: annotations.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' }); } }); // Developer debug: Google Images first result for image audit app.get('/api/paintings/:id/debug-image-search/more', async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const limit = Math.min(20, Math.max(1, parseInt(req.query.limit, 10) || 20)); const result = await pool.query( `SELECT p.title, a.name AS artist_name FROM paintings p JOIN artists a ON a.id = p.artist_id WHERE p.id = $1`, [paintingId] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Painting not found' }); } const { title, artist_name: artistName } = result.rows[0]; const search = await searchPaintingImagesMany(artistName, title, limit); res.json(search); } catch (err) { console.error('Debug image search (more) error:', err.message); res.status(500).json({ error: 'Image search failed' }); } }); app.get('/api/paintings/:id/debug-image-search', async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const result = await pool.query( `SELECT p.title, a.name AS artist_name FROM paintings p JOIN artists a ON a.id = p.artist_id WHERE p.id = $1`, [paintingId] ); if (result.rows.length === 0) { return res.status(404).json({ error: 'Painting not found' }); } const { title, artist_name: artistName } = result.rows[0]; const search = await searchGoogleImagesFirst(artistName, title); res.json(search); } catch (err) { console.error('Debug image search error:', err.message); res.status(500).json({ error: 'Image search failed' }); } }); // Developer debug: replace painting image with a search result URL app.post('/api/paintings/:id/fix-image', async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const { imageUrl, searchUrl, source, pageUrl, thumbUrl } = req.body ?? {}; if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) { return res.status(400).json({ error: 'Valid imageUrl required' }); } const updated = await replacePaintingImageFromUrl(paintingId, imageUrl, { searchUrl: typeof searchUrl === 'string' ? searchUrl : undefined, source: typeof source === 'string' ? source : undefined, pageUrl: typeof pageUrl === 'string' ? pageUrl : undefined, thumbUrl: typeof thumbUrl === 'string' ? thumbUrl : undefined, }); await pool.query( `UPDATE paintings SET checkup_fixed = true, checkup_checked = true WHERE id = $1`, [paintingId] ); res.json({ ...updated, fixed: true, checked: true }); } catch (err) { console.error('Fix image error:', err.message); res.status(500).json({ error: friendlyImageFetchError(err) }); } }); app.post('/api/paintings/:id/clear-image', async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const updated = await clearPaintingImage(paintingId); await pool.query( `UPDATE paintings SET checkup_fixed = true, checkup_checked = true WHERE id = $1`, [paintingId] ); res.json({ ...updated, fixed: true, checked: true }); } catch (err) { console.error('Clear image error:', err.message); res.status(500).json({ error: err.message || 'Could not clear image' }); } }); app.post('/api/paintings/:id/upload-image', async (req, res) => { try { const paintingId = parseInt(req.params.id, 10); const { imageData, mimeType } = req.body ?? {}; if (!imageData || typeof imageData !== 'string') { return res.status(400).json({ error: 'imageData required' }); } const buffer = Buffer.from(imageData, 'base64'); if (!buffer.length) { return res.status(400).json({ error: 'Empty image data' }); } if (buffer.length > 15 * 1024 * 1024) { return res.status(400).json({ error: 'Image too large (max 15 MB)' }); } const updated = await replacePaintingImageFromBuffer( paintingId, buffer, typeof mimeType === 'string' ? mimeType : 'image/jpeg' ); await pool.query( `UPDATE paintings SET checkup_fixed = true, checkup_checked = true WHERE id = $1`, [paintingId] ); res.json({ ...updated, fixed: true, checked: true }); } catch (err) { console.error('Upload image error:', err.message); res.status(500).json({ error: err.message || 'Could not upload image' }); } }); // Proxy remote image for debug preview (avoids hotlink / CORS blocks) app.get('/api/debug/image-proxy', async (req, res) => { try { const imageUrl = req.query.url; const searchUrl = req.query.searchUrl; const source = req.query.source; if (!imageUrl || typeof imageUrl !== 'string' || !/^https?:\/\//i.test(imageUrl)) { return res.status(400).json({ error: 'Valid url query required' }); } const buffer = await fetchImageBuffer(imageUrl, { searchUrl: typeof searchUrl === 'string' ? searchUrl : undefined, source: typeof source === 'string' ? source : undefined, }); const ext = pickExt(imageUrl).toLowerCase(); const type = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg'; res.setHeader('Content-Type', type); res.setHeader('Cache-Control', 'no-store'); res.send(buffer); } catch (err) { console.error('Image proxy error:', err.message); res.status(502).json({ error: 'Could not load preview image' }); } }); // 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, HOST, () => { console.log(`Gallery listening on http://${HOST}:${PORT}`); if (PUBLIC_URL) console.log(`Public URL: ${PUBLIC_URL}`); });