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:
co-authored by
Cursor
parent
48bd17e985
commit
5ddc3fd7f0
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & right walls — up to ~55 per wing</li>
|
||||
|
||||
Reference in New Issue
Block a user