Add curator authentication with audit logging and fix empty 3D gallery sessions.

Introduce session-based curator login, gate debug/checkup routes, log mutations to curator_audit_log, and keep guest hall preload public. Fix gallery view mounting so WebGL halls render reliably after navigation.
This commit is contained in:
Danila Khodjaef
2026-07-06 00:17:01 +03:00
parent aa31a2aa6e
commit 9da065acbe
27 changed files with 1252 additions and 112 deletions
+165 -60
View File
@@ -6,6 +6,9 @@ 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 '../components/CuratorLoginModal.css';
import { useAuth } from '../context/AuthContext';
import { api, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
import { createViewChangeScheduler } from '../utils/timelineView';
@@ -96,6 +99,7 @@ function catalogNavigateTarget(
}
export default function HomePage() {
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 });
@@ -110,6 +114,9 @@ export default function HomePage() {
const [portraitRevisions, setPortraitRevisions] = useState<Record<number, number>>({});
const [debugMode, setDebugMode] = useState(readDebugMode);
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
const [loginOpen, setLoginOpen] = useState(false);
const [loginRedirect, setLoginRedirect] = useState<'checkup' | null>(null);
const effectiveDebugMode = debugMode && isCurator;
const [galleryRevision, setGalleryRevision] = useState(0);
const viewRef = useRef(view);
viewRef.current = view;
@@ -193,6 +200,37 @@ export default function HomePage() {
writeDebugShowMore(enabled);
};
const openCuratorLogin = (redirect: 'checkup' | null = null) => {
setLoginRedirect(redirect);
setLoginOpen(true);
};
const handleCuratorLogin = async (user: string, password: string) => {
await login(user, password);
setLoginOpen(false);
if (loginRedirect === 'checkup') {
setView({ type: 'checkup' });
}
setLoginRedirect(null);
};
const handleCuratorLogout = async () => {
await logout();
writeDebugMode(false);
setDebugMode(false);
if (view.type === 'checkup') {
setView({ type: 'timeline' });
}
};
const openCheckup = () => {
if (!isCurator) {
openCuratorLogin('checkup');
return;
}
setView({ type: 'checkup' });
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
@@ -355,11 +393,22 @@ export default function HomePage() {
[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 handleArtistClick = async (artistId: number) => {
try {
const data = await api.getArtist(artistId);
setGallerySession({ kind: 'artist', artistId, data });
setView({ type: 'gallery', artistId, data });
openArtistGallery(artistId, data);
} catch {
setError('Failed to load artist gallery.');
}
@@ -368,8 +417,7 @@ export default function HomePage() {
const handleMovementClick = async (movementId: number) => {
try {
const data = await api.getMovementGallery(movementId);
setGallerySession({ kind: 'movement', movementId, data });
setView({ type: 'movement-gallery', movementId, data });
openMovementGallery(movementId, data);
} catch {
setError('Failed to load movement gallery.');
}
@@ -539,33 +587,46 @@ export default function HomePage() {
const galleryActive = view.type === 'gallery' || view.type === 'movement-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 };
}
return gallerySession;
}, [view, gallerySession]);
return (
<>
{gallerySession && (
<div className={galleryActive ? undefined : 'gallery-session-suspended'} aria-hidden={!galleryActive}>
{gallerySession.kind === 'artist' ? (
{displayGallery && (
<div
className={galleryActive ? 'gallery-session-active' : 'gallery-session-suspended'}
aria-hidden={!galleryActive}
>
{displayGallery.kind === 'artist' ? (
<VirtualGallery
key={`artist-${gallerySession.artistId}-${galleryRevision}`}
key={`artist-${displayGallery.artistId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
mode="artist"
data={gallerySession.data}
data={displayGallery.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onNavigateArtist={handleArtistClick}
onBack={() => setView({ type: 'timeline' })}
onBioClick={() =>
handleBioClick(gallerySession.data, {
handleBioClick(displayGallery.data, {
type: 'gallery',
artistId: gallerySession.artistId,
data: gallerySession.data,
artistId: displayGallery.artistId,
data: displayGallery.data,
})
}
/>
) : (
<VirtualGallery
key={`movement-${gallerySession.movementId}-${galleryRevision}`}
key={`movement-${displayGallery.movementId}-${galleryRevision}-${galleryActive ? 'live' : 'parked'}`}
mode="movement"
data={gallerySession.data}
data={displayGallery.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
@@ -588,21 +649,17 @@ export default function HomePage() {
gallerySession?.kind === 'artist' &&
gallerySession.artistId === returnTo.artistId
) {
setView({
type: 'gallery',
artistId: gallerySession.artistId,
data: gallerySession.data,
});
openArtistGallery(gallerySession.artistId, gallerySession.data);
} else if (
returnTo.type === 'movement-gallery' &&
gallerySession?.kind === 'movement' &&
gallerySession.movementId === returnTo.movementId
) {
setView({
type: 'movement-gallery',
movementId: gallerySession.movementId,
data: gallerySession.data,
});
openMovementGallery(gallerySession.movementId, gallerySession.data);
} else if (returnTo.type === 'gallery') {
openArtistGallery(returnTo.artistId, returnTo.data);
} else if (returnTo.type === 'movement-gallery') {
openMovementGallery(returnTo.movementId, returnTo.data);
} else {
setView(returnTo);
}
@@ -614,8 +671,8 @@ export default function HomePage() {
setView({ type: 'bio', artistId: artistData.artist.id, data: artistData, returnTo: view });
}}
onInfluenceArtistClick={handleArtistClick}
debugMode={debugMode}
debugShowMore={debugShowMore}
debugMode={effectiveDebugMode}
debugShowMore={debugShowMore && isCurator}
onPaintingImageFixed={handlePaintingImageFixed}
onPaintingCheckupFlagsUpdated={handlePaintingCheckupFlagsUpdated}
onPaintingRemoved={handlePaintingRemoved}
@@ -627,12 +684,12 @@ export default function HomePage() {
<div className="home-overlay">
<ArtistBio
artist={view.data.artist}
debugMode={debugMode}
debugShowMore={debugShowMore}
debugMode={effectiveDebugMode}
debugShowMore={debugShowMore && isCurator}
portraitRevision={portraitRevisions[view.data.artist.id]}
onBack={() => setView(view.returnTo)}
onEnterGallery={() =>
setView({ type: 'gallery', artistId: view.artistId, data: view.data })
openArtistGallery(view.artistId, view.data)
}
onArtistPortraitFixed={handleArtistPortraitFixed}
onArtistCheckupFlagsUpdated={handleArtistCheckupFlagsUpdated}
@@ -641,43 +698,91 @@ export default function HomePage() {
)}
{view.type === 'checkup' && (
<CheckupPage
onBack={() => setView({ type: 'timeline' })}
onOpenPainting={handlePaintingClick}
/>
isCurator ? (
<CheckupPage
onBack={() => setView({ type: 'timeline' })}
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={() => setView({ type: 'timeline' })}>
Back to gallery
</button>
</div>
</div>
)
)}
<CuratorLoginModal
open={loginOpen}
onClose={() => {
setLoginOpen(false);
setLoginRedirect(null);
}}
onLogin={handleCuratorLogin}
/>
{view.type === 'timeline' && (
<div className="home-page">
<header className="site-header">
<div className="site-dev-tools">
<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={() => setView({ type: 'checkup' })}
title="Open painting image checkup table"
>
Checkup
</button>
{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={openCheckup}
title="Open painting image checkup table"
>
Checkup
</button>
<button
type="button"
className="curator-logout-btn"
onClick={handleCuratorLogout}
title="Sign out curator session"
>
Logout
</button>
</>
) : (
<button
type="button"
className="curator-login-btn"
onClick={() => openCuratorLogin()}
title="Sign in as curator to use debug tools"
>
Curator login
</button>
)}
</div>
<h1>Virtual Art Gallery</h1>
<p className="site-subtitle">Watch art movements branch forward through time each flowing from what came before</p>