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
@@ -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) => (
|
||||
|
||||
Reference in New Issue
Block a user