Add Russian i18n with DB translations, locale API, and curator review UI.

UI chrome via react-i18next, catalog text in entity_translations with ru.wikipedia seeding, locale-aware search, and Translations page for publish workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-15 11:51:46 +03:00
co-authored by Cursor
parent f247b418d8
commit ca58c43648
51 changed files with 2252 additions and 101 deletions
+102 -11
View File
@@ -9,9 +9,31 @@ import type {
ArtistNavigation,
CatalogSearchResponse,
} from '../types';
import { readStoredLocale, type AppLocale } from '../utils/localeStorage';
const API = '/api';
let apiLocale: AppLocale = readStoredLocale();
export function setApiLocale(locale: AppLocale) {
apiLocale = locale;
}
export function getApiLocale(): AppLocale {
return apiLocale;
}
function localeParams(base?: URLSearchParams): URLSearchParams {
const params = base ?? new URLSearchParams();
if (apiLocale !== 'en') params.set('locale', apiLocale);
return params;
}
function localizedPath(path: string, params?: URLSearchParams): string {
const qs = localeParams(params).toString();
return qs ? `${path}?${qs}` : path;
}
const fetchCredentials: RequestInit = { credentials: 'include' };
export type AuthRole = 'user' | 'curator';
@@ -293,18 +315,19 @@ export const api = {
const params = new URLSearchParams();
if (start != null) params.set('start', String(start));
if (end != null) params.set('end', String(end));
const qs = params.toString();
return fetchJson<CatalogBootstrap>(`${API}/catalog/bootstrap${qs ? `?${qs}` : ''}`);
return fetchJson<CatalogBootstrap>(localizedPath(`${API}/catalog/bootstrap`, params));
},
getTimeline: (start: number, end: number) =>
fetchJson<TimelineData>(`${API}/timeline?start=${start}&end=${end}`),
getTimeline: (start: number, end: number) => {
const params = new URLSearchParams({ start: String(start), end: String(end) });
return fetchJson<TimelineData>(localizedPath(`${API}/timeline`, params));
},
search: (q: string, options?: { limit?: number; types?: string }) => {
const params = new URLSearchParams({ q });
if (options?.limit != null) params.set('limit', String(options.limit));
if (options?.types) params.set('types', options.types);
return fetchJson<CatalogSearchResponse>(`${API}/search?${params}`);
return fetchJson<CatalogSearchResponse>(localizedPath(`${API}/search`, params));
},
getArtists: (start?: number, end?: number, movementId?: number) => {
@@ -312,20 +335,20 @@ export const api = {
if (start != null) params.set('start', String(start));
if (end != null) params.set('end', String(end));
if (movementId != null) params.set('movement_id', String(movementId));
return fetchJson<Artist[]>(`${API}/artists?${params}`);
return fetchJson<Artist[]>(localizedPath(`${API}/artists`, params));
},
/** Lightweight artist rows for the timeline (no biography text). */
getTimelineArtists: () => fetchJson<Artist[]>(`${API}/artists?timeline=1`),
getTimelineArtists: () => fetchJson<Artist[]>(localizedPath(`${API}/artists`, new URLSearchParams({ timeline: '1' }))),
getArtist: (id: number) => fetchJson<ArtistDetail>(`${API}/artists/${id}`),
getArtist: (id: number) => fetchJson<ArtistDetail>(localizedPath(`${API}/artists/${id}`)),
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(`${API}/movements/${id}/gallery`),
getMovementGallery: (id: number) => fetchJson<MovementGalleryDetail>(localizedPath(`${API}/movements/${id}/gallery`)),
getArtistNavigation: (id: number) =>
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
fetchJson<ArtistNavigation>(localizedPath(`${API}/artists/${id}/navigation`)),
getPainting: (id: number) => fetchJson<PaintingDetail>(`${API}/paintings/${id}`),
getPainting: (id: number) => fetchJson<PaintingDetail>(localizedPath(`${API}/paintings/${id}`)),
getPaintingDebugImageSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
@@ -448,9 +471,77 @@ export const api = {
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}),
getTranslationCoverage: (locale = 'ru') =>
fetchJson<{ locale: string; coverage: Record<string, number> }>(
`${API}/translations/coverage?locale=${encodeURIComponent(locale)}`
),
getTranslationWorklist: (entityType: string, locale = 'ru') =>
fetchJson<{ items: TranslationWorklistItem[] }>(
`${API}/translations/worklist/${encodeURIComponent(entityType)}?locale=${encodeURIComponent(locale)}`
),
getEntityTranslation: (entityType: string, id: number) =>
fetchJson<TranslationDetail>(`${API}/translations/${encodeURIComponent(entityType)}/${id}`),
saveEntityTranslation: (
entityType: string,
id: number,
payload: { locale: string; fields: Record<string, string>; status?: string }
) =>
fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}`, {
...fetchCredentials,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Save failed: ${res.status}`);
}
return res.json();
}),
publishEntityTranslation: (entityType: string, id: number, locale = 'ru') =>
fetch(`${API}/translations/${encodeURIComponent(entityType)}/${id}/publish`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Publish failed: ${res.status}`);
}
return res.json();
}),
preloadArtistImages,
};
export interface TranslationWorklistItem {
entityId: number;
label: string;
publishedCount: number;
draftCount: number;
missingFields: string[];
}
export interface TranslationDetail {
entityType: string;
entityId: number;
canonical: Record<string, unknown>;
translatableFields: string[];
translations: Array<{
locale: string;
field_name: string;
value: string;
status: string;
source: string | null;
updated_at: string;
}>;
}
export function debugImageProxyUrl(
imageUrl: string,
context?: { searchUrl?: string; source?: string }