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
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>client</title>
|
||||
<title>Virtual Art Gallery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+76
-1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
+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 }
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import { readStoredLocale, writeStoredLocale } from '../utils/localeStorage';
|
||||
|
||||
import enCommon from '../locales/en/common.json';
|
||||
import enHome from '../locales/en/home.json';
|
||||
import enSearch from '../locales/en/search.json';
|
||||
import enGallery from '../locales/en/gallery.json';
|
||||
import enPainting from '../locales/en/painting.json';
|
||||
import enBio from '../locales/en/bio.json';
|
||||
import enAnnotations from '../locales/en/annotations.json';
|
||||
import enDebug from '../locales/en/debug.json';
|
||||
import enTranslations from '../locales/en/translations.json';
|
||||
|
||||
import ruCommon from '../locales/ru/common.json';
|
||||
import ruHome from '../locales/ru/home.json';
|
||||
import ruSearch from '../locales/ru/search.json';
|
||||
import ruGallery from '../locales/ru/gallery.json';
|
||||
import ruPainting from '../locales/ru/painting.json';
|
||||
import ruBio from '../locales/ru/bio.json';
|
||||
import ruAnnotations from '../locales/ru/annotations.json';
|
||||
import ruDebug from '../locales/ru/debug.json';
|
||||
import ruTranslations from '../locales/ru/translations.json';
|
||||
|
||||
const initialLocale = readStoredLocale();
|
||||
writeStoredLocale(initialLocale);
|
||||
|
||||
void i18n.use(initReactI18next).init({
|
||||
lng: initialLocale,
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: ['en', 'ru'],
|
||||
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations'],
|
||||
defaultNS: 'common',
|
||||
resources: {
|
||||
en: {
|
||||
common: enCommon,
|
||||
home: enHome,
|
||||
search: enSearch,
|
||||
gallery: enGallery,
|
||||
painting: enPainting,
|
||||
bio: enBio,
|
||||
annotations: enAnnotations,
|
||||
debug: enDebug,
|
||||
translations: enTranslations,
|
||||
},
|
||||
ru: {
|
||||
common: ruCommon,
|
||||
home: ruHome,
|
||||
search: ruSearch,
|
||||
gallery: ruGallery,
|
||||
painting: ruPainting,
|
||||
bio: ruBio,
|
||||
annotations: ruAnnotations,
|
||||
debug: ruDebug,
|
||||
translations: ruTranslations,
|
||||
},
|
||||
},
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Art history notes",
|
||||
"readSource": "Read source",
|
||||
"categoryTechnique": "Technique",
|
||||
"categoryComposition": "Composition",
|
||||
"categorySubject": "Subject",
|
||||
"categorySymbolism": "Symbolism",
|
||||
"categoryHistorical": "Historical context",
|
||||
"categoryOther": "Notes"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enterGallery": "Enter Gallery",
|
||||
"noBio": "No biography available yet.",
|
||||
"wikipediaSource": "Text adapted from Wikipedia",
|
||||
"backToTimeline": "← Back to Timeline"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"loading": "Loading…",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"error": "Something went wrong",
|
||||
"localeEn": "EN",
|
||||
"localeRu": "RU",
|
||||
"language": "Language"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"checked": "Checked",
|
||||
"fixIt": "Fix it",
|
||||
"more": "More",
|
||||
"clear": "Clear",
|
||||
"upload": "Upload",
|
||||
"searching": "Searching…",
|
||||
"saving": "Saving…",
|
||||
"loginTitle": "Curator login",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"signIn": "Sign in",
|
||||
"loginFailed": "Login failed"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"backToTimeline": "← Back to Timeline",
|
||||
"exitToTimeline": "Exit to Timeline",
|
||||
"loadingGallery": "Loading gallery…",
|
||||
"instructionsTitle": "Gallery controls",
|
||||
"instructionMove": "Drag to look around",
|
||||
"instructionZoom": "Scroll to zoom",
|
||||
"instructionClick": "Click a painting to view details",
|
||||
"wingOf": "Wing {{current}} of {{total}}",
|
||||
"exitConfirmTitle": "Leave gallery?",
|
||||
"exitConfirmBody": "Return to the timeline or stay in the hall.",
|
||||
"stay": "Stay",
|
||||
"exit": "Exit"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Virtual Art Gallery",
|
||||
"subtitle": "Explore art history on an interactive timeline — enter 3D galleries and discover connections between artists and masterpieces.",
|
||||
"loadingArtHistory": "Loading art history…",
|
||||
"loadingPortraits": "Loading portraits…",
|
||||
"openingArtistGallery": "Opening artist gallery…",
|
||||
"openingMovementGallery": "Opening movement gallery…",
|
||||
"backToTimeline": "← Back to Timeline",
|
||||
"backToGallery": "← Back to Gallery",
|
||||
"curatorLogin": "Curator login",
|
||||
"curatorLogout": "Log out",
|
||||
"signedInAs": "Signed in as {{username}}",
|
||||
"debugMode": "Debug mode",
|
||||
"showMoreDebug": "Show more (debug)",
|
||||
"checkup": "Checkup",
|
||||
"translations": "Translations",
|
||||
"curatorRequiredTitle": "Curator access required",
|
||||
"curatorRequiredBody": "Sign in as a curator to use this tool.",
|
||||
"backToGalleryBtn": "Back to gallery"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"aboutArtist": "About {{name}}",
|
||||
"influencedBy": "Influenced By",
|
||||
"influenced": "Influenced",
|
||||
"noInfluences": "No documented influences for this work.",
|
||||
"noInfluencedWorks": "No documented works influenced by this painting yet.",
|
||||
"catalogPosition": "Catalog position",
|
||||
"lightboxHint": "Click anywhere to close",
|
||||
"prevPainting": "Previous painting",
|
||||
"nextPainting": "Next painting"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"label": "Search",
|
||||
"placeholder": "Search artists, paintings, movements…",
|
||||
"resultsLabel": "Search results",
|
||||
"noMatches": "No matches found.",
|
||||
"groupArtist": "Artists",
|
||||
"groupMovement": "Movements",
|
||||
"groupPainting": "Paintings",
|
||||
"searchFailed": "Search failed"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Translation review",
|
||||
"back": "← Back to gallery",
|
||||
"locale": "Locale",
|
||||
"entityType": "Entity type",
|
||||
"status": "Status",
|
||||
"all": "All",
|
||||
"draft": "Draft",
|
||||
"reviewed": "Reviewed",
|
||||
"published": "Published",
|
||||
"coverage": "Coverage",
|
||||
"artistsBio": "Artists with bio (ru)",
|
||||
"paintingsTitle": "Paintings with title alias (ru)",
|
||||
"canonical": "English (canonical)",
|
||||
"translation": "Translation",
|
||||
"saveDraft": "Save draft",
|
||||
"publish": "Publish",
|
||||
"noRows": "No translation rows match the filter.",
|
||||
"loadFailed": "Failed to load translations"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "Историко-художественные заметки",
|
||||
"readSource": "Источник",
|
||||
"categoryTechnique": "Техника",
|
||||
"categoryComposition": "Композиция",
|
||||
"categorySubject": "Сюжет",
|
||||
"categorySymbolism": "Символика",
|
||||
"categoryHistorical": "Исторический контекст",
|
||||
"categoryOther": "Заметки"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"enterGallery": "Войти в галерею",
|
||||
"noBio": "Биография пока недоступна.",
|
||||
"wikipediaSource": "Текст адаптирован из Википедии",
|
||||
"backToTimeline": "← На шкалу времени"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"loading": "Загрузка…",
|
||||
"close": "Закрыть",
|
||||
"back": "Назад",
|
||||
"save": "Сохранить",
|
||||
"cancel": "Отмена",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"error": "Что-то пошло не так",
|
||||
"localeEn": "EN",
|
||||
"localeRu": "RU",
|
||||
"language": "Язык"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"checked": "Проверено",
|
||||
"fixIt": "Исправить",
|
||||
"more": "Ещё",
|
||||
"clear": "Очистить",
|
||||
"upload": "Загрузить",
|
||||
"searching": "Поиск…",
|
||||
"saving": "Сохранение…",
|
||||
"loginTitle": "Вход куратора",
|
||||
"username": "Имя пользователя",
|
||||
"password": "Пароль",
|
||||
"signIn": "Войти",
|
||||
"loginFailed": "Ошибка входа"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"backToTimeline": "← На шкалу времени",
|
||||
"exitToTimeline": "Выход на шкалу времени",
|
||||
"loadingGallery": "Загрузка галереи…",
|
||||
"instructionsTitle": "Управление",
|
||||
"instructionMove": "Перетаскивайте для обзора",
|
||||
"instructionZoom": "Колёсико — масштаб",
|
||||
"instructionClick": "Нажмите на картину для подробностей",
|
||||
"wingOf": "Крыло {{current}} из {{total}}",
|
||||
"exitConfirmTitle": "Покинуть галерею?",
|
||||
"exitConfirmBody": "Вернуться на шкалу времени или остаться в зале.",
|
||||
"stay": "Остаться",
|
||||
"exit": "Выход"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Виртуальная художественная галерея",
|
||||
"subtitle": "Исследуйте историю искусства на интерактивной шкале времени — входите в 3D-залы и открывайте связи между художниками и шедеврами.",
|
||||
"loadingArtHistory": "Загрузка истории искусства…",
|
||||
"loadingPortraits": "Загрузка портретов…",
|
||||
"openingArtistGallery": "Открытие галереи художника…",
|
||||
"openingMovementGallery": "Открытие галереи направления…",
|
||||
"backToTimeline": "← На шкалу времени",
|
||||
"backToGallery": "← В галерею",
|
||||
"curatorLogin": "Вход куратора",
|
||||
"curatorLogout": "Выйти",
|
||||
"signedInAs": "Вы вошли как {{username}}",
|
||||
"debugMode": "Режим отладки",
|
||||
"showMoreDebug": "Показать больше (отладка)",
|
||||
"checkup": "Проверка",
|
||||
"translations": "Переводы",
|
||||
"curatorRequiredTitle": "Требуется доступ куратора",
|
||||
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
|
||||
"backToGalleryBtn": "Вернуться в галерею"
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"aboutArtist": "О {{name}}",
|
||||
"influencedBy": "Под влиянием",
|
||||
"influenced": "Влияние на",
|
||||
"noInfluences": "Для этой работы нет задокументированных влияний.",
|
||||
"noInfluencedWorks": "Пока нет задокументированных работ под влиянием этой картины.",
|
||||
"catalogPosition": "Позиция в каталоге",
|
||||
"lightboxHint": "Нажмите в любом месте, чтобы закрыть",
|
||||
"prevPainting": "Предыдущая картина",
|
||||
"nextPainting": "Следующая картина"
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"label": "Поиск",
|
||||
"placeholder": "Художники, картины, направления…",
|
||||
"resultsLabel": "Результаты поиска",
|
||||
"noMatches": "Ничего не найдено.",
|
||||
"groupArtist": "Художники",
|
||||
"groupMovement": "Направления",
|
||||
"groupPainting": "Картины",
|
||||
"searchFailed": "Ошибка поиска"
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"title": "Проверка переводов",
|
||||
"back": "← В галерею",
|
||||
"locale": "Язык",
|
||||
"entityType": "Тип объекта",
|
||||
"status": "Статус",
|
||||
"all": "Все",
|
||||
"draft": "Черновик",
|
||||
"reviewed": "Проверено",
|
||||
"published": "Опубликовано",
|
||||
"coverage": "Охват",
|
||||
"artistsBio": "Художники с биографией (ru)",
|
||||
"paintingsTitle": "Картины с русским названием",
|
||||
"canonical": "Английский (оригинал)",
|
||||
"translation": "Перевод",
|
||||
"saveDraft": "Сохранить черновик",
|
||||
"publish": "Опубликовать",
|
||||
"noRows": "Нет строк по выбранному фильтру.",
|
||||
"loadFailed": "Не удалось загрузить переводы"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import { AuthProvider } from './context/AuthContext.tsx'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Timeline from '../components/Timeline';
|
||||
import TimelineEventGuides from '../components/TimelineEventGuides';
|
||||
import MovementBands from '../components/MovementBands';
|
||||
@@ -6,13 +7,17 @@ import VirtualGallery from '../components/VirtualGallery';
|
||||
import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
import CheckupPage from '../pages/CheckupPage';
|
||||
import TranslationsPage from '../pages/TranslationsPage';
|
||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||
import CatalogSearchBar from '../components/CatalogSearchBar';
|
||||
import LocaleSwitcher from '../components/LocaleSwitcher';
|
||||
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
||||
import '../components/CatalogSearchBar.css';
|
||||
import '../components/CuratorLoginModal.css';
|
||||
import '../components/LocaleSwitcher.css';
|
||||
import '../pages/TranslationsPage.css';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
|
||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
|
||||
import { createViewChangeScheduler } from '../utils/timelineView';
|
||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||
@@ -22,6 +27,7 @@ import './HomePage.css';
|
||||
type View =
|
||||
| { type: 'timeline' }
|
||||
| { type: 'checkup' }
|
||||
| { type: 'translations' }
|
||||
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
||||
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
||||
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
|
||||
@@ -102,6 +108,7 @@ function catalogNavigateTarget(
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation('home');
|
||||
const { isCurator, username, login, logout } = useAuth();
|
||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||
@@ -117,10 +124,11 @@ export default function HomePage() {
|
||||
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
||||
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
||||
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
|
||||
const [localeVersion, setLocaleVersion] = useState(0);
|
||||
const [debugMode, setDebugMode] = useState(readDebugMode);
|
||||
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [loginRedirect, setLoginRedirect] = useState<'checkup' | null>(null);
|
||||
const [loginRedirect, setLoginRedirect] = useState<'checkup' | 'translations' | null>(null);
|
||||
const effectiveDebugMode = debugMode && isCurator;
|
||||
const [galleryRevision, setGalleryRevision] = useState(0);
|
||||
const viewRef = useRef(view);
|
||||
@@ -170,6 +178,11 @@ export default function HomePage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [localeVersion]);
|
||||
|
||||
const handleLocaleChange = useCallback((locale: 'en' | 'ru') => {
|
||||
setApiLocale(locale);
|
||||
setLocaleVersion((v) => v + 1);
|
||||
}, []);
|
||||
|
||||
const viewChangeScheduler = useRef(
|
||||
@@ -209,7 +222,7 @@ export default function HomePage() {
|
||||
writeDebugShowMore(enabled);
|
||||
};
|
||||
|
||||
const openCuratorLogin = (redirect: 'checkup' | null = null) => {
|
||||
const openCuratorLogin = (redirect: 'checkup' | 'translations' | null = null) => {
|
||||
setLoginRedirect(redirect);
|
||||
setLoginOpen(true);
|
||||
};
|
||||
@@ -219,6 +232,8 @@ export default function HomePage() {
|
||||
setLoginOpen(false);
|
||||
if (loginRedirect === 'checkup') {
|
||||
setView({ type: 'checkup' });
|
||||
} else if (loginRedirect === 'translations') {
|
||||
setView({ type: 'translations' });
|
||||
}
|
||||
setLoginRedirect(null);
|
||||
};
|
||||
@@ -227,7 +242,7 @@ export default function HomePage() {
|
||||
await logout();
|
||||
writeDebugMode(false);
|
||||
setDebugMode(false);
|
||||
if (view.type === 'checkup') {
|
||||
if (view.type === 'checkup' || view.type === 'translations') {
|
||||
goToTimelineHome();
|
||||
}
|
||||
};
|
||||
@@ -240,6 +255,14 @@ export default function HomePage() {
|
||||
setView({ type: 'checkup' });
|
||||
};
|
||||
|
||||
const openTranslations = () => {
|
||||
if (!isCurator) {
|
||||
openCuratorLogin('translations');
|
||||
return;
|
||||
}
|
||||
setView({ type: 'translations' });
|
||||
};
|
||||
|
||||
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
|
||||
const data = await api.getPainting(paintingId);
|
||||
const patch: Partial<Painting> = {
|
||||
@@ -663,7 +686,7 @@ export default function HomePage() {
|
||||
key={view.paintingId}
|
||||
data={view.data}
|
||||
artistPaintings={sortedDetailArtistPaintings}
|
||||
backLabel={view.returnTo.type === 'timeline' ? '← Back to Timeline' : '← Back to Gallery'}
|
||||
backLabel={view.returnTo.type === 'timeline' ? t('backToTimeline') : t('backToGallery')}
|
||||
onBack={() => {
|
||||
if (view.returnTo.type === 'timeline') {
|
||||
goToTimelineHome();
|
||||
@@ -723,6 +746,25 @@ export default function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === 'translations' && (
|
||||
isCurator ? (
|
||||
<TranslationsPage onBack={goToTimelineHome} />
|
||||
) : (
|
||||
<div className="curator-login-gate">
|
||||
<h2>{t('curatorRequiredTitle')}</h2>
|
||||
<p>{t('curatorRequiredBody')}</p>
|
||||
<div className="curator-login-gate-actions">
|
||||
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('translations')}>
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
|
||||
{t('backToGalleryBtn')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{view.type === 'checkup' && (
|
||||
isCurator ? (
|
||||
<CheckupPage
|
||||
@@ -782,13 +824,21 @@ export default function HomePage() {
|
||||
/>
|
||||
Show more
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openTranslations}
|
||||
title="Review and publish Russian translations"
|
||||
>
|
||||
{t('translations')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="checkup-link-btn"
|
||||
onClick={openCheckup}
|
||||
title="Open painting image checkup table"
|
||||
>
|
||||
Checkup
|
||||
{t('checkup')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -796,7 +846,7 @@ export default function HomePage() {
|
||||
onClick={handleCuratorLogout}
|
||||
title="Sign out curator session"
|
||||
>
|
||||
Logout
|
||||
{t('curatorLogout')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
@@ -806,12 +856,13 @@ export default function HomePage() {
|
||||
onClick={() => openCuratorLogin()}
|
||||
title="Sign in as curator to use debug tools"
|
||||
>
|
||||
Curator login
|
||||
{t('curatorLogin')}
|
||||
</button>
|
||||
)}
|
||||
<LocaleSwitcher onLocaleChange={handleLocaleChange} />
|
||||
</div>
|
||||
<h1>Virtual Art Gallery</h1>
|
||||
<p className="site-subtitle">Watch art movements branch forward through time — each flowing from what came before</p>
|
||||
<h1>{t('title')}</h1>
|
||||
<p className="site-subtitle">{t('subtitle')}</p>
|
||||
<CatalogSearchBar
|
||||
onSelectArtist={handleArtistClick}
|
||||
onSelectMovement={handleMovementClick}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
.translations-page {
|
||||
padding: 1rem 1.5rem 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
color: #f5f0e8;
|
||||
}
|
||||
|
||||
.translations-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.translations-back {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: inherit;
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.translations-coverage {
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.translations-toolbar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.translations-toolbar select {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.translations-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.translations-list {
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.translations-list table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.translations-list th,
|
||||
.translations-list td {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.translations-row-selected {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.translations-list tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.translations-editor {
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.translations-field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.translations-field textarea {
|
||||
width: 100%;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.translations-canonical {
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.85;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.translations-error {
|
||||
color: #ffb4b4;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.translations-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api, type TranslationDetail, type TranslationWorklistItem } from '../api/client';
|
||||
import './TranslationsPage.css';
|
||||
|
||||
type EntityType = 'artist' | 'painting' | 'movement';
|
||||
|
||||
interface Props {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function TranslationsPage({ onBack }: Props) {
|
||||
const { t } = useTranslation('translations');
|
||||
const [entityType, setEntityType] = useState<EntityType>('artist');
|
||||
const [items, setItems] = useState<TranslationWorklistItem[]>([]);
|
||||
const [coverage, setCoverage] = useState<Record<string, number> | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [detail, setDetail] = useState<TranslationDetail | null>(null);
|
||||
const [draftFields, setDraftFields] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [cov, list] = await Promise.all([
|
||||
api.getTranslationCoverage('ru'),
|
||||
api.getTranslationWorklist(entityType, 'ru'),
|
||||
]);
|
||||
setCoverage(cov.coverage);
|
||||
setItems(list.items);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [entityType, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadList();
|
||||
setSelectedId(null);
|
||||
setDetail(null);
|
||||
}, [loadList]);
|
||||
|
||||
const openItem = async (entityId: number) => {
|
||||
setSelectedId(entityId);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getEntityTranslation(entityType, entityId);
|
||||
setDetail(data);
|
||||
const ruFields: Record<string, string> = {};
|
||||
for (const field of data.translatableFields) {
|
||||
const row = data.translations.find((tr) => tr.locale === 'ru' && tr.field_name === field);
|
||||
ruFields[field] = row?.value ?? '';
|
||||
}
|
||||
setDraftFields(ruFields);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const saveDraft = async () => {
|
||||
if (!selectedId) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.saveEntityTranslation(entityType, selectedId, {
|
||||
locale: 'ru',
|
||||
fields: draftFields,
|
||||
status: 'draft',
|
||||
});
|
||||
await api.publishEntityTranslation(entityType, selectedId, 'ru');
|
||||
await loadList();
|
||||
await openItem(selectedId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('loadFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="translations-page">
|
||||
<header className="translations-header">
|
||||
<button type="button" className="translations-back" onClick={onBack}>
|
||||
{t('back')}
|
||||
</button>
|
||||
<h1>{t('title')}</h1>
|
||||
</header>
|
||||
|
||||
{coverage && (
|
||||
<div className="translations-coverage">
|
||||
<strong>{t('coverage')}:</strong>{' '}
|
||||
{t('artistsBio')}: {coverage.artists_bio_full}/{coverage.artists_total} ·{' '}
|
||||
{t('paintingsTitle')}: {coverage.paintings_title}/{coverage.paintings_total} ·{' '}
|
||||
{t('published')}: {coverage.published_count} · {t('draft')}: {coverage.draft_count}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="translations-toolbar">
|
||||
<label>
|
||||
{t('entityType')}
|
||||
<select value={entityType} onChange={(e) => setEntityType(e.target.value as EntityType)}>
|
||||
<option value="artist">artist</option>
|
||||
<option value="painting">painting</option>
|
||||
<option value="movement">movement</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <p className="translations-error">{error}</p>}
|
||||
{loading && <p>{t('loadFailed')}…</p>}
|
||||
|
||||
<div className="translations-layout">
|
||||
<div className="translations-list">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>{t('canonical')}</th>
|
||||
<th>{t('status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
key={item.entityId}
|
||||
className={selectedId === item.entityId ? 'translations-row-selected' : ''}
|
||||
onClick={() => void openItem(item.entityId)}
|
||||
>
|
||||
<td>{item.entityId}</td>
|
||||
<td>{item.label}</td>
|
||||
<td>
|
||||
{item.publishedCount} / {item.draftCount} draft · {item.missingFields.length} missing
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!loading && items.length === 0 && <p>{t('noRows')}</p>}
|
||||
</div>
|
||||
|
||||
{detail && selectedId && (
|
||||
<div className="translations-editor">
|
||||
<h2>{String(detail.canonical.name || detail.canonical.title || selectedId)}</h2>
|
||||
{detail.translatableFields.map((field) => (
|
||||
<div key={field} className="translations-field">
|
||||
<label>{field}</label>
|
||||
<p className="translations-canonical">
|
||||
<strong>{t('canonical')}:</strong>{' '}
|
||||
{String(detail.canonical[field] ?? '')}
|
||||
</p>
|
||||
<textarea
|
||||
rows={field.includes('bio') || field === 'body' ? 8 : 3}
|
||||
value={draftFields[field] ?? ''}
|
||||
onChange={(e) => setDraftFields((prev) => ({ ...prev, [field]: e.target.value }))}
|
||||
placeholder={t('translation')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" disabled={saving} onClick={() => void saveDraft()}>
|
||||
{saving ? '…' : t('publish')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export const LOCALE_STORAGE_KEY = 'gallery_locale';
|
||||
export const SUPPORTED_LOCALES = ['en', 'ru'] as const;
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
export function readStoredLocale(): AppLocale {
|
||||
try {
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (stored === 'ru' || stored === 'en') return stored;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const nav = typeof navigator !== 'undefined' ? navigator.language : 'en';
|
||||
return nav.toLowerCase().startsWith('ru') ? 'ru' : 'en';
|
||||
}
|
||||
|
||||
export function writeStoredLocale(locale: AppLocale) {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
|
||||
export function localeQuery(locale: AppLocale): string {
|
||||
return locale === 'en' ? '' : `locale=${encodeURIComponent(locale)}`;
|
||||
}
|
||||
|
||||
export function withLocale(url: string, locale: AppLocale): string {
|
||||
if (locale === 'en') return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}locale=${encodeURIComponent(locale)}`;
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
|
||||
Reference in New Issue
Block a user