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>
126 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|