Initial commit: virtual art gallery application.
Express API with PostgreSQL, React/Vite/Three.js frontend, and locally cached artwork images. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
const { Pool } = require('pg');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST,
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected database error:', err);
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
@@ -0,0 +1,138 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pool = require('./db');
|
||||
const { savePaintingImages } = require('../scripts/image-fetcher');
|
||||
|
||||
const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || './data/images');
|
||||
const FETCH_TIMEOUT_MS = 15000;
|
||||
const inflight = new Map();
|
||||
|
||||
function withTimeout(promise, ms) {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Image fetch timeout')), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
function localFileExists(relPath) {
|
||||
if (!relPath) return false;
|
||||
return fs.existsSync(path.join(IMAGE_DIR, relPath));
|
||||
}
|
||||
|
||||
function syncPaintingFromDisk(row) {
|
||||
const safeBase = `${row.artist_name}_${row.title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
let imagePath = row.image_path;
|
||||
let thumbPath = row.thumbnail_path;
|
||||
|
||||
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) {
|
||||
const full = path.join(IMAGE_DIR, 'paintings', safeBase + ext);
|
||||
if (!imagePath && fs.existsSync(full)) {
|
||||
imagePath = `paintings/${safeBase}${ext}`;
|
||||
}
|
||||
const thumb = path.join(IMAGE_DIR, 'paintings', 'thumbs', safeBase + '_thumb' + ext);
|
||||
if (!thumbPath && fs.existsSync(thumb)) {
|
||||
thumbPath = `paintings/thumbs/${safeBase}_thumb${ext}`;
|
||||
}
|
||||
}
|
||||
if (!thumbPath && imagePath) thumbPath = imagePath;
|
||||
return { imagePath, thumbPath };
|
||||
}
|
||||
|
||||
/** Fast preload: link local files only, no external API calls */
|
||||
async function preloadArtistImagesLocal(artistId) {
|
||||
const rows = await pool.query(
|
||||
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
|
||||
FROM paintings p
|
||||
JOIN artists a ON a.id = p.artist_id
|
||||
WHERE p.artist_id = $1`,
|
||||
[artistId]
|
||||
);
|
||||
|
||||
let linked = 0;
|
||||
for (const row of rows.rows) {
|
||||
const hasLocal =
|
||||
localFileExists(row.thumbnail_path) || localFileExists(row.image_path);
|
||||
if (hasLocal) {
|
||||
linked++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const synced = syncPaintingFromDisk(row);
|
||||
if (synced.imagePath || synced.thumbPath) {
|
||||
await pool.query(
|
||||
`UPDATE paintings SET image_path = COALESCE($1, image_path), thumbnail_path = COALESCE($2, thumbnail_path) WHERE id = $3`,
|
||||
[synced.imagePath, synced.thumbPath, row.id]
|
||||
);
|
||||
linked++;
|
||||
}
|
||||
}
|
||||
|
||||
return { fetched: linked, total: rows.rows.length };
|
||||
}
|
||||
|
||||
async function ensurePaintingImages(paintingId, size = 'thumb') {
|
||||
const key = `${paintingId}:${size}`;
|
||||
if (inflight.has(key)) return inflight.get(key);
|
||||
|
||||
const promise = (async () => {
|
||||
const result = await pool.query(
|
||||
`SELECT p.id, p.title, p.wikipedia_title, p.image_path, p.thumbnail_path, 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 null;
|
||||
|
||||
const row = result.rows[0];
|
||||
const wantThumb = size !== 'full';
|
||||
|
||||
if (wantThumb && localFileExists(row.thumbnail_path)) return row.thumbnail_path;
|
||||
if (!wantThumb && localFileExists(row.image_path)) return row.image_path;
|
||||
if (wantThumb && localFileExists(row.image_path)) return row.image_path;
|
||||
|
||||
const synced = syncPaintingFromDisk(row);
|
||||
if (synced.imagePath || synced.thumbPath) {
|
||||
await pool.query(
|
||||
`UPDATE paintings
|
||||
SET image_path = COALESCE($1, image_path),
|
||||
thumbnail_path = COALESCE($2, thumbnail_path)
|
||||
WHERE id = $3`,
|
||||
[synced.imagePath, synced.thumbPath, paintingId]
|
||||
);
|
||||
if (wantThumb && localFileExists(synced.thumbPath)) return synced.thumbPath;
|
||||
if (wantThumb && localFileExists(synced.imagePath)) return synced.imagePath;
|
||||
if (!wantThumb && localFileExists(synced.imagePath)) return synced.imagePath;
|
||||
}
|
||||
|
||||
if (!row.wikipedia_title) return null;
|
||||
|
||||
const saved = await withTimeout(
|
||||
savePaintingImages(
|
||||
row.wikipedia_title,
|
||||
`${row.artist_name}_${row.title}`,
|
||||
IMAGE_DIR,
|
||||
{ artistName: row.artist_name, paintingTitle: row.title, type: 'painting' }
|
||||
),
|
||||
FETCH_TIMEOUT_MS
|
||||
).catch(() => ({ imagePath: null, thumbnailPath: null }));
|
||||
|
||||
if (saved.imagePath || saved.thumbnailPath) {
|
||||
await pool.query(
|
||||
`UPDATE paintings
|
||||
SET image_path = COALESCE($1, image_path),
|
||||
thumbnail_path = COALESCE($2, thumbnail_path)
|
||||
WHERE id = $3`,
|
||||
[saved.imagePath, saved.thumbnailPath, paintingId]
|
||||
);
|
||||
}
|
||||
|
||||
if (wantThumb) return saved.thumbnailPath || saved.imagePath;
|
||||
return saved.imagePath || saved.thumbnailPath;
|
||||
})().finally(() => inflight.delete(key));
|
||||
|
||||
inflight.set(key, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
module.exports = { ensurePaintingImages, preloadArtistImagesLocal, IMAGE_DIR };
|
||||
+252
@@ -0,0 +1,252 @@
|
||||
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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user