Add guided tours and unify left-to-right hall wall hang.

Visitors walk published tours in a 3D hall with stop notes; curators edit drafts via Tour editor. All galleries (artist, movement, tour) place the first work left of the entrance view and the last on the right.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-16 20:27:36 +03:00
co-authored by Cursor
parent 48bd17e985
commit 5ddc3fd7f0
31 changed files with 1890 additions and 124 deletions
+34
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;
+18 -1
View File
@@ -14,6 +14,8 @@ interface Props {
data: PaintingDetail;
artistPaintings?: Painting[];
backLabel?: string;
tourTitle?: string | null;
tourText?: string | null;
onBack: () => void;
onPaintingClick: (paintingId: number) => void;
onCatalogNavigate: (paintingId: number) => void;
@@ -194,6 +196,8 @@ export default function PaintingDetailView({
data,
artistPaintings = [],
backLabel = '← Back to Gallery',
tourTitle = null,
tourText = null,
onBack,
onPaintingClick,
onCatalogNavigate,
@@ -207,6 +211,7 @@ export default function PaintingDetailView({
}: 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);
@@ -470,7 +475,9 @@ 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>
@@ -569,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>
+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>
);
}
+80 -64
View File
@@ -10,6 +10,7 @@ import type {
ArtistPeriod,
ArtistNavigation,
MovementArtistGroup,
TourGalleryDetail,
} from '../types';
import { galleryImageUrlWithRevision, imageUrl, api } from '../api/client';
import { comparePaintingsChronological, paintingHasInfluenceLinks, paintingWallCaption } from '../utils/paintingUtils';
@@ -75,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;
@@ -97,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;
@@ -290,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),
];
}
@@ -377,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({
@@ -1690,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.mode, props.mode === 'movement' ? props.data.movement.id : props.data.artist.id, props.data.paintings]);
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);
@@ -1779,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);
@@ -1790,11 +1798,11 @@ 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);
@@ -1809,7 +1817,7 @@ export default function VirtualGallery(props: Props) {
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;
@@ -1869,7 +1877,7 @@ export default function VirtualGallery(props: Props) {
}, [hallKey, initialPos, initialTarget]);
const openExitNav = useCallback(async () => {
if (isMovement) {
if (isWingedHall) {
setShowExitNav(true);
return;
}
@@ -1883,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(
@@ -1910,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;
@@ -1930,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(() => {
@@ -1947,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();
@@ -1975,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
@@ -2029,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})` : ''}
@@ -2039,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`
@@ -2059,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>
@@ -2124,11 +2140,11 @@ export default function VirtualGallery(props: Props) {
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}
@@ -2139,7 +2155,7 @@ export default function VirtualGallery(props: Props) {
</Canvas>
</div>
{!isMovement && showExitNav && (
{!isWingedHall && showExitNav && (
<NavigationPanel
navigation={navigation}
loading={navLoading}
@@ -2148,7 +2164,7 @@ export default function VirtualGallery(props: Props) {
/>
)}
{isMovement && showExitNav && (
{isWingedHall && showExitNav && (
<MovementHallNavPanel
movementName={hallTitle}
halls={movementHalls}
@@ -2176,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>