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:
co-authored by
Cursor
parent
99b8559607
commit
1408811948
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ interface Props {
|
||||
onArtistClick: (artistId: number) => void;
|
||||
onMovementClick?: (movementId: number) => void;
|
||||
onArtistHover?: (info: { birthYear: number; deathYear: number; color: string } | null) => void;
|
||||
onPortraitsLoadingChange?: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
interface MovementLayout {
|
||||
@@ -660,6 +661,7 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
|
||||
onArtistHover,
|
||||
onHoverStart,
|
||||
onHoverEnd,
|
||||
onPortraitLoadChange,
|
||||
}: {
|
||||
artist: Artist;
|
||||
portraitRevision?: number;
|
||||
@@ -680,9 +682,29 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
|
||||
onArtistHover?: (lifespan: { birthYear: number; deathYear: number; color: string } | null) => void;
|
||||
onHoverStart: () => void;
|
||||
onHoverEnd: () => void;
|
||||
onPortraitLoadChange?: (delta: number) => void;
|
||||
}) {
|
||||
const thumbSrc = portraitThumbUrl(artist, portraitRevision);
|
||||
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 (
|
||||
<div className={`artist-on-band${isHovered ? ' artist-on-band-active' : ''}`}>
|
||||
@@ -729,12 +751,19 @@ const MovementArtistPortrait = memo(function MovementArtistPortrait({
|
||||
alt={artist.name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={settlePortraitLoad}
|
||||
onError={(e) => {
|
||||
settlePortraitLoad();
|
||||
(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>
|
||||
</button>
|
||||
@@ -754,8 +783,11 @@ export default function MovementBands({
|
||||
onArtistClick,
|
||||
onMovementClick,
|
||||
onArtistHover,
|
||||
onPortraitsLoadingChange,
|
||||
}: Props) {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const pendingPortraitsRef = useRef(0);
|
||||
const [portraitsLoading, setPortraitsLoading] = useState(false);
|
||||
const [panning, setPanning] = useState(false);
|
||||
const [interacting, setInteracting] = useState(false);
|
||||
const [canvasHeight, setCanvasHeight] = useState(DEFAULT_CANVAS_HEIGHT);
|
||||
@@ -769,6 +801,15 @@ export default function MovementBands({
|
||||
viewRef.current = { viewStart, viewEnd };
|
||||
onViewChangeRef.current = onViewChange;
|
||||
|
||||
const handlePortraitLoadChange = useCallback((delta: number) => {
|
||||
pendingPortraitsRef.current += delta;
|
||||
setPortraitsLoading(pendingPortraitsRef.current > 0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onPortraitsLoadingChange?.(portraitsLoading);
|
||||
}, [portraitsLoading, onPortraitsLoadingChange]);
|
||||
|
||||
const markInteracting = useCallback(() => {
|
||||
setInteracting(true);
|
||||
if (interactionTimer.current != null) {
|
||||
@@ -860,15 +901,23 @@ export default function MovementBands({
|
||||
return map;
|
||||
}, [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(
|
||||
() =>
|
||||
movements.filter(
|
||||
(m) =>
|
||||
m.end_year >= viewStart &&
|
||||
m.start_year <= viewEnd &&
|
||||
(artistsByMovement.get(m.id)?.length ?? 0) > 0
|
||||
movementsWithArtists.has(m.id)
|
||||
),
|
||||
[movements, viewStart, viewEnd, artistsByMovement]
|
||||
[movements, viewStart, viewEnd, movementsWithArtists]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -1342,6 +1391,7 @@ export default function MovementBands({
|
||||
onArtistHover={onArtistHover}
|
||||
onHoverStart={() => setHoveredArtistKey(artistKey)}
|
||||
onHoverEnd={() => setHoveredArtistKey(null)}
|
||||
onPortraitLoadChange={handlePortraitLoadChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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 { Text, Environment } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
@@ -11,7 +12,6 @@ import type {
|
||||
MovementArtistGroup,
|
||||
} from '../types';
|
||||
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
|
||||
import { loadTextureQueued } from '../utils/textureLoadQueue';
|
||||
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
|
||||
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
|
||||
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
|
||||
@@ -25,6 +25,36 @@ import {
|
||||
type MovementHallLayout,
|
||||
} from '../utils/movementHallLayout';
|
||||
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 {
|
||||
imageRevisions?: Record<number, number>;
|
||||
@@ -551,6 +581,7 @@ function CanvasCover({
|
||||
function usePaintingTexture(url: string | null) {
|
||||
const [texture, setTexture] = useState<THREE.Texture | null>(null);
|
||||
const [failed, setFailed] = useState(!url);
|
||||
const textureLoad = useContext(GalleryTextureLoadContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
@@ -562,9 +593,21 @@ function usePaintingTexture(url: string | null) {
|
||||
setFailed(false);
|
||||
let disposed = false;
|
||||
let loaded: THREE.Texture | null = null;
|
||||
let settled = false;
|
||||
const loader = new THREE.TextureLoader();
|
||||
loader.setCrossOrigin('anonymous');
|
||||
|
||||
loadTextureQueued(url)
|
||||
.then((tex) => {
|
||||
const finish = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
textureLoad?.end();
|
||||
};
|
||||
|
||||
textureLoad?.begin();
|
||||
loader.load(
|
||||
url,
|
||||
(tex) => {
|
||||
finish();
|
||||
if (disposed) {
|
||||
tex.dispose();
|
||||
return;
|
||||
@@ -579,17 +622,21 @@ function usePaintingTexture(url: string | null) {
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
tex.anisotropy = 4;
|
||||
setTexture(tex);
|
||||
})
|
||||
.catch(() => {
|
||||
},
|
||||
undefined,
|
||||
() => {
|
||||
finish();
|
||||
if (!disposed) setFailed(true);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
finish();
|
||||
loaded?.dispose();
|
||||
setTexture(null);
|
||||
};
|
||||
}, [url]);
|
||||
}, [url, textureLoad]);
|
||||
|
||||
return { texture, failed };
|
||||
}
|
||||
@@ -1485,6 +1532,14 @@ function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) {
|
||||
target.y = EYE_HEIGHT;
|
||||
}
|
||||
|
||||
function FrameloopSync({ active }: { active: boolean }) {
|
||||
const { invalidate } = useThree();
|
||||
useEffect(() => {
|
||||
if (active) invalidate();
|
||||
}, [active, invalidate]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function CameraController({
|
||||
position,
|
||||
target,
|
||||
@@ -1643,7 +1698,7 @@ export default function VirtualGallery(props: Props) {
|
||||
return [...props.data.paintings].sort(comparePaintingsChronological);
|
||||
}
|
||||
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 [paintings, setPaintings] = useState(initialPaintings);
|
||||
@@ -1655,6 +1710,46 @@ export default function VirtualGallery(props: Props) {
|
||||
const [nearExit, setNearExit] = useState(false);
|
||||
const [nearPassage, setNearPassage] = 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(() => {
|
||||
setPaintings(initialPaintings);
|
||||
@@ -1974,7 +2069,10 @@ export default function VirtualGallery(props: Props) {
|
||||
onPointerLeave={endCanvasDrag}
|
||||
onPointerCancel={endCanvasDrag}
|
||||
>
|
||||
{syncStatus && (
|
||||
{active && texturesPending > 0 && (
|
||||
<GalleryLoadingMarker overlay message="Loading paintings…" />
|
||||
)}
|
||||
{syncStatus && texturesPending === 0 && (
|
||||
<div className="gallery-loading-overlay gallery-sync-badge">
|
||||
<p>{syncStatus}</p>
|
||||
</div>
|
||||
@@ -1982,11 +2080,18 @@ export default function VirtualGallery(props: Props) {
|
||||
{!showExitNav && (
|
||||
<div className="gallery-exit-hint">{exitHint}</div>
|
||||
)}
|
||||
{glLost && (
|
||||
<GalleryLoadingMarker overlay message="Restoring gallery…" />
|
||||
)}
|
||||
<Canvas
|
||||
key={`${hallKey}-${glEpoch}`}
|
||||
shadows
|
||||
frameloop={active ? 'always' : 'never'}
|
||||
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
|
||||
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
|
||||
onCreated={handleCanvasCreated}
|
||||
>
|
||||
<FrameloopSync active={active} />
|
||||
<color attach="background" args={[sceneBackground]} />
|
||||
<fog attach="fog" args={[sceneFog, 18, fogFar]} />
|
||||
<ambientLight intensity={ambientIntensity} />
|
||||
@@ -1997,13 +2102,16 @@ export default function VirtualGallery(props: Props) {
|
||||
castShadow
|
||||
shadow-mapSize={[1024, 1024]}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<Environment
|
||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
||||
/>
|
||||
</Suspense>
|
||||
<ArtistHall
|
||||
<SceneErrorBoundary fallback={null}>
|
||||
<Suspense fallback={null}>
|
||||
<Environment
|
||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
||||
/>
|
||||
</Suspense>
|
||||
</SceneErrorBoundary>
|
||||
<GalleryTextureLoadContext.Provider value={textureLoad}>
|
||||
<ArtistHall
|
||||
layout={layout}
|
||||
hallTitle={hallTitle}
|
||||
hallSubtitle={hallSubtitle}
|
||||
@@ -2020,6 +2128,7 @@ export default function VirtualGallery(props: Props) {
|
||||
onNextHall={goToNextHall}
|
||||
nearPassage={nearPassage}
|
||||
/>
|
||||
</GalleryTextureLoadContext.Provider>
|
||||
<CameraController position={camPos} target={camTarget} />
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
@@ -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 TimelineEventGuides from '../components/TimelineEventGuides';
|
||||
import MovementBands from '../components/MovementBands';
|
||||
const VirtualGallery = lazy(() => import('../components/VirtualGallery'));
|
||||
import VirtualGallery from '../components/VirtualGallery';
|
||||
import PaintingDetailView from '../components/PaintingDetail';
|
||||
import ArtistBio from '../components/ArtistBio';
|
||||
import CheckupPage from '../pages/CheckupPage';
|
||||
import CuratorLoginModal from '../components/CuratorLoginModal';
|
||||
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
|
||||
import '../components/CuratorLoginModal.css';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
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 [artists, setArtists] = useState<Artist[]>([]);
|
||||
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 [detailArtistPaintings, setDetailArtistPaintings] = useState<Painting[]>([]);
|
||||
const [imageRevisions, setImageRevisions] = useState<Record<number, number>>({});
|
||||
@@ -399,21 +402,27 @@ export default function HomePage() {
|
||||
}, []);
|
||||
|
||||
const handleArtistClick = async (artistId: number) => {
|
||||
setGalleryEntryLoading('Opening artist gallery…');
|
||||
try {
|
||||
await api.preloadArtistImages(artistId).catch(() => undefined);
|
||||
const data = await api.getArtist(artistId);
|
||||
openArtistGallery(artistId, data);
|
||||
} catch {
|
||||
setError('Failed to load artist gallery.');
|
||||
} finally {
|
||||
setGalleryEntryLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMovementClick = async (movementId: number) => {
|
||||
setGalleryEntryLoading('Opening movement gallery…');
|
||||
try {
|
||||
const data = await api.getMovementGallery(movementId);
|
||||
openMovementGallery(movementId, data);
|
||||
} catch {
|
||||
setError('Failed to load movement gallery.');
|
||||
} finally {
|
||||
setGalleryEntryLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -599,9 +608,8 @@ export default function HomePage() {
|
||||
aria-hidden={!galleryActive}
|
||||
>
|
||||
{displayGallery.kind === 'artist' ? (
|
||||
<Suspense fallback={null}>
|
||||
<VirtualGallery
|
||||
key={`artist-${displayGallery.artistId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
|
||||
key={`artist-${displayGallery.artistId}-${galleryRevision}`}
|
||||
mode="artist"
|
||||
data={displayGallery.data}
|
||||
imageRevisions={imageRevisions}
|
||||
@@ -617,11 +625,9 @@ export default function HomePage() {
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
) : (
|
||||
<Suspense fallback={null}>
|
||||
<VirtualGallery
|
||||
key={`movement-${displayGallery.movementId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
|
||||
key={`movement-${displayGallery.movementId}-${galleryRevision}`}
|
||||
mode="movement"
|
||||
data={displayGallery.data}
|
||||
imageRevisions={imageRevisions}
|
||||
@@ -629,7 +635,6 @@ export default function HomePage() {
|
||||
onPaintingClick={handlePaintingClick}
|
||||
onBack={() => setView({ type: 'timeline' })}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -787,6 +792,16 @@ export default function HomePage() {
|
||||
</header>
|
||||
|
||||
<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
|
||||
eras={timelineData.eras}
|
||||
viewStart={viewStart}
|
||||
@@ -799,9 +814,7 @@ export default function HomePage() {
|
||||
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{loading && timelineData.movements.length === 0 ? (
|
||||
<div className="loading home-movements-section">Loading art history...</div>
|
||||
) : (
|
||||
{!loading && (
|
||||
<>
|
||||
<TimelineEventGuides viewStart={viewStart} viewEnd={viewEnd} />
|
||||
<div className="home-movements-section">
|
||||
@@ -817,6 +830,7 @@ export default function HomePage() {
|
||||
onArtistClick={handleArtistClick}
|
||||
onMovementClick={handleMovementClick}
|
||||
onArtistHover={setHoveredLifespan}
|
||||
onPortraitsLoadingChange={setPortraitsLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user