Add movement gallery wings with period interiors and expand timeline features.
Movement galleries split large catalogs into chronological wings (~55 works), use era-themed 3D interiors with side-wall windows, wing navigator on the back exit, and front archways between wings. Also adds painting annotations, timeline event guides, portrait hover highlights, and documentation/API updates. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
0972b5df99
commit
df29848d89
+153
-55
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import Timeline from '../components/Timeline';
|
||||
import TimelineEventGuides from '../components/TimelineEventGuides';
|
||||
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, type FixArtistPortraitResult } from '../api/client';
|
||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail } from '../types';
|
||||
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
|
||||
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
|
||||
import { readDebugMode, writeDebugMode } from '../utils/debugMode';
|
||||
import './HomePage.css';
|
||||
@@ -15,9 +16,25 @@ type View =
|
||||
| { type: 'timeline' }
|
||||
| { type: 'checkup' }
|
||||
| { type: 'gallery'; artistId: number; data: ArtistDetail }
|
||||
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
|
||||
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
|
||||
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
|
||||
|
||||
type GallerySession =
|
||||
| { kind: 'artist'; artistId: number; data: ArtistDetail }
|
||||
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail };
|
||||
|
||||
function patchPaintingInMovementDetail(
|
||||
detail: MovementGalleryDetail,
|
||||
paintingId: number,
|
||||
patch: Partial<Painting>
|
||||
): MovementGalleryDetail {
|
||||
return {
|
||||
...detail,
|
||||
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
|
||||
};
|
||||
}
|
||||
|
||||
function patchPaintingInArtistDetail(
|
||||
detail: ArtistDetail,
|
||||
paintingId: number,
|
||||
@@ -38,9 +55,7 @@ function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>)
|
||||
|
||||
export default function HomePage() {
|
||||
const [view, setView] = useState<View>({ type: 'timeline' });
|
||||
const [gallerySession, setGallerySession] = useState<{ artistId: number; data: ArtistDetail } | null>(
|
||||
null
|
||||
);
|
||||
const [gallerySession, setGallerySession] = useState<GallerySession | null>(null);
|
||||
const [bounds, setBounds] = useState({ min: -800, max: 2025 });
|
||||
const [viewStart, setViewStart] = useState(-800);
|
||||
const [viewEnd, setViewEnd] = useState(2025);
|
||||
@@ -52,6 +67,11 @@ export default function HomePage() {
|
||||
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
||||
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
|
||||
const [debugMode, setDebugMode] = useState(readDebugMode);
|
||||
const [hoveredLifespan, setHoveredLifespan] = useState<{
|
||||
birthYear: number;
|
||||
deathYear: number;
|
||||
color: string;
|
||||
} | null>(null);
|
||||
const detailReturnToRef = useRef<View>({ type: 'timeline' });
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,7 +88,9 @@ export default function HomePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (view.type === 'gallery') {
|
||||
setGallerySession({ artistId: view.artistId, data: view.data });
|
||||
setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data });
|
||||
} else if (view.type === 'movement-gallery') {
|
||||
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
|
||||
} else if (view.type === 'timeline') {
|
||||
setGallerySession(null);
|
||||
}
|
||||
@@ -132,6 +154,12 @@ export default function HomePage() {
|
||||
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
if (returnTo.type === 'movement-gallery') {
|
||||
returnTo = {
|
||||
...returnTo,
|
||||
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
return { ...current, data: updatedData, returnTo };
|
||||
});
|
||||
|
||||
@@ -139,11 +167,15 @@ export default function HomePage() {
|
||||
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
|
||||
);
|
||||
setGallerySession((session) => {
|
||||
if (session?.kind === 'artist' && session.artistId === data.painting.artist_id) {
|
||||
return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
if (session?.kind === 'movement') {
|
||||
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePaintingCheckupFlagsUpdated = useCallback(
|
||||
@@ -167,6 +199,12 @@ export default function HomePage() {
|
||||
data: patchPaintingInArtistDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
if (returnTo.type === 'movement-gallery') {
|
||||
returnTo = {
|
||||
...returnTo,
|
||||
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
|
||||
};
|
||||
}
|
||||
return { ...current, data: updatedData, returnTo };
|
||||
});
|
||||
|
||||
@@ -174,11 +212,15 @@ export default function HomePage() {
|
||||
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
|
||||
);
|
||||
setGallerySession((session) => {
|
||||
if (session?.kind === 'artist' && session.artistId === data.painting.artist_id) {
|
||||
return { ...session, data: patchPaintingInArtistDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
if (session?.kind === 'movement') {
|
||||
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
|
||||
}
|
||||
return session;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
@@ -209,7 +251,7 @@ export default function HomePage() {
|
||||
});
|
||||
|
||||
setGallerySession((session) =>
|
||||
session && session.artistId === artistId
|
||||
session?.kind === 'artist' && session.artistId === artistId
|
||||
? { ...session, data: patchArtistInArtistDetail(session.data, patch) }
|
||||
: session
|
||||
);
|
||||
@@ -253,13 +295,23 @@ export default function HomePage() {
|
||||
const handleArtistClick = async (artistId: number) => {
|
||||
try {
|
||||
const data = await api.getArtist(artistId);
|
||||
setGallerySession({ artistId, data });
|
||||
setGallerySession({ kind: 'artist', artistId, data });
|
||||
setView({ type: 'gallery', artistId, data });
|
||||
} catch {
|
||||
setError('Failed to load artist gallery.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMovementClick = async (movementId: number) => {
|
||||
try {
|
||||
const data = await api.getMovementGallery(movementId);
|
||||
setGallerySession({ kind: 'movement', movementId, data });
|
||||
setView({ type: 'movement-gallery', movementId, data });
|
||||
} catch {
|
||||
setError('Failed to load movement gallery.');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaintingClick = async (paintingId: number) => {
|
||||
try {
|
||||
const data = await api.getPainting(paintingId);
|
||||
@@ -303,11 +355,17 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
const artistId = view.data.painting.artist_id;
|
||||
if (gallerySession?.artistId === artistId) {
|
||||
if (gallerySession?.kind === 'artist' && gallerySession.artistId === artistId) {
|
||||
setDetailArtistPaintings(gallerySession.data.paintings);
|
||||
return;
|
||||
}
|
||||
|
||||
const returnTo = detailReturnToRef.current;
|
||||
if (returnTo.type === 'movement-gallery') {
|
||||
setDetailArtistPaintings(sortArtistPaintingsChronological(returnTo.data.paintings));
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
api.getArtist(artistId)
|
||||
.then((data) => {
|
||||
@@ -327,21 +385,39 @@ export default function HomePage() {
|
||||
[detailArtistPaintings]
|
||||
);
|
||||
|
||||
const galleryActive = view.type === 'gallery';
|
||||
const galleryActive = view.type === 'gallery' || view.type === 'movement-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 })}
|
||||
/>
|
||||
{gallerySession.kind === 'artist' ? (
|
||||
<VirtualGallery
|
||||
mode="artist"
|
||||
data={gallerySession.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onNavigateArtist={handleArtistClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
onBioClick={() =>
|
||||
handleBioClick(gallerySession.data, {
|
||||
type: 'gallery',
|
||||
artistId: gallerySession.artistId,
|
||||
data: gallerySession.data,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<VirtualGallery
|
||||
mode="movement"
|
||||
data={gallerySession.data}
|
||||
imageRevisions={imageRevisions}
|
||||
active={galleryActive}
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -352,12 +428,26 @@ export default function HomePage() {
|
||||
artistPaintings={sortedDetailArtistPaintings}
|
||||
onBack={() => {
|
||||
const returnTo = view.returnTo;
|
||||
if (returnTo.type === 'gallery' && gallerySession?.artistId === returnTo.artistId) {
|
||||
if (
|
||||
returnTo.type === 'gallery' &&
|
||||
gallerySession?.kind === 'artist' &&
|
||||
gallerySession.artistId === returnTo.artistId
|
||||
) {
|
||||
setView({
|
||||
type: 'gallery',
|
||||
artistId: gallerySession.artistId,
|
||||
data: gallerySession.data,
|
||||
});
|
||||
} else if (
|
||||
returnTo.type === 'movement-gallery' &&
|
||||
gallerySession?.kind === 'movement' &&
|
||||
gallerySession.movementId === returnTo.movementId
|
||||
) {
|
||||
setView({
|
||||
type: 'movement-gallery',
|
||||
movementId: gallerySession.movementId,
|
||||
data: gallerySession.data,
|
||||
});
|
||||
} else {
|
||||
setView(returnTo);
|
||||
}
|
||||
@@ -424,34 +514,42 @@ export default function HomePage() {
|
||||
<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}
|
||||
/>
|
||||
<div className="home-timeline-stack">
|
||||
<Timeline
|
||||
eras={timelineData.eras}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
onViewChange={handleViewChange}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
lifespanHighlight={hoveredLifespan}
|
||||
/>
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{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}
|
||||
portraitRevisions={portraitRevisions}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
onViewChange={handleViewChange}
|
||||
onArtistClick={handleArtistClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="loading home-movements-section">Loading art history...</div>
|
||||
) : (
|
||||
<>
|
||||
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} />
|
||||
<div className="home-movements-section">
|
||||
<MovementBands
|
||||
movements={timelineData.movements}
|
||||
artists={artists}
|
||||
portraitRevisions={portraitRevisions}
|
||||
viewStart={viewStart}
|
||||
viewEnd={viewEnd}
|
||||
absoluteMin={bounds.min}
|
||||
absoluteMax={bounds.max}
|
||||
onViewChange={handleViewChange}
|
||||
onArtistClick={handleArtistClick}
|
||||
onMovementClick={handleMovementClick}
|
||||
onArtistHover={setHoveredLifespan}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user