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:
Danila Khodjaef
2026-07-15 11:51:46 +03:00
co-authored by Cursor
parent f247b418d8
commit ca58c43648
51 changed files with 2252 additions and 101 deletions
+86 -16
View File
@@ -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);