Merge branch 'main' of https://gitea.mysuperlab.netcraze.pro/Danilka/Art-gallery
This commit is contained in:
commit
77befd94b2
482 files changed
+19800
-1037
No files matched your search
@@ -1,4 +1,5 @@
|
||||
.artist-bio {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #e8d5b5;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) : '']
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & right walls — up to ~55 per wing</li>
|
||||
|
||||
Reference in new issue
Block a user