Files
Art-gallery/client/src/pages/HomePage.tsx
T
Danila KhodjaefandCursor 5ddc3fd7f0 Add guided tours and unify left-to-right hall wall hang.
Visitors walk published tours in a 3D hall with stop notes; curators edit drafts via Tour editor. All galleries (artist, movement, tour) place the first work left of the entrance view and the last on the right.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 20:27:36 +03:00

1171 lines
41 KiB
TypeScript

import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
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 TranslationsPage from '../pages/TranslationsPage';
import InfluencesPage from '../pages/InfluencesPage';
import ToursPage from '../pages/ToursPage';
import CuratorLoginModal from '../components/CuratorLoginModal';
import ToursPopup from '../components/ToursPopup';
import CatalogSearchBar from '../components/CatalogSearchBar';
import LocaleSwitcher from '../components/LocaleSwitcher';
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
import '../components/CatalogSearchBar.css';
import '../components/CuratorLoginModal.css';
import '../components/ToursPopup.css';
import '../components/LocaleSwitcher.css';
import '../pages/TranslationsPage.css';
import '../pages/InfluencesPage.css';
import '../pages/ToursPage.css';
import { useAuth } from '../context/AuthContext';
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type {
TimelineData,
Artist,
ArtistDetail,
Painting,
PaintingDetail,
MovementGalleryDetail,
TourGalleryDetail,
} from '../types';
import { createViewChangeScheduler } from '../utils/timelineView';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode';
import './HomePage.css';
type View =
| { type: 'timeline' }
| { type: 'checkup' }
| { type: 'translations' }
| { type: 'influences' }
| { type: 'tours' }
| { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
| { 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 }
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | null;
function patchPaintingInMovementDetail(
detail: MovementGalleryDetail,
paintingId: number,
patch: Partial<Painting>
): MovementGalleryDetail {
return {
...detail,
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
};
}
function patchPaintingInTourDetail(
detail: TourGalleryDetail,
paintingId: number,
patch: Partial<Painting>
): TourGalleryDetail {
return {
...detail,
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
};
}
function patchPaintingInArtistDetail(
detail: ArtistDetail,
paintingId: number,
patch: Partial<Painting>
): ArtistDetail {
return {
...detail,
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
};
}
function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>): ArtistDetail {
return {
...detail,
artist: { ...detail.artist, ...patch },
};
}
function patchReturnToAfterRemove(
returnTo: View,
freshArtist?: ArtistDetail,
freshMovement?: MovementGalleryDetail,
freshTour?: TourGalleryDetail
): View {
if (returnTo.type === 'gallery' && freshArtist && returnTo.artistId === freshArtist.artist.id) {
return { ...returnTo, data: freshArtist };
}
if (
returnTo.type === 'movement-gallery' &&
freshMovement &&
returnTo.movementId === freshMovement.movement.id
) {
return { ...returnTo, data: freshMovement };
}
if (returnTo.type === 'tour-gallery' && freshTour && returnTo.tourId === freshTour.tour.id) {
return { ...returnTo, data: freshTour };
}
if (returnTo.type === 'painting') {
return {
...returnTo,
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour),
};
}
if (returnTo.type === 'bio') {
const data =
freshArtist && returnTo.artistId === freshArtist.artist.id ? freshArtist : returnTo.data;
return {
...returnTo,
data,
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour),
};
}
return returnTo;
}
function catalogNavigateTarget(
sorted: Painting[],
removedId: number
): number | null {
const idx = sorted.findIndex((p) => p.id === removedId);
if (idx < 0) return null;
const remaining = sorted.filter((p) => p.id !== removedId);
if (remaining.length === 0) return null;
return idx < remaining.length ? remaining[idx].id : remaining[remaining.length - 1].id;
}
export default function HomePage() {
const { t } = useTranslation('home');
const { isCurator, username, login, logout } = useAuth();
const [view, setView] = useState<View>({ type: 'timeline' });
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);
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>>({});
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
const [localeVersion, setLocaleVersion] = useState(0);
const [debugMode, setDebugMode] = useState(readDebugMode);
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
const [loginOpen, setLoginOpen] = useState(false);
const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
const [toursPopupOpen, setToursPopupOpen] = useState(false);
const effectiveDebugMode = debugMode && isCurator;
const [galleryRevision, setGalleryRevision] = useState(0);
const viewRef = useRef(view);
viewRef.current = view;
const [hoveredLifespan, setHoveredLifespan] = useState<{
birthYear: number;
deathYear: number;
color: string;
} | null>(null);
const detailReturnToRef = useRef<View>({ type: 'timeline' });
useEffect(() => {
if (view.type === 'gallery') {
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 === 'tour-gallery') {
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
} else if (view.type === 'timeline') {
setGallerySession(null);
}
}, [view]);
// Load full catalog once — pan/zoom filters client-side (MovementBands, Timeline).
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
const catalog = await api.getCatalogBootstrap();
if (cancelled) return;
const min = catalog.bounds.min_year ?? -800;
const max = catalog.bounds.max_year ?? 2025;
setBounds({ min, max });
setViewStart(min);
setViewEnd(max);
setTimelineData({ eras: catalog.eras, movements: catalog.movements });
setArtists(catalog.artists);
setError(null);
} catch {
if (!cancelled) setError('Could not load gallery data. Is the server running?');
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [localeVersion]);
const handleLocaleChange = useCallback((locale: 'en' | 'ru') => {
setApiLocale(locale);
setLocaleVersion((v) => v + 1);
}, []);
const viewChangeScheduler = useRef(
createViewChangeScheduler((start, end) => {
setViewStart(start);
setViewEnd(end);
})
);
useEffect(() => () => viewChangeScheduler.current.cancel(), []);
const handleViewChange = useCallback((start: number, end: number) => {
viewChangeScheduler.current.schedule(start, end);
}, []);
const goToTimelineHome = useCallback(() => {
detailReturnToRef.current = { type: 'timeline' };
setGallerySession(null);
setHoveredLifespan(null);
setGalleryEntryLoading(null);
setViewStart(bounds.min);
setViewEnd(bounds.max);
setGalleryRevision((revision) => revision + 1);
setView({ type: 'timeline' });
}, [bounds.min, bounds.max]);
const toggleDebugMode = () => {
setDebugMode((prev) => {
const next = !prev;
writeDebugMode(next);
return next;
});
};
const setDebugShowMoreEnabled = (enabled: boolean) => {
setDebugShowMore(enabled);
writeDebugShowMore(enabled);
};
const openCuratorLogin = (redirect: CuratorLoginRedirect = null) => {
setLoginRedirect(redirect);
setLoginOpen(true);
};
const handleCuratorLogin = async (user: string, password: string) => {
await login(user, password);
setLoginOpen(false);
if (loginRedirect === 'checkup') {
setView({ type: 'checkup' });
} else if (loginRedirect === 'translations') {
setView({ type: 'translations' });
} else if (loginRedirect === 'influences') {
setView({ type: 'influences' });
} else if (loginRedirect === 'tours') {
setView({ type: 'tours' });
}
setLoginRedirect(null);
};
const handleCuratorLogout = async () => {
await logout();
writeDebugMode(false);
setDebugMode(false);
if (
view.type === 'checkup' ||
view.type === 'translations' ||
view.type === 'influences' ||
view.type === 'tours'
) {
goToTimelineHome();
}
};
const openCheckup = () => {
if (!isCurator) {
openCuratorLogin('checkup');
return;
}
setView({ type: 'checkup' });
};
const openTranslations = () => {
if (!isCurator) {
openCuratorLogin('translations');
return;
}
setView({ type: 'translations' });
};
const openInfluences = () => {
if (!isCurator) {
openCuratorLogin('influences');
return;
}
setView({ type: 'influences' });
};
const openToursEditor = () => {
if (!isCurator) {
openCuratorLogin('tours');
return;
}
setView({ type: 'tours' });
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
image_path: fixResult.imagePath,
thumbnail_path: fixResult.thumbnailPath,
image_cache_key: fixResult.image_cache_key ?? null,
thumbnail_cache_key: fixResult.thumbnail_cache_key ?? null,
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),
};
}
if (returnTo.type === 'movement-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'tour-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo };
});
setDetailArtistPaintings((list) =>
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
);
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) };
}
if (session?.kind === 'tour') {
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
}
return 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),
};
}
if (returnTo.type === 'movement-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'tour-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo };
});
setDetailArtistPaintings((list) =>
list.map((p) => (p.id === paintingId ? { ...p, ...patch } : p))
);
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) };
}
if (session?.kind === 'tour') {
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
}
return session;
});
},
[]
);
const applyArtistPatch = useCallback((artistId: number, patch: Partial<Artist>) => {
setArtists((list) => list.map((a) => (a.id === artistId ? { ...a, ...patch } : a)));
setView((current) => {
if (current.type === 'bio' && current.artistId === artistId) {
return { ...current, data: patchArtistInArtistDetail(current.data, patch) };
}
if (current.type === 'gallery' && current.artistId === artistId) {
return { ...current, data: patchArtistInArtistDetail(current.data, patch) };
}
if (current.type === 'painting' && current.data.painting.artist_id === artistId) {
return {
...current,
data: {
...current.data,
painting: {
...current.data.painting,
artist_portrait: patch.portrait_path ?? current.data.painting.artist_portrait,
},
},
};
}
return current;
});
setGallerySession((session) =>
session?.kind === 'artist' && session.artistId === artistId
? { ...session, data: patchArtistInArtistDetail(session.data, patch) }
: session
);
}, []);
const handleArtistPortraitFixed = useCallback(
async (artistId: number, fixResult: FixArtistPortraitResult) => {
const data = await api.getArtist(artistId);
const patch: Partial<Artist> = {
portrait_path: fixResult.portraitPath,
portrait_thumb_path: fixResult.portraitThumbPath ?? null,
portrait_cache_key: fixResult.portrait_cache_key ?? null,
portrait_thumb_cache_key: fixResult.portrait_thumb_cache_key ?? null,
checkup_checked: fixResult.checked ?? true,
checkup_fixed: fixResult.fixed ?? true,
};
setPortraitRevisions((prev) => ({ ...prev, [artistId]: (prev[artistId] ?? 0) + 1 }));
applyArtistPatch(artistId, patch);
setView((current) =>
current.type === 'bio' && current.artistId === artistId
? { ...current, data: { ...data, artist: { ...data.artist, ...patch } } }
: current
);
},
[applyArtistPatch]
);
const handleArtistCheckupFlagsUpdated = useCallback(
async (artistId: number, flags: { checked: boolean; fixed: boolean }) => {
const patch: Partial<Artist> = {
checkup_checked: flags.checked,
checkup_fixed: flags.fixed,
};
applyArtistPatch(artistId, patch);
setView((current) =>
current.type === 'bio' && current.artistId === artistId
? { ...current, data: patchArtistInArtistDetail(current.data, patch) }
: current
);
},
[applyArtistPatch]
);
const openArtistGallery = useCallback((artistId: number, data: ArtistDetail) => {
const session: GallerySession = { kind: 'artist', artistId, data };
setGallerySession(session);
setView({ type: 'gallery', artistId, data });
}, []);
const openMovementGallery = useCallback((movementId: number, data: MovementGalleryDetail) => {
const session: GallerySession = { kind: 'movement', movementId, data };
setGallerySession(session);
setView({ type: 'movement-gallery', movementId, data });
}, []);
const openTourGallery = useCallback((tourId: number, data: TourGalleryDetail) => {
const session: GallerySession = { kind: 'tour', tourId, data };
setGallerySession(session);
setView({ type: 'tour-gallery', tourId, data });
}, []);
const handleSelectPublishedTour = useCallback(
async (tourId: number) => {
setToursPopupOpen(false);
setGalleryEntryLoading(t('openingTourGallery'));
try {
const data = await api.getTour(tourId);
if (!data.paintings.length) {
setError(t('tourEmpty'));
return;
}
openTourGallery(tourId, data);
} catch {
setError(t('tourLoadFailed'));
} finally {
setGalleryEntryLoading(null);
}
},
[openTourGallery, t]
);
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);
}
};
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 handlePaintingRemoved = useCallback(
async (paintingId: number, artistId: number) => {
const currentView = viewRef.current;
if (currentView.type !== 'painting' || currentView.paintingId !== paintingId) return;
const sorted =
gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery'
? detailArtistPaintings
: sortArtistPaintingsChronological(detailArtistPaintings);
const nextId = catalogNavigateTarget(sorted, paintingId);
const inMovementCatalog =
gallerySession?.kind === 'movement' || currentView.returnTo.type === 'movement-gallery';
const inTourCatalog =
gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery';
await api.deletePainting(paintingId);
const freshArtist = await api.getArtist(artistId);
let freshMovement: MovementGalleryDetail | undefined;
let freshTour: TourGalleryDetail | undefined;
if (gallerySession?.kind === 'movement') {
freshMovement = await api.getMovementGallery(gallerySession.movementId);
} else if (currentView.returnTo.type === 'movement-gallery') {
freshMovement = await api.getMovementGallery(currentView.returnTo.movementId);
}
if (gallerySession?.kind === 'tour') {
freshTour = await api.getTour(gallerySession.tourId);
} else if (currentView.returnTo.type === 'tour-gallery') {
freshTour = await api.getTour(currentView.returnTo.tourId);
}
const freshCatalog =
inTourCatalog && freshTour
? freshTour.paintings
: inMovementCatalog && freshMovement
? sortArtistPaintingsChronological(freshMovement.paintings)
: sortArtistPaintingsChronological(freshArtist.paintings);
const removedIdx = sorted.findIndex((p) => p.id === paintingId);
const navigateId =
nextId && freshCatalog.some((p) => p.id === nextId)
? nextId
: freshCatalog.length > 0
? freshCatalog[Math.min(removedIdx, freshCatalog.length - 1)]?.id ??
freshCatalog[0].id
: null;
setDetailArtistPaintings(freshCatalog);
setGallerySession((session) => {
if (!session) return session;
if (session.kind === 'artist' && session.artistId === artistId) {
return { ...session, data: freshArtist };
}
if (session.kind === 'movement' && freshMovement) {
return { ...session, data: freshMovement };
}
if (session.kind === 'tour' && freshTour) {
return { ...session, data: freshTour };
}
return session;
});
setImageRevisions((prev) => {
const next = { ...prev };
delete next[paintingId];
return next;
});
setGalleryRevision((v) => v + 1);
const patchedReturnTo = patchReturnToAfterRemove(
currentView.returnTo,
freshArtist,
freshMovement,
freshTour
);
detailReturnToRef.current = patchedReturnTo;
setView((current) => {
if (current.type === 'gallery' && current.artistId === artistId) {
return { ...current, data: freshArtist };
}
if (current.type === 'movement-gallery' && freshMovement) {
return { ...current, data: freshMovement };
}
if (current.type === 'tour-gallery' && freshTour) {
return { ...current, data: freshTour };
}
if (current.type !== 'painting' || current.paintingId !== paintingId) {
return current;
}
if (navigateId) return current;
return patchedReturnTo;
});
if (navigateId) {
const data = await api.getPainting(navigateId);
setView({
type: 'painting',
paintingId: navigateId,
data,
returnTo: patchedReturnTo,
});
}
},
[detailArtistPaintings, gallerySession]
);
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?.kind === 'tour') {
setDetailArtistPaintings(gallerySession.data.paintings);
return;
}
if (gallerySession?.kind === 'artist' && gallerySession.artistId === artistId) {
setDetailArtistPaintings(gallerySession.data.paintings);
return;
}
const returnTo = detailReturnToRef.current;
if (returnTo.type === 'tour-gallery') {
setDetailArtistPaintings(returnTo.data.paintings);
return;
}
if (returnTo.type === 'movement-gallery') {
setDetailArtistPaintings(sortArtistPaintingsChronological(returnTo.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(() => {
const fromTourSession = gallerySession?.kind === 'tour';
const fromTourReturn =
view.type === 'painting' && view.returnTo.type === 'tour-gallery';
if (fromTourSession || fromTourReturn) {
return detailArtistPaintings;
}
return sortArtistPaintingsChronological(detailArtistPaintings);
}, [detailArtistPaintings, gallerySession, view]);
const tourOverlay =
view.type === 'painting' && gallerySession?.kind === 'tour'
? {
title: gallerySession.data.tour.title,
text: gallerySession.data.stopBodies[view.paintingId] ?? '',
}
: view.type === 'painting' && view.returnTo.type === 'tour-gallery'
? {
title: view.returnTo.data.tour.title,
text: view.returnTo.data.stopBodies[view.paintingId] ?? '',
}
: null;
const galleryActive =
view.type === 'gallery' || view.type === 'movement-gallery' || view.type === 'tour-gallery';
const displayGallery = useMemo((): GallerySession | null => {
if (view.type === 'gallery') {
return { kind: 'artist', artistId: view.artistId, data: view.data };
}
if (view.type === 'movement-gallery') {
return { kind: 'movement', movementId: view.movementId, data: view.data };
}
if (view.type === 'tour-gallery') {
return { kind: 'tour', tourId: view.tourId, data: view.data };
}
return gallerySession;
}, [view, gallerySession]);
return (
<>
{displayGallery && (
<div
className={galleryActive ? 'gallery-session-active' : 'gallery-session-suspended'}
aria-hidden={!galleryActive}
>
{displayGallery.kind === 'artist' ? (
<VirtualGallery
key={`artist-${displayGallery.artistId}-${galleryRevision}`}
mode="artist"
data={displayGallery.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onNavigateArtist={handleArtistClick}
onBack={goToTimelineHome}
onBioClick={() =>
handleBioClick(displayGallery.data, {
type: 'gallery',
artistId: displayGallery.artistId,
data: displayGallery.data,
})
}
/>
) : displayGallery.kind === 'movement' ? (
<VirtualGallery
key={`movement-${displayGallery.movementId}-${galleryRevision}`}
mode="movement"
data={displayGallery.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onBack={goToTimelineHome}
/>
) : (
<VirtualGallery
key={`tour-${displayGallery.tourId}-${galleryRevision}`}
mode="tour"
data={displayGallery.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onBack={goToTimelineHome}
/>
)}
</div>
)}
{view.type === 'painting' && (
<div className="home-overlay">
<PaintingDetailView
key={view.paintingId}
data={view.data}
artistPaintings={sortedDetailArtistPaintings}
backLabel={view.returnTo.type === 'timeline' ? t('backToTimeline') : t('backToGallery')}
tourTitle={tourOverlay?.title ?? null}
tourText={tourOverlay ? tourOverlay.text : null}
onBack={() => {
if (view.returnTo.type === 'timeline') {
goToTimelineHome();
return;
}
const returnTo = view.returnTo;
if (
returnTo.type === 'gallery' &&
gallerySession?.kind === 'artist' &&
gallerySession.artistId === returnTo.artistId
) {
openArtistGallery(gallerySession.artistId, gallerySession.data);
} else if (
returnTo.type === 'movement-gallery' &&
gallerySession?.kind === 'movement' &&
gallerySession.movementId === returnTo.movementId
) {
openMovementGallery(gallerySession.movementId, gallerySession.data);
} else if (
returnTo.type === 'tour-gallery' &&
gallerySession?.kind === 'tour' &&
gallerySession.tourId === returnTo.tourId
) {
openTourGallery(gallerySession.tourId, gallerySession.data);
} else if (returnTo.type === 'gallery') {
openArtistGallery(returnTo.artistId, returnTo.data);
} else if (returnTo.type === 'movement-gallery') {
openMovementGallery(returnTo.movementId, returnTo.data);
} else if (returnTo.type === 'tour-gallery') {
openTourGallery(returnTo.tourId, returnTo.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={effectiveDebugMode}
debugShowMore={debugShowMore && isCurator}
onPaintingImageFixed={handlePaintingImageFixed}
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
onPaintingRemoved={handlePaintingRemoved}
/>
</div>
)}
{view.type === 'bio' && (
<div className="home-overlay">
<ArtistBio
artist={view.data.artist}
debugMode={effectiveDebugMode}
debugShowMore={debugShowMore && isCurator}
portraitRevision={portraitRevisions[view.data.artist.id]}
onBack={() => setView(view.returnTo)}
onEnterGallery={() =>
openArtistGallery(view.artistId, view.data)
}
onArtistPortraitFixed={handleArtistPortraitFixed}
onArtistCheckupFlagsUpdated={handleArtistCheckupFlagsUpdated}
/>
</div>
)}
{view.type === 'influences' && (
isCurator ? (
<InfluencesPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('influences')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'tours' && (
isCurator ? (
<ToursPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('tours')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'translations' && (
isCurator ? (
<TranslationsPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('translations')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'checkup' && (
isCurator ? (
<CheckupPage
onBack={goToTimelineHome}
onOpenPainting={handlePaintingClick}
/>
) : (
<div className="curator-login-gate">
<h2>Curator access required</h2>
<p>The painting checkup table is available to logged-in curators only.</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('checkup')}>
Curator login
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
Back to gallery
</button>
</div>
</div>
)
)}
<CuratorLoginModal
open={loginOpen}
onClose={() => {
setLoginOpen(false);
setLoginRedirect(null);
}}
onLogin={handleCuratorLogin}
/>
<ToursPopup
open={toursPopupOpen}
onClose={() => setToursPopupOpen(false)}
onSelectTour={(tourId) => void handleSelectPublishedTour(tourId)}
/>
{view.type === 'timeline' && (
<div className="home-page">
<header className="site-header">
<div className="site-dev-tools">
{isCurator ? (
<>
<span className="curator-session-label" title={`Signed in as ${username}`}>
{username}
</span>
<button
type="button"
className={`debug-mode-toggle${debugMode ? ' debug-mode-toggle-active' : ''}`}
onClick={toggleDebugMode}
title="Toggle developer image audit mode on painting details and artist bios"
>
Debug mode{debugMode ? ': ON' : ''}
</button>
<label
className={`debug-show-more-toggle${debugShowMore ? ' debug-show-more-toggle-active' : ''}${!debugMode ? ' debug-show-more-toggle-muted' : ''}`}
title="When debug mode is on, open the More search results popup automatically on each painting or artist page"
>
<input
type="checkbox"
checked={debugShowMore}
onChange={(e) => setDebugShowMoreEnabled(e.target.checked)}
/>
Show more
</label>
<button
type="button"
className="checkup-link-btn"
onClick={openInfluences}
title="Manage influence links"
>
{t('influences')}
</button>
<button
type="button"
className="checkup-link-btn"
onClick={openToursEditor}
title="Create and edit guided tours"
>
{t('toursEditor')}
</button>
<button
type="button"
className="checkup-link-btn"
onClick={openTranslations}
title="Review and publish Russian translations"
>
{t('translations')}
</button>
<button
type="button"
className="checkup-link-btn"
onClick={openCheckup}
title="Open painting image checkup table"
>
{t('checkup')}
</button>
<button
type="button"
className="curator-logout-btn"
onClick={handleCuratorLogout}
title="Sign out curator session"
>
{t('curatorLogout')}
</button>
</>
) : (
<button
type="button"
className="curator-login-btn"
onClick={() => openCuratorLogin()}
title="Sign in as curator to use debug tools"
>
{t('curatorLogin')}
</button>
)}
<button
type="button"
className="checkup-link-btn"
onClick={() => setToursPopupOpen(true)}
title={t('tours')}
>
{t('tours')}
</button>
<LocaleSwitcher onLocaleChange={handleLocaleChange} />
</div>
<h1>{t('title')}</h1>
<p className="site-subtitle">{t('subtitle')}</p>
<CatalogSearchBar
onSelectArtist={handleArtistClick}
onSelectMovement={handleMovementClick}
onSelectPainting={handlePaintingClick}
/>
</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}
viewEnd={viewEnd}
onViewChange={handleViewChange}
absoluteMin={bounds.min}
absoluteMax={bounds.max}
lifespanHighlight={hoveredLifespan}
/>
{error && <div className="error-banner">{error}</div>}
{!loading && (
<>
<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}
onPortraitsLoadingChange={setPortraitsLoading}
/>
</div>
</>
)}
</div>
</div>
)}
</>
);
}