Add Russian i18n with DB translations, locale API, and curator review UI.
UI chrome via react-i18next, catalog text in entity_translations with ru.wikipedia seeding, locale-aware search, and Translations page for publish workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f247b418d8
commit
ca58c43648
+86
-16
@@ -14,6 +14,18 @@ const authRoutes = require('./routes/auth');
|
||||
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
|
||||
const { getVersionInfo } = require('./version-info');
|
||||
const { searchCatalog } = require('./search-service');
|
||||
const {
|
||||
resolveLocale,
|
||||
translationStatuses,
|
||||
localizeEras,
|
||||
localizeMovements,
|
||||
localizeArtists,
|
||||
localizePeriods,
|
||||
localizePaintings,
|
||||
localizeAnnotations,
|
||||
localizeInfluenceSources,
|
||||
} = require('./translation-service');
|
||||
const translationRoutes = require('./routes/translations');
|
||||
const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
|
||||
|
||||
const app = express();
|
||||
@@ -30,6 +42,7 @@ app.use(compression());
|
||||
app.use(express.json({ limit: '20mb' }));
|
||||
app.use(createSessionMiddleware());
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/translations', translationRoutes);
|
||||
app.use(
|
||||
'/images',
|
||||
express.static(IMAGE_DIR, {
|
||||
@@ -119,6 +132,13 @@ function sendCatalogCacheHeaders(res, etagSource) {
|
||||
return etag;
|
||||
}
|
||||
|
||||
function localeContext(req) {
|
||||
return {
|
||||
locale: resolveLocale(req),
|
||||
statuses: translationStatuses(req),
|
||||
};
|
||||
}
|
||||
|
||||
const INFLUENCE_LINKS_EXISTS = `
|
||||
EXISTS (
|
||||
SELECT 1 FROM painting_influence_sources pis
|
||||
@@ -127,6 +147,7 @@ const INFLUENCE_LINKS_EXISTS = `
|
||||
|
||||
const INFLUENCED_BY_SQL = `
|
||||
SELECT
|
||||
pis.id AS influence_source_id,
|
||||
pis.source_type,
|
||||
pis.period_note,
|
||||
pis.period_start_year,
|
||||
@@ -163,6 +184,7 @@ const INFLUENCED_BY_SQL = `
|
||||
|
||||
const INFLUENCED_SQL = `
|
||||
SELECT
|
||||
pis.id AS influence_source_id,
|
||||
pis.notes,
|
||||
pis.source,
|
||||
pis.aspects,
|
||||
@@ -187,9 +209,11 @@ app.get('/api/search', async (req, res) => {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q : '';
|
||||
const limit = parseInt(req.query.limit, 10);
|
||||
const types = typeof req.query.types === 'string' ? req.query.types : undefined;
|
||||
const { locale } = localeContext(req);
|
||||
const result = await searchCatalog(q, {
|
||||
limit: Number.isFinite(limit) ? limit : 20,
|
||||
types,
|
||||
locale,
|
||||
});
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json(result);
|
||||
@@ -204,9 +228,12 @@ app.get('/api/timeline', async (req, res) => {
|
||||
const { start, end } = req.query;
|
||||
const startYear = parseInt(start) || -3000;
|
||||
const endYear = parseInt(end) || 2100;
|
||||
const { locale, statuses } = localeContext(req);
|
||||
|
||||
const { eras, movements } = await fetchTimelineErasAndMovements(startYear, endYear);
|
||||
res.json({ eras, movements });
|
||||
const localizedEras = await localizeEras(eras, locale, statuses);
|
||||
const localizedMovements = await localizeMovements(movements, locale, statuses);
|
||||
res.json({ locale, eras: localizedEras, movements: localizedMovements });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch timeline' });
|
||||
@@ -219,6 +246,7 @@ app.get('/api/catalog/bootstrap', async (req, res) => {
|
||||
const bounds = await fetchCatalogBounds();
|
||||
const startYear = parseInt(req.query.start, 10) || bounds.min_year || -3000;
|
||||
const endYear = parseInt(req.query.end, 10) || bounds.max_year || 2100;
|
||||
const { locale, statuses } = localeContext(req);
|
||||
|
||||
const etagSource = await catalogBootstrapEtag();
|
||||
const etag = sendCatalogCacheHeaders(res, etagSource);
|
||||
@@ -231,7 +259,17 @@ app.get('/api/catalog/bootstrap', async (req, res) => {
|
||||
fetchTimelineArtists(startYear, endYear),
|
||||
]);
|
||||
|
||||
res.json({ bounds, eras, movements, artists });
|
||||
const localizedEras = await localizeEras(eras, locale, statuses);
|
||||
const localizedMovements = await localizeMovements(movements, locale, statuses);
|
||||
const localizedArtists = await localizeArtists(artists, locale, statuses);
|
||||
|
||||
res.json({
|
||||
locale,
|
||||
bounds,
|
||||
eras: localizedEras,
|
||||
movements: localizedMovements,
|
||||
artists: localizedArtists,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch catalog bootstrap' });
|
||||
@@ -256,6 +294,7 @@ app.get('/api/movements/:id/gallery', async (req, res) => {
|
||||
const paintings = await pool.query(
|
||||
`SELECT p.*,
|
||||
a.name AS artist_name,
|
||||
a.id AS artist_id,
|
||||
p.checkup_checked,
|
||||
p.checkup_fixed,
|
||||
(${INFLUENCE_LINKS_EXISTS}) AS has_influence_links
|
||||
@@ -266,9 +305,14 @@ app.get('/api/movements/:id/gallery', async (req, res) => {
|
||||
[id]
|
||||
);
|
||||
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localizedMovement = (await localizeMovements([movement.rows[0]], locale, statuses))[0];
|
||||
const localizedPaintings = await localizePaintings(paintings.rows, locale, statuses);
|
||||
|
||||
res.json({
|
||||
movement: movement.rows[0],
|
||||
paintings: paintings.rows.map(enrichPaintingRow),
|
||||
locale,
|
||||
movement: localizedMovement,
|
||||
paintings: localizedPaintings.map(enrichPaintingRow),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -281,12 +325,14 @@ 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
|
||||
`SELECT id, name, birth_year, death_year, portrait_path, bio_short, movement_id
|
||||
FROM artists WHERE movement_id = $1
|
||||
ORDER BY birth_year`,
|
||||
[id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localized = await localizeArtists(result.rows, locale, statuses);
|
||||
res.json(localized);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch artists' });
|
||||
@@ -322,7 +368,9 @@ app.get('/api/artists', async (req, res) => {
|
||||
|
||||
query += ' ORDER BY a.birth_year';
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localized = await localizeArtists(result.rows, locale, statuses);
|
||||
res.json(localized);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch artists' });
|
||||
@@ -401,9 +449,14 @@ app.get('/api/artists/:id/navigation', async (req, res) => {
|
||||
),
|
||||
]);
|
||||
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const locPred = await localizeArtists(predecessors.rows, locale, statuses);
|
||||
const locSucc = await localizeArtists(successors.rows, locale, statuses);
|
||||
|
||||
res.json({
|
||||
predecessors: groupByMovement(predecessors.rows),
|
||||
successors: groupByMovement(successors.rows),
|
||||
locale,
|
||||
predecessors: groupByMovement(locPred),
|
||||
successors: groupByMovement(locSucc),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -645,10 +698,16 @@ app.get('/api/artists/:id', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Artist not found' });
|
||||
}
|
||||
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localizedArtist = (await localizeArtists([artist.rows[0]], locale, statuses))[0];
|
||||
const localizedPeriods = await localizePeriods(periods.rows, locale, statuses);
|
||||
const localizedPaintings = await localizePaintings(paintings.rows, locale, statuses);
|
||||
|
||||
res.json({
|
||||
artist: enrichArtistRow(artist.rows[0]),
|
||||
periods: periods.rows,
|
||||
paintings: paintings.rows.map(enrichPaintingRow),
|
||||
locale,
|
||||
artist: enrichArtistRow(localizedArtist),
|
||||
periods: localizedPeriods,
|
||||
paintings: localizedPaintings.map(enrichPaintingRow),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -803,11 +862,22 @@ app.get('/api/paintings/:id', async (req, res) => {
|
||||
),
|
||||
]);
|
||||
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localizedPainting = (await localizePaintings([painting.rows[0]], locale, statuses))[0];
|
||||
const localizedInfluencedBy = await localizeInfluenceSources(influencedBy.rows, locale, statuses);
|
||||
const localizedInfluenced = await localizeInfluenceSources(
|
||||
influenced.rows.map(enrichPaintingRow),
|
||||
locale,
|
||||
statuses,
|
||||
);
|
||||
const localizedAnnotations = await localizeAnnotations(annotations.rows, locale, statuses);
|
||||
|
||||
res.json({
|
||||
painting: enrichPaintingRow(painting.rows[0]),
|
||||
influencedBy: influencedBy.rows,
|
||||
influenced: influenced.rows.map(enrichPaintingRow),
|
||||
annotations: annotations.rows,
|
||||
locale,
|
||||
painting: enrichPaintingRow(localizedPainting),
|
||||
influencedBy: localizedInfluencedBy,
|
||||
influenced: localizedInfluenced,
|
||||
annotations: localizedAnnotations,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
Reference in New Issue
Block a user