Add gallery loading indicators and recover from WebGL context loss.

- Show loading markers while the catalog, portrait thumbnails, and 3D
  halls load so users know loading is still in progress.
- Recover the 3D hall from a lost WebGL context by remounting the canvas
  with a fresh context instead of leaving a permanent dark window, and
  guard the HDR environment map behind an error boundary.
- Show movement streams whose span overlaps the view even when their
  artists lived outside the visible time range.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-07 12:31:56 +03:00
co-authored by Cursor
parent 99b8559607
commit 1408811948
5 changed files with 300 additions and 31 deletions
@@ -0,0 +1,61 @@
.gallery-loading-marker {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
color: rgba(232, 213, 181, 0.92);
font-family: Georgia, serif;
font-size: 15px;
text-align: center;
pointer-events: none;
}
.gallery-loading-marker p {
margin: 0;
}
.gallery-loading-marker-spinner {
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid rgba(201, 169, 110, 0.22);
border-top-color: #c9a96e;
animation: gallery-loading-spin 0.85s linear infinite;
}
.gallery-loading-marker-overlay {
position: absolute;
inset: 0;
z-index: 20;
background: rgba(10, 10, 20, 0.72);
backdrop-filter: blur(2px);
}
.gallery-loading-marker-banner {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
z-index: 12;
flex-direction: row;
gap: 10px;
padding: 8px 16px;
border-radius: 999px;
background: rgba(15, 15, 26, 0.88);
border: 1px solid rgba(201, 169, 110, 0.35);
font-size: 13px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
}
.gallery-loading-marker-banner .gallery-loading-marker-spinner {
width: 18px;
height: 18px;
border-width: 2px;
}
@keyframes gallery-loading-spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,35 @@
import './GalleryLoadingMarker.css';
interface Props {
message?: string;
/** Full-area overlay (dims background). */
overlay?: boolean;
/** Compact strip along the bottom — does not block interaction. */
banner?: boolean;
className?: string;
}
export default function GalleryLoadingMarker({
message = 'Loading…',
overlay = false,
banner = false,
className = '',
}: Props) {
const modeClass = overlay
? ' gallery-loading-marker-overlay'
: banner
? ' gallery-loading-marker-banner'
: '';
return (
<div
className={`gallery-loading-marker${modeClass}${className ? ` ${className}` : ''}`}
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="gallery-loading-marker-spinner" aria-hidden />
<p>{message}</p>
</div>
);
}
+53 -3
View File
@@ -18,6 +18,7 @@ interface Props {
onArtistClick: (artistId: number) => void; onArtistClick: (artistId: number) => void;
onMovementClick?: (movementId: number) => void; onMovementClick?: (movementId: number) => void;
onArtistHover?: (info: { birthYear: number; deathYear: number; color: string } | null) => void; onArtistHover?: (info: { birthYear: number; deathYear: number; color: string } | null) => void;
onPortraitsLoadingChange?: (loading: boolean) => void;
} }
interface MovementLayout { interface MovementLayout {
@@ -660,6 +661,7 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
onArtistHover, onArtistHover,
onHoverStart, onHoverStart,
onHoverEnd, onHoverEnd,
onPortraitLoadChange,
}: { }: {
artist: Artist; artist: Artist;
portraitRevision?: number; portraitRevision?: number;
@@ -680,9 +682,29 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
onArtistHover?: (lifespan: { birthYear: number; deathYear: number; color: string } | null) => void; onArtistHover?: (lifespan: { birthYear: number; deathYear: number; color: string } | null) => void;
onHoverStart: () => void; onHoverStart: () => void;
onHoverEnd: () => void; onHoverEnd: () => void;
onPortraitLoadChange?: (delta: number) => void;
}) { }) {
const thumbSrc = portraitThumbUrl(artist, portraitRevision); const thumbSrc = portraitThumbUrl(artist, portraitRevision);
const queuedSrc = useQueuedImageSrc(thumbSrc); const queuedSrc = useQueuedImageSrc(thumbSrc);
const portraitPendingRef = useRef(false);
const settlePortraitLoad = useCallback(() => {
if (!portraitPendingRef.current) return;
portraitPendingRef.current = false;
onPortraitLoadChange?.(-1);
}, [onPortraitLoadChange]);
useEffect(() => {
if (!thumbSrc) return;
portraitPendingRef.current = true;
onPortraitLoadChange?.(1);
return () => {
if (portraitPendingRef.current) {
portraitPendingRef.current = false;
onPortraitLoadChange?.(-1);
}
};
}, [thumbSrc, onPortraitLoadChange]);
return ( return (
<div className={`artist-on-band${isHovered ? ' artist-on-band-active' : ''}`}> <div className={`artist-on-band${isHovered ? ' artist-on-band-active' : ''}`}>
@@ -729,12 +751,19 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
alt={artist.name} alt={artist.name}
loading="lazy" loading="lazy"
decoding="async" decoding="async"
onLoad={settlePortraitLoad}
onError={(e) => { onError={(e) => {
settlePortraitLoad();
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg'; (e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}} }}
/> />
) : ( ) : (
<img src="/placeholder-portrait.svg" alt="" aria-hidden /> <img
src="/placeholder-portrait.svg"
alt=""
aria-hidden
onLoad={settlePortraitLoad}
/>
)} )}
<span className="artist-name">{artist.name}</span> <span className="artist-name">{artist.name}</span>
</button> </button>
@@ -754,8 +783,11 @@ export default function MovementBands({
onArtistClick, onArtistClick,
onMovementClick, onMovementClick,
onArtistHover, onArtistHover,
onPortraitsLoadingChange,
}: Props) { }: Props) {
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const pendingPortraitsRef = useRef(0);
const [portraitsLoading, setPortraitsLoading] = useState(false);
const [panning, setPanning] = useState(false); const [panning, setPanning] = useState(false);
const [interacting, setInteracting] = useState(false); const [interacting, setInteracting] = useState(false);
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT); const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
@@ -769,6 +801,15 @@ export default function MovementBands({
viewRef.current = { viewStart, viewEnd }; viewRef.current = { viewStart, viewEnd };
onViewChangeRef.current = onViewChange; onViewChangeRef.current = onViewChange;
const handlePortraitLoadChange = useCallback((delta: number) => {
pendingPortraitsRef.current += delta;
setPortraitsLoading(pendingPortraitsRef.current > 0);
}, []);
useEffect(() => {
onPortraitsLoadingChange?.(portraitsLoading);
}, [portraitsLoading, onPortraitsLoadingChange]);
const markInteracting = useCallback(() => { const markInteracting = useCallback(() => {
setInteracting(true); setInteracting(true);
if (interactionTimer.current != null) { if (interactionTimer.current != null) {
@@ -860,15 +901,23 @@ export default function MovementBands({
return map; return map;
}, [artists, viewStart, viewEnd]); }, [artists, viewStart, viewEnd]);
const movementsWithArtists = useMemo(() => {
const set = new Set<number>();
for (const artist of artists) {
if (artist.movement_id) set.add(artist.movement_id);
}
return set;
}, [artists]);
const visibleMovements = useMemo( const visibleMovements = useMemo(
() => () =>
movements.filter( movements.filter(
(m) => (m) =>
m.end_year >= viewStart && m.end_year >= viewStart &&
m.start_year <= viewEnd && m.start_year <= viewEnd &&
(artistsByMovement.get(m.id)?.length ?? 0) > 0 movementsWithArtists.has(m.id)
), ),
[movements, viewStart, viewEnd, artistsByMovement] [movements, viewStart, viewEnd, movementsWithArtists]
); );
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -1342,6 +1391,7 @@ export default function MovementBands({
onArtistHover={onArtistHover} onArtistHover={onArtistHover}
onHoverStart={() => setHoveredArtistKey(artistKey)} onHoverStart={() => setHoveredArtistKey(artistKey)}
onHoverEnd={() => setHoveredArtistKey(null)} onHoverEnd={() => setHoveredArtistKey(null)}
onPortraitLoadChange={handlePortraitLoadChange}
/> />
); );
})} })}
+119 -10
View File
@@ -1,4 +1,5 @@
import { useRef, useState, useEffect, useMemo, Suspense, useCallback } from 'react'; import { useRef, useState, useEffect, useMemo, Suspense, useCallback, createContext, useContext, Component } from 'react';
import type { ReactNode } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber'; import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Text, Environment } from '@react-three/drei'; import { Text, Environment } from '@react-three/drei';
import * as THREE from 'three'; import * as THREE from 'three';
@@ -11,7 +12,6 @@ import type {
MovementArtistGroup, MovementArtistGroup,
} from '../types'; } from '../types';
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client'; import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
import { loadTextureQueued } from '../utils/textureLoadQueue';
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils'; import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures'; import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles'; import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
@@ -25,6 +25,36 @@ import {
type MovementHallLayout, type MovementHallLayout,
} from '../utils/movementHallLayout'; } from '../utils/movementHallLayout';
import './VirtualGallery.css'; import './VirtualGallery.css';
import GalleryLoadingMarker from './GalleryLoadingMarker';
const GalleryTextureLoadContext = createContext<{
begin: () => void;
end: () => void;
} | null>(null);
/**
* Keeps a single failing subtree (e.g. the network-loaded HDR environment map)
* from unmounting the whole 3D scene and leaving a dark window.
*/
class SceneErrorBoundary extends Component<
{ children: ReactNode; fallback?: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: unknown) {
console.warn('Gallery scene subtree failed, continuing without it.', error);
}
render() {
if (this.state.hasError) return this.props.fallback ?? null;
return this.props.children;
}
}
interface BaseGalleryProps { interface BaseGalleryProps {
imageRevisions?: Record<number, number>; imageRevisions?: Record<number, number>;
@@ -551,6 +581,7 @@ function CanvasCover({
function usePaintingTexture(url: string | null) { function usePaintingTexture(url: string | null) {
const [texture, setTexture] = useState<THREE.Texture | null>(null); const [texture, setTexture] = useState<THREE.Texture | null>(null);
const [failed, setFailed] = useState(!url); const [failed, setFailed] = useState(!url);
const textureLoad = useContext(GalleryTextureLoadContext);
useEffect(() => { useEffect(() => {
if (!url) { if (!url) {
@@ -562,9 +593,21 @@ function usePaintingTexture(url: string | null) {
setFailed(false); setFailed(false);
let disposed = false; let disposed = false;
let loaded: THREE.Texture | null = null; let loaded: THREE.Texture | null = null;
let settled = false;
const loader = new THREE.TextureLoader();
loader.setCrossOrigin('anonymous');
loadTextureQueued(url) const finish = () => {
.then((tex) => { if (settled) return;
settled = true;
textureLoad?.end();
};
textureLoad?.begin();
loader.load(
url,
(tex) => {
finish();
if (disposed) { if (disposed) {
tex.dispose(); tex.dispose();
return; return;
@@ -579,17 +622,21 @@ function usePaintingTexture(url: string | null) {
tex.colorSpace = THREE.SRGBColorSpace; tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4; tex.anisotropy = 4;
setTexture(tex); setTexture(tex);
}) },
.catch(() => { undefined,
() => {
finish();
if (!disposed) setFailed(true); if (!disposed) setFailed(true);
}); }
);
return () => { return () => {
disposed = true; disposed = true;
finish();
loaded?.dispose(); loaded?.dispose();
setTexture(null); setTexture(null);
}; };
}, [url]); }, [url, textureLoad]);
return { texture, failed }; return { texture, failed };
} }
@@ -1485,6 +1532,14 @@ function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) {
target.y = EYE_HEIGHT; target.y = EYE_HEIGHT;
} }
function FrameloopSync({ active }: { active: boolean }) {
const { invalidate } = useThree();
useEffect(() => {
if (active) invalidate();
}, [active, invalidate]);
return null;
}
function CameraController({ function CameraController({
position, position,
target, target,
@@ -1643,7 +1698,7 @@ export default function VirtualGallery(props: Props) {
return [...props.data.paintings].sort(comparePaintingsChronological); return [...props.data.paintings].sort(comparePaintingsChronological);
} }
return props.data.paintings; return props.data.paintings;
}, [props]); }, [props.mode, props.mode === 'movement' ? props.data.movement.id : props.data.artist.id, props.data.paintings]);
const initialPeriods = isMovement ? [] : props.data.periods; const initialPeriods = isMovement ? [] : props.data.periods;
const [paintings, setPaintings] = useState(initialPaintings); const [paintings, setPaintings] = useState(initialPaintings);
@@ -1655,6 +1710,46 @@ export default function VirtualGallery(props: Props) {
const [nearExit, setNearExit] = useState(false); const [nearExit, setNearExit] = useState(false);
const [nearPassage, setNearPassage] = useState(false); const [nearPassage, setNearPassage] = useState(false);
const [isLooking, setIsLooking] = useState(false); const [isLooking, setIsLooking] = useState(false);
const [texturesPending, setTexturesPending] = useState(0);
const [glEpoch, setGlEpoch] = useState(0);
const [glLost, setGlLost] = useState(false);
const textureLoad = useMemo(
() => ({
begin: () => setTexturesPending((n) => n + 1),
end: () => setTexturesPending((n) => Math.max(0, n - 1)),
}),
[]
);
useEffect(() => {
setTexturesPending(0);
}, [hallKey, glEpoch]);
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
const canvas = state.gl.domElement;
// A freshly created canvas has a healthy context, so clear any lingering
// "restoring" state from a previous loss/remount.
setGlLost(false);
const onLost = (event: Event) => {
// Prevent the default so the browser can restore the context, and
// force a clean remount to obtain a fresh WebGL context if it does not.
event.preventDefault();
setGlLost(true);
window.setTimeout(() => {
setGlLost((stillLost) => {
if (stillLost) setGlEpoch((n) => n + 1);
return stillLost;
});
}, 600);
};
const onRestored = () => {
setGlLost(false);
setGlEpoch((n) => n + 1);
};
canvas.addEventListener('webglcontextlost', onLost as EventListener, false);
canvas.addEventListener('webglcontextrestored', onRestored as EventListener, false);
}, []);
useEffect(() => { useEffect(() => {
setPaintings(initialPaintings); setPaintings(initialPaintings);
@@ -1974,7 +2069,10 @@ export default function VirtualGallery(props: Props) {
onPointerLeave={endCanvasDrag} onPointerLeave={endCanvasDrag}
onPointerCancel={endCanvasDrag} onPointerCancel={endCanvasDrag}
> >
{syncStatus && ( {active && texturesPending > 0 && (
<GalleryLoadingMarker overlay message="Loading paintings…" />
)}
{syncStatus && texturesPending === 0 && (
<div className="gallery-loading-overlay gallery-sync-badge"> <div className="gallery-loading-overlay gallery-sync-badge">
<p>{syncStatus}</p> <p>{syncStatus}</p>
</div> </div>
@@ -1982,11 +2080,18 @@ export default function VirtualGallery(props: Props) {
{!showExitNav && ( {!showExitNav && (
<div className="gallery-exit-hint">{exitHint}</div> <div className="gallery-exit-hint">{exitHint}</div>
)} )}
{glLost && (
<GalleryLoadingMarker overlay message="Restoring gallery…" />
)}
<Canvas <Canvas
key={`${hallKey}-${glEpoch}`}
shadows shadows
frameloop={active ? 'always' : 'never'} frameloop={active ? 'always' : 'never'}
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }} camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
onCreated={handleCanvasCreated}
> >
<FrameloopSync active={active} />
<color attach="background" args={[sceneBackground]} /> <color attach="background" args={[sceneBackground]} />
<fog attach="fog" args={[sceneFog, 18, fogFar]} /> <fog attach="fog" args={[sceneFog, 18, fogFar]} />
<ambientLight intensity={ambientIntensity} /> <ambientLight intensity={ambientIntensity} />
@@ -1997,12 +2102,15 @@ export default function VirtualGallery(props: Props) {
castShadow castShadow
shadow-mapSize={[1024, 1024]} shadow-mapSize={[1024, 1024]}
/> />
<SceneErrorBoundary fallback={null}>
<Suspense fallback={null}> <Suspense fallback={null}>
<Environment <Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'} preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.35 : 0.15} environmentIntensity={interiorStyle ? 0.35 : 0.15}
/> />
</Suspense> </Suspense>
</SceneErrorBoundary>
<GalleryTextureLoadContext.Provider value={textureLoad}>
<ArtistHall <ArtistHall
layout={layout} layout={layout}
hallTitle={hallTitle} hallTitle={hallTitle}
@@ -2020,6 +2128,7 @@ export default function VirtualGallery(props: Props) {
onNextHall={goToNextHall} onNextHall={goToNextHall}
nearPassage={nearPassage} nearPassage={nearPassage}
/> />
</GalleryTextureLoadContext.Provider>
<CameraController position={camPos} target={camTarget} /> <CameraController position={camPos} target={camTarget} />
</Canvas> </Canvas>
</div> </div>
+25 -11
View File
@@ -1,12 +1,13 @@
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import Timeline from '../components/Timeline'; import Timeline from '../components/Timeline';
import TimelineEventGuides from '../components/TimelineEventGuides'; import TimelineEventGuides from '../components/TimelineEventGuides';
import MovementBands from '../components/MovementBands'; import MovementBands from '../components/MovementBands';
const VirtualGallery = lazy(() => import('../components/VirtualGallery')); import VirtualGallery from '../components/VirtualGallery';
import PaintingDetailView from '../components/PaintingDetail'; import PaintingDetailView from '../components/PaintingDetail';
import ArtistBio from '../components/ArtistBio'; import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage'; import CheckupPage from '../pages/CheckupPage';
import CuratorLoginModal from '../components/CuratorLoginModal'; import CuratorLoginModal from '../components/CuratorLoginModal';
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
import '../components/CuratorLoginModal.css'; import '../components/CuratorLoginModal.css';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client'; import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
@@ -108,6 +109,8 @@ export default function HomePage() {
const [timelineData, setTimelineData] = useState<TimelineData>({ eras: [], movements: [] }); const [timelineData, setTimelineData] = useState<TimelineData>({ eras: [], movements: [] });
const [artists, setArtists] = useState<Artist[]>([]); const [artists, setArtists] = useState<Artist[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [portraitsLoading, setPortraitsLoading] = useState(false);
const [galleryEntryLoading, setGalleryEntryLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]); const [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({}); const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
@@ -399,21 +402,27 @@ export default function HomePage() {
}, []); }, []);
const handleArtistClick = async (artistId: number) => { const handleArtistClick = async (artistId: number) => {
setGalleryEntryLoading('Opening artist gallery…');
try { try {
await api.preloadArtistImages(artistId).catch(() => undefined); await api.preloadArtistImages(artistId).catch(() => undefined);
const data = await api.getArtist(artistId); const data = await api.getArtist(artistId);
openArtistGallery(artistId, data); openArtistGallery(artistId, data);
} catch { } catch {
setError('Failed to load artist gallery.'); setError('Failed to load artist gallery.');
} finally {
setGalleryEntryLoading(null);
} }
}; };
const handleMovementClick = async (movementId: number) => { const handleMovementClick = async (movementId: number) => {
setGalleryEntryLoading('Opening movement gallery…');
try { try {
const data = await api.getMovementGallery(movementId); const data = await api.getMovementGallery(movementId);
openMovementGallery(movementId, data); openMovementGallery(movementId, data);
} catch { } catch {
setError('Failed to load movement gallery.'); setError('Failed to load movement gallery.');
} finally {
setGalleryEntryLoading(null);
} }
}; };
@@ -599,9 +608,8 @@ export default function HomePage() {
aria-hidden={!galleryActive} aria-hidden={!galleryActive}
> >
{displayGallery.kind === 'artist' ? ( {displayGallery.kind === 'artist' ? (
<Suspense fallback={null}>
<VirtualGallery <VirtualGallery
key={`artist-${displayGallery.artistId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`} key={`artist-${displayGallery.artistId}-${galleryRevision}`}
mode="artist" mode="artist"
data={displayGallery.data} data={displayGallery.data}
imageRevisions={imageRevisions} imageRevisions={imageRevisions}
@@ -617,11 +625,9 @@ export default function HomePage() {
}) })
} }
/> />
</Suspense>
) : ( ) : (
<Suspense fallback={null}>
<VirtualGallery <VirtualGallery
key={`movement-${displayGallery.movementId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`} key={`movement-${displayGallery.movementId}-${galleryRevision}`}
mode="movement" mode="movement"
data={displayGallery.data} data={displayGallery.data}
imageRevisions={imageRevisions} imageRevisions={imageRevisions}
@@ -629,7 +635,6 @@ export default function HomePage() {
onPaintingClick={handlePaintingClick} onPaintingClick={handlePaintingClick}
onBack={() => setView({ type: 'timeline' })} onBack={() => setView({ type: 'timeline' })}
/> />
</Suspense>
)} )}
</div> </div>
)} )}
@@ -787,6 +792,16 @@ export default function HomePage() {
</header> </header>
<div className="home-timeline-stack"> <div className="home-timeline-stack">
{loading && (
<GalleryLoadingMarker overlay message="Loading art history…" />
)}
{galleryEntryLoading && (
<GalleryLoadingMarker overlay message={galleryEntryLoading} />
)}
{portraitsLoading && !loading && !galleryEntryLoading && (
<GalleryLoadingMarker banner message="Loading portraits…" />
)}
<Timeline <Timeline
eras={timelineData.eras} eras={timelineData.eras}
viewStart={viewStart} viewStart={viewStart}
@@ -799,9 +814,7 @@ export default function HomePage() {
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{loading && timelineData.movements.length === 0 ? ( {!loading && (
<div className="loading home-movements-section">Loading art history...</div>
) : (
<> <>
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} /> <TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} />
<div className="home-movements-section"> <div className="home-movements-section">
@@ -817,6 +830,7 @@ export default function HomePage() {
onArtistClick={handleArtistClick} onArtistClick={handleArtistClick}
onMovementClick={handleMovementClick} onMovementClick={handleMovementClick}
onArtistHover={setHoveredLifespan} onArtistHover={setHoveredLifespan}
onPortraitsLoadingChange={setPortraitsLoading}
/> />
</div> </div>
</> </>