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` ## `GET /api/search`
Public catalog search over **artists**, **paintings**, and **art movements**. Used by the timeline header search bar (`CatalogSearchBar.tsx`). 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: [] }`) | | `q` | string | — | Search text (min **2** characters after trim; shorter returns `{ q, results: [] }`) |
| `limit` | int | 20 | Max results total (capped at **50**) | | `limit` | int | 20 | Max results total (capped at **50**) |
| `types` | string | all | Optional comma list: `artist`, `painting`, `movement` | | `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`):** **Matching (case-insensitive `ILIKE`):**
@@ -190,6 +217,7 @@ Public catalog search over **artists**, **paintings**, and **art movements**. Us
| Artist | `name`, `wikipedia_title`, movement name | | Artist | `name`, `wikipedia_title`, movement name |
| Movement | movement `name`, era name | | Movement | movement `name`, era name |
| Painting | `title`, `wikipedia_title`, `year` (as text), artist name, movement 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. 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 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 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 ? 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) 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: 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: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: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: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: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: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) 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:server` | API with nodemon reload (local HMR workflow) |
| `npm run dev:client` | Vite dev server on :5173 | | `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: **Production frontend:** build the client, then start the server:
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title> <title>Virtual Art Gallery</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+76 -1
View File
@@ -10,8 +10,10 @@
"dependencies": { "dependencies": {
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1", "@react-three/fiber": "^9.6.1",
"i18next": "^26.3.6",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.9",
"react-router-dom": "^7.18.0", "react-router-dom": "^7.18.0",
"three": "^0.184.0" "three": "^0.184.0"
}, },
@@ -2077,6 +2079,43 @@
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
"license": "Apache-2.0" "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": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -2815,6 +2854,33 @@
"react": "^19.2.7" "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": { "node_modules/react-router": {
"version": "7.18.0", "version": "7.18.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
@@ -3159,7 +3225,7 @@
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "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": { "node_modules/webgl-constants": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
+2
View File
@@ -12,8 +12,10 @@
"dependencies": { "dependencies": {
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.1", "@react-three/fiber": "^9.6.1",
"i18next": "^26.3.6",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-i18next": "^17.0.9",
"react-router-dom": "^7.18.0", "react-router-dom": "^7.18.0",
"three": "^0.184.0" "three": "^0.184.0"
}, },
+102 -11
View File
@@ -9,9 +9,31 @@ import type {
ArtistNavigation, ArtistNavigation,
CatalogSearchResponse, CatalogSearchResponse,
} from '../types'; } from '../types';
import { readStoredLocale, type AppLocale } from '../utils/localeStorage';
const API = '/api'; 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' }; const fetchCredentials: RequestInit = { credentials: 'include' };
export type AuthRole = 'user' | 'curator'; export type AuthRole = 'user' | 'curator';
@@ -293,18 +315,19 @@ export const api = {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (start != null) params.set('start', String(start)); if (start != null) params.set('start', String(start));
if (end != null) params.set('end', String(end)); if (end != null) params.set('end', String(end));
const qs = params.toString(); return fetchJson<CatalogBootstrap>(localizedPath(`${API}/catalog/bootstrap`, params));
return fetchJson<CatalogBootstrap>(`${API}/catalog/bootstrap${qs ? `?${qs}` : ''}`);
}, },
getTimeline: (start: number, end: number) => getTimeline: (start: number, end: number) => {
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`), 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 }) => { search: (q: string, options?: { limit?: number; types?: string }) => {
const params = new URLSearchParams({ q }); const params = new URLSearchParams({ q });
if (options?.limit != null) params.set('limit', String(options.limit)); if (options?.limit != null) params.set('limit', String(options.limit));
if (options?.types) params.set('types', options.types); 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) => { getArtists: (start?: number, end?: number, movementId?: number) => {
@@ -312,20 +335,20 @@ export const api = {
if (start != null) params.set('start', String(start)); if (start != null) params.set('start', String(start));
if (end != null) params.set('end', String(end)); if (end != null) params.set('end', String(end));
if (movementId != null) params.set('movement_id', String(movementId)); 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). */ /** 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) => 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) => getPaintingDebugImageSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`), fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
@@ -448,9 +471,77 @@ export const api = {
return res.json() as Promise<{ checked: boolean; fixed: boolean }>; 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, 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( export function debugImageProxyUrl(
imageUrl: string, imageUrl: string,
context?: { searchUrl?: string; source?: string } context?: { searchUrl?: string; source?: string }
+13 -11
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { CatalogSearchResult } from '../types'; import type { CatalogSearchResult } from '../types';
import { api, imageUrl, portraitThumbUrl } from '../api/client'; import { api, imageUrl, portraitThumbUrl } from '../api/client';
import './CatalogSearchBar.css'; import './CatalogSearchBar.css';
@@ -10,11 +11,6 @@ interface Props {
} }
const TYPE_ORDER: CatalogSearchResult['type'][] = ['artist', 'movement', 'painting']; 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 { function resultKey(item: CatalogSearchResult): string {
return `${item.type}-${item.id}`; return `${item.type}-${item.id}`;
@@ -30,6 +26,12 @@ export default function CatalogSearchBar({
onSelectMovement, onSelectMovement,
onSelectPainting, onSelectPainting,
}: Props) { }: Props) {
const { t } = useTranslation('search');
const typeLabels: Record<CatalogSearchResult['type'], string> = {
artist: t('groupArtist'),
movement: t('groupMovement'),
painting: t('groupPainting'),
};
const listboxId = useId(); const listboxId = useId();
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@@ -91,7 +93,7 @@ export default function CatalogSearchBar({
.catch(() => { .catch(() => {
if (cancelled) return; if (cancelled) return;
setResults([]); setResults([]);
setError('Search failed.'); setError(t('searchFailed'));
setOpen(true); setOpen(true);
setActiveIndex(-1); setActiveIndex(-1);
}) })
@@ -141,7 +143,7 @@ export default function CatalogSearchBar({
return ( return (
<div className="catalog-search" ref={rootRef}> <div className="catalog-search" ref={rootRef}>
<label className="catalog-search-label" htmlFor={`${listboxId}-input`}> <label className="catalog-search-label" htmlFor={`${listboxId}-input`}>
Search {t('label')}
</label> </label>
<div className="catalog-search-field"> <div className="catalog-search-field">
<input <input
@@ -156,7 +158,7 @@ export default function CatalogSearchBar({
aria-activedescendant={ aria-activedescendant={
showPanel && activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined showPanel && activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined
} }
placeholder="Search artists, paintings, movements…" placeholder={t('placeholder')}
value={query} value={query}
autoComplete="off" autoComplete="off"
spellCheck={false} spellCheck={false}
@@ -177,15 +179,15 @@ export default function CatalogSearchBar({
id={`${listboxId}-listbox`} id={`${listboxId}-listbox`}
className="catalog-search-panel" className="catalog-search-panel"
role="listbox" role="listbox"
aria-label="Search results" aria-label={t('resultsLabel')}
> >
{error && <p className="catalog-search-message catalog-search-error">{error}</p>} {error && <p className="catalog-search-message catalog-search-error">{error}</p>}
{!error && !loading && flatResults.length === 0 && ( {!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) => ( {grouped.map((group) => (
<div key={group.type} className="catalog-search-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"> <ul className="catalog-search-list">
{group.items.map((item) => { {group.items.map((item) => {
const flatIndex = flatResults.findIndex((r) => resultKey(r) === resultKey(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 { useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import './CuratorLoginModal.css'; import './CuratorLoginModal.css';
interface Props { interface Props {
@@ -8,6 +9,8 @@ interface Props {
} }
export default function CuratorLoginModal({ open, onClose, onLogin }: Props) { export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
const { t } = useTranslation('debug');
const { t: tc } = useTranslation('common');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -32,7 +35,7 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
await onLogin(username.trim(), password); await onLogin(username.trim(), password);
onClose(); onClose();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Login failed'); setError(err instanceof Error ? err.message : t('loginFailed'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -47,13 +50,13 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
aria-modal="true" aria-modal="true"
onMouseDown={(e) => e.stopPropagation()} 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"> <p className="curator-login-hint">
Debug tools and catalog edits require a curator account. Debug tools and catalog edits require a curator account.
</p> </p>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<label className="curator-login-field"> <label className="curator-login-field">
<span>Username</span> <span>{t('username')}</span>
<input <input
type="text" type="text"
autoComplete="username" autoComplete="username"
@@ -64,7 +67,7 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
/> />
</label> </label>
<label className="curator-login-field"> <label className="curator-login-field">
<span>Password</span> <span>{t('password')}</span>
<input <input
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
@@ -77,10 +80,10 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
{error && <p className="curator-login-error">{error}</p>} {error && <p className="curator-login-error">{error}</p>}
<div className="curator-login-actions"> <div className="curator-login-actions">
<button type="button" className="curator-login-cancel" onClick={onClose} disabled={submitting}> <button type="button" className="curator-login-cancel" onClick={onClose} disabled={submitting}>
Cancel {tc('cancel')}
</button> </button>
<button type="submit" className="curator-login-submit" disabled={submitting}> <button type="submit" className="curator-login-submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'} {submitting ? t('saving') : t('signIn')}
</button> </button>
</div> </div>
</form> </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 { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { PaintingAnnotation } from '../types'; import type { PaintingAnnotation } from '../types';
import './PaintingAnnotations.css'; import './PaintingAnnotations.css';
const CATEGORY_LABELS: Record<string, string> = { const CATEGORY_KEYS: Record<string, string> = {
technique: 'Technique', technique: 'categoryTechnique',
composition: 'Composition', composition: 'categoryComposition',
symbolism: 'Symbolism', symbolism: 'categorySymbolism',
history: 'History', history: 'categoryHistorical',
subject: 'Subject', subject: 'categorySubject',
}; };
interface PanelProps { interface PanelProps {
@@ -57,6 +58,7 @@ export default function PaintingAnnotationsPanel({
activeId, activeId,
onSelect, onSelect,
}: PanelProps) { }: PanelProps) {
const { t } = useTranslation('annotations');
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map()); const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
if (!annotations.length) return null; if (!annotations.length) return null;
@@ -70,11 +72,12 @@ export default function PaintingAnnotationsPanel({
return ( return (
<aside className="painting-annotations-panel" aria-label="Art history annotations"> <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"> <ul className="painting-annotations-list">
{annotations.map((ann, index) => { {annotations.map((ann, index) => {
const isActive = activeId === ann.id; 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 ( return (
<li <li
key={ann.id} key={ann.id}
@@ -110,7 +113,7 @@ export default function PaintingAnnotationsPanel({
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
Read source {t('readSource')}
</a> </a>
)} )}
</li> </li>
+7 -5
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState, type SyntheticEvent } from 'react'; import { useCallback, useEffect, useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { InfluenceLink, Painting, PaintingDetail } from '../types'; import type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client'; import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal'; import DebugSearchResultsModal from './DebugSearchResultsModal';
@@ -204,6 +205,7 @@ export default function PaintingDetailView({
onPaintingCheckupFlagsUpdated, onPaintingCheckupFlagsUpdated,
onPaintingRemoved, onPaintingRemoved,
}: Props) { }: Props) {
const { t } = useTranslation('painting');
const { painting, influencedBy, influenced, annotations = [] } = data; const { painting, influencedBy, influenced, annotations = [] } = data;
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
const [imageVersion, setImageVersion] = useState(0); const [imageVersion, setImageVersion] = useState(0);
@@ -474,15 +476,15 @@ export default function PaintingDetailView({
</p> </p>
</div> </div>
<button className="bio-btn" onClick={onArtistBio}> <button className="bio-btn" onClick={onArtistBio}>
About {painting.artist_name} {t('aboutArtist', { name: painting.artist_name })}
</button> </button>
</header> </header>
<div className="painting-layout"> <div className="painting-layout">
<aside className="influence-panel influence-left"> <aside className="influence-panel influence-left">
<h3>Influenced By</h3> <h3>{t('influencedBy')}</h3>
{influencedBy.length === 0 ? ( {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"> <div className="influence-list">
{influencedBy.map((inf, index) => ( {influencedBy.map((inf, index) => (
@@ -575,9 +577,9 @@ export default function PaintingDetailView({
</main> </main>
<aside className="influence-panel influence-right"> <aside className="influence-panel influence-right">
<h3>Influenced</h3> <h3>{t('influenced')}</h3>
{influenced.length === 0 ? ( {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"> <div className="influence-list">
{influenced.map((inf, index) => ( {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 { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import './i18n'
import App from './App.tsx' import App from './App.tsx'
import { AuthProvider } from './context/AuthContext.tsx' import { AuthProvider } from './context/AuthContext.tsx'
+61 -10
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import Timeline from '../components/Timeline'; import Timeline from '../components/Timeline';
import TimelineEventGuides from '../components/TimelineEventGuides'; import TimelineEventGuides from '../components/TimelineEventGuides';
import MovementBands from '../components/MovementBands'; import MovementBands from '../components/MovementBands';
@@ -6,13 +7,17 @@ import VirtualGallery from '../components/VirtualGallery';
import PaintingDetailView from '../components/PaintingDetail'; import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio'; import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage'; import CheckupPage from '../pages/CheckupPage';
import TranslationsPage from '../pages/TranslationsPage';
import CuratorLoginModal from '../components/CuratorLoginModal'; import CuratorLoginModal from '../components/CuratorLoginModal';
import CatalogSearchBar from '../components/CatalogSearchBar'; import CatalogSearchBar from '../components/CatalogSearchBar';
import LocaleSwitcher from '../components/LocaleSwitcher';
import GalleryLoadingMarker from '../components/GalleryLoadingMarker'; import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
import '../components/CatalogSearchBar.css'; import '../components/CatalogSearchBar.css';
import '../components/CuratorLoginModal.css'; import '../components/CuratorLoginModal.css';
import '../components/LocaleSwitcher.css';
import '../pages/TranslationsPage.css';
import { useAuth } from '../context/AuthContext'; 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 type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
import { createViewChangeScheduler } from '../utils/timelineView'; import { createViewChangeScheduler } from '../utils/timelineView';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils'; import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
@@ -22,6 +27,7 @@ import './HomePage.css';
type View = type View =
| { type: 'timeline' } | { type: 'timeline' }
| { type: 'checkup' } | { type: 'checkup' }
| { type: 'translations' }
| { type: 'gallery'; artistId: number; data: ArtistDetail } | { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail } | { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View } | { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
@@ -102,6 +108,7 @@ function catalogNavigateTarget(
} }
export default function HomePage() { export default function HomePage() {
const { t } = useTranslation('home');
const { isCurator, username, login, logout } = useAuth(); const { isCurator, username, login, logout } = useAuth();
const [view, setView] = useState<View>({ type: 'timeline' }); const [view, setView] = useState<View>({ type: 'timeline' });
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null); const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
@@ -117,10 +124,11 @@ export default function HomePage() {
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]); const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({}); const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({}); const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
const [localeVersion, setLocaleVersion] = useState(0);
const [debugMode, setDebugMode] = useState(readDebugMode); const [debugMode, setDebugMode] = useState(readDebugMode);
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore); const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
const [loginOpen, setLoginOpen] = useState(false); 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 effectiveDebugMode = debugMode && isCurator;
const [galleryRevision, setGalleryRevision] = useState(0); const [galleryRevision, setGalleryRevision] = useState(0);
const viewRef = useRef(view); const viewRef = useRef(view);
@@ -170,6 +178,11 @@ export default function HomePage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [localeVersion]);
const handleLocaleChange = useCallback((locale: 'en' | 'ru') => {
setApiLocale(locale);
setLocaleVersion((v) => v + 1);
}, []); }, []);
const viewChangeScheduler = useRef( const viewChangeScheduler = useRef(
@@ -209,7 +222,7 @@ export default function HomePage() {
writeDebugShowMore(enabled); writeDebugShowMore(enabled);
}; };
const openCuratorLogin = (redirect: 'checkup' | null = null) => { const openCuratorLogin = (redirect: 'checkup' | 'translations' | null = null) => {
setLoginRedirect(redirect); setLoginRedirect(redirect);
setLoginOpen(true); setLoginOpen(true);
}; };
@@ -219,6 +232,8 @@ export default function HomePage() {
setLoginOpen(false); setLoginOpen(false);
if (loginRedirect === 'checkup') { if (loginRedirect === 'checkup') {
setView({ type: 'checkup' }); setView({ type: 'checkup' });
} else if (loginRedirect === 'translations') {
setView({ type: 'translations' });
} }
setLoginRedirect(null); setLoginRedirect(null);
}; };
@@ -227,7 +242,7 @@ export default function HomePage() {
await logout(); await logout();
writeDebugMode(false); writeDebugMode(false);
setDebugMode(false); setDebugMode(false);
if (view.type === 'checkup') { if (view.type === 'checkup' || view.type === 'translations') {
goToTimelineHome(); goToTimelineHome();
} }
}; };
@@ -240,6 +255,14 @@ export default function HomePage() {
setView({ type: 'checkup' }); setView({ type: 'checkup' });
}; };
const openTranslations = () => {
if (!isCurator) {
openCuratorLogin('translations');
return;
}
setView({ type: 'translations' });
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => { const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId); const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = { const patch: Partial<Painting> = {
@@ -663,7 +686,7 @@ export default function HomePage() {
key={view.paintingId} key={view.paintingId}
data={view.data} data={view.data}
artistPaintings={sortedDetailArtistPaintings} artistPaintings={sortedDetailArtistPaintings}
backLabel={view.returnTo.type === 'timeline' ? '← Back to Timeline' : '← Back to Gallery'} backLabel={view.returnTo.type === 'timeline' ? t('backToTimeline') : t('backToGallery')}
onBack={() => { onBack={() => {
if (view.returnTo.type === 'timeline') { if (view.returnTo.type === 'timeline') {
goToTimelineHome(); goToTimelineHome();
@@ -723,6 +746,25 @@ export default function HomePage() {
</div> </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' && ( {view.type === 'checkup' && (
isCurator ? ( isCurator ? (
<CheckupPage <CheckupPage
@@ -782,13 +824,21 @@ export default function HomePage() {
/> />
Show more Show more
</label> </label>
<button
type="button"
className="checkup-link-btn"
onClick={openTranslations}
title="Review and publish Russian translations"
>
{t('translations')}
</button>
<button <button
type="button" type="button"
className="checkup-link-btn" className="checkup-link-btn"
onClick={openCheckup} onClick={openCheckup}
title="Open painting image checkup table" title="Open painting image checkup table"
> >
Checkup {t('checkup')}
</button> </button>
<button <button
type="button" type="button"
@@ -796,7 +846,7 @@ export default function HomePage() {
onClick={handleCuratorLogout} onClick={handleCuratorLogout}
title="Sign out curator session" title="Sign out curator session"
> >
Logout {t('curatorLogout')}
</button> </button>
</> </>
) : ( ) : (
@@ -806,12 +856,13 @@ export default function HomePage() {
onClick={() => openCuratorLogin()} onClick={() => openCuratorLogin()}
title="Sign in as curator to use debug tools" title="Sign in as curator to use debug tools"
> >
Curator login {t('curatorLogin')}
</button> </button>
)} )}
<LocaleSwitcher onLocaleChange={handleLocaleChange} />
</div> </div>
<h1>Virtual Art Gallery</h1> <h1>{t('title')}</h1>
<p className="site-subtitle">Watch art movements branch forward through time each flowing from what came before</p> <p className="site-subtitle">{t('subtitle')}</p>
<CatalogSearchBar <CatalogSearchBar
onSelectArtist={handleArtistClick} onSelectArtist={handleArtistClick}
onSelectMovement={handleMovementClick} 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, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"resolveJsonModule": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "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:artist-palette": "node scripts/migrate-artist-palette.js",
"dev:migrate:search": "node scripts/migrate-search.js", "dev:migrate:search": "node scripts/migrate-search.js",
"dev:migrate:sync-timestamps": "node scripts/migrate-sync-timestamps.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:backfill-updated-at": "node scripts/backfill-updated-at.js",
"dev:import-painter-palette": "node scripts/import-painter-palette.js", "dev:import-painter-palette": "node scripts/import-painter-palette.js",
"dev:analyze-painter-palette": "node scripts/analyze-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_influences',
'painting_influence_sources', 'painting_influence_sources',
'painting_annotations', 'painting_annotations',
'entity_translations',
]; ];
const NATURAL_KEY_FN = { const NATURAL_KEY_FN = {
@@ -40,6 +41,7 @@ const NATURAL_KEY_FN = {
painting_influences: (r) => `${r.painting_id}:${r.influenced_by_painting_id}`, 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_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}`, 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) { 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 { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
const { getVersionInfo } = require('./version-info'); const { getVersionInfo } = require('./version-info');
const { searchCatalog } = require('./search-service'); 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 { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
const app = express(); const app = express();
@@ -30,6 +42,7 @@ app.use(compression());
app.use(express.json({ limit: '20mb' })); app.use(express.json({ limit: '20mb' }));
app.use(createSessionMiddleware()); app.use(createSessionMiddleware());
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/translations', translationRoutes);
app.use( app.use(
'/images', '/images',
express.static(IMAGE_DIR, { express.static(IMAGE_DIR, {
@@ -119,6 +132,13 @@ function sendCatalogCacheHeaders(res, etagSource) {
return etag; return etag;
} }
function localeContext(req) {
return {
locale: resolveLocale(req),
statuses: translationStatuses(req),
};
}
const INFLUENCE_LINKS_EXISTS = ` const INFLUENCE_LINKS_EXISTS = `
EXISTS ( EXISTS (
SELECT 1 FROM painting_influence_sources pis SELECT 1 FROM painting_influence_sources pis
@@ -127,6 +147,7 @@ const INFLUENCE_LINKS_EXISTS = `
const INFLUENCED_BY_SQL = ` const INFLUENCED_BY_SQL = `
SELECT SELECT
pis.id AS influence_source_id,
pis.source_type, pis.source_type,
pis.period_note, pis.period_note,
pis.period_start_year, pis.period_start_year,
@@ -163,6 +184,7 @@ const INFLUENCED_BY_SQL = `
const INFLUENCED_SQL = ` const INFLUENCED_SQL = `
SELECT SELECT
pis.id AS influence_source_id,
pis.notes, pis.notes,
pis.source, pis.source,
pis.aspects, pis.aspects,
@@ -187,9 +209,11 @@ app.get('/api/search', async (req, res) => {
const q = typeof req.query.q === 'string' ? req.query.q : ''; const q = typeof req.query.q === 'string' ? req.query.q : '';
const limit = parseInt(req.query.limit, 10); const limit = parseInt(req.query.limit, 10);
const types = typeof req.query.types === 'string' ? req.query.types : undefined; const types = typeof req.query.types === 'string' ? req.query.types : undefined;
const { locale } = localeContext(req);
const result = await searchCatalog(q, { const result = await searchCatalog(q, {
limit: Number.isFinite(limit) ? limit : 20, limit: Number.isFinite(limit) ? limit : 20,
types, types,
locale,
}); });
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
res.json(result); res.json(result);
@@ -204,9 +228,12 @@ app.get('/api/timeline', async (req, res) => {
const { start, end } = req.query; const { start, end } = req.query;
const startYear = parseInt(start) || -3000; const startYear = parseInt(start) || -3000;
const endYear = parseInt(end) || 2100; const endYear = parseInt(end) || 2100;
const { locale, statuses } = localeContext(req);
const { eras, movements } = await fetchTimelineErasAndMovements(startYear, endYear); 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) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Failed to fetch timeline' }); 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 bounds = await fetchCatalogBounds();
const startYear = parseInt(req.query.start, 10) || bounds.min_year || -3000; const startYear = parseInt(req.query.start, 10) || bounds.min_year || -3000;
const endYear = parseInt(req.query.end, 10) || bounds.max_year || 2100; const endYear = parseInt(req.query.end, 10) || bounds.max_year || 2100;
const { locale, statuses } = localeContext(req);
const etagSource = await catalogBootstrapEtag(); const etagSource = await catalogBootstrapEtag();
const etag = sendCatalogCacheHeaders(res, etagSource); const etag = sendCatalogCacheHeaders(res, etagSource);
@@ -231,7 +259,17 @@ app.get('/api/catalog/bootstrap', async (req, res) => {
fetchTimelineArtists(startYear, endYear), 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) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Failed to fetch catalog bootstrap' }); 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( const paintings = await pool.query(
`SELECT p.*, `SELECT p.*,
a.name AS artist_name, a.name AS artist_name,
a.id AS artist_id,
p.checkup_checked, p.checkup_checked,
p.checkup_fixed, p.checkup_fixed,
(${INFLUENCE_LINKS_EXISTS}) AS has_influence_links (${INFLUENCE_LINKS_EXISTS}) AS has_influence_links
@@ -266,9 +305,14 @@ app.get('/api/movements/:id/gallery', async (req, res) => {
[id] [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({ res.json({
movement: movement.rows[0], locale,
paintings: paintings.rows.map(enrichPaintingRow), movement: localizedMovement,
paintings: localizedPaintings.map(enrichPaintingRow),
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@@ -281,12 +325,14 @@ app.get('/api/movements/:id/artists', async (req, res) => {
try { try {
const { id } = req.params; const { id } = req.params;
const result = await pool.query( 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 FROM artists WHERE movement_id = $1
ORDER BY birth_year`, ORDER BY birth_year`,
[id] [id]
); );
res.json(result.rows); const { locale, statuses } = localeContext(req);
const localized = await localizeArtists(result.rows, locale, statuses);
res.json(localized);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Failed to fetch artists' }); 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'; query += ' ORDER BY a.birth_year';
const result = await pool.query(query, params); 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) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Failed to fetch artists' }); 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({ res.json({
predecessors: groupByMovement(predecessors.rows), locale,
successors: groupByMovement(successors.rows), predecessors: groupByMovement(locPred),
successors: groupByMovement(locSucc),
}); });
} catch (err) { } catch (err) {
console.error(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' }); 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({ res.json({
artist: enrichArtistRow(artist.rows[0]), locale,
periods: periods.rows, artist: enrichArtistRow(localizedArtist),
paintings: paintings.rows.map(enrichPaintingRow), periods: localizedPeriods,
paintings: localizedPaintings.map(enrichPaintingRow),
}); });
} catch (err) { } catch (err) {
console.error(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({ res.json({
painting: enrichPaintingRow(painting.rows[0]), locale,
influencedBy: influencedBy.rows, painting: enrichPaintingRow(localizedPainting),
influenced: influenced.rows.map(enrichPaintingRow), influencedBy: localizedInfluencedBy,
annotations: annotations.rows, influenced: localizedInfluenced,
annotations: localizedAnnotations,
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
+1
View File
@@ -14,6 +14,7 @@ const INCREMENTAL_MIGRATIONS = [
'migrate-perf-indexes.sql', 'migrate-perf-indexes.sql',
'migrate-search.sql', 'migrate-search.sql',
'migrate-sync-timestamps.sql', 'migrate-sync-timestamps.sql',
'migrate-i18n.sql',
]; ];
async function bootstrapCurator() { 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 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) { function parseTypes(typesParam) {
if (!typesParam || typeof typesParam !== 'string') { if (!typesParam || typeof typesParam !== 'string') {
return ['artist', 'movement', 'painting']; return ['artist', 'movement', 'painting'];
@@ -13,10 +24,18 @@ function parseTypes(typesParam) {
return parsed.length > 0 ? parsed : ['artist', 'movement', 'painting']; 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( const { rows } = await pool.query(
`SELECT a.id, a.name, a.birth_year, a.death_year, `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, m.name AS movement_name,
CASE WHEN a.name ILIKE $2 THEN 0 ELSE 1 END AS rank CASE WHEN a.name ILIKE $2 THEN 0 ELSE 1 END AS rank
FROM artists a FROM artists a
@@ -24,49 +43,140 @@ async function searchArtists(pattern, prefixPattern, perTypeLimit) {
WHERE a.name ILIKE $1 WHERE a.name ILIKE $1
OR a.wikipedia_title ILIKE $1 OR a.wikipedia_title ILIKE $1
OR m.name ILIKE $1 OR m.name ILIKE $1
${translationClause}
ORDER BY rank, a.name ORDER BY rank, a.name
LIMIT $3`, LIMIT $3`,
[pattern, prefixPattern, perTypeLimit] params,
); );
return rows.map((row) => ({
type: 'artist', if (locale === 'en') {
id: row.id, return rows.map((row) => ({
name: row.name, type: 'artist',
birth_year: row.birth_year, id: row.id,
death_year: row.death_year, name: row.name,
movement_name: row.movement_name, birth_year: row.birth_year,
portrait_path: row.portrait_path, death_year: row.death_year,
portrait_thumb_path: row.portrait_thumb_path, movement_name: row.movement_name,
rank: row.rank, 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( 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, e.name AS era_name,
CASE WHEN m.name ILIKE $2 THEN 0 ELSE 1 END AS rank CASE WHEN m.name ILIKE $2 THEN 0 ELSE 1 END AS rank
FROM art_movements m FROM art_movements m
LEFT JOIN historical_eras e ON m.era_id = e.id LEFT JOIN historical_eras e ON m.era_id = e.id
WHERE m.name ILIKE $1 WHERE m.name ILIKE $1
OR e.name ILIKE $1 OR e.name ILIKE $1
${movementTrans}
${eraTrans}
ORDER BY rank, m.name ORDER BY rank, m.name
LIMIT $3`, 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) => ({ return rows.map((row) => ({
type: 'movement', type: 'movement',
id: row.id, id: row.id,
name: row.name, name: movementMap.get(`${row.id}:name`) || row.name,
color: row.color, color: row.color,
start_year: row.start_year, start_year: row.start_year,
end_year: row.end_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, 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( const { rows } = await pool.query(
`SELECT p.id, p.title, p.year, p.thumbnail_path, p.image_path, `SELECT p.id, p.title, p.year, p.thumbnail_path, p.image_path,
a.id AS artist_id, a.name AS artist_name, 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 a.name ILIKE $1
OR m.name ILIKE $1 OR m.name ILIKE $1
OR CAST(p.year AS TEXT) ILIKE $1 OR CAST(p.year AS TEXT) ILIKE $1
${paintingTrans}
${artistTrans}
ORDER BY rank, p.year NULLS LAST, p.title ORDER BY rank, p.year NULLS LAST, p.title
LIMIT $3`, 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) => ({ return rows.map((row) => ({
type: 'painting', type: 'painting',
id: row.id, id: row.id,
title: row.title, title: paintingMap.get(`${row.id}:title`) || row.title,
year: row.year, year: row.year,
artist_id: row.artist_id, 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, movement_name: row.movement_name,
thumbnail_path: row.thumbnail_path, thumbnail_path: row.thumbnail_path,
image_path: row.image_path, image_path: row.image_path,
@@ -101,9 +245,10 @@ async function searchPaintings(pattern, prefixPattern, perTypeLimit) {
async function searchCatalog(query, options = {}) { async function searchCatalog(query, options = {}) {
const q = String(query || '').trim(); const q = String(query || '').trim();
if (q.length < 2) { 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 limit = Math.min(50, Math.max(1, Number(options.limit) || 20));
const types = parseTypes(options.types); const types = parseTypes(options.types);
const perTypeLimit = Math.max(1, Math.ceil(limit / types.length)); const perTypeLimit = Math.max(1, Math.ceil(limit / types.length));
@@ -111,9 +256,9 @@ async function searchCatalog(query, options = {}) {
const prefixPattern = `${q}%`; const prefixPattern = `${q}%`;
const tasks = []; const tasks = [];
if (types.includes('artist')) tasks.push(searchArtists(pattern, prefixPattern, perTypeLimit)); if (types.includes('artist')) tasks.push(searchArtists(pattern, prefixPattern, perTypeLimit, locale));
if (types.includes('movement')) tasks.push(searchMovements(pattern, prefixPattern, perTypeLimit)); if (types.includes('movement')) tasks.push(searchMovements(pattern, prefixPattern, perTypeLimit, locale));
if (types.includes('painting')) tasks.push(searchPaintings(pattern, prefixPattern, perTypeLimit)); if (types.includes('painting')) tasks.push(searchPaintings(pattern, prefixPattern, perTypeLimit, locale));
const groups = await Promise.all(tasks); const groups = await Promise.all(tasks);
const merged = groups const merged = groups
@@ -129,7 +274,7 @@ async function searchCatalog(query, options = {}) {
.slice(0, limit) .slice(0, limit)
.map(({ rank: _rank, ...rest }) => rest); .map(({ rank: _rank, ...rest }) => rest);
return { q, results: merged }; return { q, locale, results: merged };
} }
module.exports = { searchCatalog }; 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,
};