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
+28
View File
@@ -171,6 +171,32 @@ Movements are filtered to those with at least one artist active in the requested
---
## Locale (`?locale=ru`)
Public catalog endpoints accept optional **`locale`** query param (`en` default, `ru` supported) or `Accept-Language: ru`.
Affected routes: `/api/catalog/bootstrap`, `/api/timeline`, `/api/search`, `/api/artists`, `/api/artists/:id`, `/api/paintings/:id`, `/api/movements/:id/gallery`, `/api/movements/:id/artists`, `/api/artists/:id/navigation`.
Responses include `"locale": "ru"` when resolved. Display field names are unchanged; values come from `entity_translations` when `status = published`, else canonical English.
Full guide: [i18n-russian.md](i18n-russian.md).
---
## Translations (curator)
Requires curator session. Base path: `/api/translations`.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/translations/coverage?locale=ru` | Coverage stats |
| `GET` | `/api/translations/worklist/:entityType?locale=ru` | Artists/paintings/movements with translation status |
| `GET` | `/api/translations/:entityType/:id` | Canonical + all translation rows |
| `PUT` | `/api/translations/:entityType/:id` | Upsert fields `{ locale, fields, status }` |
| `POST` | `/api/translations/:entityType/:id/publish` | Publish all draft/reviewed rows for locale |
---
## `GET /api/search`
Public catalog search over **artists**, **paintings**, and **art movements**. Used by the timeline header search bar (`CatalogSearchBar.tsx`).
@@ -182,6 +208,7 @@ Public catalog search over **artists**, **paintings**, and **art movements**. Us
| `q` | string | — | Search text (min **2** characters after trim; shorter returns `{ q, results: [] }`) |
| `limit` | int | 20 | Max results total (capped at **50**) |
| `types` | string | all | Optional comma list: `artist`, `painting`, `movement` |
| `locale` | string | `en` | `ru` — search and return published Russian aliases when available |
**Matching (case-insensitive `ILIKE`):**
@@ -190,6 +217,7 @@ Public catalog search over **artists**, **paintings**, and **art movements**. Us
| Artist | `name`, `wikipedia_title`, movement name |
| Movement | movement `name`, era name |
| Painting | `title`, `wikipedia_title`, `year` (as text), artist name, movement name |
| All (when `locale=ru`) | Published rows in `entity_translations` for `name` / `title` |
Prefix matches on primary labels (`name` / `title`) rank before substring matches.
+1 -1
View File
@@ -1,6 +1,6 @@
this file contains draft for future releases and features
1. Multy language support, russian version at least, search for best implementation, preferably story in db and easily expandable. tool to check and correct translation
1. ~~Multi language support, russian version at least~~ — done: UI i18n (EN/RU) + `entity_translations` DB + curator Translations tool — [i18n-russian.md](i18n-russian.md)
2. tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities
3. tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ?
4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md)
+1 -1
View File
@@ -107,7 +107,7 @@ Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_image
Processed in FK order:
`historical_eras` → `art_movements` → `artists` → `artist_periods` → `paintings` → `painting_influences` → `painting_influence_sources` → `painting_annotations`
`historical_eras` → `art_movements` → `artists` → `artist_periods` → `paintings` → `painting_influences` → `painting_influence_sources` → `painting_annotations` → **`entity_translations`**
---
+105
View File
@@ -0,0 +1,105 @@
# Russian localization (i18n)
Full Russian support: **UI chrome** via `react-i18next`, **catalog text** via PostgreSQL `entity_translations`, with Cyrillic display aliases where available and English fallback.
---
## Architecture
| Layer | English | Russian |
|-------|---------|---------|
| UI labels, buttons | `client/src/locales/en/*.json` | `client/src/locales/ru/*.json` |
| Names / titles (display) | canonical DB columns | `entity_translations` (`name`, `title`) |
| Bios, notes, descriptions | canonical DB columns | `entity_translations` (`bio_full`, `body`, `notes`, …) |
English remains canonical in main tables. Russian rows use `status`: `draft``reviewed``published`. Public API returns only **`published`** (unless curator preview).
---
## User-facing locale switch
Timeline header: **EN | RU** toggle (`LocaleSwitcher`).
- Persists `gallery_locale` in `localStorage`
- Sets `document.documentElement.lang`
- Passes `?locale=ru` on catalog API requests
- Refetches bootstrap catalog when locale changes
---
## Setup (dev)
```powershell
npm run dev:migrate # includes migrate-i18n.sql
npm run dev:fetch-artist-bios-ru # draft bios + Cyrillic names from ru.wikipedia
# Curator: Translations page → review → Publish
npm run dev:import-translations -- --file path/to/translations.json --publish
```
Optional manual import JSON:
```json
[
{ "entity_type": "painting", "entity_id": 42, "field_name": "title", "value": "Джоконда", "status": "published", "source": "manual" }
]
```
CSV: `entity_type,entity_id,field_name,value,status,source`
---
## Curator translation review
1. Sign in as curator
2. Header → **Translations** (or **Переводы** in RU UI)
3. Pick entity type (artist / painting / movement)
4. Select row → edit Russian fields side-by-side with English canonical
5. **Publish** saves and marks rows `published`
Coverage stats show artists with `bio_full`, paintings with `title` alias, draft vs published counts.
API (curator-only): see [API.md](API.md#translations-curator).
---
## Translatable fields (v1)
| entity_type | fields |
|-------------|--------|
| `era`, `movement` | `name`, `description` |
| `artist` | `name`, `bio_short`, `bio_full` |
| `artist_period` | `name`, `description` |
| `painting` | `title`, `description` |
| `annotation` | `label`, `body` |
| `influence_source` | `notes`, `aspects`, `quote`, `period_note` |
---
## API locale
Public endpoints accept `?locale=ru` or `Accept-Language: ru`. Responses include `"locale": "ru"` on catalog payloads; field names unchanged — values are already resolved.
Search matches canonical text **or** published Russian aliases.
---
## Prod rollout
1. `npm run dev:migrate` on dev; prod schema: `npm run harmonize:schema` (dev → prod only)
2. Seed Russian on dev: `npm run dev:fetch-artist-bios-ru`
3. Curator review + publish
4. `npm run harmonize` to sync `entity_translations` to prod (or full promote if preferred)
`entity_translations` is included in [`harmonize-db.js`](../scripts/harmonize-db.js) catalog sync.
---
## npm scripts
| Script | Purpose |
|--------|---------|
| `dev:migrate:i18n` | Apply `entity_translations` table only |
| `dev:fetch-artist-bios-ru` | Fetch ru.wikipedia bios into translations (draft) |
| `dev:import-translations` | Import JSON/CSV translation rows |
See also [setup.md](setup.md), [environments.md](environments.md), [harmonize-dev-prod.md](harmonize-dev-prod.md).
+4 -1
View File
@@ -89,6 +89,9 @@ npm run dev:update-influences # painting influence graph for detail vie
npm run dev:migrate:checkup-flags # optional: review/fixed flags for Checkup page (paintings)
npm run dev:migrate:artist-checkup-flags # optional: same flags for artist portraits (bio debug)
npm run dev:migrate:search # optional on very old DBs — also applied by dev:migrate / prod Step 4
npm run dev:migrate:i18n # optional — entity_translations (also in dev:migrate)
npm run dev:fetch-artist-bios-ru # draft Russian bios + Cyrillic names from ru.wikipedia
npm run dev:import-translations -- --file path/to/file.json # bulk translation import
npm run dev:migrate:painting-annotations # optional: art-history notes table
npm run dev:update-painting-annotations # optional: load curated notes (+ --wikipedia for Wikipedia intros)
npm run dev:fetch-images -- --limit=50 # random sample; 10s max per painting (default)
@@ -117,7 +120,7 @@ Image fetch can take hours if you run it for the entire catalog. The first line
| `npm run dev:server` | API with nodemon reload (local HMR workflow) |
| `npm run dev:client` | Vite dev server on :5173 |
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. For incremental dev ↔ prod merge (both sides edited), see [harmonize-dev-prod.md](harmonize-dev-prod.md). Quick reference: [FAC.md](FAC.md).
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. For Russian UI + catalog text, see [i18n-russian.md](i18n-russian.md). Quick reference: [FAC.md](FAC.md).
**Production frontend:** build the client, then start the server:
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<title>Virtual Art Gallery</title>
</head>
<body>
<div id="root"></div>
+76 -1
View File
@@ -10,8 +10,10 @@
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"i18next": "^26.3.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.9",
"react-router-dom": "^7.18.0",
"three": "^0.184.0"
},
@@ -2077,6 +2079,43 @@
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -2815,6 +2854,33 @@
"react": "^19.2.7"
}
},
"node_modules/react-i18next": {
"version": "17.0.9",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.9.tgz",
"integrity": "sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6 || ^7"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
@@ -3159,7 +3225,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -3337,6 +3403,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/webgl-constants": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
+2
View File
@@ -12,8 +12,10 @@
"dependencies": {
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1",
"i18next": "^26.3.6",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-i18next": "^17.0.9",
"react-router-dom": "^7.18.0",
"three": "^0.184.0"
},
+102 -11
View File
@@ -9,9 +9,31 @@ import type {
ArtistNavigation,
CatalogSearchResponse,
} from '../types';
import { readStoredLocale, type AppLocale } from '../utils/localeStorage';
const API = '/api';
let apiLocale: AppLocale = readStoredLocale();
export function setApiLocale(locale: AppLocale) {
apiLocale = locale;
}
export function getApiLocale(): AppLocale {
return apiLocale;
}
function localeParams(base?: URLSearchParams): URLSearchParams {
const params = base ?? new URLSearchParams();
if (apiLocale !== 'en') params.set('locale', apiLocale);
return params;
}
function localizedPath(path: string, params?: URLSearchParams): string {
const qs = localeParams(params).toString();
return qs ? `${path}?${qs}` : path;
}
const fetchCredentials: RequestInit = { credentials: 'include' };
export type AuthRole = 'user' | 'curator';
@@ -293,18 +315,19 @@ export const api = {
const params = new URLSearchParams();
if (start != null) params.set('start', String(start));
if (end != null) params.set('end', String(end));
const qs = params.toString();
return fetchJson<CatalogBootstrap>(`${API}/catalog/bootstrap${qs ? `?${qs}` : ''}`);
return fetchJson<CatalogBootstrap>(localizedPath(`${API}/catalog/bootstrap`, params));
},
getTimeline: (start: number, end: number) =>
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`),
getTimeline: (start: number, end: number) => {
const params = new URLSearchParams({ start: String(start), end: String(end) });
return fetchJson<TimelineData>(localizedPath(`${API}/timeline`, params));
},
search: (q: string, options?: { limit?: number; types?: string }) => {
const params = new URLSearchParams({ q });
if (options?.limit != null) params.set('limit', String(options.limit));
if (options?.types) params.set('types', options.types);
return fetchJson<CatalogSearchResponse>(`${API}/search?${params}`);
return fetchJson<CatalogSearchResponse>(localizedPath(`${API}/search`, params));
},
getArtists: (start?: number, end?: number, movementId?: number) => {
@@ -312,20 +335,20 @@ export const api = {
if (start != null) params.set('start', String(start));
if (end != null) params.set('end', String(end));
if (movementId != null) params.set('movement_id', String(movementId));
return fetchJson<Artist[]>(`${API}/artists?${params}`);
return fetchJson<Artist[]>(localizedPath(`${API}/artists`, params));
},
/** Lightweight artist rows for the timeline (no biography text). */
getTimelineArtists: () => fetchJson<Artist[]>(`${API}/artists?timeline=1`),
getTimelineArtists: () => fetchJson<Artist[]>(localizedPath(`${API}/artists`, new URLSearchParams({ timeline: '1' }))),
getArtist: (id: number) => fetchJson<ArtistDetail>(`${API}/artists/${id}`),
getArtist: (id: number) => fetchJson<ArtistDetail>(localizedPath(`${API}/artists/${id}`)),
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(`${API}/movements/${id}/gallery`),
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
getArtistNavigation: (id: number) =>
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
getPainting: (id: number) => fetchJson<PaintingDetail>(`${API}/paintings/${id}`),
getPainting: (id: number) => fetchJson<PaintingDetail>(localizedPath(`${API}/paintings/${id}`)),
getPaintingDebugImageSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
@@ -448,9 +471,77 @@ export const api = {
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}),
getTranslationCoverage: (locale = 'ru') =>
fetchJson<{ locale: string; coverage: Record<string, number> }>(
`${API}/translations/coverage?locale=${encodeURIComponent(locale)}`
),
getTranslationWorklist: (entityType: string, locale = 'ru') =>
fetchJson<{ items: TranslationWorklistItem[] }>(
`${API}/translations/worklist/${encodeURIComponent(entityType)}?locale=${encodeURIComponent(locale)}`
),
getEntityTranslation: (entityType: string, id: number) =>
fetchJson<TranslationDetail>(`${API}/translations/${encodeURIComponent(entityType)}/${id}`),
saveEntityTranslation: (
entityType: string,
id: number,
payload: { locale: string; fields: Record<string, string>; status?: string }
) =>
fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}`, {
...fetchCredentials,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Save failed: ${res.status}`);
}
return res.json();
}),
publishEntityTranslation: (entityType: string, id: number, locale = 'ru') =>
fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}/publish`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Publish failed: ${res.status}`);
}
return res.json();
}),
preloadArtistImages,
};
export interface TranslationWorklistItem {
entityId: number;
label: string;
publishedCount: number;
draftCount: number;
missingFields: string[];
}
export interface TranslationDetail {
entityType: string;
entityId: number;
canonical: Record<string, unknown>;
translatableFields: string[];
translations: Array<{
locale: string;
field_name: string;
value: string;
status: string;
source: string | null;
updated_at: string;
}>;
}
export function debugImageProxyUrl(
imageUrl: string,
context?: { searchUrl?: string; source?: string }
+13 -11
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { CatalogSearchResult } from '../types';
import { api, imageUrl, portraitThumbUrl } from '../api/client';
import './CatalogSearchBar.css';
@@ -10,11 +11,6 @@ interface Props {
}
const TYPE_ORDER: CatalogSearchResult['type'][] = ['artist', 'movement', 'painting'];
const TYPE_LABELS: Record<CatalogSearchResult['type'], string> = {
artist: 'Artists',
movement: 'Movements',
painting: 'Paintings',
};
function resultKey(item: CatalogSearchResult): string {
return `${item.type}-${item.id}`;
@@ -30,6 +26,12 @@ export default function CatalogSearchBar({
onSelectMovement,
onSelectPainting,
}: Props) {
const { t } = useTranslation('search');
const typeLabels: Record<CatalogSearchResult['type'], string> = {
artist: t('groupArtist'),
movement: t('groupMovement'),
painting: t('groupPainting'),
};
const listboxId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -91,7 +93,7 @@ export default function CatalogSearchBar({
.catch(() => {
if (cancelled) return;
setResults([]);
setError('Search failed.');
setError(t('searchFailed'));
setOpen(true);
setActiveIndex(-1);
})
@@ -141,7 +143,7 @@ export default function CatalogSearchBar({
return (
<div className="catalog-search" ref={rootRef}>
<label className="catalog-search-label" htmlFor={`${listboxId}-input`}>
Search
{t('label')}
</label>
<div className="catalog-search-field">
<input
@@ -156,7 +158,7 @@ export default function CatalogSearchBar({
aria-activedescendant={
showPanel && activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined
}
placeholder="Search artists, paintings, movements…"
placeholder={t('placeholder')}
value={query}
autoComplete="off"
spellCheck={false}
@@ -177,15 +179,15 @@ export default function CatalogSearchBar({
id={`${listboxId}-listbox`}
className="catalog-search-panel"
role="listbox"
aria-label="Search results"
aria-label={t('resultsLabel')}
>
{error && <p className="catalog-search-message catalog-search-error">{error}</p>}
{!error && !loading && flatResults.length === 0 && (
<p className="catalog-search-message">No matches found.</p>
<p className="catalog-search-message">{t('noMatches')}</p>
)}
{grouped.map((group) => (
<div key={group.type} className="catalog-search-group">
<p className="catalog-search-group-label">{TYPE_LABELS[group.type]}</p>
<p className="catalog-search-group-label">{typeLabels[group.type]}</p>
<ul className="catalog-search-list">
{group.items.map((item) => {
const flatIndex = flatResults.findIndex((r) => resultKey(r) === resultKey(item));
+9 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import './CuratorLoginModal.css';
interface Props {
@@ -8,6 +9,8 @@ interface Props {
}
export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
const { t } = useTranslation('debug');
const { t: tc } = useTranslation('common');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
@@ -32,7 +35,7 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
await onLogin(username.trim(), password);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
setError(err instanceof Error ? err.message : t('loginFailed'));
} finally {
setSubmitting(false);
}
@@ -47,13 +50,13 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
<h2 id="curator-login-title">Curator login</h2>
<h2 id="curator-login-title">{t('loginTitle')}</h2>
<p className="curator-login-hint">
Debug tools and catalog edits require a curator account.
</p>
<form onSubmit={handleSubmit}>
<label className="curator-login-field">
<span>Username</span>
<span>{t('username')}</span>
<input
type="text"
autoComplete="username"
@@ -64,7 +67,7 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
/>
</label>
<label className="curator-login-field">
<span>Password</span>
<span>{t('password')}</span>
<input
type="password"
autoComplete="current-password"
@@ -77,10 +80,10 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
{error && <p className="curator-login-error">{error}</p>}
<div className="curator-login-actions">
<button type="button" className="curator-login-cancel" onClick={onClose} disabled={submitting}>
Cancel
{tc('cancel')}
</button>
<button type="submit" className="curator-login-submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
{submitting ? t('saving') : t('signIn')}
</button>
</div>
</form>
+26
View File
@@ -0,0 +1,26 @@
.locale-switcher {
display: inline-flex;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
overflow: hidden;
}
.locale-switcher-btn {
background: transparent;
color: inherit;
border: none;
padding: 0.25rem 0.55rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
opacity: 0.75;
}
.locale-switcher-btn-active {
background: rgba(255, 255, 255, 0.15);
opacity: 1;
}
.locale-switcher-btn:hover {
opacity: 1;
}
+43
View File
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { setApiLocale } from '../api/client';
import { writeStoredLocale, type AppLocale } from '../utils/localeStorage';
import './LocaleSwitcher.css';
interface Props {
onLocaleChange?: (locale: AppLocale) => void;
}
export default function LocaleSwitcher({ onLocaleChange }: Props) {
const { t } = useTranslation('common');
const current = (i18n.language === 'ru' ? 'ru' : 'en') as AppLocale;
const setLocale = (locale: AppLocale) => {
if (locale === current) return;
void i18n.changeLanguage(locale);
writeStoredLocale(locale);
setApiLocale(locale);
onLocaleChange?.(locale);
};
return (
<div className="locale-switcher" role="group" aria-label={t('language')}>
<button
type="button"
className={`locale-switcher-btn${current === 'en' ? ' locale-switcher-btn-active' : ''}`}
onClick={() => setLocale('en')}
aria-pressed={current === 'en'}
>
{t('localeEn')}
</button>
<button
type="button"
className={`locale-switcher-btn${current === 'ru' ? ' locale-switcher-btn-active' : ''}`}
onClick={() => setLocale('ru')}
aria-pressed={current === 'ru'}
>
{t('localeRu')}
</button>
</div>
);
}
+12 -9
View File
@@ -1,13 +1,14 @@
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { PaintingAnnotation } from '../types';
import './PaintingAnnotations.css';
const CATEGORY_LABELS: Record<string, string> = {
technique: 'Technique',
composition: 'Composition',
symbolism: 'Symbolism',
history: 'History',
subject: 'Subject',
const CATEGORY_KEYS: Record<string, string> = {
technique: 'categoryTechnique',
composition: 'categoryComposition',
symbolism: 'categorySymbolism',
history: 'categoryHistorical',
subject: 'categorySubject',
};
interface PanelProps {
@@ -57,6 +58,7 @@ export default function PaintingAnnotationsPanel({
activeId,
onSelect,
}: PanelProps) {
const { t } = useTranslation('annotations');
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
if (!annotations.length) return null;
@@ -70,11 +72,12 @@ export default function PaintingAnnotationsPanel({
return (
<aside className="painting-annotations-panel" aria-label="Art history annotations">
<h3 className="painting-annotations-title">Art history notes</h3>
<h3 className="painting-annotations-title">{t('title')}</h3>
<ul className="painting-annotations-list">
{annotations.map((ann, index) => {
const isActive = activeId === ann.id;
const category = CATEGORY_LABELS[ann.category] || ann.category;
const categoryKey = CATEGORY_KEYS[ann.category];
const category = categoryKey ? t(categoryKey) : ann.category;
return (
<li
key={ann.id}
@@ -110,7 +113,7 @@ export default function PaintingAnnotationsPanel({
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Read source
{t('readSource')}
</a>
)}
</li>
+7 -5
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
@@ -204,6 +205,7 @@ export default function PaintingDetailView({
onPaintingCheckupFlagsUpdated,
onPaintingRemoved,
}: Props) {
const { t } = useTranslation('painting');
const { painting, influencedBy, influenced, annotations = [] } = data;
const [fullscreen, setFullscreen] = useState(false);
const [imageVersion, setImageVersion] = useState(0);
@@ -474,15 +476,15 @@ export default function PaintingDetailView({
</p>
</div>
<button className="bio-btn" onClick={onArtistBio}>
About {painting.artist_name}
{t('aboutArtist', { name: painting.artist_name })}
</button>
</header>
<div className="painting-layout">
<aside className="influence-panel influence-left">
<h3>Influenced By</h3>
<h3>{t('influencedBy')}</h3>
{influencedBy.length === 0 ? (
<p className="no-influences">No documented influences for this work.</p>
<p className="no-influences">{t('noInfluences')}</p>
) : (
<div className="influence-list">
{influencedBy.map((inf, index) => (
@@ -575,9 +577,9 @@ export default function PaintingDetailView({
</main>
<aside className="influence-panel influence-right">
<h3>Influenced</h3>
<h3>{t('influenced')}</h3>
{influenced.length === 0 ? (
<p className="no-influences">No documented works influenced by this painting yet.</p>
<p className="no-influences">{t('noInfluencedWorks')}</p>
) : (
<div className="influence-list">
{influenced.map((inf, index) => (
+61
View File
@@ -0,0 +1,61 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { readStoredLocale, writeStoredLocale } from '../utils/localeStorage';
import enCommon from '../locales/en/common.json';
import enHome from '../locales/en/home.json';
import enSearch from '../locales/en/search.json';
import enGallery from '../locales/en/gallery.json';
import enPainting from '../locales/en/painting.json';
import enBio from '../locales/en/bio.json';
import enAnnotations from '../locales/en/annotations.json';
import enDebug from '../locales/en/debug.json';
import enTranslations from '../locales/en/translations.json';
import ruCommon from '../locales/ru/common.json';
import ruHome from '../locales/ru/home.json';
import ruSearch from '../locales/ru/search.json';
import ruGallery from '../locales/ru/gallery.json';
import ruPainting from '../locales/ru/painting.json';
import ruBio from '../locales/ru/bio.json';
import ruAnnotations from '../locales/ru/annotations.json';
import ruDebug from '../locales/ru/debug.json';
import ruTranslations from '../locales/ru/translations.json';
const initialLocale = readStoredLocale();
writeStoredLocale(initialLocale);
void i18n.use(initReactI18next).init({
lng: initialLocale,
fallbackLng: 'en',
supportedLngs: ['en', 'ru'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations'],
defaultNS: 'common',
resources: {
en: {
common: enCommon,
home: enHome,
search: enSearch,
gallery: enGallery,
painting: enPainting,
bio: enBio,
annotations: enAnnotations,
debug: enDebug,
translations: enTranslations,
},
ru: {
common: ruCommon,
home: ruHome,
search: ruSearch,
gallery: ruGallery,
painting: ruPainting,
bio: ruBio,
annotations: ruAnnotations,
debug: ruDebug,
translations: ruTranslations,
},
},
interpolation: { escapeValue: false },
});
export default i18n;
+10
View File
@@ -0,0 +1,10 @@
{
"title": "Art history notes",
"readSource": "Read source",
"categoryTechnique": "Technique",
"categoryComposition": "Composition",
"categorySubject": "Subject",
"categorySymbolism": "Symbolism",
"categoryHistorical": "Historical context",
"categoryOther": "Notes"
}
+6
View File
@@ -0,0 +1,6 @@
{
"enterGallery": "Enter Gallery",
"noBio": "No biography available yet.",
"wikipediaSource": "Text adapted from Wikipedia",
"backToTimeline": "← Back to Timeline"
}
+13
View File
@@ -0,0 +1,13 @@
{
"loading": "Loading…",
"close": "Close",
"back": "Back",
"save": "Save",
"cancel": "Cancel",
"yes": "Yes",
"no": "No",
"error": "Something went wrong",
"localeEn": "EN",
"localeRu": "RU",
"language": "Language"
}
+14
View File
@@ -0,0 +1,14 @@
{
"checked": "Checked",
"fixIt": "Fix it",
"more": "More",
"clear": "Clear",
"upload": "Upload",
"searching": "Searching…",
"saving": "Saving…",
"loginTitle": "Curator login",
"username": "Username",
"password": "Password",
"signIn": "Sign in",
"loginFailed": "Login failed"
}
+14
View File
@@ -0,0 +1,14 @@
{
"backToTimeline": "← Back to Timeline",
"exitToTimeline": "Exit to Timeline",
"loadingGallery": "Loading gallery…",
"instructionsTitle": "Gallery controls",
"instructionMove": "Drag to look around",
"instructionZoom": "Scroll to zoom",
"instructionClick": "Click a painting to view details",
"wingOf": "Wing {{current}} of {{total}}",
"exitConfirmTitle": "Leave gallery?",
"exitConfirmBody": "Return to the timeline or stay in the hall.",
"stay": "Stay",
"exit": "Exit"
}
+20
View File
@@ -0,0 +1,20 @@
{
"title": "Virtual Art Gallery",
"subtitle": "Explore art history on an interactive timeline — enter 3D galleries and discover connections between artists and masterpieces.",
"loadingArtHistory": "Loading art history…",
"loadingPortraits": "Loading portraits…",
"openingArtistGallery": "Opening artist gallery…",
"openingMovementGallery": "Opening movement gallery…",
"backToTimeline": "← Back to Timeline",
"backToGallery": "← Back to Gallery",
"curatorLogin": "Curator login",
"curatorLogout": "Log out",
"signedInAs": "Signed in as {{username}}",
"debugMode": "Debug mode",
"showMoreDebug": "Show more (debug)",
"checkup": "Checkup",
"translations": "Translations",
"curatorRequiredTitle": "Curator access required",
"curatorRequiredBody": "Sign in as a curator to use this tool.",
"backToGalleryBtn": "Back to gallery"
}
+11
View File
@@ -0,0 +1,11 @@
{
"aboutArtist": "About {{name}}",
"influencedBy": "Influenced By",
"influenced": "Influenced",
"noInfluences": "No documented influences for this work.",
"noInfluencedWorks": "No documented works influenced by this painting yet.",
"catalogPosition": "Catalog position",
"lightboxHint": "Click anywhere to close",
"prevPainting": "Previous painting",
"nextPainting": "Next painting"
}
+10
View File
@@ -0,0 +1,10 @@
{
"label": "Search",
"placeholder": "Search artists, paintings, movements…",
"resultsLabel": "Search results",
"noMatches": "No matches found.",
"groupArtist": "Artists",
"groupMovement": "Movements",
"groupPainting": "Paintings",
"searchFailed": "Search failed"
}
+20
View File
@@ -0,0 +1,20 @@
{
"title": "Translation review",
"back": "← Back to gallery",
"locale": "Locale",
"entityType": "Entity type",
"status": "Status",
"all": "All",
"draft": "Draft",
"reviewed": "Reviewed",
"published": "Published",
"coverage": "Coverage",
"artistsBio": "Artists with bio (ru)",
"paintingsTitle": "Paintings with title alias (ru)",
"canonical": "English (canonical)",
"translation": "Translation",
"saveDraft": "Save draft",
"publish": "Publish",
"noRows": "No translation rows match the filter.",
"loadFailed": "Failed to load translations"
}
+10
View File
@@ -0,0 +1,10 @@
{
"title": "Историко-художественные заметки",
"readSource": "Источник",
"categoryTechnique": "Техника",
"categoryComposition": "Композиция",
"categorySubject": "Сюжет",
"categorySymbolism": "Символика",
"categoryHistorical": "Исторический контекст",
"categoryOther": "Заметки"
}
+6
View File
@@ -0,0 +1,6 @@
{
"enterGallery": "Войти в галерею",
"noBio": "Биография пока недоступна.",
"wikipediaSource": "Текст адаптирован из Википедии",
"backToTimeline": "← На шкалу времени"
}
+13
View File
@@ -0,0 +1,13 @@
{
"loading": "Загрузка…",
"close": "Закрыть",
"back": "Назад",
"save": "Сохранить",
"cancel": "Отмена",
"yes": "Да",
"no": "Нет",
"error": "Что-то пошло не так",
"localeEn": "EN",
"localeRu": "RU",
"language": "Язык"
}
+14
View File
@@ -0,0 +1,14 @@
{
"checked": "Проверено",
"fixIt": "Исправить",
"more": "Ещё",
"clear": "Очистить",
"upload": "Загрузить",
"searching": "Поиск…",
"saving": "Сохранение…",
"loginTitle": "Вход куратора",
"username": "Имя пользователя",
"password": "Пароль",
"signIn": "Войти",
"loginFailed": "Ошибка входа"
}
+14
View File
@@ -0,0 +1,14 @@
{
"backToTimeline": "← На шкалу времени",
"exitToTimeline": "Выход на шкалу времени",
"loadingGallery": "Загрузка галереи…",
"instructionsTitle": "Управление",
"instructionMove": "Перетаскивайте для обзора",
"instructionZoom": "Колёсико — масштаб",
"instructionClick": "Нажмите на картину для подробностей",
"wingOf": "Крыло {{current}} из {{total}}",
"exitConfirmTitle": "Покинуть галерею?",
"exitConfirmBody": "Вернуться на шкалу времени или остаться в зале.",
"stay": "Остаться",
"exit": "Выход"
}
+20
View File
@@ -0,0 +1,20 @@
{
"title": "Виртуальная художественная галерея",
"subtitle": "Исследуйте историю искусства на интерактивной шкале времени — входите в 3D-залы и открывайте связи между художниками и шедеврами.",
"loadingArtHistory": "Загрузка истории искусства…",
"loadingPortraits": "Загрузка портретов…",
"openingArtistGallery": "Открытие галереи художника…",
"openingMovementGallery": "Открытие галереи направления…",
"backToTimeline": "← На шкалу времени",
"backToGallery": "← В галерею",
"curatorLogin": "Вход куратора",
"curatorLogout": "Выйти",
"signedInAs": "Вы вошли как {{username}}",
"debugMode": "Режим отладки",
"showMoreDebug": "Показать больше (отладка)",
"checkup": "Проверка",
"translations": "Переводы",
"curatorRequiredTitle": "Требуется доступ куратора",
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
"backToGalleryBtn": "Вернуться в галерею"
}
+11
View File
@@ -0,0 +1,11 @@
{
"aboutArtist": "О {{name}}",
"influencedBy": "Под влиянием",
"influenced": "Влияние на",
"noInfluences": "Для этой работы нет задокументированных влияний.",
"noInfluencedWorks": "Пока нет задокументированных работ под влиянием этой картины.",
"catalogPosition": "Позиция в каталоге",
"lightboxHint": "Нажмите в любом месте, чтобы закрыть",
"prevPainting": "Предыдущая картина",
"nextPainting": "Следующая картина"
}
+10
View File
@@ -0,0 +1,10 @@
{
"label": "Поиск",
"placeholder": "Художники, картины, направления…",
"resultsLabel": "Результаты поиска",
"noMatches": "Ничего не найдено.",
"groupArtist": "Художники",
"groupMovement": "Направления",
"groupPainting": "Картины",
"searchFailed": "Ошибка поиска"
}
+20
View File
@@ -0,0 +1,20 @@
{
"title": "Проверка переводов",
"back": "← В галерею",
"locale": "Язык",
"entityType": "Тип объекта",
"status": "Статус",
"all": "Все",
"draft": "Черновик",
"reviewed": "Проверено",
"published": "Опубликовано",
"coverage": "Охват",
"artistsBio": "Художники с биографией (ru)",
"paintingsTitle": "Картины с русским названием",
"canonical": "Английский (оригинал)",
"translation": "Перевод",
"saveDraft": "Сохранить черновик",
"publish": "Опубликовать",
"noRows": "Нет строк по выбранному фильтру.",
"loadFailed": "Не удалось загрузить переводы"
}
+1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import './i18n'
import App from './App.tsx'
import { AuthProvider } from './context/AuthContext.tsx'
+61 -10
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import Timeline from '../components/Timeline';
import TimelineEventGuides from '../components/TimelineEventGuides';
import MovementBands from '../components/MovementBands';
@@ -6,13 +7,17 @@ import VirtualGallery from '../components/VirtualGallery';
import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage';
import TranslationsPage from '../pages/TranslationsPage';
import CuratorLoginModal from '../components/CuratorLoginModal';
import CatalogSearchBar from '../components/CatalogSearchBar';
import LocaleSwitcher from '../components/LocaleSwitcher';
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
import '../components/CatalogSearchBar.css';
import '../components/CuratorLoginModal.css';
import '../components/LocaleSwitcher.css';
import '../pages/TranslationsPage.css';
import { useAuth } from '../context/AuthContext';
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
import { createViewChangeScheduler } from '../utils/timelineView';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
@@ -22,6 +27,7 @@ import './HomePage.css';
type View =
| { type: 'timeline' }
| { type: 'checkup' }
| { type: 'translations' }
| { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
@@ -102,6 +108,7 @@ function catalogNavigateTarget(
}
export default function HomePage() {
const { t } = useTranslation('home');
const { isCurator, username, login, logout } = useAuth();
const [view, setView] = useState<View>({ type: 'timeline' });
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
@@ -117,10 +124,11 @@ export default function HomePage() {
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
const [localeVersion, setLocaleVersion] = useState(0);
const [debugMode, setDebugMode] = useState(readDebugMode);
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
const [loginOpen, setLoginOpen] = useState(false);
const [loginRedirect, setLoginRedirect] = useState<'checkup' | null>(null);
const [loginRedirect, setLoginRedirect] = useState<'checkup' | 'translations' | null>(null);
const effectiveDebugMode = debugMode && isCurator;
const [galleryRevision, setGalleryRevision] = useState(0);
const viewRef = useRef(view);
@@ -170,6 +178,11 @@ export default function HomePage() {
return () => {
cancelled = true;
};
}, [localeVersion]);
const handleLocaleChange = useCallback((locale: 'en' | 'ru') => {
setApiLocale(locale);
setLocaleVersion((v) => v + 1);
}, []);
const viewChangeScheduler = useRef(
@@ -209,7 +222,7 @@ export default function HomePage() {
writeDebugShowMore(enabled);
};
const openCuratorLogin = (redirect: 'checkup' | null = null) => {
const openCuratorLogin = (redirect: 'checkup' | 'translations' | null = null) => {
setLoginRedirect(redirect);
setLoginOpen(true);
};
@@ -219,6 +232,8 @@ export default function HomePage() {
setLoginOpen(false);
if (loginRedirect === 'checkup') {
setView({ type: 'checkup' });
} else if (loginRedirect === 'translations') {
setView({ type: 'translations' });
}
setLoginRedirect(null);
};
@@ -227,7 +242,7 @@ export default function HomePage() {
await logout();
writeDebugMode(false);
setDebugMode(false);
if (view.type === 'checkup') {
if (view.type === 'checkup' || view.type === 'translations') {
goToTimelineHome();
}
};
@@ -240,6 +255,14 @@ export default function HomePage() {
setView({ type: 'checkup' });
};
const openTranslations = () => {
if (!isCurator) {
openCuratorLogin('translations');
return;
}
setView({ type: 'translations' });
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
@@ -663,7 +686,7 @@ export default function HomePage() {
key={view.paintingId}
data={view.data}
artistPaintings={sortedDetailArtistPaintings}
backLabel={view.returnTo.type === 'timeline' ? '← Back to Timeline' : '← Back to Gallery'}
backLabel={view.returnTo.type === 'timeline' ? t('backToTimeline') : t('backToGallery')}
onBack={() => {
if (view.returnTo.type === 'timeline') {
goToTimelineHome();
@@ -723,6 +746,25 @@ export default function HomePage() {
</div>
)}
{view.type === 'translations' && (
isCurator ? (
<TranslationsPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('translations')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'checkup' && (
isCurator ? (
<CheckupPage
@@ -782,13 +824,21 @@ export default function HomePage() {
/>
Show more
</label>
<button
type="button"
className="checkup-link-btn"
onClick={openTranslations}
title="Review and publish Russian translations"
>
{t('translations')}
</button>
<button
type="button"
className="checkup-link-btn"
onClick={openCheckup}
title="Open painting image checkup table"
>
Checkup
{t('checkup')}
</button>
<button
type="button"
@@ -796,7 +846,7 @@ export default function HomePage() {
onClick={handleCuratorLogout}
title="Sign out curator session"
>
Logout
{t('curatorLogout')}
</button>
</>
) : (
@@ -806,12 +856,13 @@ export default function HomePage() {
onClick={() => openCuratorLogin()}
title="Sign in as curator to use debug tools"
>
Curator login
{t('curatorLogin')}
</button>
)}
<LocaleSwitcher onLocaleChange={handleLocaleChange} />
</div>
<h1>Virtual Art Gallery</h1>
<p className="site-subtitle">Watch art movements branch forward through time each flowing from what came before</p>
<h1>{t('title')}</h1>
<p className="site-subtitle">{t('subtitle')}</p>
<CatalogSearchBar
onSelectArtist={handleArtistClick}
onSelectMovement={handleMovementClick}
+103
View File
@@ -0,0 +1,103 @@
.translations-page {
padding: 1rem 1.5rem 2rem;
max-width: 1400px;
margin: 0 auto;
color: #f5f0e8;
}
.translations-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.translations-back {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: inherit;
padding: 0.35rem 0.75rem;
border-radius: 6px;
cursor: pointer;
}
.translations-coverage {
margin-bottom: 1rem;
opacity: 0.9;
}
.translations-toolbar {
margin-bottom: 1rem;
}
.translations-toolbar select {
margin-left: 0.5rem;
}
.translations-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.translations-list {
max-height: 70vh;
overflow: auto;
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
}
.translations-list table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.translations-list th,
.translations-list td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-align: left;
}
.translations-row-selected {
background: rgba(255, 255, 255, 0.08);
cursor: pointer;
}
.translations-list tbody tr {
cursor: pointer;
}
.translations-editor {
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
padding: 1rem;
max-height: 70vh;
overflow: auto;
}
.translations-field {
margin-bottom: 1rem;
}
.translations-field textarea {
width: 100%;
margin-top: 0.35rem;
}
.translations-canonical {
font-size: 0.85rem;
opacity: 0.85;
margin: 0.25rem 0;
}
.translations-error {
color: #ffb4b4;
}
@media (max-width: 900px) {
.translations-layout {
grid-template-columns: 1fr;
}
}
+171
View File
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api, type TranslationDetail, type TranslationWorklistItem } from '../api/client';
import './TranslationsPage.css';
type EntityType = 'artist' | 'painting' | 'movement';
interface Props {
onBack: () => void;
}
export default function TranslationsPage({ onBack }: Props) {
const { t } = useTranslation('translations');
const [entityType, setEntityType] = useState<EntityType>('artist');
const [items, setItems] = useState<TranslationWorklistItem[]>([]);
const [coverage, setCoverage] = useState<Record<string, number> | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [detail, setDetail] = useState<TranslationDetail | null>(null);
const [draftFields, setDraftFields] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadList = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [cov, list] = await Promise.all([
api.getTranslationCoverage('ru'),
api.getTranslationWorklist(entityType, 'ru'),
]);
setCoverage(cov.coverage);
setItems(list.items);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [entityType, t]);
useEffect(() => {
void loadList();
setSelectedId(null);
setDetail(null);
}, [loadList]);
const openItem = async (entityId: number) => {
setSelectedId(entityId);
setError(null);
try {
const data = await api.getEntityTranslation(entityType, entityId);
setDetail(data);
const ruFields: Record<string, string> = {};
for (const field of data.translatableFields) {
const row = data.translations.find((tr) => tr.locale === 'ru' && tr.field_name === field);
ruFields[field] = row?.value ?? '';
}
setDraftFields(ruFields);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
}
};
const saveDraft = async () => {
if (!selectedId) return;
setSaving(true);
setError(null);
try {
await api.saveEntityTranslation(entityType, selectedId, {
locale: 'ru',
fields: draftFields,
status: 'draft',
});
await api.publishEntityTranslation(entityType, selectedId, 'ru');
await loadList();
await openItem(selectedId);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
return (
<div className="translations-page">
<header className="translations-header">
<button type="button" className="translations-back" onClick={onBack}>
{t('back')}
</button>
<h1>{t('title')}</h1>
</header>
{coverage && (
<div className="translations-coverage">
<strong>{t('coverage')}:</strong>{' '}
{t('artistsBio')}: {coverage.artists_bio_full}/{coverage.artists_total} ·{' '}
{t('paintingsTitle')}: {coverage.paintings_title}/{coverage.paintings_total} ·{' '}
{t('published')}: {coverage.published_count} · {t('draft')}: {coverage.draft_count}
</div>
)}
<div className="translations-toolbar">
<label>
{t('entityType')}
<select value={entityType} onChange={(e) => setEntityType(e.target.value as EntityType)}>
<option value="artist">artist</option>
<option value="painting">painting</option>
<option value="movement">movement</option>
</select>
</label>
</div>
{error && <p className="translations-error">{error}</p>}
{loading && <p>{t('loadFailed')}</p>}
<div className="translations-layout">
<div className="translations-list">
<table>
<thead>
<tr>
<th>ID</th>
<th>{t('canonical')}</th>
<th>{t('status')}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr
key={item.entityId}
className={selectedId === item.entityId ? 'translations-row-selected' : ''}
onClick={() => void openItem(item.entityId)}
>
<td>{item.entityId}</td>
<td>{item.label}</td>
<td>
{item.publishedCount} / {item.draftCount} draft · {item.missingFields.length} missing
</td>
</tr>
))}
</tbody>
</table>
{!loading && items.length === 0 && <p>{t('noRows')}</p>}
</div>
{detail && selectedId && (
<div className="translations-editor">
<h2>{String(detail.canonical.name || detail.canonical.title || selectedId)}</h2>
{detail.translatableFields.map((field) => (
<div key={field} className="translations-field">
<label>{field}</label>
<p className="translations-canonical">
<strong>{t('canonical')}:</strong>{' '}
{String(detail.canonical[field] ?? '')}
</p>
<textarea
rows={field.includes('bio') || field === 'body' ? 8 : 3}
value={draftFields[field] ?? ''}
onChange={(e) => setDraftFields((prev) => ({ ...prev, [field]: e.target.value }))}
placeholder={t('translation')}
/>
</div>
))}
<button type="button" disabled={saving} onClick={() => void saveDraft()}>
{saving ? '…' : t('publish')}
</button>
</div>
)}
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
export const LOCALE_STORAGE_KEY = 'gallery_locale';
export const SUPPORTED_LOCALES = ['en', 'ru'] as const;
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
export function readStoredLocale(): AppLocale {
try {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored === 'ru' || stored === 'en') return stored;
} catch {
/* ignore */
}
const nav = typeof navigator !== 'undefined' ? navigator.language : 'en';
return nav.toLowerCase().startsWith('ru') ? 'ru' : 'en';
}
export function writeStoredLocale(locale: AppLocale) {
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
document.documentElement.lang = locale;
}
export function localeQuery(locale: AppLocale): string {
return locale === 'en' ? '' : `locale=${encodeURIComponent(locale)}`;
}
export function withLocale(url: string, locale: AppLocale): string {
if (locale === 'en') return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}locale=${encodeURIComponent(locale)}`;
}
+1
View File
@@ -12,6 +12,7 @@
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx",
+36
View File
@@ -0,0 +1,36 @@
-- Internationalization: locale-specific catalog text (canonical English stays in main tables).
CREATE TABLE IF NOT EXISTS entity_translations (
id SERIAL PRIMARY KEY,
entity_type VARCHAR(32) NOT NULL,
entity_id INTEGER NOT NULL,
locale VARCHAR(10) NOT NULL,
field_name VARCHAR(64) NOT NULL,
value TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
source VARCHAR(120),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (entity_type, entity_id, locale, field_name)
);
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
ON entity_translations (entity_type, entity_id, locale);
CREATE INDEX IF NOT EXISTS entity_translations_locale_field_idx
ON entity_translations (locale, field_name);
CREATE INDEX IF NOT EXISTS entity_translations_value_lower_idx
ON entity_translations (locale, lower(value));
CREATE OR REPLACE FUNCTION entity_translations_touch_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS entity_translations_updated_at ON entity_translations;
CREATE TRIGGER entity_translations_updated_at
BEFORE UPDATE ON entity_translations
FOR EACH ROW EXECUTE PROCEDURE entity_translations_touch_updated_at();
+3
View File
@@ -30,6 +30,9 @@
"dev:migrate:artist-palette": "node scripts/migrate-artist-palette.js",
"dev:migrate:search": "node scripts/migrate-search.js",
"dev:migrate:sync-timestamps": "node scripts/migrate-sync-timestamps.js",
"dev:migrate:i18n": "node scripts/migrate-i18n.js",
"dev:fetch-artist-bios-ru": "node scripts/fetch-artist-bios-ru.js",
"dev:import-translations": "node scripts/import-translations.js",
"dev:backfill-updated-at": "node scripts/backfill-updated-at.js",
"dev:import-painter-palette": "node scripts/import-painter-palette.js",
"dev:analyze-painter-palette": "node scripts/analyze-painter-palette.js",
+202
View File
@@ -0,0 +1,202 @@
require('dotenv').config();
const https = require('https');
const pool = require('../server/db');
const { upsertTranslation } = require('../server/translation-service');
const USER_AGENT = 'VirtualArtGallery/1.0 (educational art history project; local museum gallery)';
const MIN_DELAY_MS = 3500;
const MAX_RETRIES = 8;
const LOCALE = 'ru';
const WIKI_API = 'https://ru.wikipedia.org/w/api.php';
const ARTIST_WIKI_OVERRIDES = {
Zeuxis: 'Zeuxis (painter)',
'Ivan Klyun': 'Ivan Kliun',
'Jean-Antoine Watteau': 'Antoine Watteau',
};
let lastRequestTime = 0;
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function throttle() {
const elapsed = Date.now() - lastRequestTime;
if (elapsed < MIN_DELAY_MS) await sleep(MIN_DELAY_MS - elapsed);
lastRequestTime = Date.now();
}
function fetchJson(url) {
return new Promise((resolve, reject) => {
https
.get(url, { headers: { 'User-Agent': USER_AGENT } }, (res) => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
return fetchJson(res.headers.location).then(resolve).catch(reject);
}
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () => {
if (res.statusCode !== 200) return reject(new Error(`HTTP ${res.statusCode}`));
try {
resolve(JSON.parse(data));
} catch (e) {
reject(e);
}
});
})
.on('error', reject);
});
}
async function withRetry(fn) {
for (let i = 0; i < MAX_RETRIES; i++) {
try {
return await fn();
} catch (err) {
if (i === MAX_RETRIES - 1) throw err;
await sleep(4000 * (i + 1));
}
}
}
function firstSentences(text, count = 2) {
const parts = text.match(/[^.!?]+[.!?]+(?:\s|$)/g);
if (!parts?.length) return text.trim();
return parts.slice(0, count).join('').trim();
}
async function resolveRuPageTitle(enTitle) {
await throttle();
const params = new URLSearchParams({
action: 'query',
format: 'json',
titles: enTitle,
prop: 'langlinks',
lllang: 'ru',
lllimit: '1',
});
const data = await withRetry(() => fetchJson(`https://en.wikipedia.org/w/api.php?${params}`));
const pages = data.query?.pages || {};
const page = Object.values(pages)[0];
const link = page?.langlinks?.[0];
if (link?.['*']) return link['*'];
return enTitle;
}
async function fetchRuExtract(title) {
await throttle();
const params = new URLSearchParams({
action: 'query',
format: 'json',
prop: 'extracts|pageprops',
explaintext: '1',
exintro: '0',
ppprop: 'disambiguation',
titles: title,
});
const data = await withRetry(() => fetchJson(`${WIKI_API}?${params}`));
const pages = data.query?.pages || {};
const page = Object.values(pages)[0];
if (!page || page.missing !== undefined) return null;
const extract = page.extract?.trim();
if (!extract) return null;
return { title: page.title, extract };
}
async function resolveArtistBioRu(artist) {
const enTitle = ARTIST_WIKI_OVERRIDES[artist.wikipedia_title]
|| ARTIST_WIKI_OVERRIDES[artist.name]
|| artist.wikipedia_title
|| artist.name;
const ruTitle = await resolveRuPageTitle(enTitle);
const page = await fetchRuExtract(ruTitle);
if (!page) return null;
return {
name: page.title,
bio_short: firstSentences(page.extract, 2),
bio_full: page.extract,
};
}
async function main() {
const force = process.argv.includes('--force');
const limitArg = process.argv.find((a) => a.startsWith('--limit='));
const limit = limitArg ? parseInt(limitArg.split('=')[1], 10) : null;
const { rows: artists } = await pool.query(`
SELECT id, name, wikipedia_title FROM artists ORDER BY name
`);
let targets = artists;
if (limit) targets = targets.slice(0, limit);
let updated = 0;
let skipped = 0;
let failed = 0;
for (const artist of targets) {
if (!force) {
const { rows: existing } = await pool.query(
`SELECT 1 FROM entity_translations
WHERE entity_type = 'artist' AND entity_id = $1 AND locale = $2 AND field_name = 'bio_full'`,
[artist.id, LOCALE],
);
if (existing.length) {
skipped += 1;
continue;
}
}
try {
const bio = await resolveArtistBioRu(artist);
if (!bio) {
console.warn(`${artist.name} — no ru.wikipedia article`);
failed += 1;
continue;
}
await upsertTranslation({
entityType: 'artist',
entityId: artist.id,
locale: LOCALE,
fieldName: 'name',
value: bio.name,
status: 'draft',
source: 'wikipedia_ru',
});
await upsertTranslation({
entityType: 'artist',
entityId: artist.id,
locale: LOCALE,
fieldName: 'bio_short',
value: bio.bio_short,
status: 'draft',
source: 'wikipedia_ru',
});
await upsertTranslation({
entityType: 'artist',
entityId: artist.id,
locale: LOCALE,
fieldName: 'bio_full',
value: bio.bio_full,
status: 'draft',
source: 'wikipedia_ru',
});
console.log(`${artist.name}${bio.name}`);
updated += 1;
} catch (err) {
console.error(`${artist.name}${err.message}`);
failed += 1;
}
}
console.log(`\nDone: ${updated} updated, ${skipped} skipped, ${failed} failed`);
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+2
View File
@@ -29,6 +29,7 @@ const CATALOG_TABLES = [
'painting_influences',
'painting_influence_sources',
'painting_annotations',
'entity_translations',
];
const NATURAL_KEY_FN = {
@@ -40,6 +41,7 @@ const NATURAL_KEY_FN = {
painting_influences: (r) => `${r.painting_id}:${r.influenced_by_painting_id}`,
painting_influence_sources: (r) => `${r.painting_id}:${r.source_type}:${r.source_painting_id || 0}:${r.source_artist_id || 0}:${r.source_movement_id || 0}`,
painting_annotations: (r) => `${r.painting_id}:${String(r.label || '').trim().toLowerCase()}:${r.sort_order}`,
entity_translations: (r) => `${r.entity_type}:${r.entity_id}:${r.locale}:${r.field_name}`,
};
function parseArgs(argv) {
+78
View File
@@ -0,0 +1,78 @@
/**
* Import translation rows from JSON or CSV.
*
* JSON format: [{ "entity_type": "artist", "entity_id": 1, "field_name": "name", "value": "...", "status": "draft" }]
* CSV format: entity_type,entity_id,field_name,value[,status][,source]
*/
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const { upsertTranslation } = require('../server/translation-service');
const pool = require('../server/db');
function parseArgs(argv) {
const fileIdx = argv.indexOf('--file');
const localeIdx = argv.indexOf('--locale');
if (fileIdx === -1 || !argv[fileIdx + 1]) {
throw new Error('--file <path> is required');
}
return {
filePath: path.resolve(argv[fileIdx + 1]),
locale: localeIdx !== -1 && argv[localeIdx + 1] ? argv[localeIdx + 1] : 'ru',
publish: argv.includes('--publish'),
};
}
function parseCsv(content) {
const lines = content.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith('#'));
const rows = [];
for (const line of lines) {
const parts = line.split(',').map((p) => p.trim().replace(/^"|"$/g, ''));
if (parts[0] === 'entity_type') continue;
if (parts.length < 4) continue;
rows.push({
entity_type: parts[0],
entity_id: parseInt(parts[1], 10),
field_name: parts[2],
value: parts[3],
status: parts[4] || 'draft',
source: parts[5] || 'import_csv',
});
}
return rows;
}
async function main() {
const { filePath, locale, publish } = parseArgs(process.argv.slice(2));
const raw = fs.readFileSync(filePath, 'utf8');
let rows;
if (filePath.endsWith('.json')) {
rows = JSON.parse(raw);
} else {
rows = parseCsv(raw);
}
let count = 0;
for (const row of rows) {
if (!row.entity_type || !row.entity_id || !row.field_name || row.value == null) continue;
const status = publish ? 'published' : (row.status || 'draft');
await upsertTranslation({
entityType: row.entity_type,
entityId: row.entity_id,
locale: row.locale || locale,
fieldName: row.field_name,
value: String(row.value),
status,
source: row.source || 'import',
});
count += 1;
}
console.log(`Imported ${count} translation rows from ${filePath}`);
await pool.end();
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});
+17
View File
@@ -0,0 +1,17 @@
require('dotenv').config();
const fs = require('fs');
const path = require('path');
const pool = require('../server/db');
async function main() {
const sqlPath = path.join(__dirname, '../db/migrate-i18n.sql');
const sql = fs.readFileSync(sqlPath, 'utf8');
await pool.query(sql);
console.log('entity_translations table ready');
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+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);
+1
View File
@@ -14,6 +14,7 @@ const INCREMENTAL_MIGRATIONS = [
'migrate-perf-indexes.sql',
'migrate-search.sql',
'migrate-sync-timestamps.sql',
'migrate-i18n.sql',
];
async function bootstrapCurator() {
+221
View File
@@ -0,0 +1,221 @@
const express = require('express');
const pool = require('../db');
const { requireCurator } = require('../middleware/auth');
const { logCuratorAction } = require('../audit-log');
const {
TRANSLATABLE_FIELDS,
getEntityCanonical,
listTranslations,
upsertTranslation,
getTranslationCoverage,
} = require('../translation-service');
const router = express.Router();
const VALID_ENTITY_TYPES = new Set(Object.keys(TRANSLATABLE_FIELDS));
router.get('/worklist/:entityType', requireCurator, async (req, res) => {
try {
const entityType = req.params.entityType;
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
if (!VALID_ENTITY_TYPES.has(entityType)) {
return res.status(400).json({ error: 'Invalid entity type' });
}
const tables = {
artist: { table: 'artists', label: 'name', idCol: 'id' },
painting: { table: 'paintings', label: 'title', idCol: 'id' },
movement: { table: 'art_movements', label: 'name', idCol: 'id' },
};
const spec = tables[entityType];
if (!spec) {
return res.json({ items: [] });
}
const { rows } = await pool.query(
`SELECT id AS entity_id, ${spec.label} AS label FROM ${spec.table} ORDER BY ${spec.label} LIMIT 1000`,
);
const ids = rows.map((r) => r.entity_id);
const { rows: transRows } = ids.length
? await pool.query(
`SELECT entity_id, field_name, status
FROM entity_translations
WHERE entity_type = $1 AND entity_id = ANY($2::int[]) AND locale = $3`,
[entityType, ids, locale],
)
: { rows: [] };
const byEntity = new Map();
for (const tr of transRows) {
if (!byEntity.has(tr.entity_id)) byEntity.set(tr.entity_id, []);
byEntity.get(tr.entity_id).push(tr);
}
const fields = TRANSLATABLE_FIELDS[entityType] || [];
const items = rows.map((row) => {
const existing = byEntity.get(row.entity_id) || [];
const publishedCount = existing.filter((t) => t.status === 'published').length;
const draftCount = existing.filter((t) => t.status !== 'published').length;
const haveFields = new Set(existing.map((t) => t.field_name));
const missingFields = fields.filter((f) => !haveFields.has(f));
return {
entityId: row.entity_id,
label: row.label,
publishedCount,
draftCount,
missingFields,
};
});
res.json({ items });
} catch (err) {
console.error('Translation worklist error:', err.message);
res.status(500).json({ error: 'Failed to load worklist' });
}
});
router.get('/coverage', requireCurator, async (req, res) => {
try {
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
const coverage = await getTranslationCoverage(locale);
res.json({ locale, coverage });
} catch (err) {
console.error('Translation coverage error:', err.message);
res.status(500).json({ error: 'Failed to load coverage' });
}
});
router.get('/', requireCurator, async (req, res) => {
try {
const entityType = typeof req.query.entityType === 'string' ? req.query.entityType : undefined;
const locale = typeof req.query.locale === 'string' ? req.query.locale : 'ru';
const status = typeof req.query.status === 'string' ? req.query.status : undefined;
const rows = await listTranslations({ entityType, locale, status });
res.json({ translations: rows });
} catch (err) {
console.error('List translations error:', err.message);
res.status(500).json({ error: 'Failed to list translations' });
}
});
router.get('/:entityType/:id', requireCurator, async (req, res) => {
try {
const entityType = req.params.entityType;
const entityId = parseInt(req.params.id, 10);
if (!VALID_ENTITY_TYPES.has(entityType) || !Number.isFinite(entityId)) {
return res.status(400).json({ error: 'Invalid entity type or id' });
}
const canonical = await getEntityCanonical(entityType, entityId);
if (!canonical) {
return res.status(404).json({ error: 'Entity not found' });
}
const { rows } = await pool.query(
`SELECT locale, field_name, value, status, source, updated_at
FROM entity_translations
WHERE entity_type = $1 AND entity_id = $2
ORDER BY locale, field_name`,
[entityType, entityId],
);
res.json({
entityType,
entityId,
canonical,
translatableFields: TRANSLATABLE_FIELDS[entityType],
translations: rows,
});
} catch (err) {
console.error('Get translation error:', err.message);
res.status(500).json({ error: 'Failed to load translation' });
}
});
router.put('/:entityType/:id', requireCurator, async (req, res) => {
try {
const entityType = req.params.entityType;
const entityId = parseInt(req.params.id, 10);
const locale = typeof req.body?.locale === 'string' ? req.body.locale : 'ru';
const fields = req.body?.fields;
const status = typeof req.body?.status === 'string' ? req.body.status : 'draft';
if (!VALID_ENTITY_TYPES.has(entityType) || !Number.isFinite(entityId)) {
return res.status(400).json({ error: 'Invalid entity type or id' });
}
if (!fields || typeof fields !== 'object') {
return res.status(400).json({ error: 'fields object required' });
}
const canonical = await getEntityCanonical(entityType, entityId);
if (!canonical) {
return res.status(404).json({ error: 'Entity not found' });
}
const allowed = new Set(TRANSLATABLE_FIELDS[entityType]);
const saved = [];
for (const [fieldName, value] of Object.entries(fields)) {
if (!allowed.has(fieldName) || typeof value !== 'string') continue;
const row = await upsertTranslation({
entityType,
entityId,
locale,
fieldName,
value,
status,
source: 'manual',
});
saved.push(row);
}
await logCuratorAction({
userId: req.curatorUser.id,
action: 'translation.upsert',
resourceType: entityType,
resourceId: entityId,
details: { locale, fieldCount: saved.length, status },
req,
});
res.json({ saved });
} catch (err) {
console.error('Upsert translation error:', err.message);
res.status(500).json({ error: 'Failed to save translation' });
}
});
router.post('/:entityType/:id/publish', requireCurator, async (req, res) => {
try {
const entityType = req.params.entityType;
const entityId = parseInt(req.params.id, 10);
const locale = typeof req.body?.locale === 'string' ? req.body.locale : 'ru';
if (!VALID_ENTITY_TYPES.has(entityType) || !Number.isFinite(entityId)) {
return res.status(400).json({ error: 'Invalid entity type or id' });
}
const { rowCount } = await pool.query(
`UPDATE entity_translations
SET status = 'published', updated_at = now()
WHERE entity_type = $1 AND entity_id = $2 AND locale = $3 AND status IN ('draft', 'reviewed')`,
[entityType, entityId, locale],
);
await logCuratorAction({
userId: req.curatorUser.id,
action: 'translation.publish',
resourceType: entityType,
resourceId: entityId,
details: { locale, updated: rowCount },
req,
});
res.json({ published: rowCount });
} catch (err) {
console.error('Publish translation error:', err.message);
res.status(500).json({ error: 'Failed to publish translations' });
}
});
module.exports = router;
+173 -28
View File
@@ -2,6 +2,17 @@ const pool = require('./db');
const VALID_TYPES = new Set(['artist', 'painting', 'movement']);
const TRANSLATION_MATCH = (entityType, entityAlias, fieldName, localeParam) => `
EXISTS (
SELECT 1 FROM entity_translations t
WHERE t.entity_type = '${entityType}'
AND t.entity_id = ${entityAlias}.id
AND t.locale = ${localeParam}
AND t.field_name = '${fieldName}'
AND t.status IN ('published', 'reviewed')
AND t.value ILIKE $1
)`;
function parseTypes(typesParam) {
if (!typesParam || typeof typesParam !== 'string') {
return ['artist', 'movement', 'painting'];
@@ -13,10 +24,18 @@ function parseTypes(typesParam) {
return parsed.length > 0 ? parsed : ['artist', 'movement', 'painting'];
}
async function searchArtists(pattern, prefixPattern, perTypeLimit) {
async function searchArtists(pattern, prefixPattern, perTypeLimit, locale = 'en') {
const localeParam = locale !== 'en' ? '$4' : null;
const translationClause = locale !== 'en'
? ` OR ${TRANSLATION_MATCH('artist', 'a', 'name', localeParam)}`
: '';
const params = locale !== 'en'
? [pattern, prefixPattern, perTypeLimit, locale]
: [pattern, prefixPattern, perTypeLimit];
const { rows } = await pool.query(
`SELECT a.id, a.name, a.birth_year, a.death_year,
a.portrait_path, a.portrait_thumb_path,
a.portrait_path, a.portrait_thumb_path, a.movement_id,
m.name AS movement_name,
CASE WHEN a.name ILIKE $2 THEN 0 ELSE 1 END AS rank
FROM artists a
@@ -24,49 +43,140 @@ async function searchArtists(pattern, prefixPattern, perTypeLimit) {
WHERE a.name ILIKE $1
OR a.wikipedia_title ILIKE $1
OR m.name ILIKE $1
${translationClause}
ORDER BY rank, a.name
LIMIT $3`,
[pattern, prefixPattern, perTypeLimit]
params,
);
return rows.map((row) => ({
type: 'artist',
id: row.id,
name: row.name,
birth_year: row.birth_year,
death_year: row.death_year,
movement_name: row.movement_name,
portrait_path: row.portrait_path,
portrait_thumb_path: row.portrait_thumb_path,
rank: row.rank,
}));
if (locale === 'en') {
return rows.map((row) => ({
type: 'artist',
id: row.id,
name: row.name,
birth_year: row.birth_year,
death_year: row.death_year,
movement_name: row.movement_name,
portrait_path: row.portrait_path,
portrait_thumb_path: row.portrait_thumb_path,
rank: row.rank,
}));
}
const {
loadTranslationsByType,
PUBLIC_STATUSES,
} = require('./translation-service');
const artistIds = rows.map((r) => r.id);
const movementIds = rows.map((r) => r.movement_id).filter(Boolean);
const artistMap = await loadTranslationsByType('artist', artistIds, locale, ['name'], PUBLIC_STATUSES);
const movementMap = movementIds.length
? await loadTranslationsByType('movement', movementIds, locale, ['name'], PUBLIC_STATUSES)
: new Map();
const localized = rows.map((row) => {
const name = artistMap.get(`${row.id}:name`) || row.name;
let movement_name = row.movement_name;
if (row.movement_id && movementMap.has(`${row.movement_id}:name`)) {
movement_name = movementMap.get(`${row.movement_id}:name`);
}
return {
type: 'artist',
id: row.id,
name,
birth_year: row.birth_year,
death_year: row.death_year,
movement_name,
portrait_path: row.portrait_path,
portrait_thumb_path: row.portrait_thumb_path,
rank: row.rank,
};
});
return localized;
}
async function searchMovements(pattern, prefixPattern, perTypeLimit) {
async function searchMovements(pattern, prefixPattern, perTypeLimit, locale = 'en') {
const localeParam = locale !== 'en' ? '$4' : null;
const movementTrans = locale !== 'en'
? ` OR ${TRANSLATION_MATCH('movement', 'm', 'name', localeParam)}`
: '';
const eraTrans = locale !== 'en'
? ` OR ${TRANSLATION_MATCH('era', 'e', 'name', localeParam)}`
: '';
const params = locale !== 'en'
? [pattern, prefixPattern, perTypeLimit, locale]
: [pattern, prefixPattern, perTypeLimit];
const { rows } = await pool.query(
`SELECT m.id, m.name, m.color, m.start_year, m.end_year,
`SELECT m.id, m.name, m.color, m.start_year, m.end_year, m.era_id,
e.name AS era_name,
CASE WHEN m.name ILIKE $2 THEN 0 ELSE 1 END AS rank
FROM art_movements m
LEFT JOIN historical_eras e ON m.era_id = e.id
WHERE m.name ILIKE $1
OR e.name ILIKE $1
${movementTrans}
${eraTrans}
ORDER BY rank, m.name
LIMIT $3`,
[pattern, prefixPattern, perTypeLimit]
params,
);
if (locale === 'en') {
return rows.map((row) => ({
type: 'movement',
id: row.id,
name: row.name,
color: row.color,
start_year: row.start_year,
end_year: row.end_year,
era_name: row.era_name,
rank: row.rank,
}));
}
const { loadTranslationsByType, PUBLIC_STATUSES } = require('./translation-service');
const movementMap = await loadTranslationsByType(
'movement',
rows.map((r) => r.id),
locale,
['name'],
PUBLIC_STATUSES,
);
const eraMap = await loadTranslationsByType(
'era',
rows.map((r) => r.era_id).filter(Boolean),
locale,
['name'],
PUBLIC_STATUSES,
);
return rows.map((row) => ({
type: 'movement',
id: row.id,
name: row.name,
name: movementMap.get(`${row.id}:name`) || row.name,
color: row.color,
start_year: row.start_year,
end_year: row.end_year,
era_name: row.era_name,
era_name: (row.era_id && eraMap.get(`${row.era_id}:name`)) || row.era_name,
rank: row.rank,
}));
}
async function searchPaintings(pattern, prefixPattern, perTypeLimit) {
async function searchPaintings(pattern, prefixPattern, perTypeLimit, locale = 'en') {
const localeParam = locale !== 'en' ? '$4' : null;
const paintingTrans = locale !== 'en'
? ` OR ${TRANSLATION_MATCH('painting', 'p', 'title', localeParam)}`
: '';
const artistTrans = locale !== 'en'
? ` OR ${TRANSLATION_MATCH('artist', 'a', 'name', localeParam)}`
: '';
const params = locale !== 'en'
? [pattern, prefixPattern, perTypeLimit, locale]
: [pattern, prefixPattern, perTypeLimit];
const { rows } = await pool.query(
`SELECT p.id, p.title, p.year, p.thumbnail_path, p.image_path,
a.id AS artist_id, a.name AS artist_name,
@@ -80,17 +190,51 @@ async function searchPaintings(pattern, prefixPattern, perTypeLimit) {
OR a.name ILIKE $1
OR m.name ILIKE $1
OR CAST(p.year AS TEXT) ILIKE $1
${paintingTrans}
${artistTrans}
ORDER BY rank, p.year NULLS LAST, p.title
LIMIT $3`,
[pattern, prefixPattern, perTypeLimit]
params,
);
if (locale === 'en') {
return rows.map((row) => ({
type: 'painting',
id: row.id,
title: row.title,
year: row.year,
artist_id: row.artist_id,
artist_name: row.artist_name,
movement_name: row.movement_name,
thumbnail_path: row.thumbnail_path,
image_path: row.image_path,
rank: row.rank,
}));
}
const { loadTranslationsByType, PUBLIC_STATUSES } = require('./translation-service');
const paintingMap = await loadTranslationsByType(
'painting',
rows.map((r) => r.id),
locale,
['title'],
PUBLIC_STATUSES,
);
const artistMap = await loadTranslationsByType(
'artist',
rows.map((r) => r.artist_id),
locale,
['name'],
PUBLIC_STATUSES,
);
return rows.map((row) => ({
type: 'painting',
id: row.id,
title: row.title,
title: paintingMap.get(`${row.id}:title`) || row.title,
year: row.year,
artist_id: row.artist_id,
artist_name: row.artist_name,
artist_name: artistMap.get(`${row.artist_id}:name`) || row.artist_name,
movement_name: row.movement_name,
thumbnail_path: row.thumbnail_path,
image_path: row.image_path,
@@ -101,9 +245,10 @@ async function searchPaintings(pattern, prefixPattern, perTypeLimit) {
async function searchCatalog(query, options = {}) {
const q = String(query || '').trim();
if (q.length < 2) {
return { q, results: [] };
return { q, locale: options.locale || 'en', results: [] };
}
const locale = options.locale || 'en';
const limit = Math.min(50, Math.max(1, Number(options.limit) || 20));
const types = parseTypes(options.types);
const perTypeLimit = Math.max(1, Math.ceil(limit / types.length));
@@ -111,9 +256,9 @@ async function searchCatalog(query, options = {}) {
const prefixPattern = `${q}%`;
const tasks = [];
if (types.includes('artist')) tasks.push(searchArtists(pattern, prefixPattern, perTypeLimit));
if (types.includes('movement')) tasks.push(searchMovements(pattern, prefixPattern, perTypeLimit));
if (types.includes('painting')) tasks.push(searchPaintings(pattern, prefixPattern, perTypeLimit));
if (types.includes('artist')) tasks.push(searchArtists(pattern, prefixPattern, perTypeLimit, locale));
if (types.includes('movement')) tasks.push(searchMovements(pattern, prefixPattern, perTypeLimit, locale));
if (types.includes('painting')) tasks.push(searchPaintings(pattern, prefixPattern, perTypeLimit, locale));
const groups = await Promise.all(tasks);
const merged = groups
@@ -129,7 +274,7 @@ async function searchCatalog(query, options = {}) {
.slice(0, limit)
.map(({ rank: _rank, ...rest }) => rest);
return { q, results: merged };
return { q, locale, results: merged };
}
module.exports = { searchCatalog };
+340
View File
@@ -0,0 +1,340 @@
const pool = require('./db');
const SUPPORTED_LOCALES = new Set(['en', 'ru']);
const DEFAULT_LOCALE = 'en';
const PUBLIC_STATUSES = ['published'];
const CURATOR_STATUSES = ['draft', 'reviewed', 'published'];
const TRANSLATABLE_FIELDS = {
era: ['name', 'description'],
movement: ['name', 'description'],
artist: ['name', 'bio_short', 'bio_full'],
artist_period: ['name', 'description'],
painting: ['title', 'description'],
annotation: ['label', 'body'],
influence_source: ['notes', 'aspects', 'quote', 'period_note'],
};
function resolveLocale(req) {
const q = req?.query?.locale;
if (typeof q === 'string' && SUPPORTED_LOCALES.has(q.toLowerCase())) {
return q.toLowerCase();
}
const accept = req?.headers?.['accept-language'];
if (typeof accept === 'string' && /\bru\b/i.test(accept.split(',')[0])) {
return 'ru';
}
return DEFAULT_LOCALE;
}
function translationStatuses(req) {
if (req?.curatorUser && req?.query?.preview === '1') {
return CURATOR_STATUSES;
}
return PUBLIC_STATUSES;
}
function translationKey(entityId, fieldName) {
return `${entityId}:${fieldName}`;
}
async function loadTranslationsByType(entityType, ids, locale, fields, statuses) {
const map = new Map();
if (locale === DEFAULT_LOCALE || !ids.length || !fields.length) return map;
const uniqueIds = [...new Set(ids.filter((id) => Number.isFinite(id)))];
if (!uniqueIds.length) return map;
const { rows } = await pool.query(
`SELECT entity_id, field_name, value, status
FROM entity_translations
WHERE entity_type = $1
AND entity_id = ANY($2::int[])
AND locale = $3
AND field_name = ANY($4::text[])
AND status = ANY($5::text[])`,
[entityType, uniqueIds, locale, fields, statuses],
);
for (const row of rows) {
map.set(translationKey(row.entity_id, row.field_name), row.value);
}
return map;
}
function applyFields(row, entityType, entityId, fields, map, target = row) {
for (const field of fields) {
const value = map.get(translationKey(entityId, field));
if (value != null && value !== '') {
target[field] = value;
}
}
return target;
}
async function localizeEras(eras, locale, statuses) {
if (locale === DEFAULT_LOCALE || !eras.length) return eras;
const map = await loadTranslationsByType(
'era',
eras.map((e) => e.id),
locale,
TRANSLATABLE_FIELDS.era,
statuses,
);
return eras.map((era) => applyFields({ ...era }, 'era', era.id, TRANSLATABLE_FIELDS.era, map));
}
async function localizeMovements(movements, locale, statuses, eraNameById = new Map()) {
if (locale === DEFAULT_LOCALE || !movements.length) return movements;
const map = await loadTranslationsByType(
'movement',
movements.map((m) => m.id),
locale,
TRANSLATABLE_FIELDS.movement,
statuses,
);
const eraIds = movements.map((m) => m.era_id).filter(Boolean);
const eraMap = eraIds.length
? await loadTranslationsByType('era', eraIds, locale, ['name'], statuses)
: new Map();
return movements.map((movement) => {
const out = applyFields({ ...movement }, 'movement', movement.id, TRANSLATABLE_FIELDS.movement, map);
if (movement.era_id && eraMap.has(translationKey(movement.era_id, 'name'))) {
out.era_name = eraMap.get(translationKey(movement.era_id, 'name'));
} else if (movement.era_id && eraNameById.has(movement.era_id)) {
out.era_name = eraNameById.get(movement.era_id);
}
return out;
});
}
async function localizeArtists(artists, locale, statuses) {
if (locale === DEFAULT_LOCALE || !artists.length) return artists;
const artistMap = await loadTranslationsByType(
'artist',
artists.map((a) => a.id),
locale,
TRANSLATABLE_FIELDS.artist,
statuses,
);
const movementIds = artists.map((a) => a.movement_id).filter(Boolean);
const movementMap = movementIds.length
? await loadTranslationsByType('movement', movementIds, locale, ['name'], statuses)
: new Map();
return artists.map((artist) => {
const out = applyFields({ ...artist }, 'artist', artist.id, TRANSLATABLE_FIELDS.artist, artistMap);
if (artist.movement_id && movementMap.has(translationKey(artist.movement_id, 'name'))) {
out.movement_name = movementMap.get(translationKey(artist.movement_id, 'name'));
}
return out;
});
}
async function localizePeriods(periods, locale, statuses) {
if (locale === DEFAULT_LOCALE || !periods.length) return periods;
const map = await loadTranslationsByType(
'artist_period',
periods.map((p) => p.id),
locale,
TRANSLATABLE_FIELDS.artist_period,
statuses,
);
return periods.map((period) =>
applyFields({ ...period }, 'artist_period', period.id, TRANSLATABLE_FIELDS.artist_period, map),
);
}
async function localizePaintings(paintings, locale, statuses) {
if (locale === DEFAULT_LOCALE || !paintings.length) return paintings;
const paintingMap = await loadTranslationsByType(
'painting',
paintings.map((p) => p.id),
locale,
TRANSLATABLE_FIELDS.painting,
statuses,
);
const artistIds = paintings.map((p) => p.artist_id).filter(Boolean);
const artistMap = artistIds.length
? await loadTranslationsByType('artist', artistIds, locale, ['name'], statuses)
: new Map();
return paintings.map((painting) => {
const out = applyFields({ ...painting }, 'painting', painting.id, TRANSLATABLE_FIELDS.painting, paintingMap);
const artistId = painting.artist_id;
if (artistId && artistMap.has(translationKey(artistId, 'name'))) {
out.artist_name = artistMap.get(translationKey(artistId, 'name'));
}
return out;
});
}
async function localizeAnnotations(annotations, locale, statuses) {
if (locale === DEFAULT_LOCALE || !annotations.length) return annotations;
const map = await loadTranslationsByType(
'annotation',
annotations.map((a) => a.id),
locale,
TRANSLATABLE_FIELDS.annotation,
statuses,
);
return annotations.map((ann) =>
applyFields({ ...ann }, 'annotation', ann.id, TRANSLATABLE_FIELDS.annotation, map),
);
}
async function localizeInfluenceSources(rows, locale, statuses) {
if (locale === DEFAULT_LOCALE || !rows.length) return rows;
const sourceIds = rows.map((r) => r.influence_source_id || r.id).filter(Boolean);
const map = await loadTranslationsByType(
'influence_source',
sourceIds,
locale,
TRANSLATABLE_FIELDS.influence_source,
statuses,
);
const paintingIds = rows.map((r) => r.id).filter(Boolean);
const artistIds = rows.map((r) => r.artist_id || r.source_artist_id).filter(Boolean);
const movementIds = rows.map((r) => r.movement_id).filter(Boolean);
const [paintingMap, artistMap, movementMap] = await Promise.all([
paintingIds.length
? loadTranslationsByType('painting', paintingIds, locale, ['title'], statuses)
: Promise.resolve(new Map()),
artistIds.length
? loadTranslationsByType('artist', artistIds, locale, ['name'], statuses)
: Promise.resolve(new Map()),
movementIds.length
? loadTranslationsByType('movement', movementIds, locale, ['name'], statuses)
: Promise.resolve(new Map()),
]);
return rows.map((row) => {
const sourceId = row.influence_source_id || row.id;
const out = applyFields(
{ ...row },
'influence_source',
sourceId,
TRANSLATABLE_FIELDS.influence_source,
map,
);
if (row.id && paintingMap.has(translationKey(row.id, 'title'))) {
out.title = paintingMap.get(translationKey(row.id, 'title'));
}
const artistId = row.artist_id || row.source_artist_id;
if (artistId && artistMap.has(translationKey(artistId, 'name'))) {
if (row.source_artist_name != null) out.source_artist_name = artistMap.get(translationKey(artistId, 'name'));
if (row.artist_name != null) out.artist_name = artistMap.get(translationKey(artistId, 'name'));
}
if (row.movement_id && movementMap.has(translationKey(row.movement_id, 'name'))) {
out.movement_name = movementMap.get(translationKey(row.movement_id, 'name'));
}
return out;
});
}
async function upsertTranslation({
entityType,
entityId,
locale,
fieldName,
value,
status = 'draft',
source = 'manual',
}) {
const { rows } = await pool.query(
`INSERT INTO entity_translations (entity_type, entity_id, locale, field_name, value, status, source)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (entity_type, entity_id, locale, field_name)
DO UPDATE SET value = EXCLUDED.value, status = EXCLUDED.status, source = EXCLUDED.source, updated_at = now()
RETURNING *`,
[entityType, entityId, locale, fieldName, value, status, source],
);
return rows[0];
}
async function getEntityCanonical(entityType, entityId) {
const tables = {
era: { table: 'historical_eras', fields: ['name', 'description'] },
movement: { table: 'art_movements', fields: ['name', 'description'] },
artist: { table: 'artists', fields: ['name', 'bio_short', 'bio_full'] },
artist_period: { table: 'artist_periods', fields: ['name', 'description'] },
painting: { table: 'paintings', fields: ['title', 'description'] },
annotation: { table: 'painting_annotations', fields: ['label', 'body'] },
influence_source: {
table: 'painting_influence_sources',
fields: ['notes', 'aspects', 'quote', 'period_note'],
},
};
const spec = tables[entityType];
if (!spec) return null;
const cols = ['id', ...spec.fields].join(', ');
const { rows } = await pool.query(
`SELECT ${cols} FROM ${spec.table} WHERE id = $1`,
[entityId],
);
return rows[0] || null;
}
async function listTranslations(filters = {}) {
const conditions = [];
const params = [];
if (filters.entityType) {
params.push(filters.entityType);
conditions.push(`entity_type = $${params.length}`);
}
if (filters.locale) {
params.push(filters.locale);
conditions.push(`locale = $${params.length}`);
}
if (filters.status) {
params.push(filters.status);
conditions.push(`status = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const { rows } = await pool.query(
`SELECT * FROM entity_translations ${where} ORDER BY entity_type, entity_id, field_name LIMIT 5000`,
params,
);
return rows;
}
async function getTranslationCoverage(locale = 'ru') {
const { rows } = await pool.query(
`SELECT
(SELECT COUNT(*)::int FROM artists) AS artists_total,
(SELECT COUNT(DISTINCT entity_id)::int FROM entity_translations
WHERE entity_type = 'artist' AND locale = $1 AND field_name = 'bio_full' AND status = 'published') AS artists_bio_full,
(SELECT COUNT(*)::int FROM paintings) AS paintings_total,
(SELECT COUNT(DISTINCT entity_id)::int FROM entity_translations
WHERE entity_type = 'painting' AND locale = $1 AND field_name = 'title' AND status = 'published') AS paintings_title,
(SELECT COUNT(*)::int FROM entity_translations WHERE locale = $1 AND status = 'draft') AS draft_count,
(SELECT COUNT(*)::int FROM entity_translations WHERE locale = $1 AND status = 'published') AS published_count`,
[locale],
);
return rows[0];
}
module.exports = {
SUPPORTED_LOCALES,
DEFAULT_LOCALE,
TRANSLATABLE_FIELDS,
PUBLIC_STATUSES,
CURATOR_STATUSES,
resolveLocale,
translationStatuses,
loadTranslationsByType,
localizeEras,
localizeMovements,
localizeArtists,
localizePeriods,
localizePaintings,
localizeAnnotations,
localizeInfluenceSources,
upsertTranslation,
getEntityCanonical,
listTranslations,
getTranslationCoverage,
};