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:
co-authored by
Cursor
parent
0ece1195fa
commit
bf7db9b25e
@@ -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>“{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)}
|
||||
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>“{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>
|
||||
)}
|
||||
{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}
|
||||
|
||||
Reference in New Issue
Block a user