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 KhodjaefandCursor committed 2026-06-20 10:29:43 +03:00
1 parent 0ece1195fa
commit bf7db9b25e
246 files changed
+2429 -301

No files matched your search

+117
View File
@@ -1,4 +1,5 @@
.painting-detail {
position: relative;
min-height: 100vh;
background: linear-gradient(180deg, #1a1a2e 0%, #0f0f1a 100%);
color: #e8d5b5;
@@ -164,6 +165,48 @@
color: #c9a96e;
}
.influence-title-static {
cursor: default;
}
.influence-card-movement {
display: flex;
flex-direction: row;
}
.influence-movement-swatch {
width: 12px;
flex-shrink: 0;
}
.influence-card-artist .influence-portrait-btn img {
height: 120px;
object-fit: cover;
object-position: top center;
}
.influence-period {
font-size: 11px;
color: #a89070;
font-style: italic;
margin: 0;
}
.influence-discovered-tag {
font-size: 10px;
color: #7a6a55;
font-style: italic;
}
.influence-title-btn:disabled {
cursor: default;
opacity: 0.85;
}
.influence-image-btn:disabled {
cursor: default;
}
.influence-aspects {
display: flex;
flex-wrap: wrap;
@@ -366,6 +409,80 @@
color: rgba(232, 213, 181, 0.85);
}
.debug-image-panel {
position: fixed;
left: 16px;
bottom: 16px;
z-index: 60;
width: min(280px, calc(100vw - 32px));
padding: 12px;
border-radius: 8px;
border: 1px solid rgba(232, 160, 64, 0.45);
background: rgba(15, 15, 26, 0.94);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
font-family: ui-monospace, 'Cascadia Code', monospace;
}
.debug-image-panel h4 {
margin: 0 0 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #e8a040;
}
.debug-image-query {
margin: 0 0 8px;
font-size: 10px;
line-height: 1.4;
color: rgba(232, 213, 181, 0.75);
word-break: break-word;
}
.debug-image-preview {
display: block;
width: 100%;
max-height: 160px;
object-fit: contain;
background: #2a1f15;
border-radius: 4px;
margin-bottom: 8px;
}
.debug-fix-btn {
width: 100%;
padding: 8px 10px;
border: 1px solid #e8a040;
border-radius: 6px;
background: rgba(232, 160, 64, 0.15);
color: #e8a040;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.debug-fix-btn:hover:not(:disabled) {
background: rgba(232, 160, 64, 0.28);
}
.debug-fix-btn:disabled {
opacity: 0.6;
cursor: wait;
}
.debug-image-status {
margin: 0;
font-size: 10px;
color: rgba(201, 169, 110, 0.7);
}
.debug-image-error {
margin: 0 0 8px;
font-size: 10px;
color: #ff8a80;
}
@media (max-width: 1024px) {
.painting-layout {
grid-template-columns: 1fr;
+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}