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
+50
View File
@@ -46,6 +46,34 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
return res.json();
}
export interface DebugImageSearchResult {
query: string;
imageUrl: string | null;
searchUrl: string;
source: string;
sourceLabel?: string;
thumbUrl?: string;
}
export interface PaintingCheckupRow {
id: number;
title: string;
artist_name: string;
year: number | null;
gallery_file: string | null;
gallery_preview: string | null;
detail_file: string;
detail_preview: string | null;
detail_on_demand: boolean;
gallery_file_exists: boolean;
detail_file_exists: boolean | null;
}
export interface PaintingCheckupData {
paintings: PaintingCheckupRow[];
total: number;
}
export const api = {
getBounds: () => fetchJson<YearBounds>(`${API}/bounds`),
@@ -66,4 +94,26 @@ export const api = {
fetchJson<ArtistNavigation>(`${API}/artists/${id}/navigation`),
getPainting: (id: number) => fetchJson<PaintingDetail>(`${API}/paintings/${id}`),
getPaintingDebugImageSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
fixPaintingImage: (id: number, imageUrl: string) =>
fetch(`${API}/paintings/${id}/fix-image`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Fix failed: ${res.status}`);
}
return res.json() as Promise<{ imagePath: string; thumbnailPath: string }>;
}),
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
};
export function debugImageProxyUrl(imageUrl: string): string {
return `${API}/debug/image-proxy?url=${encodeURIComponent(imageUrl)}`;
}
+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}
+213
View File
@@ -0,0 +1,213 @@
.checkup-page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%);
color: #e8d5b5;
}
.checkup-header {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px 24px 12px;
border-bottom: 1px solid rgba(201, 169, 110, 0.25);
flex-shrink: 0;
}
.checkup-back-btn {
padding: 8px 14px;
border: 1px solid rgba(201, 169, 110, 0.35);
border-radius: 6px;
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
font-size: 13px;
cursor: pointer;
flex-shrink: 0;
}
.checkup-back-btn:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
.checkup-title-block h1 {
margin: 0;
font-family: 'Georgia', serif;
font-size: 26px;
color: #e8d5b5;
}
.checkup-title-block p {
margin: 6px 0 0;
font-size: 13px;
color: rgba(201, 169, 110, 0.65);
}
.checkup-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 20px;
padding: 12px 24px;
flex-shrink: 0;
}
.checkup-filter {
flex: 1;
min-width: 220px;
max-width: 420px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.3);
background: rgba(15, 15, 26, 0.9);
color: #e8d5b5;
font-size: 13px;
}
.checkup-stats {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
font-size: 11px;
font-family: ui-monospace, 'Cascadia Code', monospace;
color: rgba(201, 169, 110, 0.7);
}
.checkup-error {
margin: 0 24px 12px;
padding: 10px 14px;
border-radius: 6px;
background: rgba(139, 0, 0, 0.3);
border: 1px solid rgba(255, 100, 100, 0.4);
color: #ffaaaa;
}
.checkup-loading {
padding: 48px;
text-align: center;
color: rgba(201, 169, 110, 0.6);
font-family: 'Georgia', serif;
}
.checkup-table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
padding: 0 24px 24px;
}
.checkup-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.checkup-table thead {
position: sticky;
top: 0;
z-index: 1;
}
.checkup-table th {
text-align: left;
padding: 10px 12px;
background: rgba(26, 26, 46, 0.98);
border-bottom: 2px solid rgba(201, 169, 110, 0.35);
color: #c9a96e;
font-weight: 600;
font-size: 12px;
white-space: nowrap;
}
.checkup-table td {
padding: 10px 12px;
border-bottom: 1px solid rgba(201, 169, 110, 0.12);
vertical-align: middle;
}
.checkup-thumb-cell {
width: 200px;
}
.checkup-thumb {
position: relative;
width: 180px;
height: 140px;
border-radius: 6px;
overflow: hidden;
background: #2a1f15;
border: 1px solid rgba(201, 169, 110, 0.25);
display: flex;
align-items: center;
justify-content: center;
}
.checkup-thumb img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.checkup-thumb-empty {
flex-direction: column;
gap: 4px;
color: rgba(201, 169, 110, 0.45);
font-size: 12px;
text-align: center;
}
.checkup-thumb-empty small {
font-size: 10px;
opacity: 0.75;
}
.checkup-thumb-missing {
border-color: rgba(255, 138, 128, 0.45);
}
.checkup-thumb-badge {
position: absolute;
left: 6px;
bottom: 6px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(15, 15, 26, 0.88);
border: 1px solid rgba(255, 138, 128, 0.45);
font-size: 9px;
font-family: ui-monospace, 'Cascadia Code', monospace;
color: #ff8a80;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.checkup-thumb-badge-api {
border-color: rgba(158, 197, 232, 0.45);
color: #9ec5e8;
}
.checkup-table tbody tr:hover {
background: rgba(201, 169, 110, 0.06);
}
.checkup-year {
white-space: nowrap;
color: rgba(232, 213, 181, 0.85);
}
.checkup-title-link {
background: none;
border: none;
padding: 0;
color: #e8d5b5;
font: inherit;
text-align: left;
cursor: pointer;
text-decoration: underline;
text-decoration-color: rgba(201, 169, 110, 0.35);
}
.checkup-title-link:hover {
color: #c9a96e;
}
+193
View File
@@ -0,0 +1,193 @@
import { useEffect, useMemo, useState } from 'react';
import { api, type PaintingCheckupRow } from '../api/client';
import './CheckupPage.css';
interface Props {
onBack: () => void;
onOpenPainting?: (paintingId: number) => void;
}
function previewSrc(row: PaintingCheckupRow, kind: 'gallery' | 'detail'): string | null {
if (kind === 'gallery') {
const file = row.gallery_preview ?? row.gallery_file;
return file ? `/images/${file}` : null;
}
if (row.detail_on_demand) {
return `/api/paintings/${row.id}/image?size=thumb`;
}
const file = row.detail_preview ?? row.detail_file;
return file ? `/images/${file}` : null;
}
function ImagePreviewCell({
row,
kind,
}: {
row: PaintingCheckupRow;
kind: 'gallery' | 'detail';
}) {
const path = kind === 'gallery' ? row.gallery_file : row.detail_file;
const src = previewSrc(row, kind);
const exists =
kind === 'gallery' ? row.gallery_file_exists : row.detail_file_exists;
const missing = path && exists === false;
if (!path) {
return (
<div className="checkup-thumb checkup-thumb-empty">
<span>No image</span>
<small>Hidden in 3D</small>
</div>
);
}
return (
<div className={`checkup-thumb${missing ? ' checkup-thumb-missing' : ''}`}>
{src && (
<img
src={src}
alt=""
loading="lazy"
decoding="async"
title={path}
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
/>
)}
{missing && <span className="checkup-thumb-badge">missing file</span>}
{row.detail_on_demand && kind === 'detail' && (
<span className="checkup-thumb-badge checkup-thumb-badge-api">on-demand</span>
)}
</div>
);
}
export default function CheckupPage({ onBack, onOpenPainting }: Props) {
const [rows, setRows] = useState<PaintingCheckupRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
useEffect(() => {
let cancelled = false;
setLoading(true);
api.getPaintingCheckup()
.then((data) => {
if (!cancelled) {
setRows(data.paintings);
setError(null);
}
})
.catch(() => {
if (!cancelled) setError('Could not load checkup data. Is the server running?');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return rows;
return rows.filter(
(row) =>
row.title.toLowerCase().includes(q) ||
row.artist_name.toLowerCase().includes(q) ||
String(row.year ?? '').includes(q) ||
(row.gallery_file ?? '').toLowerCase().includes(q) ||
row.detail_file.toLowerCase().includes(q)
);
}, [rows, filter]);
const stats = useMemo(() => {
const noGallery = rows.filter((r) => !r.gallery_file).length;
const onDemand = rows.filter((r) => r.detail_on_demand).length;
const missingGallery = rows.filter((r) => r.gallery_file && !r.gallery_file_exists).length;
const missingDetail = rows.filter(
(r) => r.detail_file_exists === false
).length;
return { noGallery, onDemand, missingGallery, missingDetail };
}, [rows]);
return (
<div className="checkup-page">
<header className="checkup-header">
<button type="button" className="checkup-back-btn" onClick={onBack}>
Back to timeline
</button>
<div className="checkup-title-block">
<h1>Painting checkup</h1>
<p>Compare image files used in the 3D gallery vs painting detail view.</p>
</div>
</header>
<div className="checkup-toolbar">
<input
type="search"
className="checkup-filter"
placeholder="Filter by title, artist, year…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<div className="checkup-stats">
<span>{filtered.length} shown</span>
<span>{stats.noGallery} no gallery file</span>
<span>{stats.onDemand} detail on-demand</span>
<span>{stats.missingGallery} gallery missing</span>
<span>{stats.missingDetail} detail missing</span>
</div>
</div>
{error && <div className="checkup-error">{error}</div>}
{loading ? (
<div className="checkup-loading">Loading paintings</div>
) : (
<div className="checkup-table-wrap">
<table className="checkup-table">
<thead>
<tr>
<th>Painting</th>
<th>Artist</th>
<th>Year</th>
<th>Gallery</th>
<th>Detail</th>
</tr>
</thead>
<tbody>
{filtered.map((row) => (
<tr key={row.id}>
<td>
{onOpenPainting ? (
<button
type="button"
className="checkup-title-link"
onClick={() => onOpenPainting(row.id)}
>
{row.title}
</button>
) : (
row.title
)}
</td>
<td>{row.artist_name}</td>
<td className="checkup-year">{row.year ?? '—'}</td>
<td className="checkup-thumb-cell">
<ImagePreviewCell row={row} kind="gallery" />
</td>
<td className="checkup-thumb-cell">
<ImagePreviewCell row={row} kind="detail" />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+39
View File
@@ -32,6 +32,45 @@
text-align: center;
padding: 24px 16px 8px;
flex-shrink: 0;
position: relative;
}
.site-dev-tools {
position: absolute;
top: 16px;
right: 16px;
display: flex;
gap: 8px;
align-items: center;
}
.debug-mode-toggle,
.checkup-link-btn {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: rgba(201, 169, 110, 0.75);
font-size: 12px;
font-family: ui-monospace, 'Cascadia Code', monospace;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.debug-mode-toggle:hover,
.checkup-link-btn:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
.debug-mode-toggle-active {
border-color: #e8a040;
background: rgba(232, 160, 64, 0.15);
color: #e8a040;
}
.checkup-link-btn {
text-decoration: none;
}
.site-header h1 {
+77
View File
@@ -4,13 +4,16 @@ import MovementBands from '../components/MovementBands';
import VirtualGallery from '../components/VirtualGallery';
import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage';
import { api } from '../api/client';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
import './HomePage.css';
type View =
| { type: 'timeline' }
| { type: 'checkup' }
| { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
@@ -28,6 +31,7 @@ export default function HomePage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
const [debugMode, setDebugMode] = useState(readDebugMode);
const detailReturnToRef = useRef<View>({ type: 'timeline' });
useEffect(() => {
@@ -76,6 +80,51 @@ export default function HomePage() {
setViewEnd(end);
};
const toggleDebugMode = () => {
setDebugMode((prev) => {
const next = !prev;
writeDebugMode(next);
return next;
});
};
const handlePaintingImageFixed = useCallback(async (paintingId: number) => {
const data = await api.getPainting(paintingId);
setView((current) =>
current.type === 'painting' && current.paintingId === paintingId
? { ...current, data }
: current
);
setDetailArtistPaintings((list) =>
list.map((p) =>
p.id === paintingId
? { ...p, image_path: data.painting.image_path, thumbnail_path: data.painting.thumbnail_path }
: p
)
);
if (gallerySession?.artistId === data.painting.artist_id) {
setGallerySession((session) =>
session
? {
...session,
data: {
...session.data,
paintings: session.data.paintings.map((p) =>
p.id === paintingId
? {
...p,
image_path: data.painting.image_path,
thumbnail_path: data.painting.thumbnail_path,
}
: p
),
},
}
: session
);
}
}, [gallerySession]);
const handleArtistClick = async (artistId: number) => {
try {
const data = await api.getArtist(artistId);
@@ -182,6 +231,9 @@ export default function HomePage() {
const artistData = await api.getArtist(view.data.painting.artist_id);
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
}}
onInfluenceArtistClick={handleArtistClick}
debugMode={debugMode}
onPaintingImageFixed={handlePaintingImageFixed}
/>
</div>
)}
@@ -198,9 +250,34 @@ export default function HomePage() {
</div>
)}
{view.type === 'checkup' && (
<CheckupPage
onBack={() => setView({ type: 'timeline' })}
onOpenPainting={handlePaintingClick}
/>
)}
{view.type === 'timeline' && (
<div className="home-page">
<header className="site-header">
<div className="site-dev-tools">
<button
type="button"
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
onClick={toggleDebugMode}
title="Toggle developer image audit mode on painting details"
>
Debug mode{debugMode ? ': ON' : ''}
</button>
<button
type="button"
className="checkup-link-btn"
onClick={() => setView({ type: 'checkup' })}
title="Open painting image checkup table"
>
Checkup
</button>
</div>
<h1>Virtual Art Gallery</h1>
<p className="site-subtitle">Watch art movements branch forward through time each flowing from what came before</p>
</header>
+22 -8
View File
@@ -63,19 +63,33 @@ export interface Painting {
has_influence_links?: boolean | string | number;
}
export type InfluenceSourceType = 'painting' | 'artist' | 'movement';
export interface InfluenceLink {
notes: string;
source: string;
source_type: InfluenceSourceType;
notes?: string;
source?: string;
aspects?: string;
quote?: string;
source_author?: string;
source_url?: string;
id: number;
title: string;
year: number;
image_path: string;
artist_name: string;
artist_id: number;
confidence?: string;
discovered_via?: string;
period_note?: string;
period_start_year?: number | null;
period_end_year?: number | null;
id?: number;
title?: string;
year?: number | null;
image_path?: string | null;
artist_name?: string;
artist_id?: number;
source_artist_id?: number;
source_artist_name?: string;
artist_portrait?: string | null;
movement_id?: number;
movement_name?: string;
movement_color?: string;
}
export interface PaintingDetail {
+17
View File
@@ -0,0 +1,17 @@
const DEBUG_MODE_KEY = 'gallery-debug-mode';
export function readDebugMode(): boolean {
try {
return localStorage.getItem(DEBUG_MODE_KEY) === '1';
} catch {
return false;
}
}
export function writeDebugMode(enabled: boolean): void {
try {
localStorage.setItem(DEBUG_MODE_KEY, enabled ? '1' : '0');
} catch {
// ignore
}
}