Files
Art-gallery/client/src/pages/HomePage.tsx
T
Danila KhodjaefandCursor 019ce4e136 Improve 3D gallery and debug checkup workflow for image curation.
Add parquet floor, museum-style exit, movement-tinted walls, and gold/black
frames with gallery sync after Fix it. Debug panel gets Checked and Fix it
buttons; documentation and image-fetch reliability updates included.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-20 22:05:24 +03:00

380 lines
12 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import Timeline from '../components/Timeline';
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, type FixPaintingImageResult } 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 };
function patchPaintingInArtistDetail(
detail: ArtistDetail,
paintingId: number,
patch: Partial<Painting>
): ArtistDetail {
return {
...detail,
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
};
}
export default function HomePage() {
const [view, setView] = useState<View>({ type: 'timeline' });
const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>(
null
);
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
const [viewStart, setViewStart] = useState(-800);
const [viewEnd, setViewEnd] = useState(2025);
const [timelineData, setTimelineData] = useState<TimelineData>({ eras: [], movements: [] });
const [artists, setArtists] = useState<Artist[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
const [debugMode, setDebugMode] = useState(readDebugMode);
const detailReturnToRef = useRef<View>({ type: 'timeline' });
useEffect(() => {
api.getBounds()
.then((b) => {
const min = b.min_year ?? -800;
const max = b.max_year ?? 2025;
setBounds({ min, max });
setViewStart(min);
setViewEnd(max);
})
.catch(() => {});
}, []);
useEffect(() => {
if (view.type === 'gallery') {
setGallerySession({ artistId: view.artistId, data: view.data });
} else if (view.type === 'timeline') {
setGallerySession(null);
}
}, [view]);
const loadTimelineData = useCallback(async (start: number, end: number) => {
try {
setLoading(true);
const [timeline, artistList] = await Promise.all([
api.getTimeline(start, end),
api.getArtists(start, end),
]);
setTimelineData(timeline);
setArtists(artistList);
setError(null);
} catch {
setError('Could not load gallery data. Is the server running?');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadTimelineData(viewStart, viewEnd);
}, [viewStart, viewEnd, loadTimelineData]);
const handleViewChange = (start: number, end: number) => {
setViewStart(start);
setViewEnd(end);
};
const toggleDebugMode = () => {
setDebugMode((prev) => {
const next = !prev;
writeDebugMode(next);
return next;
});
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
image_path: fixResult.imagePath ?? data.painting.image_path,
thumbnail_path: fixResult.thumbnailPath ?? data.painting.thumbnail_path,
checkup_checked: fixResult.checked ?? true,
checkup_fixed: fixResult.fixed ?? true,
};
const updatedData: PaintingDetail = {
...data,
painting: { ...data.painting, ...patch },
};
setImageRevisions((prev) => ({ ...prev, [paintingId]: (prev[paintingId] ?? 0) + 1 }));
setView((current) => {
if (current.type !== 'painting' || current.paintingId !== paintingId) return current;
let returnTo = current.returnTo;
if (returnTo.type === 'gallery' && returnTo.artistId === data.painting.artist_id) {
returnTo = {
...returnTo,
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo };
});
setDetailArtistPaintings((list) =>
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
);
setGallerySession((session) =>
session && session.artistId === data.painting.artist_id
? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) }
: session
);
}, []);
const handlePaintingCheckupFlagsUpdated = useCallback(
async (paintingId: number, flags: { checked: boolean; fixed: boolean }) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
checkup_checked: flags.checked,
checkup_fixed: flags.fixed,
};
const updatedData: PaintingDetail = {
...data,
painting: { ...data.painting, ...patch },
};
setView((current) => {
if (current.type !== 'painting' || current.paintingId !== paintingId) return current;
let returnTo = current.returnTo;
if (returnTo.type === 'gallery' && returnTo.artistId === data.painting.artist_id) {
returnTo = {
...returnTo,
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo };
});
setDetailArtistPaintings((list) =>
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
);
setGallerySession((session) =>
session && session.artistId === data.painting.artist_id
? { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) }
: session
);
},
[]
);
const handleArtistClick = async (artistId: number) => {
try {
const data = await api.getArtist(artistId);
setGallerySession({ artistId, data });
setView({ type: 'gallery', artistId, data });
} catch {
setError('Failed to load artist gallery.');
}
};
const handlePaintingClick = async (paintingId: number) => {
try {
const data = await api.getPainting(paintingId);
setView((prev) => {
detailReturnToRef.current = prev;
return {
type: 'painting',
paintingId,
data,
returnTo: prev,
};
});
} catch {
setError('Failed to load painting details.');
}
};
const handleCatalogNavigate = useCallback(async (paintingId: number) => {
const returnTo = detailReturnToRef.current;
try {
const data = await api.getPainting(paintingId);
setView({
type: 'painting',
paintingId,
data,
returnTo,
});
} catch {
setError('Failed to load painting details.');
}
}, []);
const handleBioClick = (artistData: ArtistDetail, returnTo: View) => {
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo });
};
useEffect(() => {
if (view.type !== 'painting') {
setDetailArtistPaintings([]);
return;
}
const artistId = view.data.painting.artist_id;
if (gallerySession?.artistId === artistId) {
setDetailArtistPaintings(gallerySession.data.paintings);
return;
}
let cancelled = false;
api.getArtist(artistId)
.then((data) => {
if (!cancelled) setDetailArtistPaintings(data.paintings);
})
.catch(() => {
if (!cancelled) setDetailArtistPaintings([]);
});
return () => {
cancelled = true;
};
}, [view, gallerySession]);
const sortedDetailArtistPaintings = useMemo(
() => sortArtistPaintingsChronological(detailArtistPaintings),
[detailArtistPaintings]
);
const galleryActive = view.type === 'gallery';
return (
<>
{gallerySession && (
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
<VirtualGallery
data={gallerySession.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onNavigateArtist={handleArtistClick}
onBack={() => setView({ type: 'timeline' })}
onBioClick={() => handleBioClick(gallerySession.data, { type: 'gallery', ...gallerySession })}
/>
</div>
)}
{view.type === 'painting' && (
<div className="home-overlay">
<PaintingDetailView
data={view.data}
artistPaintings={sortedDetailArtistPaintings}
onBack={() => {
const returnTo = view.returnTo;
if (returnTo.type === 'gallery' && gallerySession?.artistId === returnTo.artistId) {
setView({
type: 'gallery',
artistId: gallerySession.artistId,
data: gallerySession.data,
});
} else {
setView(returnTo);
}
}}
onPaintingClick={handlePaintingClick}
onCatalogNavigate={handleCatalogNavigate}
onArtistBio={async () => {
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}
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
/>
</div>
)}
{view.type === 'bio' && (
<div className="home-overlay">
<ArtistBio
artist={view.data.artist}
onBack={() => setView(view.returnTo)}
onEnterGallery={() =>
setView({ type: 'gallery', artistId: view.artistId, data: view.data })
}
/>
</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>
<Timeline
eras={timelineData.eras}
viewStart={viewStart}
viewEnd={viewEnd}
onViewChange={handleViewChange}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
/>
{error && <div className="error-banner">{error}</div>}
{loading ? (
<div className="loading home-movements-section">Loading art history...</div>
) : (
<div className="home-movements-section">
<MovementBands
movements={timelineData.movements}
artists={artists}
viewStart={viewStart}
viewEnd={viewEnd}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
onViewChange={handleViewChange}
onArtistClick={handleArtistClick}
/>
</div>
)}
</div>
)}
</>
);
}