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>
474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
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>“{inf.quote}”</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>
|
||
);
|
||
}
|