Files
Art-gallery/client/src/components/PaintingDetail.tsx
T
Danila KhodjaefandCursor 0466b77328 Add curator roles/permissions with Users admin, and fix lineage branch joins.
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>
2026-07-27 18:04:17 +03:00

822 lines
28 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 { 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>&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}
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>
);
}