Files
Art-gallery/client/src/components/PaintingDetail.tsx
T
Danila KhodjaefandCursor 019ce4e136 Improve 3D gallery and debug checkup workflow for image curation.
Add parquet floor, museum-style exit, movement-tinted walls, and gold/black
frames with gallery sync after Fix it. Debug panel gets Checked and Fix it
buttons; documentation and image-fetch reliability updates included.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 22:05:24 +03:00

474 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, type SyntheticEvent } from 'react';
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type FixPaintingImageResult } from '../api/client';
import PaintingLightbox from './PaintingLightbox';
import './PaintingDetail.css';
interface Props {
data: PaintingDetail;
artistPaintings?: Painting[];
onBack: () => void;
onPaintingClick: (paintingId: number) => void;
onCatalogNavigate: (paintingId: number) => void;
onArtistBio: () => void;
onInfluenceArtistClick?: (artistId: number) => void;
debugMode?: boolean;
onPaintingImageFixed?: (
paintingId: number,
fixResult: FixPaintingImageResult
) => void | Promise<void>;
onPaintingCheckupFlagsUpdated?: (
paintingId: number,
flags: { checked: boolean; fixed: boolean }
) => void | Promise<void>;
}
function influenceKey(inf: InfluenceLink, index: number): string {
if (inf.source_type === 'movement') return `movement-${inf.movement_id ?? inf.movement_name}-${index}`;
if (inf.source_type === 'artist') return `artist-${inf.source_artist_id ?? inf.source_artist_name}-${index}`;
return `painting-${inf.id}-${index}`;
}
function periodLabel(inf: InfluenceLink): string | null {
if (inf.period_note) return inf.period_note;
if (inf.period_start_year != null && inf.period_end_year != null) {
return `${inf.period_start_year}${inf.period_end_year}`;
}
if (inf.period_start_year != null) return `from ${inf.period_start_year}`;
return null;
}
function InfluenceCard({
inf,
onPaintingClick,
onInfluenceArtistClick,
}: {
inf: InfluenceLink;
onPaintingClick: (id: number) => void;
onInfluenceArtistClick?: (artistId: number) => void;
}) {
const aspects = inf.aspects
? inf.aspects.split(',').map((a) => a.trim()).filter(Boolean)
: [];
const period = periodLabel(inf);
const sourceType = inf.source_type || 'painting';
const meta = (
<>
{aspects.length > 0 && (
<div className="influence-aspects">
{aspects.map((aspect) => (
<span key={aspect} className="aspect-tag">{aspect}</span>
))}
</div>
)}
{period && <p className="influence-period">{period}</p>}
{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>
)}
{inf.confidence === 'discovered' && inf.discovered_via && (
<span className="influence-discovered-tag">Discovered via {inf.discovered_via}</span>
)}
</>
);
if (sourceType === 'movement') {
return (
<article className="influence-card-expanded influence-card-movement">
<div
className="influence-movement-swatch"
style={{ background: inf.movement_color || '#8B7355' }}
aria-hidden
/>
<div className="influence-body">
<div className="influence-title-btn influence-title-static">
<strong>{inf.movement_name}</strong>
<span>Art movement</span>
</div>
{meta}
</div>
</article>
);
}
if (sourceType === 'artist') {
const artistId = inf.source_artist_id;
const artistName = inf.source_artist_name || 'Unknown artist';
return (
<article className="influence-card-expanded influence-card-artist">
<button
type="button"
className="influence-image-btn influence-portrait-btn"
onClick={() => artistId && onInfluenceArtistClick?.(artistId)}
title={`View ${artistName}`}
disabled={!artistId || !onInfluenceArtistClick}
>
<img
src={imageUrl(inf.artist_portrait)}
alt={artistName}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}}
/>
</button>
<div className="influence-body">
<button
type="button"
className="influence-title-btn"
onClick={() => artistId && onInfluenceArtistClick?.(artistId)}
disabled={!artistId || !onInfluenceArtistClick}
>
<strong>{artistName}</strong>
<span>Artist influence</span>
</button>
{meta}
</div>
</article>
);
}
if (!inf.id) return null;
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 || 'Painting'}
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>
{meta}
</div>
</article>
);
}
export default function PaintingDetailView({
data,
artistPaintings = [],
onBack,
onPaintingClick,
onCatalogNavigate,
onArtistBio,
onInfluenceArtistClick,
debugMode = false,
onPaintingImageFixed,
onPaintingCheckupFlagsUpdated,
}: Props) {
const { painting, influencedBy, influenced } = data;
const [fullscreen, setFullscreen] = useState(false);
const [imageVersion, setImageVersion] = useState(0);
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
const [debugLoading, setDebugLoading] = useState(false);
const [debugError, setDebugError] = useState<string | null>(null);
const [fixing, setFixing] = useState(false);
const [markingChecked, setMarkingChecked] = useState(false);
const imageSrc = `${paintingImageUrl(painting)}${paintingImageUrl(painting).includes('?') ? '&' : '?'}v=${imageVersion}`;
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
const nextPainting =
catalogIndex >= 0 && catalogIndex < artistPaintings.length - 1
? artistPaintings[catalogIndex + 1]
: null;
const showCatalogNav = artistPaintings.length > 1 && catalogIndex >= 0;
useEffect(() => {
setFullscreen(false);
setImageVersion(0);
}, [painting.id]);
useEffect(() => {
if (!debugMode) {
setDebugSearch(null);
setDebugError(null);
return;
}
let cancelled = false;
setDebugLoading(true);
setDebugError(null);
setDebugSearch(null);
api.getPaintingDebugImageSearch(painting.id)
.then((result) => {
if (!cancelled) setDebugSearch(result);
})
.catch(() => {
if (!cancelled) setDebugError('Google image search failed.');
})
.finally(() => {
if (!cancelled) setDebugLoading(false);
});
return () => {
cancelled = true;
};
}, [debugMode, painting.id, painting.title, painting.artist_name]);
const handleFixImage = async () => {
if (!debugSearch?.imageUrl || fixing) return;
setFixing(true);
setDebugError(null);
try {
const fixResult = await api.fixPaintingImage(painting.id, debugSearch.imageUrl, {
searchUrl: debugSearch.searchUrl,
source: debugSearch.source,
thumbUrl: debugSearch.thumbUrl,
});
setImageVersion((v) => v + 1);
if (onPaintingImageFixed) {
await onPaintingImageFixed(painting.id, fixResult);
}
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not replace image.');
} finally {
setFixing(false);
}
};
const handleMarkChecked = async () => {
if (painting.checkup_checked || markingChecked) return;
setMarkingChecked(true);
setDebugError(null);
try {
const updated = await api.updatePaintingCheckupFlags(painting.id, { checked: true });
await onPaintingCheckupFlagsUpdated?.(painting.id, updated);
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not mark as checked.');
} finally {
setMarkingChecked(false);
}
};
useEffect(() => {
if (fullscreen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft' && previousPainting) {
e.preventDefault();
onCatalogNavigate(previousPainting.id);
} else if (e.key === 'ArrowRight' && nextPainting) {
e.preventDefault();
onCatalogNavigate(nextPainting.id);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [fullscreen, previousPainting, nextPainting, onCatalogNavigate]);
const handleImageError = (e: SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.src = '/placeholder-art.svg';
};
return (
<div className={`painting-detail${fullscreen ? ' painting-detail-fullscreen-active' : ''}`}>
<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}`}
{showCatalogNav && (
<span className="painting-catalog-position">
{' · '}
{catalogIndex + 1} of {artistPaintings.length}
</span>
)}
</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, index) => (
<InfluenceCard
key={influenceKey(inf, index)}
inf={inf}
onPaintingClick={onPaintingClick}
onInfluenceArtistClick={onInfluenceArtistClick}
/>
))}
</div>
)}
</aside>
<main className="painting-center">
<div className="painting-center-nav">
{showCatalogNav && (
<button
type="button"
className="painting-nav-btn painting-nav-prev"
onClick={() => previousPainting && onCatalogNavigate(previousPainting.id)}
disabled={!previousPainting}
title={previousPainting ? `Previous: ${previousPainting.title}` : 'No earlier work'}
aria-label={
previousPainting ? `Previous painting: ${previousPainting.title}` : 'No earlier work'
}
>
</button>
)}
<div
className="painting-frame-large painting-frame-clickable"
role="button"
tabIndex={0}
onClick={() => setFullscreen(true)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFullscreen(true);
}
}}
title="View full screen"
aria-label={`View ${painting.title} full screen`}
>
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
</div>
{showCatalogNav && (
<button
type="button"
className="painting-nav-btn painting-nav-next"
onClick={() => nextPainting && onCatalogNavigate(nextPainting.id)}
disabled={!nextPainting}
title={nextPainting ? `Next: ${nextPainting.title}` : 'No later work'}
aria-label={nextPainting ? `Next painting: ${nextPainting.title}` : 'No later work'}
>
</button>
)}
</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, index) => (
<InfluenceCard
key={influenceKey(inf, index)}
inf={inf}
onPaintingClick={onPaintingClick}
onInfluenceArtistClick={onInfluenceArtistClick}
/>
))}
</div>
)}
</aside>
</div>
{debugMode && (
<aside className="debug-image-panel" aria-label="Debug image search">
<h4>{debugSearch?.sourceLabel ?? 'Google image search'}</h4>
<p className="debug-image-query">
{debugSearch?.query ?? `${painting.artist_name} ${painting.title} painting`}
</p>
{debugLoading && <p className="debug-image-status">Searching</p>}
{debugError && <p className="debug-image-error">{debugError}</p>}
{!debugLoading && debugSearch?.imageUrl && (
<img
className="debug-image-preview"
src={debugImageProxyUrl(debugSearch.imageUrl, {
searchUrl: debugSearch.searchUrl,
source: debugSearch.source,
})}
alt={`Google search result for ${debugSearch.query}`}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
/>
)}
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
<p className="debug-image-status">No Google image result found.</p>
)}
<div className="debug-action-buttons">
<button
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!painting.checkup_checked || markingChecked}
>
{markingChecked ? '…' : 'Checked'}
</button>
<button
type="button"
className="debug-fix-btn"
onClick={handleFixImage}
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
>
{fixing ? '…' : 'Fix it'}
</button>
</div>
</aside>
)}
{fullscreen && (
<PaintingLightbox
src={imageSrc}
alt={painting.title}
title={painting.title}
subtitle={[painting.artist_name, painting.year ? String(painting.year) : '']
.filter(Boolean)
.join(' · ')}
closeHint="Click to return to painting details"
onClose={() => setFullscreen(false)}
/>
)}
</div>
);
}