Redesign 3D gallery as single hall per artist with exit navigation.

Restore React client source, add hall-to-hall navigation via painting influences grouped by movement, and update documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-19 10:10:13 +03:00
co-authored by Cursor
parent 44d3d4359a
commit 08f99d7a29
25 changed files with 2954 additions and 218 deletions
+149
View File
@@ -0,0 +1,149 @@
import type { PaintingDetail } from '../types';
import { paintingImageUrl } from '../api/client';
import './PaintingDetail.css';
interface Props {
data: PaintingDetail;
onBack: () => void;
onPaintingClick: (paintingId: number) => void;
onArtistBio: () => void;
}
function InfluenceCard({
inf,
onPaintingClick,
}: {
inf: PaintingDetail['influencedBy'][0];
onPaintingClick: (id: number) => void;
}) {
const aspects = inf.aspects
? inf.aspects.split(',').map((a) => a.trim()).filter(Boolean)
: [];
return (
<article className="influence-card-expanded">
<button
type="button"
className="influence-image-btn"
onClick={() => onPaintingClick(inf.id)}
title={`View ${inf.title}`}
>
<img
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path })}
alt={inf.title}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
/>
</button>
<div className="influence-body">
<button type="button" className="influence-title-btn" onClick={() => onPaintingClick(inf.id)}>
<strong>{inf.title}</strong>
<span>{inf.artist_name}{inf.year ? `, ${inf.year}` : ''}</span>
</button>
{aspects.length > 0 && (
<div className="influence-aspects">
{aspects.map((aspect) => (
<span key={aspect} className="aspect-tag">{aspect}</span>
))}
</div>
)}
{inf.notes && <p className="influence-notes">{inf.notes}</p>}
{inf.quote && (
<blockquote className="influence-quote">
<p>&ldquo;{inf.quote}&rdquo;</p>
{(inf.source_author || inf.source) && (
<footer>
{inf.source_author}
{inf.source && <cite>, {inf.source}</cite>}
</footer>
)}
</blockquote>
)}
{inf.source_url && (
<a
className="influence-source-link"
href={inf.source_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Read source: {inf.source_author || inf.source || 'Reference'}
</a>
)}
</div>
</article>
);
}
export default function PaintingDetailView({ data, onBack, onPaintingClick, onArtistBio }: Props) {
const { painting, influencedBy, influenced } = data;
return (
<div className="painting-detail">
<header className="painting-header">
<button className="back-btn" onClick={onBack}> Back to Gallery</button>
<div className="painting-title-block">
<h1>{painting.title}</h1>
<p className="painting-meta">
{painting.artist_name}
{painting.year && ` · ${painting.year}`}
</p>
</div>
<button className="bio-btn" onClick={onArtistBio}>
About {painting.artist_name}
</button>
</header>
<div className="painting-layout">
<aside className="influence-panel influence-left">
<h3>Influenced By</h3>
{influencedBy.length === 0 ? (
<p className="no-influences">No documented influences for this work.</p>
) : (
<div className="influence-list">
{influencedBy.map((inf) => (
<InfluenceCard key={inf.id} inf={inf} onPaintingClick={onPaintingClick} />
))}
</div>
)}
</aside>
<main className="painting-center">
<div className="painting-frame-large">
<img
src={paintingImageUrl(painting)}
alt={painting.title}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
/>
</div>
{painting.description && (
<div className="painting-description">
<p>{painting.description}</p>
</div>
)}
</main>
<aside className="influence-panel influence-right">
<h3>Influenced</h3>
{influenced.length === 0 ? (
<p className="no-influences">No documented works influenced by this painting yet.</p>
) : (
<div className="influence-list">
{influenced.map((inf) => (
<InfluenceCard key={inf.id} inf={inf} onPaintingClick={onPaintingClick} />
))}
</div>
)}
</aside>
</div>
</div>
);
}