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
+49 -6
View File
@@ -10,12 +10,47 @@ import type {
const API = '/api';
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url);
const fetchCredentials: RequestInit = { credentials: 'include' };
export type AuthRole = 'user' | 'curator';
export interface AuthState {
role: AuthRole;
username?: string;
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { ...fetchCredentials, ...init });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
export async function getAuthMe(): Promise<AuthState> {
return fetchJson<AuthState>(`${API}/auth/me`);
}
export async function loginCurator(username: string, password: string): Promise<AuthState> {
const res = await fetch(`${API}/auth/login`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Login failed: ${res.status}`);
}
return res.json();
}
export async function logoutCurator(): Promise<void> {
const res = await fetch(`${API}/auth/logout`, {
...fetchCredentials,
method: 'POST',
});
if (!res.ok) throw new Error(`Logout failed: ${res.status}`);
}
export function imageUrl(path: string | null | undefined): string {
if (!path) return '/placeholder-art.svg';
return `/images/${path}`;
@@ -84,6 +119,7 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
const res = await fetch(url, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
@@ -96,7 +132,10 @@ async function postJsonImageAction<T>(url: string, payload: { imageData: string;
}
export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> {
const res = await fetch(`${API}/artists/${artistId}/preload-images`, { method: 'POST' });
const res = await fetch(`${API}/artists/${artistId}/preload-images`, {
...fetchCredentials,
method: 'POST',
});
if (!res.ok) throw new Error('Preload failed');
return res.json();
}
@@ -198,6 +237,7 @@ export const api = {
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
) =>
fetch(`${API}/paintings/${id}/fix-image`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl, ...context }),
@@ -210,7 +250,7 @@ export const api = {
}),
clearPaintingImage: (id: number) =>
fetch(`${API}/paintings/${id}/clear-image`, { method: 'POST' }).then(async (res) => {
fetch(`${API}/paintings/${id}/clear-image`, { ...fetchCredentials, method: 'POST' }).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Clear failed: ${res.status}`);
@@ -219,7 +259,7 @@ export const api = {
}),
deletePainting: (id: number) =>
fetch(`${API}/paintings/${id}`, { method: 'DELETE' }).then(async (res) => {
fetch(`${API}/paintings/${id}`, { ...fetchCredentials, method: 'DELETE' }).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Remove failed: ${res.status}`);
@@ -239,6 +279,7 @@ export const api = {
flags: { checked?: boolean; fixed?: boolean }
) =>
fetch(`${API}/paintings/${id}/checkup-flags`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flags),
@@ -262,6 +303,7 @@ export const api = {
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
) =>
fetch(`${API}/artists/${id}/fix-portrait`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl, ...context }),
@@ -274,7 +316,7 @@ export const api = {
}),
clearArtistPortrait: (id: number) =>
fetch(`${API}/artists/${id}/clear-portrait`, { method: 'POST' }).then(async (res) => {
fetch(`${API}/artists/${id}/clear-portrait`, { ...fetchCredentials, method: 'POST' }).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Clear failed: ${res.status}`);
@@ -292,6 +334,7 @@ export const api = {
flags: { checked?: boolean; fixed?: boolean }
) =>
fetch(`${API}/artists/${id}/checkup-flags`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flags),
+132
View File
@@ -0,0 +1,132 @@
.curator-login-backdrop {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.65);
padding: 16px;
}
.curator-login-modal {
width: min(100%, 360px);
padding: 24px;
border-radius: 10px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: linear-gradient(180deg, #1a1a2e 0%, #12121f 100%);
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
}
.curator-login-modal h2 {
margin: 0 0 8px;
font-family: 'Georgia', serif;
font-size: 20px;
color: #e8d5b5;
}
.curator-login-hint {
margin: 0 0 20px;
font-size: 13px;
color: rgba(201, 169, 110, 0.7);
line-height: 1.45;
}
.curator-login-field {
display: block;
margin-bottom: 14px;
}
.curator-login-field span {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: rgba(232, 213, 181, 0.85);
}
.curator-login-field input {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(0, 0, 0, 0.35);
color: #e8d5b5;
font-size: 14px;
}
.curator-login-field input:focus {
outline: 2px solid rgba(255, 220, 160, 0.45);
outline-offset: 1px;
}
.curator-login-error {
margin: 0 0 12px;
font-size: 13px;
color: #f0a0a0;
}
.curator-login-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
}
.curator-login-cancel,
.curator-login-submit {
padding: 8px 14px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.curator-login-cancel {
border: 1px solid rgba(201, 169, 110, 0.25);
background: transparent;
color: rgba(201, 169, 110, 0.8);
}
.curator-login-submit {
border: 1px solid rgba(255, 220, 160, 0.45);
background: rgba(232, 160, 64, 0.2);
color: #e8d5b5;
}
.curator-login-submit:disabled,
.curator-login-cancel:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.curator-login-gate {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
text-align: center;
background: linear-gradient(180deg, #0f0f1a 0%, #1a1a2e 40%, #16213e 100%);
color: rgba(201, 169, 110, 0.85);
}
.curator-login-gate h2 {
margin: 0;
font-family: 'Georgia', serif;
color: #e8d5b5;
}
.curator-login-gate p {
margin: 0;
max-width: 420px;
line-height: 1.5;
}
.curator-login-gate-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
@@ -0,0 +1,90 @@
import { useEffect, useState, type FormEvent } from 'react';
import './CuratorLoginModal.css';
interface Props {
open: boolean;
onClose: () => void;
onLogin: (username: string, password: string) => Promise<void>;
}
export default function CuratorLoginModal({ open, onClose, onLogin }: Props) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!open) {
setUsername('');
setPassword('');
setError(null);
setSubmitting(false);
}
}, [open]);
if (!open) return null;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
await onLogin(username.trim(), password);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setSubmitting(false);
}
};
return (
<div className="curator-login-backdrop" onMouseDown={onClose}>
<div
className="curator-login-modal"
role="dialog"
aria-labelledby="curator-login-title"
aria-modal="true"
onMouseDown={(e) => e.stopPropagation()}
>
<h2 id="curator-login-title">Curator login</h2>
<p className="curator-login-hint">
Debug tools and catalog edits require a curator account.
</p>
<form onSubmit={handleSubmit}>
<label className="curator-login-field">
<span>Username</span>
<input
type="text"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={submitting}
required
/>
</label>
<label className="curator-login-field">
<span>Password</span>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={submitting}
required
/>
</label>
{error && <p className="curator-login-error">{error}</p>}
<div className="curator-login-actions">
<button type="button" className="curator-login-cancel" onClick={onClose} disabled={submitting}>
Cancel
</button>
<button type="submit" className="curator-login-submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</div>
</form>
</div>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { getAuthMe, loginCurator, logoutCurator, type AuthRole } from '../api/client';
interface AuthContextValue {
role: AuthRole;
username?: string;
isCurator: boolean;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [role, setRole] = useState<AuthRole>('user');
const [username, setUsername] = useState<string | undefined>();
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
const me = await getAuthMe();
setRole(me.role);
setUsername(me.username);
}, []);
useEffect(() => {
refresh().finally(() => setLoading(false));
}, [refresh]);
const login = useCallback(async (user: string, password: string) => {
const me = await loginCurator(user, password);
setRole(me.role);
setUsername(me.username);
}, []);
const logout = useCallback(async () => {
await logoutCurator();
setRole('user');
setUsername(undefined);
}, []);
const value = useMemo(
() => ({
role,
username,
isCurator: role === 'curator',
loading,
login,
logout,
refresh,
}),
[role, username, loading, login, logout, refresh]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used within AuthProvider');
}
return ctx;
}
+4 -1
View File
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { AuthProvider } from './context/AuthContext.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>,
)
+37 -4
View File
@@ -27,10 +27,20 @@
.gallery-session-suspended {
position: fixed;
inset: 0;
z-index: 0;
z-index: -1;
visibility: hidden;
pointer-events: none;
display: none;
opacity: 0;
overflow: hidden;
}
.gallery-session-active {
position: fixed;
inset: 0;
z-index: 50;
visibility: visible;
pointer-events: auto;
opacity: 1;
}
.home-overlay {
@@ -56,7 +66,9 @@
}
.debug-mode-toggle,
.checkup-link-btn {
.checkup-link-btn,
.curator-login-btn,
.curator-logout-btn {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.35);
@@ -69,7 +81,9 @@
}
.debug-mode-toggle:hover,
.checkup-link-btn:hover {
.checkup-link-btn:hover,
.curator-login-btn:hover,
.curator-logout-btn:hover {
border-color: #c9a96e;
color: #e8d5b5;
}
@@ -116,6 +130,25 @@
opacity: 0.55;
}
.curator-session-label {
font-size: 11px;
color: rgba(201, 169, 110, 0.65);
font-family: ui-monospace, 'Cascadia Code', monospace;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.curator-login-btn {
border-color: rgba(255, 220, 160, 0.35);
}
.curator-logout-btn {
border-color: rgba(201, 169, 110, 0.25);
color: rgba(201, 169, 110, 0.6);
}
.checkup-link-btn {
text-decoration: none;
}
+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>