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
+13 -11
View File
@@ -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));
+9 -6
View File
@@ -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>
+26
View File
@@ -0,0 +1,26 @@
.locale-switcher {
display: inline-flex;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
overflow: hidden;
}
.locale-switcher-btn {
background: transparent;
color: inherit;
border: none;
padding: 0.25rem 0.55rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
opacity: 0.75;
}
.locale-switcher-btn-active {
background: rgba(255, 255, 255, 0.15);
opacity: 1;
}
.locale-switcher-btn:hover {
opacity: 1;
}
+43
View File
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { setApiLocale } from '../api/client';
import { writeStoredLocale, type AppLocale } from '../utils/localeStorage';
import './LocaleSwitcher.css';
interface Props {
onLocaleChange?: (locale: AppLocale) => void;
}
export default function LocaleSwitcher({ onLocaleChange }: Props) {
const { t } = useTranslation('common');
const current = (i18n.language === 'ru' ? 'ru' : 'en') as AppLocale;
const setLocale = (locale: AppLocale) => {
if (locale === current) return;
void i18n.changeLanguage(locale);
writeStoredLocale(locale);
setApiLocale(locale);
onLocaleChange?.(locale);
};
return (
<div className="locale-switcher" role="group" aria-label={t('language')}>
<button
type="button"
className={`locale-switcher-btn${current === 'en' ? ' locale-switcher-btn-active' : ''}`}
onClick={() => setLocale('en')}
aria-pressed={current === 'en'}
>
{t('localeEn')}
</button>
<button
type="button"
className={`locale-switcher-btn${current === 'ru' ? ' locale-switcher-btn-active' : ''}`}
onClick={() => setLocale('ru')}
aria-pressed={current === 'ru'}
>
{t('localeRu')}
</button>
</div>
);
}
+12 -9
View File
@@ -1,13 +1,14 @@
import { useRef } from 'react';
import { 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>
+7 -5
View File
@@ -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) => (