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>
1171 lines
41 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|