This commit is contained in:
Danila Khodjaef committed 2026-07-22 23:14:09 +03:00
commit 77befd94b2
482 files changed
+19800 -1037

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
.artist-bio {
position: relative;
min-height: 100vh;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
color: #e8d5b5;
+53 -37
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { Artist } from '../types';
import {
api,
@@ -9,7 +9,10 @@ import {
type FixArtistPortraitResult,
} from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
import DebugUploadButton from './DebugUploadButton';
import GalleryLoadingMarker from './GalleryLoadingMarker';
import '../components/PaintingDetail.css';
import '../components/GalleryLoadingMarker.css';
import './ArtistBio.css';
interface Props {
@@ -51,11 +54,13 @@ export default function ArtistBio({
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [uploading, setUploading] = useState(false);
const uploadInputRef = useRef<HTMLInputElement>(null);
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
const portraitCleared = !artist.portrait_path && !!artist.checkup_fixed;
const showPortrait = !!artist.portrait_path || !artist.checkup_fixed;
const portraitSrc = portraitUrl(artist.portrait_path, portraitRevision || undefined);
const showPortrait = !uploading && (!!artist.portrait_path || !artist.checkup_fixed);
const portraitSrc = uploading
? null
: portraitUrl(artist.portrait_path, portraitRevision || undefined, artist);
const lifespan =
artist.birth_year && artist.death_year
@@ -71,6 +76,9 @@ export default function ArtistBio({
setMoreOpen(false);
return;
}
if (uploading) {
return;
}
let cancelled = false;
setDebugLoading(true);
@@ -91,7 +99,7 @@ export default function ArtistBio({
return () => {
cancelled = true;
};
}, [debugMode, artist.id, artist.name]);
}, [debugMode, uploading, artist.id, artist.name]);
const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => {
if (onArtistPortraitFixed) {
@@ -197,20 +205,27 @@ export default function ArtistBio({
}
};
const handleUploadClick = () => {
uploadInputRef.current?.click();
const handleUploadPress = () => {
setDebugSearch(null);
setDebugLoading(false);
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setDebugError(null);
};
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || uploading || fixing || clearing) return;
const handleUploadFile = async (file: File) => {
if (uploading || fixing || clearing) return;
handleUploadPress();
setUploading(true);
setDebugError(null);
setUploadStatus('Reading file…');
try {
setUploadStatus('Uploading…');
const result = await api.uploadArtistPortrait(artist.id, file);
await applyPortraitUpdate(result);
setUploadStatus('Upload complete.');
} catch (err) {
setUploadStatus(null);
setDebugError(err instanceof Error ? err.message : 'Could not upload portrait.');
} finally {
setUploading(false);
@@ -219,6 +234,13 @@ export default function ArtistBio({
return (
<div className="artist-bio">
{uploading && (
<GalleryLoadingMarker
overlay
className="debug-upload-page-overlay"
message={uploadStatus ?? 'Loading…'}
/>
)}
<header className="bio-header">
<button className="back-btn" onClick={onBack}>← Back</button>
<h1>{artist.name}</h1>
@@ -227,9 +249,9 @@ export default function ArtistBio({
<div className="bio-content">
<div
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared ? ' bio-portrait-empty' : ''}`}
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared && !uploading ? ' bio-portrait-empty' : ''}${uploading ? ' bio-portrait-uploading' : ''}`}
>
{showPortrait ? (
{showPortrait && portraitSrc ? (
<img
src={portraitSrc}
alt={artist.name}
@@ -273,14 +295,17 @@ export default function ArtistBio({
</div>
{debugMode && (
<aside className="debug-image-panel" aria-label="Debug portrait search">
<aside
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
aria-label="Debug portrait search"
>
<h4>{debugSearch?.sourceLabel ?? 'Portrait image search'}</h4>
<p className="debug-image-query">
{debugSearch?.query ?? `${artist.name} portrait`}
</p>
{debugLoading && <p className="debug-image-status">Searching…</p>}
{debugError && <p className="debug-image-error">{debugError}</p>}
{!debugLoading && debugSearch?.imageUrl && (
{debugLoading && !uploading && <p className="debug-image-status">Searching…</p>}
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
{!uploading && !debugLoading && debugSearch?.imageUrl && (
<img
className="debug-image-preview"
src={debugImageProxyUrl(debugSearch.imageUrl, {
@@ -293,7 +318,7 @@ export default function ArtistBio({
}}
/>
)}
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
<p className="debug-image-status">No portrait image result found.</p>
)}
<div className="debug-action-buttons">
@@ -301,7 +326,7 @@ export default function ArtistBio({
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!artist.checkup_checked || markingChecked}
disabled={!!artist.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
@@ -309,7 +334,7 @@ export default function ArtistBio({
type="button"
className="debug-fix-btn"
onClick={handleFixPortrait}
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
>
{fixing ? '…' : 'Fix it'}
</button>
@@ -317,7 +342,7 @@ export default function ArtistBio({
type="button"
className="debug-more-btn"
onClick={handleOpenMore}
disabled={debugLoading || moreLoading}
disabled={debugLoading || moreLoading || uploading}
>
{moreLoading ? '…' : 'More'}
</button>
@@ -331,27 +356,18 @@ export default function ArtistBio({
>
{clearing ? '…' : 'Clear'}
</button>
<button
type="button"
className="debug-upload-btn"
onClick={handleUploadClick}
disabled={uploading || fixing || clearing}
>
{uploading ? '…' : 'Upload'}
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={handleUploadFile}
<DebugUploadButton
uploading={uploading}
disabled={fixing || clearing}
onUploadPress={handleUploadPress}
onFileSelected={handleUploadFile}
/>
</div>
</aside>
)}
<DebugSearchResultsModal
open={moreOpen}
open={moreOpen && !uploading}
title="Choose portrait"
data={moreResults}
loading={moreLoading}
+168
View File
@@ -0,0 +1,168 @@
.catalog-search {
position: relative;
z-index: 1;
width: min(520px, 100%);
margin: 16px auto 0;
}
.catalog-search-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.catalog-search-field {
position: relative;
}
.catalog-search-input {
width: 100%;
padding: 10px 40px 10px 14px;
border-radius: 8px;
border: 1px solid rgba(201, 169, 110, 0.45);
background: rgba(15, 15, 26, 0.92);
color: #e8d5b5;
font-size: 15px;
font-family: Georgia, serif;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.catalog-search-input::placeholder {
color: rgba(232, 213, 181, 0.45);
}
.catalog-search-input:focus {
border-color: #c9a96e;
box-shadow: 0 0 0 2px rgba(201, 169, 110, 0.18);
}
.catalog-search-spinner {
position: absolute;
right: 12px;
top: 50%;
width: 16px;
height: 16px;
margin-top: -8px;
border-radius: 50%;
border: 2px solid rgba(201, 169, 110, 0.22);
border-top-color: #c9a96e;
animation: catalog-search-spin 0.85s linear infinite;
}
.catalog-search-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 120;
max-height: min(420px, 60vh);
overflow: auto;
border-radius: 10px;
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(12, 12, 22, 0.98);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.catalog-search-message {
margin: 0;
padding: 14px 16px;
color: rgba(232, 213, 181, 0.75);
font-size: 14px;
}
.catalog-search-error {
color: #e8a0a0;
}
.catalog-search-group + .catalog-search-group {
border-top: 1px solid rgba(201, 169, 110, 0.12);
}
.catalog-search-group-label {
margin: 0;
padding: 10px 14px 6px;
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(201, 169, 110, 0.65);
font-family: ui-monospace, 'Cascadia Code', monospace;
}
.catalog-search-list {
list-style: none;
margin: 0;
padding: 0 6px 8px;
}
.catalog-search-option {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: #e8d5b5;
text-align: left;
cursor: pointer;
font: inherit;
}
.catalog-search-option:hover,
.catalog-search-option-active {
background: rgba(201, 169, 110, 0.12);
}
.catalog-search-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.04);
}
.catalog-search-movement-swatch {
width: 40px;
height: 40px;
border-radius: 4px;
flex-shrink: 0;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.catalog-search-option-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.catalog-search-option-title {
font-size: 14px;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.catalog-search-option-meta {
font-size: 12px;
color: rgba(232, 213, 181, 0.6);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@keyframes catalog-search-spin {
to {
transform: rotate(360deg);
}
}
+278
View File
@@ -0,0 +1,278 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { CatalogSearchResult } from '../types';
import { api, imageUrl, portraitThumbUrl } from '../api/client';
import './CatalogSearchBar.css';
interface Props {
onSelectArtist: (artistId: number) => void;
onSelectMovement: (movementId: number) => void;
onSelectPainting: (paintingId: number) => void;
}
const TYPE_ORDER: CatalogSearchResult['type'][] = ['artist', 'movement', 'painting'];
function resultKey(item: CatalogSearchResult): string {
return `${item.type}-${item.id}`;
}
function paintingThumbSrc(item: Extract<CatalogSearchResult, { type: 'painting' }>): string {
const path = item.thumbnail_path || item.image_path;
return path ? imageUrl(path) : '/placeholder-art.svg';
}
export default function CatalogSearchBar({
onSelectArtist,
onSelectMovement,
onSelectPainting,
}: Props) {
const { t } = useTranslation('search');
const typeLabels: Record<CatalogSearchResult['type'], string> = {
artist: t('groupArtist'),
movement: t('groupMovement'),
painting: t('groupPainting'),
};
const listboxId = useId();
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [query, setQuery] = useState('');
const [results, setResults] = useState<CatalogSearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const grouped = useMemo(() => {
const map = new Map<CatalogSearchResult['type'], CatalogSearchResult[]>();
for (const type of TYPE_ORDER) map.set(type, []);
for (const item of results) {
map.get(item.type)?.push(item);
}
return TYPE_ORDER.map((type) => ({ type, items: map.get(type) ?? [] })).filter((g) => g.items.length > 0);
}, [results]);
const flatResults = useMemo(() => grouped.flatMap((g) => g.items), [grouped]);
const activate = useCallback(
(item: CatalogSearchResult) => {
setOpen(false);
setQuery('');
setResults([]);
setActiveIndex(-1);
inputRef.current?.blur();
if (item.type === 'artist') onSelectArtist(item.id);
else if (item.type === 'movement') onSelectMovement(item.id);
else onSelectPainting(item.id);
},
[onSelectArtist, onSelectMovement, onSelectPainting]
);
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < 2) {
setResults([]);
setLoading(false);
setError(null);
setActiveIndex(-1);
return;
}
let cancelled = false;
setLoading(true);
setError(null);
const timer = window.setTimeout(() => {
api
.search(trimmed)
.then((data) => {
if (cancelled) return;
setResults(data.results);
setOpen(true);
setActiveIndex(data.results.length > 0 ? 0 : -1);
})
.catch(() => {
if (cancelled) return;
setResults([]);
setError(t('searchFailed'));
setOpen(true);
setActiveIndex(-1);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
}, 300);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [query]);
useEffect(() => {
const onPointerDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', onPointerDown);
return () => document.removeEventListener('mousedown', onPointerDown);
}, []);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setOpen(false);
setActiveIndex(-1);
return;
}
if (!open || flatResults.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => (i + 1) % flatResults.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => (i <= 0 ? flatResults.length - 1 : i - 1));
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
activate(flatResults[activeIndex]);
}
};
const showPanel = open && query.trim().length >= 2;
return (
<div className="catalog-search" ref={rootRef}>
<label className="catalog-search-label" htmlFor={`${listboxId}-input`}>
{t('label')}
</label>
<div className="catalog-search-field">
<input
ref={inputRef}
id={`${listboxId}-input`}
className="catalog-search-input"
type="search"
role="combobox"
aria-expanded={showPanel}
aria-controls={showPanel ? `${listboxId}-listbox` : undefined}
aria-autocomplete="list"
aria-activedescendant={
showPanel && activeIndex >= 0 ? `${listboxId}-opt-${activeIndex}` : undefined
}
placeholder={t('placeholder')}
value={query}
autoComplete="off"
spellCheck={false}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => {
if (query.trim().length >= 2) setOpen(true);
}}
onKeyDown={handleKeyDown}
/>
{loading && <span className="catalog-search-spinner" aria-hidden />}
</div>
{showPanel && (
<div
id={`${listboxId}-listbox`}
className="catalog-search-panel"
role="listbox"
aria-label={t('resultsLabel')}
>
{error && <p className="catalog-search-message catalog-search-error">{error}</p>}
{!error && !loading && flatResults.length === 0 && (
<p className="catalog-search-message">{t('noMatches')}</p>
)}
{grouped.map((group) => (
<div key={group.type} className="catalog-search-group">
<p className="catalog-search-group-label">{typeLabels[group.type]}</p>
<ul className="catalog-search-list">
{group.items.map((item) => {
const flatIndex = flatResults.findIndex((r) => resultKey(r) === resultKey(item));
const active = flatIndex === activeIndex;
return (
<li key={resultKey(item)}>
<button
type="button"
id={`${listboxId}-opt-${flatIndex}`}
role="option"
aria-selected={active}
className={`catalog-search-option${active ? ' catalog-search-option-active' : ''}`}
onMouseEnter={() => setActiveIndex(flatIndex)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => activate(item)}
>
{item.type === 'artist' && (
<>
<img
className="catalog-search-thumb"
src={portraitThumbUrl(item)}
alt=""
loading="lazy"
/>
<span className="catalog-search-option-text">
<span className="catalog-search-option-title">{item.name}</span>
<span className="catalog-search-option-meta">
{[item.movement_name, formatYears(item.birth_year, item.death_year)]
.filter(Boolean)
.join(' · ')}
</span>
</span>
</>
)}
{item.type === 'movement' && (
<>
<span
className="catalog-search-movement-swatch"
style={{ background: item.color || '#c9a96e' }}
aria-hidden
/>
<span className="catalog-search-option-text">
<span className="catalog-search-option-title">{item.name}</span>
<span className="catalog-search-option-meta">
{[item.era_name, formatYears(item.start_year, item.end_year)]
.filter(Boolean)
.join(' · ')}
</span>
</span>
</>
)}
{item.type === 'painting' && (
<>
<img
className="catalog-search-thumb"
src={paintingThumbSrc(item)}
alt=""
loading="lazy"
/>
<span className="catalog-search-option-text">
<span className="catalog-search-option-title">{item.title}</span>
<span className="catalog-search-option-meta">
{[item.artist_name, item.year != null ? String(item.year) : null, item.movement_name]
.filter(Boolean)
.join(' · ')}
</span>
</span>
</>
)}
</button>
</li>
);
})}
</ul>
</div>
))}
</div>
)}
</div>
);
}
function formatYears(start: number | null | undefined, end: number | null | undefined): string | null {
if (start == null && end == null) return null;
if (start != null && end != null) return `${start}–${end}`;
if (start != null) return String(start);
return String(end);
}
+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,93 @@
import { useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
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 { t } = useTranslation('debug');
const { t: tc } = useTranslation('common');
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 : t('loginFailed'));
} 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">{t('loginTitle')}</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>{t('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>{t('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}>
{tc('cancel')}
</button>
<button type="submit" className="curator-login-submit" disabled={submitting}>
{submitting ? t('saving') : t('signIn')}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import { useRef, type ChangeEvent } from 'react';
interface Props {
uploading: boolean;
disabled?: boolean;
onUploadPress?: () => void;
onFileSelected: (file: File) => void | Promise<void>;
}
export default function DebugUploadButton({
uploading,
disabled = false,
onUploadPress,
onFileSelected,
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const inactive = disabled || uploading;
const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
const input = e.currentTarget;
const file = input.files?.[0];
if (!file || inactive) return;
try {
await onFileSelected(file);
} finally {
input.value = '';
}
};
return (
<label
className={`debug-upload-btn${inactive ? ' debug-upload-btn-disabled' : ''}`}
aria-busy={uploading}
onClick={() => {
if (!inactive) onUploadPress?.();
}}
>
{uploading ? (
<span className="debug-upload-btn-loading">
<span className="debug-upload-btn-spinner" aria-hidden />
Uploading…
</span>
) : (
'Upload'
)}
<input
ref={inputRef}
className="debug-upload-input"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif,.jpg,.jpeg,.png,.webp,.gif,.avif"
disabled={inactive}
onChange={handleChange}
/>
</label>
);
}
@@ -0,0 +1,77 @@
.gallery-loading-marker {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
color: rgba(232, 213, 181, 0.92);
font-family: Georgia, serif;
font-size: 15px;
text-align: center;
pointer-events: none;
}
.gallery-loading-marker p {
margin: 0;
}
.gallery-loading-marker-spinner {
width: 36px;
height: 36px;
border-radius: 50%;
border: 3px solid rgba(201, 169, 110, 0.22);
border-top-color: #c9a96e;
animation: gallery-loading-spin 0.85s linear infinite;
}
.gallery-loading-marker-overlay {
position: absolute;
inset: 0;
z-index: 20;
background: rgba(10, 10, 20, 0.72);
backdrop-filter: blur(2px);
}
.gallery-loading-marker-banner {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
z-index: 12;
flex-direction: row;
gap: 10px;
padding: 8px 16px;
border-radius: 999px;
background: rgba(15, 15, 26, 0.88);
border: 1px solid rgba(201, 169, 110, 0.35);
font-size: 13px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
}
.gallery-loading-marker-banner .gallery-loading-marker-spinner {
width: 18px;
height: 18px;
border-width: 2px;
}
.gallery-loading-marker-compact {
flex-direction: row;
justify-content: flex-start;
gap: 8px;
margin: 6px 0 8px;
font-size: 11px;
color: rgba(232, 213, 181, 0.85);
}
.gallery-loading-marker-compact .gallery-loading-marker-spinner {
width: 14px;
height: 14px;
border-width: 2px;
flex-shrink: 0;
}
@keyframes gallery-loading-spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,40 @@
import './GalleryLoadingMarker.css';
interface Props {
message?: string;
/** Full-area overlay (dims background). */
overlay?: boolean;
/** Compact strip along the bottom — does not block interaction. */
banner?: boolean;
/** Inline row for small panels (debug upload, etc.). */
compact?: boolean;
className?: string;
}
export default function GalleryLoadingMarker({
message = 'Loading…',
overlay = false,
banner = false,
compact = false,
className = '',
}: Props) {
const modeClass = overlay
? ' gallery-loading-marker-overlay'
: banner
? ' gallery-loading-marker-banner'
: compact
? ' gallery-loading-marker-compact'
: '';
return (
<div
className={`gallery-loading-marker${modeClass}${className ? ` ${className}` : ''}`}
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="gallery-loading-marker-spinner" aria-hidden />
<p>{message}</p>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
.locale-switcher {
display: inline-flex;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 6px;
overflow: hidden;
}
.locale-switcher-btn {
background: transparent;
color: inherit;
border: none;
padding: 0.25rem 0.55rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
opacity: 0.75;
}
.locale-switcher-btn-active {
background: rgba(255, 255, 255, 0.15);
opacity: 1;
}
.locale-switcher-btn:hover {
opacity: 1;
}
+43
View File
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { setApiLocale } from '../api/client';
import { writeStoredLocale, type AppLocale } from '../utils/localeStorage';
import './LocaleSwitcher.css';
interface Props {
onLocaleChange?: (locale: AppLocale) => void;
}
export default function LocaleSwitcher({ onLocaleChange }: Props) {
const { t } = useTranslation('common');
const current = (i18n.language === 'ru' ? 'ru' : 'en') as AppLocale;
const setLocale = (locale: AppLocale) => {
if (locale === current) return;
void i18n.changeLanguage(locale);
writeStoredLocale(locale);
setApiLocale(locale);
onLocaleChange?.(locale);
};
return (
<div className="locale-switcher" role="group" aria-label={t('language')}>
<button
type="button"
className={`locale-switcher-btn${current === 'en' ? ' locale-switcher-btn-active' : ''}`}
onClick={() => setLocale('en')}
aria-pressed={current === 'en'}
>
{t('localeEn')}
</button>
<button
type="button"
className={`locale-switcher-btn${current === 'ru' ? ' locale-switcher-btn-active' : ''}`}
onClick={() => setLocale('ru')}
aria-pressed={current === 'ru'}
>
{t('localeRu')}
</button>
</div>
);
}
+59 -3
View File
@@ -87,17 +87,39 @@
z-index: 1;
}
.movements-flow-canvas.movements-flow-interacting {
cursor: grab;
}
.movements-flow-canvas.movements-flow-interacting.movements-flow-panning {
cursor: grabbing;
}
.movement-branch-fast {
stroke-opacity: 0.32;
stroke-width: calc(var(--stream-stroke) * 0.45);
}
.movement-stream-fast {
stroke-opacity: 0.42;
stroke-width: var(--stream-stroke);
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.movement-branch {
stroke-width: var(--stream-stroke);
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
transition: filter 0.2s ease, opacity 0.2s ease;
}
.movement-stream-fill {
stroke-width: var(--stream-stroke);
stroke-linecap: round;
vector-effect: non-scaling-stroke;
transition: filter 0.2s ease, opacity 0.2s ease;
}
.movement-stream-core {
@@ -107,6 +129,33 @@
stroke-dasharray: 8 6;
animation: stream-shimmer 16s linear infinite;
vector-effect: non-scaling-stroke;
transition: filter 0.2s ease, opacity 0.2s ease;
}
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-fill:not(.movement-stream-highlighted) {
opacity: 0.55;
}
.movements-flow-canvas.movements-flow-movement-hover .movement-stream-core:not(.movement-stream-highlighted) {
opacity: 0.1;
}
.movements-flow-canvas.movements-flow-movement-hover .movement-branch:not(.movement-branch-highlighted) {
opacity: 0.45;
}
.movement-stream-fill.movement-stream-highlighted {
filter: brightness(1.65) saturate(1.3);
}
.movement-stream-core.movement-stream-highlighted {
opacity: 0.72;
filter: brightness(1.5);
stroke-width: 2.5px;
}
.movement-branch.movement-branch-highlighted {
filter: brightness(1.55) saturate(1.2);
}
@keyframes stream-shimmer {
@@ -181,16 +230,23 @@
position: absolute;
inset: 0;
pointer-events: none;
z-index: 3;
z-index: 6;
}
.movement-flow-label {
position: absolute;
transform: translate(-4px, -100%);
max-width: 160px;
padding: 2px 6px;
pointer-events: none;
z-index: 3;
z-index: 6;
}
.movement-flow-label-above {
transform: translate(-4px, -100%);
}
.movement-flow-label-below {
transform: translate(-4px, 0);
}
.movement-flow-label-btn {
File diff suppressed because it is too large. Load diff
+12 -9
View File
@@ -1,13 +1,14 @@
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { PaintingAnnotation } from '../types';
import './PaintingAnnotations.css';
const CATEGORY_LABELS: Record<string, string> = {
technique: 'Technique',
composition: 'Composition',
symbolism: 'Symbolism',
history: 'History',
subject: 'Subject',
const CATEGORY_KEYS: Record<string, string> = {
technique: 'categoryTechnique',
composition: 'categoryComposition',
symbolism: 'categorySymbolism',
history: 'categoryHistorical',
subject: 'categorySubject',
};
interface PanelProps {
@@ -57,6 +58,7 @@ export default function PaintingAnnotationsPanel({
activeId,
onSelect,
}: PanelProps) {
const { t } = useTranslation('annotations');
const cardRefs = useRef<Map<number, HTMLLIElement>>(new Map());
if (!annotations.length) return null;
@@ -70,11 +72,12 @@ export default function PaintingAnnotationsPanel({
return (
<aside className="painting-annotations-panel" aria-label="Art history annotations">
<h3 className="painting-annotations-title">Art history notes</h3>
<h3 className="painting-annotations-title">{t('title')}</h3>
<ul className="painting-annotations-list">
{annotations.map((ann, index) => {
const isActive = activeId === ann.id;
const category = CATEGORY_LABELS[ann.category] || ann.category;
const categoryKey = CATEGORY_KEYS[ann.category];
const category = categoryKey ? t(categoryKey) : ann.category;
return (
<li
key={ann.id}
@@ -110,7 +113,7 @@ export default function PaintingAnnotationsPanel({
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
Read source
{t('readSource')}
</a>
)}
</li>
+77 -2
View File
@@ -407,6 +407,40 @@
display: block;
}
.tour-stop-panel {
max-width: 700px;
width: 100%;
margin-top: 20px;
padding: 16px 18px;
background: rgba(201, 169, 110, 0.12);
border-radius: 6px;
border: 1px solid rgba(201, 169, 110, 0.35);
}
.tour-stop-panel h3 {
margin: 0 0 10px;
font-family: Georgia, 'Times New Roman', serif;
font-size: 15px;
font-weight: 600;
color: #c9a96e;
}
.tour-stop-body {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 14px;
line-height: 1.7;
color: rgba(232, 213, 181, 0.92);
white-space: pre-wrap;
}
.tour-stop-empty {
margin: 0;
font-size: 13px;
color: rgba(201, 169, 110, 0.55);
font-style: italic;
}
.painting-description {
max-width: 700px;
margin-top: 20px;
@@ -552,6 +586,48 @@
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-align: center;
}
.debug-upload-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.debug-upload-btn-disabled {
opacity: 0.6;
cursor: wait;
pointer-events: none;
}
.debug-upload-btn-loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.debug-upload-page-overlay.gallery-loading-marker-overlay {
position: fixed;
inset: 0;
z-index: 300;
}
.painting-frame-uploading,
.bio-portrait-uploading {
min-height: 240px;
}
.debug-image-panel.debug-image-panel-blocked {
opacity: 0.55;
pointer-events: none;
}
.debug-clear-btn {
@@ -576,8 +652,7 @@
border-color: #8cbe8c;
}
.debug-clear-btn:disabled,
.debug-upload-btn:disabled {
.debug-clear-btn:disabled {
opacity: 0.6;
cursor: wait;
}
+95 -57
View File
@@ -1,14 +1,21 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react';
import { useCallback, useEffect, useState, type SyntheticEvent } from 'react';
import { useTranslation } from 'react-i18next';
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
import DebugUploadButton from './DebugUploadButton';
import GalleryLoadingMarker from './GalleryLoadingMarker';
import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations';
import PaintingLightbox from './PaintingLightbox';
import './PaintingDetail.css';
import './GalleryLoadingMarker.css';
interface Props {
data: PaintingDetail;
artistPaintings?: Painting[];
backLabel?: string;
tourTitle?: string | null;
tourText?: string | null;
onBack: () => void;
onPaintingClick: (paintingId: number) => void;
onCatalogNavigate: (paintingId: number) => void;
@@ -130,6 +137,8 @@ function InfluenceCard({
<img
src={imageUrl(inf.artist_portrait)}
alt={artistName}
loading="lazy"
decoding="async"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-portrait.svg';
}}
@@ -164,6 +173,8 @@ function InfluenceCard({
<img
src={paintingImageUrl({ id: inf.id, image_path: inf.image_path }) ?? '/placeholder-art.svg'}
alt={inf.title || 'Painting'}
loading="lazy"
decoding="async"
onError={(e) => {
(e.target as HTMLImageElement).src = '/placeholder-art.svg';
}}
@@ -184,6 +195,9 @@ function InfluenceCard({
export default function PaintingDetailView({
data,
artistPaintings = [],
backLabel = '← Back to Gallery',
tourTitle = null,
tourText = null,
onBack,
onPaintingClick,
onCatalogNavigate,
@@ -195,7 +209,9 @@ export default function PaintingDetailView({
onPaintingCheckupFlagsUpdated,
onPaintingRemoved,
}: Props) {
const { t } = useTranslation('painting');
const { painting, influencedBy, influenced, annotations = [] } = data;
const inTour = tourText != null;
const [fullscreen, setFullscreen] = useState(false);
const [imageVersion, setImageVersion] = useState(0);
const [debugSearch, setDebugSearch] = useState<DebugImageSearchResult | null>(null);
@@ -210,15 +226,13 @@ export default function PaintingDetailView({
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
const [removing, setRemoving] = useState(false);
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const baseImageUrl = paintingImageUrl(painting);
const imageSrc = baseImageUrl
? `${baseImageUrl}${baseImageUrl.includes('?') ? '&' : '?'}v=${imageVersion}`
: null;
const imageCleared = !baseImageUrl && !!painting.checkup_fixed;
const imageSrc = paintingImageUrl(painting, imageVersion || undefined);
const displayImageSrc = uploading ? null : imageSrc;
const imageCleared = !painting.image_path && !painting.thumbnail_path && !!painting.checkup_fixed;
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
@@ -239,6 +253,7 @@ export default function PaintingDetailView({
setFixing(false);
setClearing(false);
setUploading(false);
setUploadStatus(null);
setMarkingChecked(false);
setApplyingUrl(null);
setMoreLoading(false);
@@ -251,6 +266,9 @@ export default function PaintingDetailView({
setMoreOpen(false);
return;
}
if (uploading) {
return;
}
let cancelled = false;
setDebugLoading(true);
@@ -271,7 +289,7 @@ export default function PaintingDetailView({
return () => {
cancelled = true;
};
}, [debugMode, painting.id, painting.title, painting.artist_name]);
}, [debugMode, uploading, painting.id, painting.title, painting.artist_name]);
const applyImageUpdate = async (fixResult: FixPaintingImageResult) => {
setImageVersion((v) => v + 1);
@@ -378,20 +396,27 @@ export default function PaintingDetailView({
}
};
const handleUploadClick = () => {
uploadInputRef.current?.click();
const handleUploadPress = () => {
setDebugSearch(null);
setDebugLoading(false);
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setDebugError(null);
};
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || uploading || fixing || clearing) return;
const handleUploadFile = async (file: File) => {
if (uploading || fixing || clearing) return;
handleUploadPress();
setUploading(true);
setDebugError(null);
setUploadStatus('Reading file…');
try {
setUploadStatus('Uploading…');
const result = await api.uploadPaintingImage(painting.id, file);
await applyImageUpdate(result);
setUploadStatus('Upload complete.');
} catch (err) {
setUploadStatus(null);
setDebugError(err instanceof Error ? err.message : 'Could not upload image.');
} finally {
setUploading(false);
@@ -433,8 +458,15 @@ export default function PaintingDetailView({
return (
<div className={`painting-detail${fullscreen ? ' painting-detail-fullscreen-active' : ''}`}>
{uploading && (
<GalleryLoadingMarker
overlay
className="debug-upload-page-overlay"
message={uploadStatus ?? 'Loading…'}
/>
)}
<header className="painting-header">
<button className="back-btn" onClick={onBack}>← Back to Gallery</button>
<button className="back-btn" onClick={onBack}>{backLabel}</button>
<div className="painting-title-block">
<h1>{painting.title}</h1>
<p className="painting-meta">
@@ -443,21 +475,23 @@ export default function PaintingDetailView({
{showCatalogNav && (
<span className="painting-catalog-position">
{' · '}
{catalogIndex + 1} of {artistPaintings.length}
{inTour
? t('tourStopPosition', { current: catalogIndex + 1, total: artistPaintings.length })
: `${catalogIndex + 1} of ${artistPaintings.length}`}
</span>
)}
</p>
</div>
<button className="bio-btn" onClick={onArtistBio}>
About {painting.artist_name}
{t('aboutArtist', { name: painting.artist_name })}
</button>
</header>
<div className="painting-layout">
<aside className="influence-panel influence-left">
<h3>Influenced By</h3>
<h3>{t('influencedBy')}</h3>
{influencedBy.length === 0 ? (
<p className="no-influences">No documented influences for this work.</p>
<p className="no-influences">{t('noInfluences')}</p>
) : (
<div className="influence-list">
{influencedBy.map((inf, index) => (
@@ -490,12 +524,12 @@ export default function PaintingDetailView({
)}
<div
className={`painting-frame-large${imageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared ? ' painting-frame-cleared' : ''}`}
role={imageSrc ? 'button' : undefined}
tabIndex={imageSrc ? 0 : undefined}
onClick={imageSrc ? () => setFullscreen(true) : undefined}
className={`painting-frame-large${displayImageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared && !uploading ? ' painting-frame-cleared' : ''}${uploading ? ' painting-frame-uploading' : ''}`}
role={displayImageSrc ? 'button' : undefined}
tabIndex={displayImageSrc ? 0 : undefined}
onClick={displayImageSrc ? () => setFullscreen(true) : undefined}
onKeyDown={
imageSrc
displayImageSrc
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -504,14 +538,14 @@ export default function PaintingDetailView({
}
: undefined
}
title={imageSrc ? 'View full screen' : undefined}
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
title={displayImageSrc ? 'View full screen' : undefined}
aria-label={displayImageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
>
<div className="painting-frame-image-wrap">
{imageSrc ? (
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
{displayImageSrc ? (
<img src={displayImageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
) : null}
{imageSrc && annotations.length > 0 && (
{displayImageSrc && annotations.length > 0 && (
<PaintingAnnotationMarkers
annotations={annotations}
activeId={activeAnnotationId}
@@ -542,6 +576,16 @@ export default function PaintingDetailView({
</button>
)}
</div>
{inTour && (
<aside className="tour-stop-panel" aria-label={t('tourNotes')}>
<h3>{tourTitle ? t('tourNotesFor', { title: tourTitle }) : t('tourNotes')}</h3>
{tourText.trim() ? (
<p className="tour-stop-body">{tourText}</p>
) : (
<p className="tour-stop-empty">{t('tourNotesEmpty')}</p>
)}
</aside>
)}
{painting.description && (
<div className="painting-description">
<p>{painting.description}</p>
@@ -550,9 +594,9 @@ export default function PaintingDetailView({
</main>
<aside className="influence-panel influence-right">
<h3>Influenced</h3>
<h3>{t('influenced')}</h3>
{influenced.length === 0 ? (
<p className="no-influences">No documented works influenced by this painting yet.</p>
<p className="no-influences">{t('noInfluencedWorks')}</p>
) : (
<div className="influence-list">
{influenced.map((inf, index) => (
@@ -569,14 +613,17 @@ export default function PaintingDetailView({
</div>
{debugMode && (
<aside className="debug-image-panel" aria-label="Debug image search">
<aside
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
aria-label="Debug image search"
>
<h4>{debugSearch?.sourceLabel ?? 'Google image search'}</h4>
<p className="debug-image-query">
{debugSearch?.query ?? `${painting.artist_name} ${painting.title} painting`}
</p>
{debugLoading && <p className="debug-image-status">Searching…</p>}
{debugError && <p className="debug-image-error">{debugError}</p>}
{!debugLoading && debugSearch?.imageUrl && (
{debugLoading && !uploading && <p className="debug-image-status">Searching…</p>}
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
{!uploading && !debugLoading && debugSearch?.imageUrl && (
<img
className="debug-image-preview"
src={debugImageProxyUrl(debugSearch.imageUrl, {
@@ -589,7 +636,7 @@ export default function PaintingDetailView({
}}
/>
)}
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
<p className="debug-image-status">No Google image result found.</p>
)}
<div className="debug-action-buttons">
@@ -597,7 +644,7 @@ export default function PaintingDetailView({
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!painting.checkup_checked || markingChecked}
disabled={!!painting.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
@@ -605,7 +652,7 @@ export default function PaintingDetailView({
type="button"
className="debug-fix-btn"
onClick={handleFixImage}
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
>
{fixing ? '…' : 'Fix it'}
</button>
@@ -613,7 +660,7 @@ export default function PaintingDetailView({
type="button"
className="debug-more-btn"
onClick={handleOpenMore}
disabled={debugLoading || moreLoading}
disabled={debugLoading || moreLoading || uploading}
>
{moreLoading ? '…' : 'More'}
</button>
@@ -627,20 +674,11 @@ export default function PaintingDetailView({
>
{clearing ? '…' : 'Clear'}
</button>
<button
type="button"
className="debug-upload-btn"
onClick={handleUploadClick}
disabled={uploading || fixing || clearing}
>
{uploading ? '…' : 'Upload'}
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={handleUploadFile}
<DebugUploadButton
uploading={uploading}
disabled={fixing || clearing}
onUploadPress={handleUploadPress}
onFileSelected={handleUploadFile}
/>
</div>
<div className="debug-action-buttons debug-action-buttons-danger">
@@ -657,7 +695,7 @@ export default function PaintingDetailView({
)}
<DebugSearchResultsModal
open={moreOpen}
open={moreOpen && !uploading}
title="Choose painting image"
data={moreResults}
loading={moreLoading}
@@ -667,9 +705,9 @@ export default function PaintingDetailView({
onSelect={handleSelectMoreResult}
/>
{fullscreen && imageSrc && (
{fullscreen && displayImageSrc && (
<PaintingLightbox
src={imageSrc}
src={displayImageSrc}
alt={painting.title}
title={painting.title}
subtitle={[painting.artist_name, painting.year ? String(painting.year) : '']
+35 -5
View File
@@ -1,4 +1,4 @@
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
import { useRef, useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
import type { HistoricalEra } from '../types';
import {
HISTORICAL_EVENTS,
@@ -6,6 +6,10 @@ import {
eventInView,
type HistoricalEvent,
} from '../data/historical-events';
import {
buildTimelineTickYears,
chooseTimelineTickInterval,
} from '../utils/timelineView';
import './Timeline.css';
interface LifespanHighlight {
@@ -36,13 +40,39 @@ function formatYear(year: number): string {
export default function Timeline({ eras, viewStart, viewEnd, onViewChange, absoluteMin, absoluteMax, lifespanHighlight }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [dragging, setDragging] = useState<'left' | 'right' | 'pan' | null>(null);
const [containerWidth, setContainerWidth] = useState(800);
const dragStart = useRef({ x: 0, viewStart: 0, viewEnd: 0 });
const span = viewEnd - viewStart;
const tickInterval = span > 500 ? 100 : span > 200 ? 50 : span > 50 ? 25 : span > 10 ? 5 : 1;
const ticks: number[] = [];
const firstTick = Math.ceil(viewStart / tickInterval) * tickInterval;
for (let y = firstTick; y <= viewEnd; y += tickInterval) ticks.push(y);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const measure = () => {
const w = el.getBoundingClientRect().width;
if (w > 0) setContainerWidth(Math.round(w));
};
measure();
const ro = new ResizeObserver(() => measure());
ro.observe(el);
window.addEventListener('resize', measure);
return () => {
ro.disconnect();
window.removeEventListener('resize', measure);
};
}, []);
const tickInterval = useMemo(
() => chooseTimelineTickInterval(span, containerWidth),
[span, containerWidth]
);
const ticks = useMemo(
() => buildTimelineTickYears(viewStart, viewEnd, tickInterval),
[viewStart, viewEnd, tickInterval]
);
const handleWheel = useCallback(
(e: React.WheelEvent) => {
+144
View File
@@ -0,0 +1,144 @@
.tours-popup-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;
}
.tours-popup-modal {
width: min(100%, 520px);
max-height: min(85vh, 640px);
overflow: auto;
padding: 20px 22px 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);
color: #e8d5b5;
}
.tours-popup-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.tours-popup-header h2 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.25rem;
color: #e8d5b5;
}
.tours-popup-close {
border: none;
background: transparent;
color: rgba(201, 169, 110, 0.85);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.tours-popup-hint {
margin: 0 0 14px;
font-size: 0.85rem;
color: rgba(201, 169, 110, 0.7);
line-height: 1.45;
}
.tours-popup-muted {
margin: 0;
color: rgba(201, 169, 110, 0.55);
font-size: 0.9rem;
}
.tours-popup-error {
margin: 0 0 10px;
color: #ffaaaa;
font-size: 0.9rem;
}
.tours-popup-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.tours-popup-card {
display: flex;
gap: 12px;
width: 100%;
text-align: left;
padding: 10px;
border-radius: 8px;
border: 1px solid rgba(201, 169, 110, 0.28);
background: rgba(0, 0, 0, 0.28);
color: inherit;
cursor: pointer;
}
.tours-popup-card:hover {
border-color: rgba(201, 169, 110, 0.55);
background: rgba(201, 169, 110, 0.08);
}
.tours-popup-cover {
flex: 0 0 72px;
width: 72px;
height: 72px;
border-radius: 4px;
overflow: hidden;
background: rgba(0, 0, 0, 0.35);
}
.tours-popup-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.tours-popup-cover-empty {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(201, 169, 110, 0.15), rgba(0, 0, 0, 0.4));
}
.tours-popup-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.tours-popup-body strong {
font-size: 0.95rem;
}
.tours-popup-body p {
margin: 0;
font-size: 0.8rem;
line-height: 1.4;
color: rgba(232, 213, 181, 0.75);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.tours-popup-meta {
font-size: 0.75rem;
color: rgba(201, 169, 110, 0.65);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api, imageUrl } from '../api/client';
import type { TourSummary } from '../types';
import './ToursPopup.css';
interface Props {
open: boolean;
onClose: () => void;
onSelectTour: (tourId: number) => void;
}
export default function ToursPopup({ open, onClose, onSelectTour }: Props) {
const { t } = useTranslation('tours');
const [tours, setTours] = useState<TourSummary[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError(null);
api
.listPublishedTours()
.then((data) => {
if (!cancelled) setTours(data.tours);
})
.catch((err) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : t('loadFailed'));
setTours([]);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, t]);
if (!open) return null;
return (
<div className="tours-popup-backdrop" role="presentation" onClick={onClose}>
<div
className="tours-popup-modal"
role="dialog"
aria-modal="true"
aria-labelledby="tours-popup-title"
onClick={(e) => e.stopPropagation()}
>
<header className="tours-popup-header">
<h2 id="tours-popup-title">{t('popupTitle')}</h2>
<button type="button" className="tours-popup-close" onClick={onClose} aria-label={t('close')}>
×
</button>
</header>
<p className="tours-popup-hint">{t('popupHint')}</p>
{loading && <p className="tours-popup-muted">{t('loading')}</p>}
{error && <p className="tours-popup-error">{error}</p>}
{!loading && !error && tours.length === 0 && (
<p className="tours-popup-muted">{t('noPublished')}</p>
)}
<ul className="tours-popup-list">
{tours.map((tour) => {
const cover = tour.coverThumbnailPath || tour.coverImagePath;
return (
<li key={tour.id}>
<button
type="button"
className="tours-popup-card"
onClick={() => onSelectTour(tour.id)}
>
<div className="tours-popup-cover">
{cover ? (
<img src={imageUrl(cover)} alt="" />
) : (
<span className="tours-popup-cover-empty" aria-hidden />
)}
</div>
<div className="tours-popup-body">
<strong>{tour.title}</strong>
{tour.description ? <p>{tour.description}</p> : null}
<span className="tours-popup-meta">
{t('stopCount', { count: tour.stopCount })}
</span>
</div>
</button>
</li>
);
})}
</ul>
</div>
</div>
);
}
+211 -102
View File
@@ -1,4 +1,5 @@
import { useRef, useState, useEffect, useMemo, Suspense, useCallback } from 'react';
import { useRef, useState, useEffect, useMemo, Suspense, useCallback, createContext, useContext, Component } from 'react';
import type { ReactNode } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { Text, Environment } from '@react-three/drei';
import * as THREE from 'three';
@@ -9,8 +10,9 @@ import type {
ArtistPeriod,
ArtistNavigation,
MovementArtistGroup,
TourGalleryDetail,
} from '../types';
import { galleryImageUrlWithRevision, imageUrl, api, preloadArtistImages } from '../api/client';
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
import { cloneSurfaceTexture, getSurfaceTexture } from '../utils/galleryProceduralTextures';
import { resolveMovementInteriorStyle, type MovementInteriorStyle, type GalleryWindowSpec } from '../data/movement-interior-styles';
@@ -24,6 +26,36 @@ import {
type MovementHallLayout,
} from '../utils/movementHallLayout';
import './VirtualGallery.css';
import GalleryLoadingMarker from './GalleryLoadingMarker';
const GalleryTextureLoadContext = createContext<{
begin: () => void;
end: () => void;
} | null>(null);
/**
* Keeps a single failing subtree (e.g. the network-loaded HDR environment map)
* from unmounting the whole 3D scene and leaving a dark window.
*/
class SceneErrorBoundary extends Component<
{ children: ReactNode; fallback?: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: unknown) {
console.warn('Gallery scene subtree failed, continuing without it.', error);
}
render() {
if (this.state.hasError) return this.props.fallback ?? null;
return this.props.children;
}
}
interface BaseGalleryProps {
imageRevisions?: Record<number, number>;
@@ -44,7 +76,12 @@ interface MovementGalleryProps extends BaseGalleryProps {
data: MovementGalleryDetail;
}
type Props = ArtistGalleryProps | MovementGalleryProps;
interface TourGalleryProps extends BaseGalleryProps {
mode: 'tour';
data: TourGalleryDetail;
}
type Props = ArtistGalleryProps | MovementGalleryProps | TourGalleryProps;
const WALL_HEIGHT = 4.2;
const WALL_THICKNESS = 0.18;
@@ -66,9 +103,6 @@ const MAX_FRAME_H = 1.35;
const MIN_HALL_SIZE = 9;
const ROW_GAP = 0.2;
const WALL_PADDING = 1.4;
/** Above this count, use a long corridor (short back wall, extended side walls). */
const CORRIDOR_CATALOG_THRESHOLD = 15;
const BACK_WALL_MAX_PAINTINGS = 8;
/** Every wall shows at most one row; side-wall depth grows to fit the catalog. */
const MAX_WALL_ROWS = 1;
const DOOR_WIDTH = 2.4;
@@ -259,36 +293,18 @@ function minSpanForWall(paintings: Painting[], maxRows: number = paintings.lengt
return Math.max(MIN_HALL_SIZE, best);
}
/** Left → right on each wall: later works on the left, earlier works on the right. */
function orderPaintingsForWallDisplay(paintings: Painting[]) {
return [...paintings].sort(comparePaintingsChronological).reverse();
}
/**
* Visit order along the side walls: first half on the left (starting at the
* entrance, left of the opening view), second half on the right (ending at the
* entrance). Back wall stays empty so first/last always sit on left/right.
*/
function distributePaintingsAcrossWalls(paintings: Painting[]) {
const sorted = [...paintings].sort(comparePaintingsChronological);
const back: Painting[] = [];
const left: Painting[] = [];
const right: Painting[] = [];
if (sorted.length <= CORRIDOR_CATALOG_THRESHOLD) {
sorted.forEach((p, i) => {
if (i % 3 === 0) back.push(p);
else if (i % 3 === 1) left.push(p);
else right.push(p);
});
} else {
const backCount = Math.min(BACK_WALL_MAX_PAINTINGS, Math.max(4, Math.ceil(sorted.length * 0.12)));
back.push(...sorted.slice(0, backCount));
sorted.slice(backCount).forEach((p, i) => {
if (i % 2 === 0) left.push(p);
else right.push(p);
});
}
const ordered = [...paintings].sort(comparePaintingsChronological);
const mid = Math.ceil(ordered.length / 2);
return [
orderPaintingsForWallDisplay(back),
orderPaintingsForWallDisplay(left),
orderPaintingsForWallDisplay(right),
[] as Painting[],
ordered.slice(0, mid),
ordered.slice(mid),
];
}
@@ -346,12 +362,13 @@ function layoutWallSlots(
position: [s.offset, y, -halfD + inset + WALL_STANDOFF + BACK_WALL_EXTRA],
});
} else if (side === 'left') {
// Flip along-wall offset so index 0 is at the entrance (+Z), left of view.
slots.push({
maxW: s.maxW,
maxH: s.maxH,
rotationY: Math.PI / 2,
side,
position: [-halfW + inset + WALL_STANDOFF, y, s.offset],
position: [-halfW + inset + WALL_STANDOFF, y, -s.offset],
});
} else {
slots.push({
@@ -550,6 +567,7 @@ function CanvasCover({
function usePaintingTexture(url: string | null) {
const [texture, setTexture] = useState<THREE.Texture | null>(null);
const [failed, setFailed] = useState(!url);
const textureLoad = useContext(GalleryTextureLoadContext);
useEffect(() => {
if (!url) {
@@ -561,11 +579,21 @@ function usePaintingTexture(url: string | null) {
setFailed(false);
let disposed = false;
let loaded: THREE.Texture | null = null;
let settled = false;
const loader = new THREE.TextureLoader();
loader.setCrossOrigin('anonymous');
const finish = () => {
if (settled) return;
settled = true;
textureLoad?.end();
};
textureLoad?.begin();
loader.load(
url,
(tex) => {
finish();
if (disposed) {
tex.dispose();
return;
@@ -583,16 +611,18 @@ function usePaintingTexture(url: string | null) {
},
undefined,
() => {
finish();
if (!disposed) setFailed(true);
}
);
return () => {
disposed = true;
finish();
loaded?.dispose();
setTexture(null);
};
}, [url]);
}, [url, textureLoad]);
return { texture, failed };
}
@@ -1488,6 +1518,14 @@ function levelHorizontalView(pos: THREE.Vector3, target: THREE.Vector3) {
target.y = EYE_HEIGHT;
}
function FrameloopSync({ active }: { active: boolean }) {
const { invalidate } = useThree();
useEffect(() => {
if (active) invalidate();
}, [active, invalidate]);
return null;
}
function CameraController({
position,
target,
@@ -1638,16 +1676,38 @@ export default function VirtualGallery(props: Props) {
} = props;
const isMovement = props.mode === 'movement';
const hallKey = isMovement ? props.data.movement.id : props.data.artist.id;
const hallTitle = isMovement ? props.data.movement.name : props.data.artist.name;
const movementColor = isMovement ? props.data.movement.color : props.data.artist.movement_color;
const isTour = props.mode === 'tour';
const isWingedHall = isMovement || isTour;
const hallKey = isTour
? props.data.tour.id
: isMovement
? props.data.movement.id
: props.data.artist.id;
const hallTitle = isTour
? props.data.tour.title
: isMovement
? props.data.movement.name
: props.data.artist.name;
const movementColor = isTour
? DEFAULT_MOVEMENT_COLOR
: isMovement
? props.data.movement.color
: props.data.artist.movement_color;
const initialPaintings = useMemo(() => {
if (props.mode === 'movement') {
return [...props.data.paintings].sort(comparePaintingsChronological);
}
return props.data.paintings;
}, [props]);
const initialPeriods = isMovement ? [] : props.data.periods;
}, [
props.mode,
props.mode === 'tour'
? props.data.tour.id
: props.mode === 'movement'
? props.data.movement.id
: props.data.artist.id,
props.data.paintings,
]);
const initialPeriods = props.mode === 'artist' ? props.data.periods : [];
const [paintings, setPaintings] = useState(initialPaintings);
const [periods, setPeriods] = useState(initialPeriods);
@@ -1658,6 +1718,44 @@ export default function VirtualGallery(props: Props) {
const [nearExit, setNearExit] = useState(false);
const [nearPassage, setNearPassage] = useState(false);
const [isLooking, setIsLooking] = useState(false);
const [texturesPending, setTexturesPending] = useState(0);
const [canvasReady, setCanvasReady] = useState(false);
const [glEpoch, setGlEpoch] = useState(0);
const [glLost, setGlLost] = useState(false);
const textureLoad = useMemo(
() => ({
begin: () => setTexturesPending((n) => n + 1),
end: () => setTexturesPending((n) => Math.max(0, n - 1)),
}),
[]
);
const handleCanvasCreated = useCallback((state: { gl: THREE.WebGLRenderer }) => {
const canvas = state.gl.domElement;
// A freshly created canvas has a healthy context, so clear any lingering
// "restoring" state from a previous loss/remount.
setGlLost(false);
setCanvasReady(true);
const onLost = (event: Event) => {
// Prevent the default so the browser can restore the context, and
// force a clean remount to obtain a fresh WebGL context if it does not.
event.preventDefault();
setGlLost(true);
window.setTimeout(() => {
setGlLost((stillLost) => {
if (stillLost) setGlEpoch((n) => n + 1);
return stillLost;
});
}, 600);
};
const onRestored = () => {
setGlLost(false);
setGlEpoch((n) => n + 1);
};
canvas.addEventListener('webglcontextlost', onLost as EventListener, false);
canvas.addEventListener('webglcontextrestored', onRestored as EventListener, false);
}, []);
useEffect(() => {
setPaintings(initialPaintings);
@@ -1675,32 +1773,13 @@ export default function VirtualGallery(props: Props) {
return;
}
let cancelled = false;
const artistId = props.data.artist.id;
(async () => {
try {
setSyncStatus('Syncing images…');
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 2000));
await Promise.race([preloadArtistImages(artistId), timeout]);
const fresh = await api.getArtist(artistId);
if (!cancelled) {
setPaintings(fresh.paintings);
setPeriods(fresh.periods);
const withImg = fresh.paintings.filter((p) => p.image_path || p.thumbnail_path).length;
setSyncStatus(
withImg < fresh.paintings.length
? `${withImg} of ${fresh.paintings.length} works have images`
: ''
);
}
} catch {
if (!cancelled) setSyncStatus('');
}
})();
return () => {
cancelled = true;
};
}, [hallKey, props.mode, initialPaintings.length, props.mode === 'artist' ? props.data.artist.id : null]);
const withImg = initialPaintings.filter((p) => p.image_path || p.thumbnail_path).length;
setSyncStatus(
withImg < initialPaintings.length
? `${withImg} of ${initialPaintings.length} works have images`
: ''
);
}, [hallKey, props.mode, initialPaintings]);
const interiorStyle = useMemo(
() => (isMovement ? resolveMovementInteriorStyle(props.data.movement) : undefined),
@@ -1708,8 +1787,8 @@ export default function VirtualGallery(props: Props) {
);
const movementHalls = useMemo(
() => (isMovement ? buildAllMovementHallLayouts(paintings) : []),
[isMovement, paintings]
() => (isWingedHall ? buildAllMovementHallLayouts(paintings) : []),
[isWingedHall, paintings]
);
const [hallIndex, setHallIndex] = useState(0);
@@ -1719,18 +1798,26 @@ export default function VirtualGallery(props: Props) {
}, [hallKey]);
const layout = useMemo(() => {
if (isMovement && movementHalls.length > 0) {
if (isWingedHall && movementHalls.length > 0) {
return movementHalls[Math.min(hallIndex, movementHalls.length - 1)];
}
return buildHallLayout(paintings, periods);
}, [isMovement, movementHalls, hallIndex, paintings, periods]);
}, [isWingedHall, movementHalls, hallIndex, paintings, periods]);
const gallerySceneLoading = active && !glLost && (!canvasReady || texturesPending > 0);
const galleryLoadingMessage = !canvasReady
? 'Loading gallery…'
: texturesPending > 0
? 'Loading paintings…'
: 'Loading gallery…';
const computedWindows = useMemo(() => {
if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined;
return computeSideWallWindows(layout as MovementHallLayout, interiorStyle);
}, [isMovement, interiorStyle, layout]);
const hasNextHall = isMovement && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
const hasNextHall = isWingedHall && movementHalls.length > 1 && hallIndex < movementHalls.length - 1;
const halfW = layout.width / 2 - 0.55;
const halfD = layout.depth / 2 - 0.35;
@@ -1790,7 +1877,7 @@ export default function VirtualGallery(props: Props) {
}, [hallKey, initialPos, initialTarget]);
const openExitNav = useCallback(async () => {
if (isMovement) {
if (isWingedHall) {
setShowExitNav(true);
return;
}
@@ -1804,9 +1891,9 @@ export default function VirtualGallery(props: Props) {
} finally {
setNavLoading(false);
}
}, [isMovement, onBack, isMovement ? undefined : props.data.artist.id]);
}, [isWingedHall, onBack, !isWingedHall ? props.data.artist.id : undefined]);
const backExitZ = isMovement ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
const backExitZ = isWingedHall ? -layout.depth / 2 + 0.55 : layout.depth / 2 - 0.55;
const frontPassageZ = layout.depth / 2 - 0.55;
const moveCamera = useCallback(
@@ -1831,7 +1918,7 @@ export default function VirtualGallery(props: Props) {
pos.x = Math.max(-halfW, Math.min(halfW, pos.x));
target.x = Math.max(-halfW, Math.min(halfW, target.x));
if (isMovement) {
if (isWingedHall) {
pos.z = Math.max(-halfD, Math.min(halfD, pos.z));
target.z = Math.max(-halfD, Math.min(halfD, target.z));
const atBackExit = pos.z < backExitZ + 0.8 && Math.abs(pos.x) < DOOR_WIDTH / 2 + 0.6;
@@ -1851,7 +1938,7 @@ export default function VirtualGallery(props: Props) {
setCamPos(pos);
setCamTarget(target);
},
[halfW, halfD, exitZ, isMovement, backExitZ, frontPassageZ, hasNextHall]
[halfW, halfD, exitZ, isWingedHall, backExitZ, frontPassageZ, hasNextHall]
);
useEffect(() => {
@@ -1868,7 +1955,7 @@ export default function VirtualGallery(props: Props) {
const onKeyDown = (e: KeyboardEvent) => {
keysPressed.current.add(e.key);
if ((e.key === 'e' || e.key === 'E') && !showExitNav) {
if (isMovement && nearPassage && hasNextHall) {
if (isWingedHall && nearPassage && hasNextHall) {
goToNextHall();
} else {
openExitNav();
@@ -1896,15 +1983,19 @@ export default function VirtualGallery(props: Props) {
window.removeEventListener('keyup', onKeyUp);
clearInterval(interval);
};
}, [active, moveCamera, showExitNav, openExitNav, isMovement, nearPassage, hasNextHall, goToNextHall]);
}, [active, moveCamera, showExitNav, openExitNav, isWingedHall, nearPassage, hasNextHall, goToNextHall]);
const handleNavigate = (artistId: number) => {
if (isMovement) return;
if (isWingedHall) return;
setShowExitNav(false);
props.onNavigateArtist(artistId);
};
const subtitle = isMovement
const subtitle = isTour
? `Guided tour · ${paintings.length} works${
movementHalls.length > 1 ? ` · Wing ${hallIndex + 1}/${movementHalls.length}` : ''
}`
: isMovement
? interiorStyle
? `${interiorStyle.subtitle} · ${paintings.length} works${
movementHalls.length > 1
@@ -1950,7 +2041,7 @@ export default function VirtualGallery(props: Props) {
}
};
const exitHint = isMovement ? (
const exitHint = isWingedHall ? (
<>
Back wall: <kbd>E</kbd> for wing navigator · Front arch: next wing
{movementHalls.length > 1 ? ` (${hallIndex + 1}/${movementHalls.length})` : ''}
@@ -1960,11 +2051,15 @@ export default function VirtualGallery(props: Props) {
);
const hallSubtitle =
isMovement && 'yearLabel' in layout
? `Wing ${hallIndex + 1} of ${movementHalls.length} · ${(layout as MovementHallLayout).yearLabel}`
isWingedHall && 'yearLabel' in layout
? `Wing ${hallIndex + 1} of ${movementHalls.length}${
isMovement ? ` · ${(layout as MovementHallLayout).yearLabel}` : ''
}`
: undefined;
const instructionsTitle = isMovement
const instructionsTitle = isTour
? `${hallTitle} · Guided tour`
: isMovement
? interiorStyle
? `${hallTitle} · ${interiorStyle.label}`
: `${hallTitle} Gallery`
@@ -1980,9 +2075,9 @@ export default function VirtualGallery(props: Props) {
</div>
<div className="gallery-header-meta">
<button type="button" className="gallery-exit-btn" onClick={openExitNav}>
{isMovement ? 'Wings / Exit →' : 'Exit →'}
{isWingedHall ? 'Wings / Exit →' : 'Exit →'}
</button>
{!isMovement && (
{!isWingedHall && (
<button className="gallery-bio-btn" onClick={props.onBioClick}>Biography</button>
)}
</div>
@@ -1996,7 +2091,13 @@ export default function VirtualGallery(props: Props) {
onPointerLeave={endCanvasDrag}
onPointerCancel={endCanvasDrag}
>
{syncStatus && (
{active && (gallerySceneLoading || glLost) && (
<GalleryLoadingMarker
overlay
message={glLost ? 'Restoring gallery…' : galleryLoadingMessage}
/>
)}
{syncStatus && !gallerySceneLoading && !glLost && (
<div className="gallery-loading-overlay gallery-sync-badge">
<p>{syncStatus}</p>
</div>
@@ -2005,10 +2106,14 @@ export default function VirtualGallery(props: Props) {
<div className="gallery-exit-hint">{exitHint}</div>
)}
<Canvas
key={`${hallKey}-${glEpoch}`}
shadows
frameloop={active ? 'always' : 'never'}
gl={{ preserveDrawingBuffer: true, powerPreference: 'high-performance' }}
camera={{ fov: 58, position: [0, EYE_HEIGHT, 2], near: 0.1, far: 80 }}
onCreated={handleCanvasCreated}
>
<FrameloopSync active={active} />
<color attach="background" args={[sceneBackground]} />
<fog attach="fog" args={[sceneFog, 18, fogFar]} />
<ambientLight intensity={ambientIntensity} />
@@ -2017,36 +2122,40 @@ export default function VirtualGallery(props: Props) {
intensity={interiorStyle ? 0.85 : 0.65}
color={interiorStyle?.sunLight ?? '#fff8f0'}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-mapSize={[1024, 1024]}
/>
<Suspense fallback={null}>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.35 : 0.15}
/>
</Suspense>
<ArtistHall
<SceneErrorBoundary fallback={null}>
<Suspense fallback={null}>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.35 : 0.15}
/>
</Suspense>
</SceneErrorBoundary>
<GalleryTextureLoadContext.Provider value={textureLoad}>
<ArtistHall
layout={layout}
hallTitle={hallTitle}
hallSubtitle={hallSubtitle}
movementColor={movementColor}
interiorStyle={interiorStyle}
imageRevisions={imageRevisions}
showCaptions={isMovement}
showCaptions={isWingedHall}
onPaintingClick={onPaintingClick}
onExitActivate={openExitNav}
nearExit={nearExit}
movementMode={isMovement}
movementMode={isWingedHall}
computedWindows={computedWindows}
hasNextHall={hasNextHall}
onNextHall={goToNextHall}
nearPassage={nearPassage}
/>
</GalleryTextureLoadContext.Provider>
<CameraController position={camPos} target={camTarget} />
</Canvas>
</div>
{!isMovement && showExitNav && (
{!isWingedHall && showExitNav && (
<NavigationPanel
navigation={navigation}
loading={navLoading}
@@ -2055,7 +2164,7 @@ export default function VirtualGallery(props: Props) {
/>
)}
{isMovement && showExitNav && (
{isWingedHall && showExitNav && (
<MovementHallNavPanel
movementName={hallTitle}
halls={movementHalls}
@@ -2083,8 +2192,8 @@ export default function VirtualGallery(props: Props) {
<li><kbd>A</kbd> / <kbd>←</kbd> / <kbd>Q</kbd> Turn left</li>
<li><kbd>D</kbd> / <kbd>→</kbd> Turn right</li>
<li>Drag on the view to look around</li>
<li>Click a painting to view details and influences</li>
{isMovement ? (
<li>Click a painting to view details{isTour ? ' and tour notes' : ' and influences'}</li>
{isWingedHall ? (
<>
<li>Date and artist labels appear below each frame</li>
<li>Works hang on left &amp; right walls — up to ~55 per wing</li>