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
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user