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
+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 }
|
||||
|
||||
Reference in New Issue
Block a user