Add influence rework, image checkup, debug mode, and fetched paintings.

Support artist and movement influence links with web discovery, a developer checkup table with gallery/detail thumbnails, and debug image search with fix-it workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-20 10:29:43 +03:00
co-authored by Cursor
parent 0ece1195fa
commit bf7db9b25e
246 changed files with 2429 additions and 301 deletions
+227 -45
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, type SyntheticEvent } from 'react';
import type { Painting, PaintingDetail } from '../types';
import { paintingImageUrl } from '../api/client';
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult } from '../api/client';
import PaintingLightbox from './PaintingLightbox';
import './PaintingDetail.css';
@@ -11,30 +11,148 @@ interface Props {
onPaintingClick: (paintingId: number) => void;
onCatalogNavigate: (paintingId: number) => void;
onArtistBio: () => void;
onInfluenceArtistClick?: (artistId: number) => void;
debugMode?: boolean;
onPaintingImageFixed?: (paintingId: number) => 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: PaintingDetail['influencedBy'][0];
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)}
onClick={() => onPaintingClick(inf.id!)}
title={`View ${inf.title}`}
>
<img
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path })}
alt={inf.title}
alt={inf.title || 'Painting'}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
@@ -42,44 +160,11 @@ function InfluenceCard({
</button>
<div className="influence-body">
<button type="button" className="influence-title-btn" onClick={() => onPaintingClick(inf.id)}>
<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>
{aspects.length > 0 && (
<div className="influence-aspects">
{aspects.map((aspect) => (
<span key={aspect} className="aspect-tag">{aspect}</span>
))}
</div>
)}
{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>
)}
{meta}
</div>
</article>
);
@@ -92,10 +177,19 @@ export default function PaintingDetailView({
onPaintingClick,
onCatalogNavigate,
onArtistBio,
onInfluenceArtistClick,
debugMode = false,
onPaintingImageFixed,
}: Props) {
const { painting, influencedBy, influenced } = data;
const [fullscreen, setFullscreen] = useState(false);
const imageSrc = paintingImageUrl(painting);
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 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;
@@ -107,8 +201,52 @@ export default function PaintingDetailView({
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 {
await api.fixPaintingImage(painting.id, debugSearch.imageUrl);
setImageVersion((v) => v + 1);
await onPaintingImageFixed?.(painting.id);
} catch (err) {
setDebugError(err instanceof Error ? err.message : 'Could not replace image.');
} finally {
setFixing(false);
}
};
useEffect(() => {
if (fullscreen) return;
@@ -159,8 +297,13 @@ export default function PaintingDetailView({
<p className="no-influences">No documented influences for this work.</p>
) : (
<div className="influence-list">
{influencedBy.map((inf) => (
<InfluenceCard key={inf.id} inf={inf} onPaintingClick={onPaintingClick} />
{influencedBy.map((inf, index) => (
<InfluenceCard
key={influenceKey(inf, index)}
inf={inf}
onPaintingClick={onPaintingClick}
onInfluenceArtistClick={onInfluenceArtistClick}
/>
))}
</div>
)}
@@ -226,14 +369,53 @@ export default function PaintingDetailView({
<p className="no-influences">No documented works influenced by this painting yet.</p>
) : (
<div className="influence-list">
{influenced.map((inf) => (
<InfluenceCard key={inf.id} inf={inf} onPaintingClick={onPaintingClick} />
{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)}
alt={`Google search result for ${debugSearch.query}`}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
/>
<button
type="button"
className="debug-fix-btn"
onClick={handleFixImage}
disabled={fixing}
>
{fixing ? 'Replacing…' : 'Fix it'}
</button>
</>
)}
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
<p className="debug-image-status">No Google image result found.</p>
)}
</aside>
)}
{fullscreen && (
<PaintingLightbox
src={imageSrc}