From ca58c43648e95a2f70af74152b6a13b45683dcd1 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Wed, 15 Jul 2026 11:51:46 +0300 Subject: [PATCH] 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 --- Documentation/API.md | 28 ++ Documentation/Plans.md | 2 +- Documentation/harmonize-dev-prod.md | 2 +- Documentation/i18n-russian.md | 105 ++++++ Documentation/setup.md | 5 +- client/index.html | 2 +- client/package-lock.json | 77 +++- client/package.json | 2 + client/src/api/client.ts | 113 +++++- client/src/components/CatalogSearchBar.tsx | 24 +- client/src/components/CuratorLoginModal.tsx | 15 +- client/src/components/LocaleSwitcher.css | 26 ++ client/src/components/LocaleSwitcher.tsx | 43 +++ client/src/components/PaintingAnnotations.tsx | 21 +- client/src/components/PaintingDetail.tsx | 12 +- client/src/i18n/index.ts | 61 ++++ client/src/locales/en/annotations.json | 10 + client/src/locales/en/bio.json | 6 + client/src/locales/en/common.json | 13 + client/src/locales/en/debug.json | 14 + client/src/locales/en/gallery.json | 14 + client/src/locales/en/home.json | 20 ++ client/src/locales/en/painting.json | 11 + client/src/locales/en/search.json | 10 + client/src/locales/en/translations.json | 20 ++ client/src/locales/ru/annotations.json | 10 + client/src/locales/ru/bio.json | 6 + client/src/locales/ru/common.json | 13 + client/src/locales/ru/debug.json | 14 + client/src/locales/ru/gallery.json | 14 + client/src/locales/ru/home.json | 20 ++ client/src/locales/ru/painting.json | 11 + client/src/locales/ru/search.json | 10 + client/src/locales/ru/translations.json | 20 ++ client/src/main.tsx | 1 + client/src/pages/HomePage.tsx | 71 +++- client/src/pages/TranslationsPage.css | 103 ++++++ client/src/pages/TranslationsPage.tsx | 171 +++++++++ client/src/utils/localeStorage.ts | 29 ++ client/tsconfig.app.json | 1 + db/migrate-i18n.sql | 36 ++ package.json | 3 + scripts/fetch-artist-bios-ru.js | 202 +++++++++++ scripts/harmonize-db.js | 2 + scripts/import-translations.js | 78 ++++ scripts/migrate-i18n.js | 17 + server/index.js | 102 +++++- server/migrate.js | 1 + server/routes/translations.js | 221 ++++++++++++ server/search-service.js | 201 +++++++++-- server/translation-service.js | 340 ++++++++++++++++++ 51 files changed, 2252 insertions(+), 101 deletions(-) create mode 100644 Documentation/i18n-russian.md create mode 100644 client/src/components/LocaleSwitcher.css create mode 100644 client/src/components/LocaleSwitcher.tsx create mode 100644 client/src/i18n/index.ts create mode 100644 client/src/locales/en/annotations.json create mode 100644 client/src/locales/en/bio.json create mode 100644 client/src/locales/en/common.json create mode 100644 client/src/locales/en/debug.json create mode 100644 client/src/locales/en/gallery.json create mode 100644 client/src/locales/en/home.json create mode 100644 client/src/locales/en/painting.json create mode 100644 client/src/locales/en/search.json create mode 100644 client/src/locales/en/translations.json create mode 100644 client/src/locales/ru/annotations.json create mode 100644 client/src/locales/ru/bio.json create mode 100644 client/src/locales/ru/common.json create mode 100644 client/src/locales/ru/debug.json create mode 100644 client/src/locales/ru/gallery.json create mode 100644 client/src/locales/ru/home.json create mode 100644 client/src/locales/ru/painting.json create mode 100644 client/src/locales/ru/search.json create mode 100644 client/src/locales/ru/translations.json create mode 100644 client/src/pages/TranslationsPage.css create mode 100644 client/src/pages/TranslationsPage.tsx create mode 100644 client/src/utils/localeStorage.ts create mode 100644 db/migrate-i18n.sql create mode 100644 scripts/fetch-artist-bios-ru.js create mode 100644 scripts/import-translations.js create mode 100644 scripts/migrate-i18n.js create mode 100644 server/routes/translations.js create mode 100644 server/translation-service.js diff --git a/Documentation/API.md b/Documentation/API.md index ee8166c..c3f9aa8 100644 --- a/Documentation/API.md +++ b/Documentation/API.md @@ -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. diff --git a/Documentation/Plans.md b/Documentation/Plans.md index 9565cfc..9f46ba4 100644 --- a/Documentation/Plans.md +++ b/Documentation/Plans.md @@ -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) diff --git a/Documentation/harmonize-dev-prod.md b/Documentation/harmonize-dev-prod.md index 1408a92..b11e9eb 100644 --- a/Documentation/harmonize-dev-prod.md +++ b/Documentation/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`** --- diff --git a/Documentation/i18n-russian.md b/Documentation/i18n-russian.md new file mode 100644 index 0000000..1eac0f9 --- /dev/null +++ b/Documentation/i18n-russian.md @@ -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). diff --git a/Documentation/setup.md b/Documentation/setup.md index 0168764..8a6f000 100644 --- a/Documentation/setup.md +++ b/Documentation/setup.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: diff --git a/client/index.html b/client/index.html index 3269aca..10f67d2 100644 --- a/client/index.html +++ b/client/index.html @@ -4,7 +4,7 @@ - client + Virtual Art Gallery
diff --git a/client/package-lock.json b/client/package-lock.json index 6f61079..69b18fc 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index 2914679..228dfeb 100644 --- a/client/package.json +++ b/client/package.json @@ -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" }, diff --git a/client/src/api/client.ts b/client/src/api/client.ts index d3c382a..b98d718 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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(`${API}/catalog/bootstrap${qs ? `?${qs}` : ''}`); + return fetchJson(localizedPath(`${API}/catalog/bootstrap`, params)); }, - getTimeline: (start: number, end: number) => - fetchJson(`${API}/timeline?start=${start}&end=${end}`), + getTimeline: (start: number, end: number) => { + const params = new URLSearchParams({ start: String(start), end: String(end) }); + return fetchJson(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(`${API}/search?${params}`); + return fetchJson(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(`${API}/artists?${params}`); + return fetchJson(localizedPath(`${API}/artists`, params)); }, /** Lightweight artist rows for the timeline (no biography text). */ - getTimelineArtists: () => fetchJson(`${API}/artists?timeline=1`), + getTimelineArtists: () => fetchJson(localizedPath(`${API}/artists`, new URLSearchParams({ timeline: '1' }))), - getArtist: (id: number) => fetchJson(`${API}/artists/${id}`), + getArtist: (id: number) => fetchJson(localizedPath(`${API}/artists/${id}`)), - getMovementGallery: (id: number) => fetchJson(`${API}/movements/${id}/gallery`), + getMovementGallery: (id: number) => fetchJson(localizedPath(`${API}/movements/${id}/gallery`)), getArtistNavigation: (id: number) => - fetchJson(`${API}/artists/${id}/navigation`), + fetchJson(localizedPath(`${API}/artists/${id}/navigation`)), - getPainting: (id: number) => fetchJson(`${API}/paintings/${id}`), + getPainting: (id: number) => fetchJson(localizedPath(`${API}/paintings/${id}`)), getPaintingDebugImageSearch: (id: number) => fetchJson(`${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 }>( + `${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(`${API}/translations/${encodeURIComponent(entityType)}/${id}`), + + saveEntityTranslation: ( + entityType: string, + id: number, + payload: { locale: string; fields: Record; 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; + 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 } diff --git a/client/src/components/CatalogSearchBar.tsx b/client/src/components/CatalogSearchBar.tsx index 2cd1332..0e468ed 100644 --- a/client/src/components/CatalogSearchBar.tsx +++ b/client/src/components/CatalogSearchBar.tsx @@ -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 = { - 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 = { + artist: t('groupArtist'), + movement: t('groupMovement'), + painting: t('groupPainting'), + }; const listboxId = useId(); const rootRef = useRef(null); const inputRef = useRef(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 (
= 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 &&

{error}

} {!error && !loading && flatResults.length === 0 && ( -

No matches found.

+

{t('noMatches')}

)} {grouped.map((group) => (
-

{TYPE_LABELS[group.type]}

+

{typeLabels[group.type]}

    {group.items.map((item) => { const flatIndex = flatResults.findIndex((r) => resultKey(r) === resultKey(item)); diff --git a/client/src/components/CuratorLoginModal.tsx b/client/src/components/CuratorLoginModal.tsx index 7943941..ee57f33 100644 --- a/client/src/components/CuratorLoginModal.tsx +++ b/client/src/components/CuratorLoginModal.tsx @@ -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(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()} > -

    Curator login

    +

    {t('loginTitle')}

    Debug tools and catalog edits require a curator account.