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>
380 lines
12 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|