import { useCallback, useEffect, useState, type SyntheticEvent } from 'react'; import { useTranslation } from 'react-i18next'; import type { InfluenceLink, Painting, PaintingDetail } from '../types'; import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client'; import DebugSearchResultsModal from './DebugSearchResultsModal'; import DebugUploadButton from './DebugUploadButton'; import GalleryLoadingMarker from './GalleryLoadingMarker'; import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations'; import PaintingLightbox from './PaintingLightbox'; import './PaintingDetail.css'; import './GalleryLoadingMarker.css'; interface Props { data: PaintingDetail; artistPaintings?: Painting[]; backLabel?: string; tourTitle?: string | null; tourText?: string | null; onBack: () => void; onPaintingClick: (paintingId: number) => void; onCatalogNavigate: (paintingId: number) => void; onArtistBio: () => void; onInfluenceArtistClick?: (artistId: number) => void; isCurator?: boolean; /** When false, hide the Checked action (needs checkup permission). Defaults to true when debugMode is on. */ canCheckup?: boolean; debugMode?: boolean; debugShowMore?: boolean; onPaintingImageFixed?: ( paintingId: number, fixResult: FixPaintingImageResult ) => void | Promise; onPaintingCheckupFlagsUpdated?: ( paintingId: number, flags: { checked: boolean; fixed: boolean } ) => void | Promise; onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise; onCuratorNotesUpdated?: (paintingId: number, curatorNotes: string) => 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 && (
{aspects.map((aspect) => ( {aspect} ))}
)} {period &&

{period}

} {inf.notes &&

{inf.notes}

} {inf.quote && (

“{inf.quote}”

{(inf.source_author || inf.source) && (
— {inf.source_author} {inf.source && , {inf.source}}
)}
)} {inf.source_url && ( e.stopPropagation()} > Read source: {inf.source_author || inf.source || 'Reference'} )} {inf.confidence === 'discovered' && inf.discovered_via && ( Discovered via {inf.discovered_via} )} ); if (sourceType === 'movement') { return (
{inf.movement_name} Art movement
{meta}
); } if (sourceType === 'artist') { const artistId = inf.source_artist_id; const artistName = inf.source_artist_name || 'Unknown artist'; return (
{meta}
); } if (!inf.id) return null; return (
{meta}
); } export default function PaintingDetailView({ data, artistPaintings = [], backLabel = '← Back to Gallery', tourTitle = null, tourText = null, onBack, onPaintingClick, onCatalogNavigate, onArtistBio, onInfluenceArtistClick, isCurator = false, canCheckup = true, debugMode = false, debugShowMore = false, onPaintingImageFixed, onPaintingCheckupFlagsUpdated, onPaintingRemoved, onCuratorNotesUpdated, }: Props) { const { t } = useTranslation('painting'); const { painting, influencedBy, influenced, annotations = [] } = data; const inTour = tourText != null; const [curatorNotes, setCuratorNotes] = useState(painting.curator_notes ?? ''); const [editingNotes, setEditingNotes] = useState(false); const [notesDraft, setNotesDraft] = useState(painting.curator_notes ?? ''); const [savingNotes, setSavingNotes] = useState(false); const [notesError, setNotesError] = useState(null); const [fullscreen, setFullscreen] = useState(false); const [imageVersion, setImageVersion] = useState(0); const [debugSearch, setDebugSearch] = useState(null); const [debugLoading, setDebugLoading] = useState(false); const [debugError, setDebugError] = useState(null); const [fixing, setFixing] = useState(false); const [markingChecked, setMarkingChecked] = useState(false); const [moreOpen, setMoreOpen] = useState(false); const [moreLoading, setMoreLoading] = useState(false); const [moreError, setMoreError] = useState(null); const [moreResults, setMoreResults] = useState> | null>(null); const [applyingUrl, setApplyingUrl] = useState(null); const [clearing, setClearing] = useState(false); const [uploading, setUploading] = useState(false); const [uploadStatus, setUploadStatus] = useState(null); const [removing, setRemoving] = useState(false); const [activeAnnotationId, setActiveAnnotationId] = useState(null); const imageSrc = paintingImageUrl(painting, imageVersion || undefined); const displayImageSrc = uploading ? null : imageSrc; const imageCleared = !painting.image_path && !painting.thumbnail_path && !!painting.checkup_fixed; 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); setMoreOpen(false); setMoreResults(null); setMoreError(null); setActiveAnnotationId(null); setRemoving(false); setFixing(false); setClearing(false); setUploading(false); setUploadStatus(null); setMarkingChecked(false); setApplyingUrl(null); setMoreLoading(false); const notes = painting.curator_notes ?? ''; setCuratorNotes(notes); setNotesDraft(notes); setEditingNotes(false); setSavingNotes(false); setNotesError(null); }, [painting.id, painting.curator_notes]); useEffect(() => { if (!debugMode) { setDebugSearch(null); setDebugError(null); setMoreOpen(false); return; } if (uploading) { 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, uploading, painting.id, painting.title, painting.artist_name]); const startEditingNotes = () => { setNotesDraft(curatorNotes); setNotesError(null); setEditingNotes(true); }; const cancelEditingNotes = () => { setNotesDraft(curatorNotes); setNotesError(null); setEditingNotes(false); }; const saveCuratorNotes = async () => { setSavingNotes(true); setNotesError(null); try { const result = await api.updatePaintingCuratorNotes(painting.id, notesDraft); setCuratorNotes(result.curatorNotes); setNotesDraft(result.curatorNotes); setEditingNotes(false); onCuratorNotesUpdated?.(painting.id, result.curatorNotes); } catch { setNotesError(t('curatorNotesSaveFailed')); } finally { setSavingNotes(false); } }; const applyImageUpdate = async (fixResult: FixPaintingImageResult) => { setImageVersion((v) => v + 1); if (onPaintingImageFixed) { await onPaintingImageFixed(painting.id, fixResult); } }; const applyFixFromSearch = async ( imageUrl: string, context: { searchUrl: string; source: string; thumbUrl?: string } ) => { const fixResult = await api.fixPaintingImage(painting.id, imageUrl, context); await applyImageUpdate(fixResult); setDebugSearch((prev) => prev ? { ...prev, imageUrl, thumbUrl: context.thumbUrl ?? prev.thumbUrl, source: context.source } : prev ); }; const handleFixImage = async () => { if (!debugSearch?.imageUrl || fixing) return; setFixing(true); setDebugError(null); try { await applyFixFromSearch(debugSearch.imageUrl, { searchUrl: debugSearch.searchUrl, source: debugSearch.source, thumbUrl: debugSearch.thumbUrl, }); } catch (err) { setDebugError(err instanceof Error ? err.message : 'Could not replace image.'); } finally { setFixing(false); } }; const handleOpenMore = useCallback(async () => { setMoreOpen(true); setMoreLoading(true); setMoreError(null); setMoreResults(null); try { const results = await api.getPaintingDebugImageSearchMore(painting.id); setMoreResults(results); } catch { setMoreError('Could not load search results.'); } finally { setMoreLoading(false); } }, [painting.id]); useEffect(() => { if (!debugMode || !debugShowMore) return; void handleOpenMore(); }, [debugMode, debugShowMore, painting.id, handleOpenMore]); const handleSelectMoreResult = async (item: DebugImageSearchResultItem) => { if (fixing || applyingUrl) return; setApplyingUrl(item.imageUrl); setMoreError(null); setDebugError(null); try { const searchUrl = moreResults?.searchUrl ?? debugSearch?.searchUrl ?? ''; await applyFixFromSearch(item.imageUrl, { searchUrl, source: item.source, thumbUrl: item.thumbUrl, }); setMoreOpen(false); } catch (err) { setMoreError(err instanceof Error ? err.message : 'Could not replace image.'); } finally { setApplyingUrl(null); } }; 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); } }; const handleClearImage = async () => { if (clearing || fixing || uploading) return; setClearing(true); setDebugError(null); try { const result = await api.clearPaintingImage(painting.id); await applyImageUpdate(result); } catch (err) { setDebugError(err instanceof Error ? err.message : 'Could not clear image.'); } finally { setClearing(false); } }; const handleUploadPress = () => { setDebugSearch(null); setDebugLoading(false); setMoreOpen(false); setMoreResults(null); setMoreError(null); setDebugError(null); }; const handleUploadFile = async (file: File) => { if (uploading || fixing || clearing) return; handleUploadPress(); setUploading(true); setUploadStatus('Reading file…'); try { setUploadStatus('Uploading…'); const result = await api.uploadPaintingImage(painting.id, file); await applyImageUpdate(result); setUploadStatus('Upload complete.'); } catch (err) { setUploadStatus(null); setDebugError(err instanceof Error ? err.message : 'Could not upload image.'); } finally { setUploading(false); } }; const handleRemoveEntry = async () => { if (removing || fixing || clearing || uploading || !onPaintingRemoved) return; setRemoving(true); setDebugError(null); try { await onPaintingRemoved(painting.id, painting.artist_id); } catch (err) { setDebugError(err instanceof Error ? err.message : 'Could not remove painting.'); setRemoving(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) => { e.currentTarget.src = '/placeholder-art.svg'; }; return (
{uploading && ( )}

{painting.title}

{painting.artist_name} {painting.year && ` · ${painting.year}`} {showCatalogNav && ( {' · '} {inTour ? t('tourStopPosition', { current: catalogIndex + 1, total: artistPaintings.length }) : `${catalogIndex + 1} of ${artistPaintings.length}`} )}

{showCatalogNav && ( )}
setFullscreen(true) : undefined} onKeyDown={ displayImageSrc ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setFullscreen(true); } } : undefined } title={displayImageSrc ? 'View full screen' : undefined} aria-label={displayImageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`} >
{displayImageSrc ? ( {painting.title} ) : null} {displayImageSrc && annotations.length > 0 && ( )}
{annotations.length > 0 && ( )} {showCatalogNav && ( )}
{inTour && ( )} {(isCurator || curatorNotes.trim()) && (