Staff accounts use admin/curator roles and fine-grained flags; transitions connect source-to-target with color gradients and stream cutout masks so overlaps stay seamless. Co-authored-by: Cursor <cursoragent@cursor.com>
822 lines
28 KiB
TypeScript
822 lines
28 KiB
TypeScript
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<void>;
|
||
onPaintingCheckupFlagsUpdated?: (
|
||
paintingId: number,
|
||
flags: { checked: boolean; fixed: boolean }
|
||
) => void | Promise<void>;
|
||
onPaintingRemoved?: (paintingId: number, artistId: number) => void | Promise<void>;
|
||
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 && (
|
||
<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}
|
||
loading="lazy"
|
||
decoding="async"
|
||
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 }) ?? '/placeholder-art.svg'}
|
||
alt={inf.title || 'Painting'}
|
||
loading="lazy"
|
||
decoding="async"
|
||
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 = [],
|
||
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<string | null>(null);
|
||
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 [moreOpen, setMoreOpen] = useState(false);
|
||
const [moreLoading, setMoreLoading] = useState(false);
|
||
const [moreError, setMoreError] = useState<string | null>(null);
|
||
const [moreResults, setMoreResults] = useState<Awaited<ReturnType<typeof api.getPaintingDebugImageSearchMore>> | null>(null);
|
||
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
|
||
const [clearing, setClearing] = useState(false);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
|
||
const [removing, setRemoving] = useState(false);
|
||
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(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<HTMLImageElement>) => {
|
||
e.currentTarget.src = '/placeholder-art.svg';
|
||
};
|
||
|
||
return (
|
||
<div className={`painting-detail${fullscreen ? ' painting-detail-fullscreen-active' : ''}`}>
|
||
{uploading && (
|
||
<GalleryLoadingMarker
|
||
overlay
|
||
className="debug-upload-page-overlay"
|
||
message={uploadStatus ?? 'Loading…'}
|
||
/>
|
||
)}
|
||
<header className="painting-header">
|
||
<button className="back-btn" onClick={onBack}>{backLabel}</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">
|
||
{' · '}
|
||
{inTour
|
||
? t('tourStopPosition', { current: catalogIndex + 1, total: artistPaintings.length })
|
||
: `${catalogIndex + 1} of ${artistPaintings.length}`}
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
<button className="bio-btn" onClick={onArtistBio}>
|
||
{t('aboutArtist', { name: painting.artist_name })}
|
||
</button>
|
||
</header>
|
||
|
||
<div className="painting-layout">
|
||
<aside className="influence-panel influence-left">
|
||
<h3>{t('influencedBy')}</h3>
|
||
{influencedBy.length === 0 ? (
|
||
<p className="no-influences">{t('noInfluences')}</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${displayImageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared && !uploading ? ' painting-frame-cleared' : ''}${uploading ? ' painting-frame-uploading' : ''}`}
|
||
role={displayImageSrc ? 'button' : undefined}
|
||
tabIndex={displayImageSrc ? 0 : undefined}
|
||
onClick={displayImageSrc ? () => 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)`}
|
||
>
|
||
<div className="painting-frame-image-wrap">
|
||
{displayImageSrc ? (
|
||
<img src={displayImageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
|
||
) : null}
|
||
{displayImageSrc && annotations.length > 0 && (
|
||
<PaintingAnnotationMarkers
|
||
annotations={annotations}
|
||
activeId={activeAnnotationId}
|
||
onSelect={setActiveAnnotationId}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{annotations.length > 0 && (
|
||
<PaintingAnnotationsPanel
|
||
annotations={annotations}
|
||
activeId={activeAnnotationId}
|
||
onSelect={setActiveAnnotationId}
|
||
/>
|
||
)}
|
||
|
||
{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>
|
||
{inTour && (
|
||
<aside className="tour-stop-panel" aria-label={t('tourNotes')}>
|
||
<h3>{tourTitle ? t('tourNotesFor', { title: tourTitle }) : t('tourNotes')}</h3>
|
||
{tourText.trim() ? (
|
||
<p className="tour-stop-body">{tourText}</p>
|
||
) : (
|
||
<p className="tour-stop-empty">{t('tourNotesEmpty')}</p>
|
||
)}
|
||
</aside>
|
||
)}
|
||
{(isCurator || curatorNotes.trim()) && (
|
||
<aside className="curator-notes-panel" aria-label={t('curatorNotes')}>
|
||
<div className="curator-notes-header">
|
||
<h3>{t('curatorNotes')}</h3>
|
||
{isCurator && !editingNotes && (
|
||
<button
|
||
type="button"
|
||
className="curator-notes-edit-btn"
|
||
onClick={startEditingNotes}
|
||
>
|
||
{t('curatorNotesEdit')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{editingNotes ? (
|
||
<div className="curator-notes-editor">
|
||
<textarea
|
||
className="curator-notes-textarea"
|
||
value={notesDraft}
|
||
onChange={(e) => setNotesDraft(e.target.value)}
|
||
rows={6}
|
||
disabled={savingNotes}
|
||
aria-label={t('curatorNotes')}
|
||
/>
|
||
{notesError && <p className="curator-notes-error">{notesError}</p>}
|
||
<div className="curator-notes-actions">
|
||
<button
|
||
type="button"
|
||
className="curator-notes-save-btn"
|
||
onClick={saveCuratorNotes}
|
||
disabled={savingNotes}
|
||
>
|
||
{savingNotes ? t('curatorNotesSaving') : t('curatorNotesSave')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="curator-notes-cancel-btn"
|
||
onClick={cancelEditingNotes}
|
||
disabled={savingNotes}
|
||
>
|
||
{t('curatorNotesCancel')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : curatorNotes.trim() ? (
|
||
<p className="curator-notes-body">{curatorNotes}</p>
|
||
) : (
|
||
<p className="curator-notes-empty">{t('curatorNotesEmpty')}</p>
|
||
)}
|
||
</aside>
|
||
)}
|
||
{painting.description && (
|
||
<div className="painting-description">
|
||
<p>{painting.description}</p>
|
||
</div>
|
||
)}
|
||
</main>
|
||
|
||
<aside className="influence-panel influence-right">
|
||
<h3>{t('influenced')}</h3>
|
||
{influenced.length === 0 ? (
|
||
<p className="no-influences">{t('noInfluencedWorks')}</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${uploading ? ' debug-image-panel-blocked' : ''}`}
|
||
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 && !uploading && <p className="debug-image-status">Searching…</p>}
|
||
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
|
||
{!uploading && !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';
|
||
}}
|
||
/>
|
||
)}
|
||
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
|
||
<p className="debug-image-status">No Google image result found.</p>
|
||
)}
|
||
<div className="debug-action-buttons">
|
||
{canCheckup && (
|
||
<button
|
||
type="button"
|
||
className="debug-checked-btn"
|
||
onClick={handleMarkChecked}
|
||
disabled={!!painting.checkup_checked || markingChecked || uploading}
|
||
>
|
||
{markingChecked ? '…' : 'Checked'}
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className="debug-fix-btn"
|
||
onClick={handleFixImage}
|
||
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
|
||
>
|
||
{fixing ? '…' : 'Fix it'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="debug-more-btn"
|
||
onClick={handleOpenMore}
|
||
disabled={debugLoading || moreLoading || uploading}
|
||
>
|
||
{moreLoading ? '…' : 'More'}
|
||
</button>
|
||
</div>
|
||
<div className="debug-action-buttons debug-action-buttons-secondary">
|
||
<button
|
||
type="button"
|
||
className="debug-clear-btn"
|
||
onClick={handleClearImage}
|
||
disabled={clearing || fixing || uploading || imageCleared}
|
||
>
|
||
{clearing ? '…' : 'Clear'}
|
||
</button>
|
||
<DebugUploadButton
|
||
uploading={uploading}
|
||
disabled={fixing || clearing}
|
||
onUploadPress={handleUploadPress}
|
||
onFileSelected={handleUploadFile}
|
||
/>
|
||
</div>
|
||
<div className="debug-action-buttons debug-action-buttons-danger">
|
||
<button
|
||
type="button"
|
||
className="debug-remove-btn"
|
||
onClick={handleRemoveEntry}
|
||
disabled={removing || fixing || clearing || uploading}
|
||
>
|
||
{removing ? '…' : 'Remove entry'}
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
)}
|
||
|
||
<DebugSearchResultsModal
|
||
open={moreOpen && !uploading}
|
||
title="Choose painting image"
|
||
data={moreResults}
|
||
loading={moreLoading}
|
||
error={moreError}
|
||
applyingUrl={applyingUrl}
|
||
onClose={() => setMoreOpen(false)}
|
||
onSelect={handleSelectMoreResult}
|
||
/>
|
||
|
||
{fullscreen && displayImageSrc && (
|
||
<PaintingLightbox
|
||
src={displayImageSrc}
|
||
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>
|
||
);
|
||
}
|