Files
Art-gallery/client/src/components/PaintingAnnotations.tsx
T
Danila KhodjaefandCursor ca58c43648 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>
2026-07-15 11:51:46 +03:00

126 lines
4.2 KiB
TypeScript

import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { PaintingAnnotation } from '../types';
import './PaintingAnnotations.css';
const CATEGORY_KEYS: Record<string, string> = {
technique: 'categoryTechnique',
composition: 'categoryComposition',
symbolism: 'categorySymbolism',
history: 'categoryHistorical',
subject: 'categorySubject',
};
interface PanelProps {
annotations: PaintingAnnotation[];
activeId: number | null;
onSelect: (id: number | null) => void;
}
export function PaintingAnnotationMarkers({
annotations,
activeId,
onSelect,
}: PanelProps) {
const positioned = annotations.filter(
(a) => a.pos_x != null && a.pos_y != null && Number.isFinite(Number(a.pos_x)) && Number.isFinite(Number(a.pos_y))
);
if (!positioned.length) return null;
return (
<div className="painting-annotation-markers" aria-hidden={false}>
{positioned.map((ann) => {
const number = annotations.indexOf(ann) + 1;
const isActive = activeId === ann.id;
return (
<button
key={ann.id}
type="button"
className={`painting-annotation-marker painting-annotation-marker-${ann.category}${isActive ? ' painting-annotation-marker-active' : ''}`}
style={{ left: `${ann.pos_x}%`, top: `${ann.pos_y}%` }}
title={ann.label || ann.body}
aria-label={`Annotation ${number}: ${ann.label || ann.body}`}
onClick={(e) => {
e.stopPropagation();
onSelect(isActive ? null : ann.id);
}}
>
{number}
</button>
);
})}
</div>
);
}
export default function PaintingAnnotationsPanel({
annotations,
activeId,
onSelect,
}: PanelProps) {
const { t } = useTranslation('annotations');
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
if (!annotations.length) return null;
const focusAnnotation = (id: number | null) => {
onSelect(id);
if (id != null) {
cardRefs.current.get(id)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
};
return (
<aside className="painting-annotations-panel" aria-label="Art history annotations">
<h3 className="painting-annotations-title">{t('title')}</h3>
<ul className="painting-annotations-list">
{annotations.map((ann, index) => {
const isActive = activeId === ann.id;
const categoryKey = CATEGORY_KEYS[ann.category];
const category = categoryKey ? t(categoryKey) : ann.category;
return (
<li
key={ann.id}
ref={(el) => {
if (el) cardRefs.current.set(ann.id, el);
else cardRefs.current.delete(ann.id);
}}
className={`painting-annotation-card painting-annotation-card-${ann.category}${isActive ? ' painting-annotation-card-active' : ''}`}
>
<button
type="button"
className="painting-annotation-card-btn"
onClick={() => focusAnnotation(isActive ? null : ann.id)}
>
<span className="painting-annotation-card-head">
<span className="painting-annotation-number">{index + 1}</span>
<span className="painting-annotation-category">{category}</span>
{ann.label && <strong className="painting-annotation-label">{ann.label}</strong>}
</span>
<p className="painting-annotation-body">{ann.body}</p>
{(ann.source_author || ann.source) && (
<p className="painting-annotation-source">
{ann.source_author}
{ann.source && <cite>, {ann.source}</cite>}
</p>
)}
</button>
{ann.source_url && (
<a
className="painting-annotation-source-link"
href={ann.source_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{t('readSource')}
</a>
)}
</li>
);
})}
</ul>
</aside>
);
}