Add Russian i18n with DB translations, locale API, and curator review UI.
UI chrome via react-i18next, catalog text in entity_translations with ru.wikipedia seeding, locale-aware search, and Translations page for publish workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f247b418d8
commit
ca58c43648
@@ -171,6 +171,32 @@ Movements are filtered to those with at least one artist active in the requested
|
||||
|
||||
---
|
||||
|
||||
## Locale (`?locale=ru`)
|
||||
|
||||
Public catalog endpoints accept optional **`locale`** query param (`en` default, `ru` supported) or `Accept-Language: ru`.
|
||||
|
||||
Affected routes: `/api/catalog/bootstrap`, `/api/timeline`, `/api/search`, `/api/artists`, `/api/artists/:id`, `/api/paintings/:id`, `/api/movements/:id/gallery`, `/api/movements/:id/artists`, `/api/artists/:id/navigation`.
|
||||
|
||||
Responses include `"locale": "ru"` when resolved. Display field names are unchanged; values come from `entity_translations` when `status = published`, else canonical English.
|
||||
|
||||
Full guide: [i18n-russian.md](i18n-russian.md).
|
||||
|
||||
---
|
||||
|
||||
## Translations (curator)
|
||||
|
||||
Requires curator session. Base path: `/api/translations`.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/api/translations/coverage?locale=ru` | Coverage stats |
|
||||
| `GET` | `/api/translations/worklist/:entityType?locale=ru` | Artists/paintings/movements with translation status |
|
||||
| `GET` | `/api/translations/:entityType/:id` | Canonical + all translation rows |
|
||||
| `PUT` | `/api/translations/:entityType/:id` | Upsert fields `{ locale, fields, status }` |
|
||||
| `POST` | `/api/translations/:entityType/:id/publish` | Publish all draft/reviewed rows for locale |
|
||||
|
||||
---
|
||||
|
||||
## `GET /api/search`
|
||||
|
||||
Public catalog search over **artists**, **paintings**, and **art movements**. Used by the timeline header search bar (`CatalogSearchBar.tsx`).
|
||||
@@ -182,6 +208,7 @@ Public catalog search over **artists**, **paintings**, and **art movements**. Us
|
||||
| `q` | string | — | Search text (min **2** characters after trim; shorter returns `{ q, results: [] }`) |
|
||||
| `limit` | int | 20 | Max results total (capped at **50**) |
|
||||
| `types` | string | all | Optional comma list: `artist`, `painting`, `movement` |
|
||||
| `locale` | string | `en` | `ru` — search and return published Russian aliases when available |
|
||||
|
||||
**Matching (case-insensitive `ILIKE`):**
|
||||
|
||||
@@ -190,6 +217,7 @@ Public catalog search over **artists**, **paintings**, and **art movements**. Us
|
||||
| Artist | `name`, `wikipedia_title`, movement name |
|
||||
| Movement | movement `name`, era name |
|
||||
| Painting | `title`, `wikipedia_title`, `year` (as text), artist name, movement name |
|
||||
| All (when `locale=ru`) | Published rows in `entity_translations` for `name` / `title` |
|
||||
|
||||
Prefix matches on primary labels (`name` / `title`) rank before substring matches.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
this file contains draft for future releases and features
|
||||
|
||||
1. Multy language support, russian version at least, search for best implementation, preferably story in db and easily expandable. tool to check and correct translation
|
||||
1. ~~Multi language support, russian version at least~~ — done: UI i18n (EN/RU) + `entity_translations` DB + curator Translations tool — [i18n-russian.md](i18n-russian.md)
|
||||
2. tool to manage links (influence/influenced by ) import csv's ( define format), edit ,add, delete, visualize, map to pictures/ entities
|
||||
3. tool to monitor/manage (plan actions) of curator actions, markers to check painting/text ?
|
||||
4. ~~tool to sync prod /env resources (both ways), db structure, db data, images, users etc~~ — done for catalog DB + images: `npm run harmonize` (schema dev→prod only; users/audit excluded) — [harmonize-dev-prod.md](harmonize-dev-prod.md)
|
||||
|
||||
@@ -107,7 +107,7 @@ Reports are written to `db/SyncReports/harmonize_db_*.json` and `harmonize_image
|
||||
|
||||
Processed in FK order:
|
||||
|
||||
`historical_eras` → `art_movements` → `artists` → `artist_periods` → `paintings` → `painting_influences` → `painting_influence_sources` → `painting_annotations`
|
||||
`historical_eras` → `art_movements` → `artists` → `artist_periods` → `paintings` → `painting_influences` → `painting_influence_sources` → `painting_annotations` → **`entity_translations`**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -89,6 +89,9 @@ npm run dev:update-influences # painting influence graph for detail vie
|
||||
npm run dev:migrate:checkup-flags # optional: review/fixed flags for Checkup page (paintings)
|
||||
npm run dev:migrate:artist-checkup-flags # optional: same flags for artist portraits (bio debug)
|
||||
npm run dev:migrate:search # optional on very old DBs — also applied by dev:migrate / prod Step 4
|
||||
npm run dev:migrate:i18n # optional — entity_translations (also in dev:migrate)
|
||||
npm run dev:fetch-artist-bios-ru # draft Russian bios + Cyrillic names from ru.wikipedia
|
||||
npm run dev:import-translations -- --file path/to/file.json # bulk translation import
|
||||
npm run dev:migrate:painting-annotations # optional: art-history notes table
|
||||
npm run dev:update-painting-annotations # optional: load curated notes (+ --wikipedia for Wikipedia intros)
|
||||
npm run dev:fetch-images -- --limit=50 # random sample; 10s max per painting (default)
|
||||
@@ -117,7 +120,7 @@ Image fetch can take hours if you run it for the entire catalog. The first line
|
||||
| `npm run dev:server` | API with nodemon reload (local HMR workflow) |
|
||||
| `npm run dev:client` | Vite dev server on :5173 |
|
||||
|
||||
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. For incremental dev ↔ prod merge (both sides edited), see [harmonize-dev-prod.md](harmonize-dev-prod.md). Quick reference: [FAC.md](FAC.md).
|
||||
See [environments.md](environments.md) for dev/prod URLs, database split, Docker deploy, and sync commands. For Russian UI + catalog text, see [i18n-russian.md](i18n-russian.md). Quick reference: [FAC.md](FAC.md).
|
||||
|
||||
**Production frontend:** build the client, then start the server:
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
<title>Virtual Art Gallery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+76
-1
@@ -10,8 +10,10 @@
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"i18next": "^26.3.6",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
@@ -2077,6 +2079,43 @@
|
||||
"integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
@@ -2815,6 +2854,33 @@
|
||||
"react": "^19.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.9",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.9.tgz",
|
||||
"integrity": "sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz",
|
||||
@@ -3159,7 +3225,7 @@
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -3337,6 +3403,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/webgl-constants": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz",
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
"dependencies": {
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.6.1",
|
||||
"i18next": "^26.3.6",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-router-dom": "^7.18.0",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
|
||||
+102
-11
@@ -9,9 +9,31 @@ import type {
|
||||
ArtistNavigation,
|
||||
CatalogSearchResponse,
|
||||
} from '../types';
|
||||
import { readStoredLocale, type AppLocale } from '../utils/localeStorage';
|
||||
|
||||
const API = '/api';
|
||||
|
||||
let apiLocale: AppLocale = readStoredLocale();
|
||||
|
||||
export function setApiLocale(locale: AppLocale) {
|
||||
apiLocale = locale;
|
||||
}
|
||||
|
||||
export function getApiLocale(): AppLocale {
|
||||
return apiLocale;
|
||||
}
|
||||
|
||||
function localeParams(base?: URLSearchParams): URLSearchParams {
|
||||
const params = base ?? new URLSearchParams();
|
||||
if (apiLocale !== 'en') params.set('locale', apiLocale);
|
||||
return params;
|
||||
}
|
||||
|
||||
function localizedPath(path: string, params?: URLSearchParams): string {
|
||||
const qs = localeParams(params).toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
const fetchCredentials: RequestInit = { credentials: 'include' };
|
||||
|
||||
export type AuthRole = 'user' | 'curator';
|
||||
@@ -293,18 +315,19 @@ export const api = {
|
||||
const params = new URLSearchParams();
|
||||
if (start != null) params.set('start', String(start));
|
||||
if (end != null) params.set('end', String(end));
|
||||
const qs = params.toString();
|
||||
return fetchJson<CatalogBootstrap>(`${API}/catalog/bootstrap${qs ? `?${qs}` : ''}`);
|
||||
return fetchJson<CatalogBootstrap>(localizedPath(`${API}/catalog/bootstrap`, params));
|
||||
},
|
||||
|
||||
getTimeline: (start: number, end: number) =>
|
||||
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`),
|
||||
getTimeline: (start: number, end: number) => {
|
||||
const params = new URLSearchParams({ start: String(start), end: String(end) });
|
||||
return fetchJson<TimelineData>(localizedPath(`${API}/timeline`, params));
|
||||
},
|
||||
|
||||
search: (q: string, options?: { limit?: number; types?: string }) => {
|
||||
const params = new URLSearchParams({ q });
|
||||
if (options?.limit != null) params.set('limit', String(options.limit));
|
||||
if (options?.types) params.set('types', options.types);
|
||||
return fetchJson<CatalogSearchResponse>(`${API}/search?${params}`);
|
||||
return fetchJson<CatalogSearchResponse>(localizedPath(`${API}/search`, params));
|
||||
},
|
||||
|
||||
getArtists: (start?: number, end?: number, movementId?: number) => {
|
||||
@@ -312,20 +335,20 @@ export const api = {
|
||||
if (start != null) params.set('start', String(start));
|
||||
if (end != null) params.set('end', String(end));
|
||||
if (movementId != null) params.set('movement_id', String(movementId));
|
||||
return fetchJson<Artist[]>(`${API}/artists?${params}`);
|
||||
return fetchJson<Artist[]>(localizedPath(`${API}/artists`, params));
|
||||
},
|
||||
|
||||
/** Lightweight artist rows for the timeline (no biography text). */
|
||||
getTimelineArtists: () => fetchJson<Artist[]>(`${API}/artists?timeline=1`),
|
||||
getTimelineArtists: () => fetchJson<Artist[]>(localizedPath(`${API}/artists`, new URLSearchParams({ timeline: '1' }))),
|
||||
|
||||
getArtist: (id: number) => fetchJson<ArtistDetail>(`${API}/artists/${id}`),
|
||||
getArtist: (id: number) => fetchJson<ArtistDetail>(localizedPath(`${API}/artists/${id}`)),
|
||||
|
||||
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(`${API}/movements/${id}/gallery`),
|
||||
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
|
||||
|
||||
getArtistNavigation: (id: number) =>
|
||||
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
|
||||
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
|
||||
|
||||
getPainting: (id: number) => fetchJson<PaintingDetail>(`${API}/paintings/${id}`),
|
||||
getPainting: (id: number) => fetchJson<PaintingDetail>(localizedPath(`${API}/paintings/${id}`)),
|
||||
|
||||
getPaintingDebugImageSearch: (id: number) =>
|
||||
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
|
||||
@@ -448,9 +471,77 @@ export const api = {
|
||||
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
|
||||
}),
|
||||
|
||||
getTranslationCoverage: (locale = 'ru') =>
|
||||
fetchJson<{ locale: string; coverage: Record<string, number> }>(
|
||||
`${API}/translations/coverage?locale=${encodeURIComponent(locale)}`
|
||||
),
|
||||
|
||||
getTranslationWorklist: (entityType: string, locale = 'ru') =>
|
||||
fetchJson<{ items: TranslationWorklistItem[] }>(
|
||||
`${API}/translations/worklist/${encodeURIComponent(entityType)}?locale=${encodeURIComponent(locale)}`
|
||||
),
|
||||
|
||||
getEntityTranslation: (entityType: string, id: number) =>
|
||||
fetchJson<TranslationDetail>(`${API}/translations/${encodeURIComponent(entityType)}/${id}`),
|
||||
|
||||
saveEntityTranslation: (
|
||||
entityType: string,
|
||||
id: number,
|
||||
payload: { locale: string; fields: Record<string, string>; status?: string }
|
||||
) =>
|
||||
fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}`, {
|
||||
...fetchCredentials,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Save failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}),
|
||||
|
||||
publishEntityTranslation: (entityType: string, id: number, locale = 'ru') =>
|
||||
fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}/publish`, {
|
||||
...fetchCredentials,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locale }),
|
||||
}).then(async (res) => {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Publish failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}),
|
||||
|
||||
preloadArtistImages,
|
||||
};
|
||||
|
||||
export interface TranslationWorklistItem {
|
||||
entityId: number;
|
||||
label: string;
|
||||
publishedCount: number;
|
||||
draftCount: number;
|
||||
missingFields: string[];
|
||||
}
|
||||
|
||||
export interface TranslationDetail {
|
||||
entityType: string;
|
||||
entityId: number;
|
||||
canonical: Record<string, unknown>;
|
||||
translatableFields: string[];
|
||||
translations: Array<{
|
||||
locale: string;
|
||||
field_name: string;
|
||||
value: string;
|
||||
status: string;
|
||||
source: string | null;
|
||||
updated_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function debugImageProxyUrl(
|
||||
imageUrl: string,
|
||||
context?: { searchUrl?: string; source?: string }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CatalogSearchResult } from '../types';
|
||||
import { api, imageUrl, portraitThumbUrl } from '../api/client';
|
||||
import './CatalogSearchBar.css';
|
||||
@@ -10,11 +11,6 @@ interface Props {
|
||||
}
|
||||
|
||||
const TYPE_ORDER: CatalogSearchResult['type'][] = ['artist', 'movement', 'painting'];
|
||||
const TYPE_LABELS: Record<CatalogSearchResult['type'], string> = {
|
||||
artist: 'Artists',
|
||||
movement: 'Movements',
|
||||
painting: 'Paintings',
|
||||
};
|
||||
|
||||
function resultKey(item: CatalogSearchResult): string {
|
||||
return `${item.type}-${item.id}`;
|
||||
@@ -30,6 +26,12 @@ export default function CatalogSearchBar({
|
||||
onSelectMovement,
|
||||
onSelectPainting,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('search');
|
||||
const typeLabels: Record<CatalogSearchResult['type'], string> = {
|
||||
artist: t('groupArtist'),
|
||||
movement: t('groupMovement'),
|
||||
painting: t('groupPainting'),
|
||||
};
|
||||
const listboxId = useId();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -91,7 +93,7 @@ export default function CatalogSearchBar({
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setResults([]);
|
||||
setError('Search failed.');
|
||||
setError(t('searchFailed'));
|
||||
setOpen(true);
|
||||
setActiveIndex(-1);
|
||||
})
|
||||
@@ -141,7 +143,7 @@ export default function CatalogSearchBar({
|
||||
return (
|
||||
<div className="catalog-search" ref={rootRef}>
|
||||
<label className="catalog-search-label" htmlFor={`${listboxId}-input`}>
|
||||
Search
|
||||
{t('label')}
|
||||
</label>
|
||||
<div className="catalog-search-field">
|
||||
<input
|
||||
@@ -156,7 +158,7 @@ export default function CatalogSearchBar({
|
||||
aria-activedescendant={
|
||||
showPanel && activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined
|
||||
}
|
||||
placeholder="Search artists, paintings, movements…"
|
||||
placeholder={t('placeholder')}
|
||||
value={query}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
@@ -177,15 +179,15 @@ export default function CatalogSearchBar({
|
||||
id={`${listboxId}-listbox`}
|
||||
className="catalog-search-panel"
|
||||
role="listbox"
|
||||
aria-label="Search results"
|
||||
aria-label={t('resultsLabel')}
|
||||
>
|
||||
{error && <p className="catalog-search-message catalog-search-error">{error}</p>}
|
||||
{!error && !loading && flatResults.length === 0 && (
|
||||
<p className="catalog-search-message">No matches found.</p>
|
||||
<p className="catalog-search-message">{t('noMatches')}</p>
|
||||
)}
|
||||
{grouped.map((group) => (
|
||||
<div key={group.type} className="catalog-search-group">
|
||||
<p className="catalog-search-group-label">{TYPE_LABELS[group.type]}</p>
|
||||
<p className="catalog-search-group-label">{typeLabels[group.type]}</p>
|
||||
<ul className="catalog-search-list">
|
||||
{group.items.map((item) => {
|
||||
const flatIndex = flatResults.findIndex((r) => resultKey(r) === resultKey(item));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './CuratorLoginModal.css';
|
||||
|
||||
interface Props {
|
||||
@@ -8,6 +9,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
|
||||
const { t } = useTranslation('debug');
|
||||
const { t: tc } = useTranslation('common');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -32,7 +35,7 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
|
||||
await onLogin(username.trim(), password);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
setError(err instanceof Error ? err.message : t('loginFailed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -47,13 +50,13 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
|
||||
aria-modal="true"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 id="curator-login-title">Curator login</h2>
|
||||
<h2 id="curator-login-title">{t('loginTitle')}</h2>
|
||||
<p className="curator-login-hint">
|
||||
Debug tools and catalog edits require a curator account.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="curator-login-field">
|
||||
<span>Username</span>
|
||||
<span>{t('username')}</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
@@ -64,7 +67,7 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
|
||||
/>
|
||||
</label>
|
||||
<label className="curator-login-field">
|
||||
<span>Password</span>
|
||||
<span>{t('password')}</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
@@ -77,10 +80,10 @@ export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
|
||||
{error && <p className="curator-login-error">{error}</p>}
|
||||
<div className="curator-login-actions">
|
||||
<button type="button" className="curator-login-cancel" onClick={onClose} disabled={submitting}>
|
||||
Cancel
|
||||
{tc('cancel')}
|
||||
</button>
|
||||
<button type="submit" className="curator-login-submit" disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
{submitting ? t('saving') : t('signIn')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { PaintingAnnotation } from '../types';
|
||||
import './PaintingAnnotations.css';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
technique: 'Technique',
|
||||
composition: 'Composition',
|
||||
symbolism: 'Symbolism',
|
||||
history: 'History',
|
||||
subject: 'Subject',
|
||||
const CATEGORY_KEYS: Record<string, string> = {
|
||||
technique: 'categoryTechnique',
|
||||
composition: 'categoryComposition',
|
||||
symbolism: 'categorySymbolism',
|
||||
history: 'categoryHistorical',
|
||||
subject: 'categorySubject',
|
||||
};
|
||||
|
||||
interface PanelProps {
|
||||
@@ -57,6 +58,7 @@ export default function PaintingAnnotationsPanel({
|
||||
activeId,
|
||||
onSelect,
|
||||
}: PanelProps) {
|
||||
const { t } = useTranslation('annotations');
|
||||
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
|
||||
|
||||
if (!annotations.length) return null;
|
||||
@@ -70,11 +72,12 @@ export default function PaintingAnnotationsPanel({
|
||||
|
||||
return (
|
||||
<aside className="painting-annotations-panel" aria-label="Art history annotations">
|
||||
<h3 className="painting-annotations-title">Art history notes</h3>
|
||||
<h3 className="painting-annotations-title">{t('title')}</h3>
|
||||
<ul className="painting-annotations-list">
|
||||
{annotations.map((ann, index) => {
|
||||
const isActive = activeId === ann.id;
|
||||
const category = CATEGORY_LABELS[ann.category] || ann.category;
|
||||
const categoryKey = CATEGORY_KEYS[ann.category];
|
||||
const category = categoryKey ? t(categoryKey) : ann.category;
|
||||
return (
|
||||
<li
|
||||
key={ann.id}
|
||||
@@ -110,7 +113,7 @@ export default function PaintingAnnotationsPanel({
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Read source
|
||||
{t('readSource')}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState, type SyntheticEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
|
||||
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
|
||||
import DebugSearchResultsModal from './DebugSearchResultsModal';
|
||||
@@ -204,6 +205,7 @@ export default function PaintingDetailView({
|
||||
onPaintingCheckupFlagsUpdated,
|
||||
onPaintingRemoved,
|
||||
}: Props) {
|
||||
const { t } = useTranslation('painting');
|
||||
const { painting, influencedBy, influenced, annotations = [] } = data;
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [imageVersion, setImageVersion] = useState(0);
|
||||
@@ -474,15 +476,15 @@ export default function PaintingDetailView({
|
||||
</p>
|
||||
</div>
|
||||
<button className="bio-btn" onClick={onArtistBio}>
|
||||
About {painting.artist_name}
|
||||
{t('aboutArtist', { name: painting.artist_name })}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="painting-layout">
|
||||
<aside className="influence-panel influence-left">
|
||||
<h3>Influenced By</h3>
|
||||
<h3>{t('influencedBy')}</h3>
|
||||
{influencedBy.length === 0 ? (
|
||||
<p className="no-influences">No documented influences for this work.</p>
|
||||
<p className="no-influences">{t('noInfluences')}</p>
|
||||
) : (
|
||||
<div className="influence-list">
|
||||
{influencedBy.map((inf, index) => (
|
||||
@@ -575,9 +577,9 @@ export default function PaintingDetailView({
|
||||
</main>
|
||||
|
||||
<aside className="influence-panel influence-right">
|
||||
<h3>Influenced</h3>
|
||||
<h3>{t('influenced')}</h3>
|
||||
{influenced.length === 0 ? (
|
||||
<p className="no-influences">No documented works influenced by this painting yet.</p>
|
||||
<p className="no-influences">{t('noInfluencedWorks')}</p>
|
||||
) : (
|
||||
<div className="influence-list">
|
||||
{influenced.map((inf, index) => (
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Art history notes",
|
||||
"readSource": "Read source",
|
||||
"categoryTechnique": "Technique",
|
||||
"categoryComposition": "Composition",
|
||||
"categorySubject": "Subject",
|
||||
"categorySymbolism": "Symbolism",
|
||||
"categoryHistorical": "Historical context",
|
||||
"categoryOther": "Notes"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enterGallery": "Enter Gallery",
|
||||
"noBio": "No biography available yet.",
|
||||
"wikipediaSource": "Text adapted from Wikipedia",
|
||||
"backToTimeline": "← Back to Timeline"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Историко-художественные заметки",
|
||||
"readSource": "Источник",
|
||||
"categoryTechnique": "Техника",
|
||||
"categoryComposition": "Композиция",
|
||||
"categorySubject": "Сюжет",
|
||||
"categorySymbolism": "Символика",
|
||||
"categoryHistorical": "Исторический контекст",
|
||||
"categoryOther": "Заметки"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enterGallery": "Войти в галерею",
|
||||
"noBio": "Биография пока недоступна.",
|
||||
"wikipediaSource": "Текст адаптирован из Википедии",
|
||||
"backToTimeline": "← На шкалу времени"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"loading": "Загрузка…",
|
||||
"close": "Закрыть",
|
||||
"back": "Назад",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"error": "Что-то пошло не так",
|
||||
"localeEn": "EN",
|
||||
"localeRu": "RU",
|
||||
"language": "Язык"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"checked": "Проверено",
|
||||
"fixIt": "Исправить",
|
||||
"more": "Ещё",
|
||||
"clear": "Очистить",
|
||||
"upload": "Загрузить",
|
||||
"searching": "Поиск…",
|
||||
"saving": "Сохранение…",
|
||||
"loginTitle": "Вход куратора",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"signIn": "Войти",
|
||||
"loginFailed": "Ошибка входа"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"backToTimeline": "← На шкалу времени",
|
||||
"exitToTimeline": "Выход на шкалу времени",
|
||||
"loadingGallery": "Загрузка галереи…",
|
||||
"instructionsTitle": "Управление",
|
||||
"instructionMove": "Перетаскивайте для обзора",
|
||||
"instructionZoom": "Колёсико — масштаб",
|
||||
"instructionClick": "Нажмите на картину для подробностей",
|
||||
"wingOf": "Крыло {{current}} из {{total}}",
|
||||
"exitConfirmTitle": "Покинуть галерею?",
|
||||
"exitConfirmBody": "Вернуться на шкалу времени или остаться в зале.",
|
||||
"stay": "Остаться",
|
||||
"exit": "Выход"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Виртуальная художественная галерея",
|
||||
"subtitle": "Исследуйте историю искусства на интерактивной шкале времени — входите в 3D-залы и открывайте связи между художниками и шедеврами.",
|
||||
"loadingArtHistory": "Загрузка истории искусства…",
|
||||
"loadingPortraits": "Загрузка портретов…",
|
||||
"openingArtistGallery": "Открытие галереи художника…",
|
||||
"openingMovementGallery": "Открытие галереи направления…",
|
||||
"backToTimeline": "← На шкалу времени",
|
||||
"backToGallery": "← В галерею",
|
||||
"curatorLogin": "Вход куратора",
|
||||
"curatorLogout": "Выйти",
|
||||
"signedInAs": "Вы вошли как {{username}}",
|
||||
"debugMode": "Режим отладки",
|
||||
"showMoreDebug": "Показать больше (отладка)",
|
||||
"checkup": "Проверка",
|
||||
"translations": "Переводы",
|
||||
"curatorRequiredTitle": "Требуется доступ куратора",
|
||||
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
|
||||
"backToGalleryBtn": "Вернуться в галерею"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"aboutArtist": "О {{name}}",
|
||||
"influencedBy": "Под влиянием",
|
||||
"influenced": "Влияние на",
|
||||
"noInfluences": "Для этой работы нет задокументированных влияний.",
|
||||
"noInfluencedWorks": "Пока нет задокументированных работ под влиянием этой картины.",
|
||||
"catalogPosition": "Позиция в каталоге",
|
||||
"lightboxHint": "Нажмите в любом месте, чтобы закрыть",
|
||||
"prevPainting": "Предыдущая картина",
|
||||
"nextPainting": "Следующая картина"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"label": "Поиск",
|
||||
"placeholder": "Художники, картины, направления…",
|
||||
"resultsLabel": "Результаты поиска",
|
||||
"noMatches": "Ничего не найдено.",
|
||||
"groupArtist": "Художники",
|
||||
"groupMovement": "Направления",
|
||||
"groupPainting": "Картины",
|
||||
"searchFailed": "Ошибка поиска"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Проверка переводов",
|
||||
"back": "← В галерею",
|
||||
"locale": "Язык",
|
||||
"entityType": "Тип объекта",
|
||||
"status": "Статус",
|
||||
"all": "Все",
|
||||
"draft": "Черновик",
|
||||
"reviewed": "Проверено",
|
||||
"published": "Опубликовано",
|
||||
"coverage": "Охват",
|
||||
"artistsBio": "Художники с биографией (ru)",
|
||||
"paintingsTitle": "Картины с русским названием",
|
||||
"canonical": "Английский (оригинал)",
|
||||
"translation": "Перевод",
|
||||
"saveDraft": "Сохранить черновик",
|
||||
"publish": "Опубликовать",
|
||||
"noRows": "Нет строк по выбранному фильтру.",
|
||||
"loadFailed": "Не удалось загрузить переводы"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import { AuthProvider } from './context/AuthContext.tsx'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Timeline from '../components/Timeline';
|
||||
import TimelineEventGuides from '../components/TimelineEventGuides';
|
||||
import MovementBands from '../components/MovementBands';
|
||||
@@ -6,13 +7,17 @@ import VirtualGallery from '../components/VirtualGallery';
|
||||
import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
import CheckupPage from '../pages/CheckupPage';
|
||||
import TranslationsPage from '../pages/TranslationsPage';
|
||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||
import CatalogSearchBar from '../components/CatalogSearchBar';
|
||||
import LocaleSwitcher from '../components/LocaleSwitcher';
|
||||
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
||||
import '../components/CatalogSearchBar.css';
|
||||
import '../components/CuratorLoginModal.css';
|
||||
import '../components/LocaleSwitcher.css';
|
||||
import '../pages/TranslationsPage.css';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
|
||||
import { createViewChangeScheduler } from '../utils/timelineView';
|
||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||
@@ -22,6 +27,7 @@ import './HomePage.css';
|
||||
type View =
|
||||
| { type: 'timeline' }
|
||||
| { type: 'checkup' }
|
||||
| { type: 'translations' }
|
||||
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
||||
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
||||
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
|
||||
@@ -102,6 +108,7 @@ function catalogNavigateTarget(
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation('home');
|
||||
const { isCurator, username, login, logout } = useAuth();
|
||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||
@@ -117,10 +124,11 @@ export default function HomePage() {
|
||||
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
||||
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
||||
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
|
||||
const [localeVersion, setLocaleVersion] = useState(0);
|
||||
const [debugMode, setDebugMode] = useState(readDebugMode);
|
||||
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [loginRedirect, setLoginRedirect] = useState<'checkup' | null>(null);
|
||||
const [loginRedirect, setLoginRedirect] = useState<'checkup' | 'translations' | null>(null);
|
||||
const effectiveDebugMode = debugMode && isCurator;
|
||||
const [galleryRevision, setGalleryRevision] = useState(0);
|
||||
const viewRef = useRef(view);
|
||||
@@ -170,6 +178,11 @@ export default function HomePage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [localeVersion]);
|
||||
|
||||
const handleLocaleChange = useCallback((locale: 'en' | 'ru') => {
|
||||
setApiLocale(locale);
|
||||
setLocaleVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
const viewChangeScheduler = useRef(
|
||||
@@ -209,7 +222,7 @@ export default function HomePage() {
|
||||
writeDebugShowMore(enabled);
|
||||
};
|
||||
|
||||
const openCuratorLogin = (redirect: 'checkup' | null = null) => {
|
||||
const openCuratorLogin = (redirect: 'checkup' | 'translations' | null = null) => {
|
||||
setLoginRedirect(redirect);
|
||||
setLoginOpen(true);
|
||||
};
|
||||
@@ -219,6 +232,8 @@ export default function HomePage() {
|
||||
setLoginOpen(false);
|
||||
if (loginRedirect === 'checkup') {
|
||||
setView({ type: 'checkup' });
|
||||
} else if (loginRedirect === 'translations') {
|
||||
setView({ type: 'translations' });
|
||||
}
|
||||
setLoginRedirect(null);
|
||||
};
|
||||
@@ -227,7 +242,7 @@ export default function HomePage() {
|
||||
await logout();
|
||||
writeDebugMode(false);
|
||||
setDebugMode(false);
|
||||
if (view.type === 'checkup') {
|
||||
if (view.type === 'checkup' || view.type === 'translations') {
|
||||
goToTimelineHome();
|
||||
}
|
||||
};
|
||||
@@ -240,6 +255,14 @@ export default function HomePage() {
|
||||
setView({ type: 'checkup' });
|
||||
};
|
||||
|
||||
const openTranslations = () => {
|
||||
if (!isCurator) {
|
||||
openCuratorLogin('translations');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'translations' });
|
||||
};
|
||||
|
||||
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||
const data = await api.getPainting(paintingId);
|
||||
const patch: Partial<Painting> = {
|
||||
@@ -663,7 +686,7 @@ export default function HomePage() {
|
||||
key={view.paintingId}
|
||||
data={view.data}
|
||||
artistPaintings={sortedDetailArtistPaintings}
|
||||
backLabel={view.returnTo.type === 'timeline' ? '← Back to Timeline' : '← Back to Gallery'}
|
||||
backLabel={view.returnTo.type === 'timeline' ? t('backToTimeline') : t('backToGallery')}
|
||||
onBack={() => {
|
||||
if (view.returnTo.type === 'timeline') {
|
||||
goToTimelineHome();
|
||||
@@ -723,6 +746,25 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === 'translations' && (
|
||||
isCurator ? (
|
||||
<TranslationsPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>{t('curatorRequiredTitle')}</h2>
|
||||
<p>{t('curatorRequiredBody')}</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('translations')}>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
{t('backToGalleryBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'checkup' && (
|
||||
isCurator ? (
|
||||
<CheckupPage
|
||||
@@ -782,13 +824,21 @@ export default function HomePage() {
|
||||
/>
|
||||
Show more
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openTranslations}
|
||||
title="Review and publish Russian translations"
|
||||
>
|
||||
{t('translations')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openCheckup}
|
||||
title="Open painting image checkup table"
|
||||
>
|
||||
Checkup
|
||||
{t('checkup')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -796,7 +846,7 @@ export default function HomePage() {
|
||||
onClick={handleCuratorLogout}
|
||||
title="Sign out curator session"
|
||||
>
|
||||
Logout
|
||||
{t('curatorLogout')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -806,12 +856,13 @@ export default function HomePage() {
|
||||
onClick={() => openCuratorLogin()}
|
||||
title="Sign in as curator to use debug tools"
|
||||
>
|
||||
Curator login
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
)}
|
||||
<LocaleSwitcher onLocaleChange={handleLocaleChange} />
|
||||
</div>
|
||||
<h1>Virtual Art Gallery</h1>
|
||||
<p className="site-subtitle">Watch art movements branch forward through time — each flowing from what came before</p>
|
||||
<h1>{t('title')}</h1>
|
||||
<p className="site-subtitle">{t('subtitle')}</p>
|
||||
<CatalogSearchBar
|
||||
onSelectArtist={handleArtistClick}
|
||||
onSelectMovement={handleMovementClick}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -30,6 +30,9 @@
|
||||
"dev:migrate:artist-palette": "node scripts/migrate-artist-palette.js",
|
||||
"dev:migrate:search": "node scripts/migrate-search.js",
|
||||
"dev:migrate:sync-timestamps": "node scripts/migrate-sync-timestamps.js",
|
||||
"dev:migrate:i18n": "node scripts/migrate-i18n.js",
|
||||
"dev:fetch-artist-bios-ru": "node scripts/fetch-artist-bios-ru.js",
|
||||
"dev:import-translations": "node scripts/import-translations.js",
|
||||
"dev:backfill-updated-at": "node scripts/backfill-updated-at.js",
|
||||
"dev:import-painter-palette": "node scripts/import-painter-palette.js",
|
||||
"dev:analyze-painter-palette": "node scripts/analyze-painter-palette.js",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -29,6 +29,7 @@ const CATALOG_TABLES = [
|
||||
'painting_influences',
|
||||
'painting_influence_sources',
|
||||
'painting_annotations',
|
||||
'entity_translations',
|
||||
];
|
||||
|
||||
const NATURAL_KEY_FN = {
|
||||
@@ -40,6 +41,7 @@ const NATURAL_KEY_FN = {
|
||||
painting_influences: (r) => `${r.painting_id}:${r.influenced_by_painting_id}`,
|
||||
painting_influence_sources: (r) => `${r.painting_id}:${r.source_type}:${r.source_painting_id || 0}:${r.source_artist_id || 0}:${r.source_movement_id || 0}`,
|
||||
painting_annotations: (r) => `${r.painting_id}:${String(r.label || '').trim().toLowerCase()}:${r.sort_order}`,
|
||||
entity_translations: (r) => `${r.entity_type}:${r.entity_id}:${r.locale}:${r.field_name}`,
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -14,6 +14,18 @@ const authRoutes = require('./routes/auth');
|
||||
const { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, IMAGE_DIR } = require('./image-service');
|
||||
const { getVersionInfo } = require('./version-info');
|
||||
const { searchCatalog } = require('./search-service');
|
||||
const {
|
||||
resolveLocale,
|
||||
translationStatuses,
|
||||
localizeEras,
|
||||
localizeMovements,
|
||||
localizeArtists,
|
||||
localizePeriods,
|
||||
localizePaintings,
|
||||
localizeAnnotations,
|
||||
localizeInfluenceSources,
|
||||
} = require('./translation-service');
|
||||
const translationRoutes = require('./routes/translations');
|
||||
const { searchGoogleImagesFirst, searchArtistPortraitFirst, searchPaintingImagesMany, searchArtistPortraitMany, fetchImageBuffer, friendlyImageFetchError, pickExt } = require('../scripts/image-fetcher');
|
||||
|
||||
const app = express();
|
||||
@@ -30,6 +42,7 @@ app.use(compression());
|
||||
app.use(express.json({ limit: '20mb' }));
|
||||
app.use(createSessionMiddleware());
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/translations', translationRoutes);
|
||||
app.use(
|
||||
'/images',
|
||||
express.static(IMAGE_DIR, {
|
||||
@@ -119,6 +132,13 @@ function sendCatalogCacheHeaders(res, etagSource) {
|
||||
return etag;
|
||||
}
|
||||
|
||||
function localeContext(req) {
|
||||
return {
|
||||
locale: resolveLocale(req),
|
||||
statuses: translationStatuses(req),
|
||||
};
|
||||
}
|
||||
|
||||
const INFLUENCE_LINKS_EXISTS = `
|
||||
EXISTS (
|
||||
SELECT 1 FROM painting_influence_sources pis
|
||||
@@ -127,6 +147,7 @@ const INFLUENCE_LINKS_EXISTS = `
|
||||
|
||||
const INFLUENCED_BY_SQL = `
|
||||
SELECT
|
||||
pis.id AS influence_source_id,
|
||||
pis.source_type,
|
||||
pis.period_note,
|
||||
pis.period_start_year,
|
||||
@@ -163,6 +184,7 @@ const INFLUENCED_BY_SQL = `
|
||||
|
||||
const INFLUENCED_SQL = `
|
||||
SELECT
|
||||
pis.id AS influence_source_id,
|
||||
pis.notes,
|
||||
pis.source,
|
||||
pis.aspects,
|
||||
@@ -187,9 +209,11 @@ app.get('/api/search', async (req, res) => {
|
||||
const q = typeof req.query.q === 'string' ? req.query.q : '';
|
||||
const limit = parseInt(req.query.limit, 10);
|
||||
const types = typeof req.query.types === 'string' ? req.query.types : undefined;
|
||||
const { locale } = localeContext(req);
|
||||
const result = await searchCatalog(q, {
|
||||
limit: Number.isFinite(limit) ? limit : 20,
|
||||
types,
|
||||
locale,
|
||||
});
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.json(result);
|
||||
@@ -204,9 +228,12 @@ app.get('/api/timeline', async (req, res) => {
|
||||
const { start, end } = req.query;
|
||||
const startYear = parseInt(start) || -3000;
|
||||
const endYear = parseInt(end) || 2100;
|
||||
const { locale, statuses } = localeContext(req);
|
||||
|
||||
const { eras, movements } = await fetchTimelineErasAndMovements(startYear, endYear);
|
||||
res.json({ eras, movements });
|
||||
const localizedEras = await localizeEras(eras, locale, statuses);
|
||||
const localizedMovements = await localizeMovements(movements, locale, statuses);
|
||||
res.json({ locale, eras: localizedEras, movements: localizedMovements });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch timeline' });
|
||||
@@ -219,6 +246,7 @@ app.get('/api/catalog/bootstrap', async (req, res) => {
|
||||
const bounds = await fetchCatalogBounds();
|
||||
const startYear = parseInt(req.query.start, 10) || bounds.min_year || -3000;
|
||||
const endYear = parseInt(req.query.end, 10) || bounds.max_year || 2100;
|
||||
const { locale, statuses } = localeContext(req);
|
||||
|
||||
const etagSource = await catalogBootstrapEtag();
|
||||
const etag = sendCatalogCacheHeaders(res, etagSource);
|
||||
@@ -231,7 +259,17 @@ app.get('/api/catalog/bootstrap', async (req, res) => {
|
||||
fetchTimelineArtists(startYear, endYear),
|
||||
]);
|
||||
|
||||
res.json({ bounds, eras, movements, artists });
|
||||
const localizedEras = await localizeEras(eras, locale, statuses);
|
||||
const localizedMovements = await localizeMovements(movements, locale, statuses);
|
||||
const localizedArtists = await localizeArtists(artists, locale, statuses);
|
||||
|
||||
res.json({
|
||||
locale,
|
||||
bounds,
|
||||
eras: localizedEras,
|
||||
movements: localizedMovements,
|
||||
artists: localizedArtists,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch catalog bootstrap' });
|
||||
@@ -256,6 +294,7 @@ app.get('/api/movements/:id/gallery', async (req, res) => {
|
||||
const paintings = await pool.query(
|
||||
`SELECT p.*,
|
||||
a.name AS artist_name,
|
||||
a.id AS artist_id,
|
||||
p.checkup_checked,
|
||||
p.checkup_fixed,
|
||||
(${INFLUENCE_LINKS_EXISTS}) AS has_influence_links
|
||||
@@ -266,9 +305,14 @@ app.get('/api/movements/:id/gallery', async (req, res) => {
|
||||
[id]
|
||||
);
|
||||
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localizedMovement = (await localizeMovements([movement.rows[0]], locale, statuses))[0];
|
||||
const localizedPaintings = await localizePaintings(paintings.rows, locale, statuses);
|
||||
|
||||
res.json({
|
||||
movement: movement.rows[0],
|
||||
paintings: paintings.rows.map(enrichPaintingRow),
|
||||
locale,
|
||||
movement: localizedMovement,
|
||||
paintings: localizedPaintings.map(enrichPaintingRow),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -281,12 +325,14 @@ app.get('/api/movements/:id/artists', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const result = await pool.query(
|
||||
`SELECT id, name, birth_year, death_year, portrait_path, bio_short
|
||||
`SELECT id, name, birth_year, death_year, portrait_path, bio_short, movement_id
|
||||
FROM artists WHERE movement_id = $1
|
||||
ORDER BY birth_year`,
|
||||
[id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localized = await localizeArtists(result.rows, locale, statuses);
|
||||
res.json(localized);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch artists' });
|
||||
@@ -322,7 +368,9 @@ app.get('/api/artists', async (req, res) => {
|
||||
|
||||
query += ' ORDER BY a.birth_year';
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localized = await localizeArtists(result.rows, locale, statuses);
|
||||
res.json(localized);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Failed to fetch artists' });
|
||||
@@ -401,9 +449,14 @@ app.get('/api/artists/:id/navigation', async (req, res) => {
|
||||
),
|
||||
]);
|
||||
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const locPred = await localizeArtists(predecessors.rows, locale, statuses);
|
||||
const locSucc = await localizeArtists(successors.rows, locale, statuses);
|
||||
|
||||
res.json({
|
||||
predecessors: groupByMovement(predecessors.rows),
|
||||
successors: groupByMovement(successors.rows),
|
||||
locale,
|
||||
predecessors: groupByMovement(locPred),
|
||||
successors: groupByMovement(locSucc),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -645,10 +698,16 @@ app.get('/api/artists/:id', async (req, res) => {
|
||||
return res.status(404).json({ error: 'Artist not found' });
|
||||
}
|
||||
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localizedArtist = (await localizeArtists([artist.rows[0]], locale, statuses))[0];
|
||||
const localizedPeriods = await localizePeriods(periods.rows, locale, statuses);
|
||||
const localizedPaintings = await localizePaintings(paintings.rows, locale, statuses);
|
||||
|
||||
res.json({
|
||||
artist: enrichArtistRow(artist.rows[0]),
|
||||
periods: periods.rows,
|
||||
paintings: paintings.rows.map(enrichPaintingRow),
|
||||
locale,
|
||||
artist: enrichArtistRow(localizedArtist),
|
||||
periods: localizedPeriods,
|
||||
paintings: localizedPaintings.map(enrichPaintingRow),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -803,11 +862,22 @@ app.get('/api/paintings/:id', async (req, res) => {
|
||||
),
|
||||
]);
|
||||
|
||||
const { locale, statuses } = localeContext(req);
|
||||
const localizedPainting = (await localizePaintings([painting.rows[0]], locale, statuses))[0];
|
||||
const localizedInfluencedBy = await localizeInfluenceSources(influencedBy.rows, locale, statuses);
|
||||
const localizedInfluenced = await localizeInfluenceSources(
|
||||
influenced.rows.map(enrichPaintingRow),
|
||||
locale,
|
||||
statuses,
|
||||
);
|
||||
const localizedAnnotations = await localizeAnnotations(annotations.rows, locale, statuses);
|
||||
|
||||
res.json({
|
||||
painting: enrichPaintingRow(painting.rows[0]),
|
||||
influencedBy: influencedBy.rows,
|
||||
influenced: influenced.rows.map(enrichPaintingRow),
|
||||
annotations: annotations.rows,
|
||||
locale,
|
||||
painting: enrichPaintingRow(localizedPainting),
|
||||
influencedBy: localizedInfluencedBy,
|
||||
influenced: localizedInfluenced,
|
||||
annotations: localizedAnnotations,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -14,6 +14,7 @@ const INCREMENTAL_MIGRATIONS = [
|
||||
'migrate-perf-indexes.sql',
|
||||
'migrate-search.sql',
|
||||
'migrate-sync-timestamps.sql',
|
||||
'migrate-i18n.sql',
|
||||
];
|
||||
|
||||
async function bootstrapCurator() {
|
||||
|
||||
@@ -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
@@ -2,6 +2,17 @@ const pool = require('./db');
|
||||
|
||||
const VALID_TYPES = new Set(['artist', 'painting', 'movement']);
|
||||
|
||||
const TRANSLATION_MATCH = (entityType, entityAlias, fieldName, localeParam) => `
|
||||
EXISTS (
|
||||
SELECT 1 FROM entity_translations t
|
||||
WHERE t.entity_type = '${entityType}'
|
||||
AND t.entity_id = ${entityAlias}.id
|
||||
AND t.locale = ${localeParam}
|
||||
AND t.field_name = '${fieldName}'
|
||||
AND t.status IN ('published', 'reviewed')
|
||||
AND t.value ILIKE $1
|
||||
)`;
|
||||
|
||||
function parseTypes(typesParam) {
|
||||
if (!typesParam || typeof typesParam !== 'string') {
|
||||
return ['artist', 'movement', 'painting'];
|
||||
@@ -13,10 +24,18 @@ function parseTypes(typesParam) {
|
||||
return parsed.length > 0 ? parsed : ['artist', 'movement', 'painting'];
|
||||
}
|
||||
|
||||
async function searchArtists(pattern, prefixPattern, perTypeLimit) {
|
||||
async function searchArtists(pattern, prefixPattern, perTypeLimit, locale = 'en') {
|
||||
const localeParam = locale !== 'en' ? '$4' : null;
|
||||
const translationClause = locale !== 'en'
|
||||
? ` OR ${TRANSLATION_MATCH('artist', 'a', 'name', localeParam)}`
|
||||
: '';
|
||||
const params = locale !== 'en'
|
||||
? [pattern, prefixPattern, perTypeLimit, locale]
|
||||
: [pattern, prefixPattern, perTypeLimit];
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT a.id, a.name, a.birth_year, a.death_year,
|
||||
a.portrait_path, a.portrait_thumb_path,
|
||||
a.portrait_path, a.portrait_thumb_path, a.movement_id,
|
||||
m.name AS movement_name,
|
||||
CASE WHEN a.name ILIKE $2 THEN 0 ELSE 1 END AS rank
|
||||
FROM artists a
|
||||
@@ -24,49 +43,140 @@ async function searchArtists(pattern, prefixPattern, perTypeLimit) {
|
||||
WHERE a.name ILIKE $1
|
||||
OR a.wikipedia_title ILIKE $1
|
||||
OR m.name ILIKE $1
|
||||
${translationClause}
|
||||
ORDER BY rank, a.name
|
||||
LIMIT $3`,
|
||||
[pattern, prefixPattern, perTypeLimit]
|
||||
params,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
type: 'artist',
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
birth_year: row.birth_year,
|
||||
death_year: row.death_year,
|
||||
movement_name: row.movement_name,
|
||||
portrait_path: row.portrait_path,
|
||||
portrait_thumb_path: row.portrait_thumb_path,
|
||||
rank: row.rank,
|
||||
}));
|
||||
|
||||
if (locale === 'en') {
|
||||
return rows.map((row) => ({
|
||||
type: 'artist',
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
birth_year: row.birth_year,
|
||||
death_year: row.death_year,
|
||||
movement_name: row.movement_name,
|
||||
portrait_path: row.portrait_path,
|
||||
portrait_thumb_path: row.portrait_thumb_path,
|
||||
rank: row.rank,
|
||||
}));
|
||||
}
|
||||
|
||||
const {
|
||||
loadTranslationsByType,
|
||||
PUBLIC_STATUSES,
|
||||
} = require('./translation-service');
|
||||
|
||||
const artistIds = rows.map((r) => r.id);
|
||||
const movementIds = rows.map((r) => r.movement_id).filter(Boolean);
|
||||
const artistMap = await loadTranslationsByType('artist', artistIds, locale, ['name'], PUBLIC_STATUSES);
|
||||
const movementMap = movementIds.length
|
||||
? await loadTranslationsByType('movement', movementIds, locale, ['name'], PUBLIC_STATUSES)
|
||||
: new Map();
|
||||
|
||||
const localized = rows.map((row) => {
|
||||
const name = artistMap.get(`${row.id}:name`) || row.name;
|
||||
let movement_name = row.movement_name;
|
||||
if (row.movement_id && movementMap.has(`${row.movement_id}:name`)) {
|
||||
movement_name = movementMap.get(`${row.movement_id}:name`);
|
||||
}
|
||||
return {
|
||||
type: 'artist',
|
||||
id: row.id,
|
||||
name,
|
||||
birth_year: row.birth_year,
|
||||
death_year: row.death_year,
|
||||
movement_name,
|
||||
portrait_path: row.portrait_path,
|
||||
portrait_thumb_path: row.portrait_thumb_path,
|
||||
rank: row.rank,
|
||||
};
|
||||
});
|
||||
|
||||
return localized;
|
||||
}
|
||||
|
||||
async function searchMovements(pattern, prefixPattern, perTypeLimit) {
|
||||
async function searchMovements(pattern, prefixPattern, perTypeLimit, locale = 'en') {
|
||||
const localeParam = locale !== 'en' ? '$4' : null;
|
||||
const movementTrans = locale !== 'en'
|
||||
? ` OR ${TRANSLATION_MATCH('movement', 'm', 'name', localeParam)}`
|
||||
: '';
|
||||
const eraTrans = locale !== 'en'
|
||||
? ` OR ${TRANSLATION_MATCH('era', 'e', 'name', localeParam)}`
|
||||
: '';
|
||||
const params = locale !== 'en'
|
||||
? [pattern, prefixPattern, perTypeLimit, locale]
|
||||
: [pattern, prefixPattern, perTypeLimit];
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT m.id, m.name, m.color, m.start_year, m.end_year,
|
||||
`SELECT m.id, m.name, m.color, m.start_year, m.end_year, m.era_id,
|
||||
e.name AS era_name,
|
||||
CASE WHEN m.name ILIKE $2 THEN 0 ELSE 1 END AS rank
|
||||
FROM art_movements m
|
||||
LEFT JOIN historical_eras e ON m.era_id = e.id
|
||||
WHERE m.name ILIKE $1
|
||||
OR e.name ILIKE $1
|
||||
${movementTrans}
|
||||
${eraTrans}
|
||||
ORDER BY rank, m.name
|
||||
LIMIT $3`,
|
||||
[pattern, prefixPattern, perTypeLimit]
|
||||
params,
|
||||
);
|
||||
|
||||
if (locale === 'en') {
|
||||
return rows.map((row) => ({
|
||||
type: 'movement',
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
color: row.color,
|
||||
start_year: row.start_year,
|
||||
end_year: row.end_year,
|
||||
era_name: row.era_name,
|
||||
rank: row.rank,
|
||||
}));
|
||||
}
|
||||
|
||||
const { loadTranslationsByType, PUBLIC_STATUSES } = require('./translation-service');
|
||||
const movementMap = await loadTranslationsByType(
|
||||
'movement',
|
||||
rows.map((r) => r.id),
|
||||
locale,
|
||||
['name'],
|
||||
PUBLIC_STATUSES,
|
||||
);
|
||||
const eraMap = await loadTranslationsByType(
|
||||
'era',
|
||||
rows.map((r) => r.era_id).filter(Boolean),
|
||||
locale,
|
||||
['name'],
|
||||
PUBLIC_STATUSES,
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
type: 'movement',
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
name: movementMap.get(`${row.id}:name`) || row.name,
|
||||
color: row.color,
|
||||
start_year: row.start_year,
|
||||
end_year: row.end_year,
|
||||
era_name: row.era_name,
|
||||
era_name: (row.era_id && eraMap.get(`${row.era_id}:name`)) || row.era_name,
|
||||
rank: row.rank,
|
||||
}));
|
||||
}
|
||||
|
||||
async function searchPaintings(pattern, prefixPattern, perTypeLimit) {
|
||||
async function searchPaintings(pattern, prefixPattern, perTypeLimit, locale = 'en') {
|
||||
const localeParam = locale !== 'en' ? '$4' : null;
|
||||
const paintingTrans = locale !== 'en'
|
||||
? ` OR ${TRANSLATION_MATCH('painting', 'p', 'title', localeParam)}`
|
||||
: '';
|
||||
const artistTrans = locale !== 'en'
|
||||
? ` OR ${TRANSLATION_MATCH('artist', 'a', 'name', localeParam)}`
|
||||
: '';
|
||||
const params = locale !== 'en'
|
||||
? [pattern, prefixPattern, perTypeLimit, locale]
|
||||
: [pattern, prefixPattern, perTypeLimit];
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT p.id, p.title, p.year, p.thumbnail_path, p.image_path,
|
||||
a.id AS artist_id, a.name AS artist_name,
|
||||
@@ -80,17 +190,51 @@ async function searchPaintings(pattern, prefixPattern, perTypeLimit) {
|
||||
OR a.name ILIKE $1
|
||||
OR m.name ILIKE $1
|
||||
OR CAST(p.year AS TEXT) ILIKE $1
|
||||
${paintingTrans}
|
||||
${artistTrans}
|
||||
ORDER BY rank, p.year NULLS LAST, p.title
|
||||
LIMIT $3`,
|
||||
[pattern, prefixPattern, perTypeLimit]
|
||||
params,
|
||||
);
|
||||
|
||||
if (locale === 'en') {
|
||||
return rows.map((row) => ({
|
||||
type: 'painting',
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
year: row.year,
|
||||
artist_id: row.artist_id,
|
||||
artist_name: row.artist_name,
|
||||
movement_name: row.movement_name,
|
||||
thumbnail_path: row.thumbnail_path,
|
||||
image_path: row.image_path,
|
||||
rank: row.rank,
|
||||
}));
|
||||
}
|
||||
|
||||
const { loadTranslationsByType, PUBLIC_STATUSES } = require('./translation-service');
|
||||
const paintingMap = await loadTranslationsByType(
|
||||
'painting',
|
||||
rows.map((r) => r.id),
|
||||
locale,
|
||||
['title'],
|
||||
PUBLIC_STATUSES,
|
||||
);
|
||||
const artistMap = await loadTranslationsByType(
|
||||
'artist',
|
||||
rows.map((r) => r.artist_id),
|
||||
locale,
|
||||
['name'],
|
||||
PUBLIC_STATUSES,
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
type: 'painting',
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
title: paintingMap.get(`${row.id}:title`) || row.title,
|
||||
year: row.year,
|
||||
artist_id: row.artist_id,
|
||||
artist_name: row.artist_name,
|
||||
artist_name: artistMap.get(`${row.artist_id}:name`) || row.artist_name,
|
||||
movement_name: row.movement_name,
|
||||
thumbnail_path: row.thumbnail_path,
|
||||
image_path: row.image_path,
|
||||
@@ -101,9 +245,10 @@ async function searchPaintings(pattern, prefixPattern, perTypeLimit) {
|
||||
async function searchCatalog(query, options = {}) {
|
||||
const q = String(query || '').trim();
|
||||
if (q.length < 2) {
|
||||
return { q, results: [] };
|
||||
return { q, locale: options.locale || 'en', results: [] };
|
||||
}
|
||||
|
||||
const locale = options.locale || 'en';
|
||||
const limit = Math.min(50, Math.max(1, Number(options.limit) || 20));
|
||||
const types = parseTypes(options.types);
|
||||
const perTypeLimit = Math.max(1, Math.ceil(limit / types.length));
|
||||
@@ -111,9 +256,9 @@ async function searchCatalog(query, options = {}) {
|
||||
const prefixPattern = `${q}%`;
|
||||
|
||||
const tasks = [];
|
||||
if (types.includes('artist')) tasks.push(searchArtists(pattern, prefixPattern, perTypeLimit));
|
||||
if (types.includes('movement')) tasks.push(searchMovements(pattern, prefixPattern, perTypeLimit));
|
||||
if (types.includes('painting')) tasks.push(searchPaintings(pattern, prefixPattern, perTypeLimit));
|
||||
if (types.includes('artist')) tasks.push(searchArtists(pattern, prefixPattern, perTypeLimit, locale));
|
||||
if (types.includes('movement')) tasks.push(searchMovements(pattern, prefixPattern, perTypeLimit, locale));
|
||||
if (types.includes('painting')) tasks.push(searchPaintings(pattern, prefixPattern, perTypeLimit, locale));
|
||||
|
||||
const groups = await Promise.all(tasks);
|
||||
const merged = groups
|
||||
@@ -129,7 +274,7 @@ async function searchCatalog(query, options = {}) {
|
||||
.slice(0, limit)
|
||||
.map(({ rank: _rank, ...rest }) => rest);
|
||||
|
||||
return { q, results: merged };
|
||||
return { q, locale, results: merged };
|
||||
}
|
||||
|
||||
module.exports = { searchCatalog };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user