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
+81
View File
@@ -5,9 +5,12 @@ import type {
Artist,
ArtistDetail,
MovementGalleryDetail,
Painting,
PaintingDetail,
ArtistNavigation,
CatalogSearchResponse,
TourSummary,
TourGalleryDetail,
} from '../types';
import { readStoredLocale, type AppLocale } from '../utils/localeStorage';
@@ -686,6 +689,82 @@ export const api = {
return res.json() as Promise<{ inserted: number; skipped: number; attempted: number }>;
}),
listPublishedTours: () =>
fetchJson<{ tours: TourSummary[] }>(`${API}/tours`),
listAdminTours: () =>
fetchJson<{ tours: TourSummary[] }>(`${API}/tours/admin`),
getTour: (id: number) =>
fetchJson<TourGalleryDetail & { locale?: string }>(`${API}/tours/${id}`),
createTour: (payload: { title: string; description?: string; status?: 'draft' | 'published' }) =>
fetch(`${API}/tours`, {
...fetchCredentials,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Create failed: ${res.status}`);
}
return res.json() as Promise<{ tour: TourSummary }>;
}),
updateTour: (
id: number,
payload: Partial<{
title: string;
description: string;
status: 'draft' | 'published';
coverPaintingId: number | null;
}>,
) =>
fetch(`${API}/tours/${id}`, {
...fetchCredentials,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Update failed: ${res.status}`);
}
return res.json() as Promise<{ tour: TourSummary }>;
}),
deleteTour: (id: number) =>
fetch(`${API}/tours/${id}`, {
...fetchCredentials,
method: 'DELETE',
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Delete failed: ${res.status}`);
}
return res.json() as Promise<{ ok: boolean }>;
}),
saveTourStops: (id: number, stops: Array<{ paintingId: number; body: string }>) =>
fetch(`${API}/tours/${id}/stops`, {
...fetchCredentials,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stops }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Save stops failed: ${res.status}`);
}
return res.json() as Promise<{
ok: boolean;
stopCount: number;
paintings: Painting[];
stopBodies: Record<number, string>;
}>;
}),
preloadArtistImages,
};
@@ -750,6 +829,8 @@ export interface InfluencePriorImport {
match: 'file' | 'data' | 'unknown';
}
export type { TourSummary, TourGalleryDetail };
export interface InfluenceImportParseResult {
filename: string;
format: string;
+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>
+5 -1
View File
@@ -12,6 +12,7 @@ import enAnnotations from '../locales/en/annotations.json';
import enDebug from '../locales/en/debug.json';
import enTranslations from '../locales/en/translations.json';
import enInfluences from '../locales/en/influences.json';
import enTours from '../locales/en/tours.json';
import ruCommon from '../locales/ru/common.json';
import ruHome from '../locales/ru/home.json';
@@ -23,6 +24,7 @@ import ruAnnotations from '../locales/ru/annotations.json';
import ruDebug from '../locales/ru/debug.json';
import ruTranslations from '../locales/ru/translations.json';
import ruInfluences from '../locales/ru/influences.json';
import ruTours from '../locales/ru/tours.json';
const initialLocale = readStoredLocale();
writeStoredLocale(initialLocale);
@@ -31,7 +33,7 @@ void i18n.use(initReactI18next).init({
lng: initialLocale,
fallbackLng: 'en',
supportedLngs: ['en', 'ru'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences'],
ns: ['common', 'home', 'search', 'gallery', 'painting', 'bio', 'annotations', 'debug', 'translations', 'influences', 'tours'],
defaultNS: 'common',
resources: {
en: {
@@ -45,6 +47,7 @@ void i18n.use(initReactI18next).init({
debug: enDebug,
translations: enTranslations,
influences: enInfluences,
tours: enTours,
},
ru: {
common: ruCommon,
@@ -57,6 +60,7 @@ void i18n.use(initReactI18next).init({
debug: ruDebug,
translations: ruTranslations,
influences: ruInfluences,
tours: ruTours,
},
},
interpolation: { escapeValue: false },
+5
View File
@@ -15,6 +15,11 @@
"checkup": "Checkup",
"translations": "Translations",
"influences": "Influences",
"tours": "Tours",
"toursEditor": "Tour editor",
"openingTourGallery": "Opening guided tour…",
"tourEmpty": "This tour has no paintings yet.",
"tourLoadFailed": "Failed to load the tour.",
"curatorRequiredTitle": "Curator access required",
"curatorRequiredBody": "Sign in as a curator to use this tool.",
"backToGalleryBtn": "Back to gallery"
+5 -1
View File
@@ -7,5 +7,9 @@
"catalogPosition": "Catalog position",
"lightboxHint": "Click anywhere to close",
"prevPainting": "Previous painting",
"nextPainting": "Next painting"
"nextPainting": "Next painting",
"tourNotes": "Tour notes",
"tourNotesFor": "Tour notes · {{title}}",
"tourNotesEmpty": "No notes for this stop.",
"tourStopPosition": "Stop {{current}} of {{total}}"
}
+28
View File
@@ -0,0 +1,28 @@
{
"title": "Guided tours",
"popupTitle": "Guided tours",
"popupHint": "Choose a curated walkthrough of selected works.",
"close": "Close",
"loading": "Loading…",
"loadFailed": "Failed to load tours",
"noPublished": "No published tours yet.",
"noTours": "No tours yet. Create one to get started.",
"stopCount": "{{count}} stops",
"back": "← Back to gallery",
"create": "Create",
"newTourPlaceholder": "New tour title…",
"selectTour": "Select a tour to edit.",
"tourTitle": "Title",
"tourDescription": "Description",
"status": "Status",
"draft": "Draft",
"published": "Published",
"saveMeta": "Save details",
"delete": "Delete tour",
"confirmDelete": "Delete this tour and all its stops?",
"searchPainting": "Search paintings to add…",
"saveStops": "Save stops",
"remove": "Remove",
"stopBodyPlaceholder": "Tour notes for this stop (English)…",
"noStops": "No stops yet. Search and add paintings."
}
+5
View File
@@ -15,6 +15,11 @@
"checkup": "Проверка",
"translations": "Переводы",
"influences": "Влияния",
"tours": "Экскурсии",
"toursEditor": "Редактор экскурсий",
"openingTourGallery": "Открытие экскурсии…",
"tourEmpty": "В этой экскурсии пока нет картин.",
"tourLoadFailed": "Не удалось загрузить экскурсию.",
"curatorRequiredTitle": "Требуется доступ куратора",
"curatorRequiredBody": "Войдите как куратор, чтобы использовать этот инструмент.",
"backToGalleryBtn": "Вернуться в галерею"
+5 -1
View File
@@ -7,5 +7,9 @@
"catalogPosition": "Позиция в каталоге",
"lightboxHint": "Нажмите в любом месте, чтобы закрыть",
"prevPainting": "Предыдущая картина",
"nextPainting": "Следующая картина"
"nextPainting": "Следующая картина",
"tourNotes": "Текст экскурсии",
"tourNotesFor": "Экскурсия · {{title}}",
"tourNotesEmpty": "Для этой остановки нет текста.",
"tourStopPosition": "Остановка {{current}} из {{total}}"
}
+28
View File
@@ -0,0 +1,28 @@
{
"title": "Экскурсии",
"popupTitle": "Экскурсии",
"popupHint": "Выберите кураторскую подборку произведений.",
"close": "Закрыть",
"loading": "Загрузка…",
"loadFailed": "Не удалось загрузить экскурсии",
"noPublished": "Пока нет опубликованных экскурсий.",
"noTours": "Экскурсий пока нет. Создайте первую.",
"stopCount": "{{count}} остановок",
"back": "← В галерею",
"create": "Создать",
"newTourPlaceholder": "Название новой экскурсии…",
"selectTour": "Выберите экскурсию для редактирования.",
"tourTitle": "Название",
"tourDescription": "Описание",
"status": "Статус",
"draft": "Черновик",
"published": "Опубликовано",
"saveMeta": "Сохранить сведения",
"delete": "Удалить экскурсию",
"confirmDelete": "Удалить эту экскурсию и все остановки?",
"searchPainting": "Поиск картин для добавления…",
"saveStops": "Сохранить остановки",
"remove": "Убрать",
"stopBodyPlaceholder": "Текст экскурсии для этой остановки (на английском)…",
"noStops": "Остановок пока нет. Найдите и добавьте картины."
}
+227 -19
View File
@@ -9,18 +9,30 @@ import ArtistBio from '../components/ArtistBio';
import CheckupPage from '../pages/CheckupPage';
import TranslationsPage from '../pages/TranslationsPage';
import InfluencesPage from '../pages/InfluencesPage';
import ToursPage from '../pages/ToursPage';
import CuratorLoginModal from '../components/CuratorLoginModal';
import ToursPopup from '../components/ToursPopup';
import CatalogSearchBar from '../components/CatalogSearchBar';
import LocaleSwitcher from '../components/LocaleSwitcher';
import GalleryLoadingMarker from '../components/GalleryLoadingMarker';
import '../components/CatalogSearchBar.css';
import '../components/CuratorLoginModal.css';
import '../components/ToursPopup.css';
import '../components/LocaleSwitcher.css';
import '../pages/TranslationsPage.css';
import '../pages/InfluencesPage.css';
import '../pages/ToursPage.css';
import { useAuth } from '../context/AuthContext';
import { api, setApiLocale, type FixPaintingImageResult, type FixArtistPortraitResult } from '../api/client';
import type { TimelineData, Artist, ArtistDetail, Painting, PaintingDetail, MovementGalleryDetail } from '../types';
import type {
TimelineData,
Artist,
ArtistDetail,
Painting,
PaintingDetail,
MovementGalleryDetail,
TourGalleryDetail,
} from '../types';
import { createViewChangeScheduler } from '../utils/timelineView';
import { sortArtistPaintingsChronological } from '../utils/paintingUtils';
import { readDebugMode, readDebugShowMore, writeDebugMode, writeDebugShowMore } from '../utils/debugMode';
@@ -31,14 +43,19 @@ type View =
| { type: 'checkup' }
| { type: 'translations' }
| { type: 'influences' }
| { type: 'tours' }
| { type: 'gallery'; artistId: number; data: ArtistDetail }
| { type: 'movement-gallery'; movementId: number; data: MovementGalleryDetail }
| { type: 'tour-gallery'; tourId: number; data: TourGalleryDetail }
| { type: 'painting'; paintingId: number; data: PaintingDetail; returnTo: View }
| { type: 'bio'; artistId: number; data: ArtistDetail; returnTo: View };
type GallerySession =
| { kind: 'artist'; artistId: number; data: ArtistDetail }
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail };
| { kind: 'movement'; movementId: number; data: MovementGalleryDetail }
| { kind: 'tour'; tourId: number; data: TourGalleryDetail };
type CuratorLoginRedirect = 'checkup' | 'translations' | 'influences' | 'tours' | null;
function patchPaintingInMovementDetail(
detail: MovementGalleryDetail,
@@ -51,6 +68,17 @@ function patchPaintingInMovementDetail(
};
}
function patchPaintingInTourDetail(
detail: TourGalleryDetail,
paintingId: number,
patch: Partial<Painting>
): TourGalleryDetail {
return {
...detail,
paintings: detail.paintings.map((p) => (p.id === paintingId ? { ...p, ...patch } : p)),
};
}
function patchPaintingInArtistDetail(
detail: ArtistDetail,
paintingId: number,
@@ -72,7 +100,8 @@ function patchArtistInArtistDetail(detail: ArtistDetail, patch: Partial<Artist>)
function patchReturnToAfterRemove(
returnTo: View,
freshArtist?: ArtistDetail,
freshMovement?: MovementGalleryDetail
freshMovement?: MovementGalleryDetail,
freshTour?: TourGalleryDetail
): View {
if (returnTo.type === 'gallery' && freshArtist && returnTo.artistId === freshArtist.artist.id) {
return { ...returnTo, data: freshArtist };
@@ -84,8 +113,14 @@ function patchReturnToAfterRemove(
) {
return { ...returnTo, data: freshMovement };
}
if (returnTo.type === 'tour-gallery' && freshTour && returnTo.tourId === freshTour.tour.id) {
return { ...returnTo, data: freshTour };
}
if (returnTo.type === 'painting') {
return { ...returnTo, returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement) };
return {
...returnTo,
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour),
};
}
if (returnTo.type === 'bio') {
const data =
@@ -93,7 +128,7 @@ function patchReturnToAfterRemove(
return {
...returnTo,
data,
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement),
returnTo: patchReturnToAfterRemove(returnTo.returnTo, freshArtist, freshMovement, freshTour),
};
}
return returnTo;
@@ -131,7 +166,8 @@ export default function HomePage() {
const [debugMode, setDebugMode] = useState(readDebugMode);
const [debugShowMore, setDebugShowMore] = useState(readDebugShowMore);
const [loginOpen, setLoginOpen] = useState(false);
const [loginRedirect, setLoginRedirect] = useState<'checkup' | 'translations' | 'influences' | null>(null);
const [loginRedirect, setLoginRedirect] = useState<CuratorLoginRedirect>(null);
const [toursPopupOpen, setToursPopupOpen] = useState(false);
const effectiveDebugMode = debugMode && isCurator;
const [galleryRevision, setGalleryRevision] = useState(0);
const viewRef = useRef(view);
@@ -148,6 +184,8 @@ export default function HomePage() {
setGallerySession({ kind: 'artist', artistId: view.artistId, data: view.data });
} else if (view.type === 'movement-gallery') {
setGallerySession({ kind: 'movement', movementId: view.movementId, data: view.data });
} else if (view.type === 'tour-gallery') {
setGallerySession({ kind: 'tour', tourId: view.tourId, data: view.data });
} else if (view.type === 'timeline') {
setGallerySession(null);
}
@@ -225,7 +263,7 @@ export default function HomePage() {
writeDebugShowMore(enabled);
};
const openCuratorLogin = (redirect: 'checkup' | 'translations' | 'influences' | null = null) => {
const openCuratorLogin = (redirect: CuratorLoginRedirect = null) => {
setLoginRedirect(redirect);
setLoginOpen(true);
};
@@ -239,6 +277,8 @@ export default function HomePage() {
setView({ type: 'translations' });
} else if (loginRedirect === 'influences') {
setView({ type: 'influences' });
} else if (loginRedirect === 'tours') {
setView({ type: 'tours' });
}
setLoginRedirect(null);
};
@@ -247,7 +287,12 @@ export default function HomePage() {
await logout();
writeDebugMode(false);
setDebugMode(false);
if (view.type === 'checkup' || view.type === 'translations' || view.type === 'influences') {
if (
view.type === 'checkup' ||
view.type === 'translations' ||
view.type === 'influences' ||
view.type === 'tours'
) {
goToTimelineHome();
}
};
@@ -276,6 +321,14 @@ export default function HomePage() {
setView({ type: 'influences' });
};
const openToursEditor = () => {
if (!isCurator) {
openCuratorLogin('tours');
return;
}
setView({ type: 'tours' });
};
const handlePaintingImageFixed = useCallback(async (paintingId: number, fixResult: FixPaintingImageResult) => {
const data = await api.getPainting(paintingId);
const patch: Partial<Painting> = {
@@ -308,6 +361,12 @@ export default function HomePage() {
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'tour-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo };
});
@@ -322,6 +381,9 @@ export default function HomePage() {
if (session?.kind === 'movement') {
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
}
if (session?.kind === 'tour') {
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
}
return session;
});
}, []);
@@ -353,6 +415,12 @@ export default function HomePage() {
data: patchPaintingInMovementDetail(returnTo.data, paintingId, patch),
};
}
if (returnTo.type === 'tour-gallery') {
returnTo = {
...returnTo,
data: patchPaintingInTourDetail(returnTo.data, paintingId, patch),
};
}
return { ...current, data: updatedData, returnTo };
});
@@ -367,6 +435,9 @@ export default function HomePage() {
if (session?.kind === 'movement') {
return { ...session, data: patchPaintingInMovementDetail(session.data, paintingId, patch) };
}
if (session?.kind === 'tour') {
return { ...session, data: patchPaintingInTourDetail(session.data, paintingId, patch) };
}
return session;
});
},
@@ -455,6 +526,32 @@ export default function HomePage() {
setView({ type: 'movement-gallery', movementId, data });
}, []);
const openTourGallery = useCallback((tourId: number, data: TourGalleryDetail) => {
const session: GallerySession = { kind: 'tour', tourId, data };
setGallerySession(session);
setView({ type: 'tour-gallery', tourId, data });
}, []);
const handleSelectPublishedTour = useCallback(
async (tourId: number) => {
setToursPopupOpen(false);
setGalleryEntryLoading(t('openingTourGallery'));
try {
const data = await api.getTour(tourId);
if (!data.paintings.length) {
setError(t('tourEmpty'));
return;
}
openTourGallery(tourId, data);
} catch {
setError(t('tourLoadFailed'));
} finally {
setGalleryEntryLoading(null);
}
},
[openTourGallery, t]
);
const handleArtistClick = async (artistId: number) => {
setGalleryEntryLoading('Opening artist gallery…');
try {
@@ -517,25 +614,38 @@ export default function HomePage() {
const currentView = viewRef.current;
if (currentView.type !== 'painting' || currentView.paintingId !== paintingId) return;
const sorted = sortArtistPaintingsChronological(detailArtistPaintings);
const sorted =
gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery'
? detailArtistPaintings
: sortArtistPaintingsChronological(detailArtistPaintings);
const nextId = catalogNavigateTarget(sorted, paintingId);
const inMovementCatalog =
gallerySession?.kind === 'movement' || currentView.returnTo.type === 'movement-gallery';
const inTourCatalog =
gallerySession?.kind === 'tour' || currentView.returnTo.type === 'tour-gallery';
await api.deletePainting(paintingId);
const freshArtist = await api.getArtist(artistId);
let freshMovement: MovementGalleryDetail | undefined;
let freshTour: TourGalleryDetail | undefined;
if (gallerySession?.kind === 'movement') {
freshMovement = await api.getMovementGallery(gallerySession.movementId);
} else if (currentView.returnTo.type === 'movement-gallery') {
freshMovement = await api.getMovementGallery(currentView.returnTo.movementId);
}
if (gallerySession?.kind === 'tour') {
freshTour = await api.getTour(gallerySession.tourId);
} else if (currentView.returnTo.type === 'tour-gallery') {
freshTour = await api.getTour(currentView.returnTo.tourId);
}
const freshCatalog =
inMovementCatalog && freshMovement
? sortArtistPaintingsChronological(freshMovement.paintings)
: sortArtistPaintingsChronological(freshArtist.paintings);
inTourCatalog && freshTour
? freshTour.paintings
: inMovementCatalog && freshMovement
? sortArtistPaintingsChronological(freshMovement.paintings)
: sortArtistPaintingsChronological(freshArtist.paintings);
const removedIdx = sorted.findIndex((p) => p.id === paintingId);
const navigateId =
@@ -556,6 +666,9 @@ export default function HomePage() {
if (session.kind === 'movement' && freshMovement) {
return { ...session, data: freshMovement };
}
if (session.kind === 'tour' && freshTour) {
return { ...session, data: freshTour };
}
return session;
});
@@ -570,7 +683,8 @@ export default function HomePage() {
const patchedReturnTo = patchReturnToAfterRemove(
currentView.returnTo,
freshArtist,
freshMovement
freshMovement,
freshTour
);
detailReturnToRef.current = patchedReturnTo;
@@ -581,6 +695,9 @@ export default function HomePage() {
if (current.type === 'movement-gallery' && freshMovement) {
return { ...current, data: freshMovement };
}
if (current.type === 'tour-gallery' && freshTour) {
return { ...current, data: freshTour };
}
if (current.type !== 'painting' || current.paintingId !== paintingId) {
return current;
}
@@ -612,12 +729,20 @@ export default function HomePage() {
}
const artistId = view.data.painting.artist_id;
if (gallerySession?.kind === 'tour') {
setDetailArtistPaintings(gallerySession.data.paintings);
return;
}
if (gallerySession?.kind === 'artist' && gallerySession.artistId === artistId) {
setDetailArtistPaintings(gallerySession.data.paintings);
return;
}
const returnTo = detailReturnToRef.current;
if (returnTo.type === 'tour-gallery') {
setDetailArtistPaintings(returnTo.data.paintings);
return;
}
if (returnTo.type === 'movement-gallery') {
setDetailArtistPaintings(sortArtistPaintingsChronological(returnTo.data.paintings));
return;
@@ -637,12 +762,31 @@ export default function HomePage() {
};
}, [view, gallerySession]);
const sortedDetailArtistPaintings = useMemo(
() => sortArtistPaintingsChronological(detailArtistPaintings),
[detailArtistPaintings]
);
const sortedDetailArtistPaintings = useMemo(() => {
const fromTourSession = gallerySession?.kind === 'tour';
const fromTourReturn =
view.type === 'painting' && view.returnTo.type === 'tour-gallery';
if (fromTourSession || fromTourReturn) {
return detailArtistPaintings;
}
return sortArtistPaintingsChronological(detailArtistPaintings);
}, [detailArtistPaintings, gallerySession, view]);
const galleryActive = view.type === 'gallery' || view.type === 'movement-gallery';
const tourOverlay =
view.type === 'painting' && gallerySession?.kind === 'tour'
? {
title: gallerySession.data.tour.title,
text: gallerySession.data.stopBodies[view.paintingId] ?? '',
}
: view.type === 'painting' && view.returnTo.type === 'tour-gallery'
? {
title: view.returnTo.data.tour.title,
text: view.returnTo.data.stopBodies[view.paintingId] ?? '',
}
: null;
const galleryActive =
view.type === 'gallery' || view.type === 'movement-gallery' || view.type === 'tour-gallery';
const displayGallery = useMemo((): GallerySession | null => {
if (view.type === 'gallery') {
@@ -651,6 +795,9 @@ export default function HomePage() {
if (view.type === 'movement-gallery') {
return { kind: 'movement', movementId: view.movementId, data: view.data };
}
if (view.type === 'tour-gallery') {
return { kind: 'tour', tourId: view.tourId, data: view.data };
}
return gallerySession;
}, [view, gallerySession]);
@@ -679,7 +826,7 @@ export default function HomePage() {
})
}
/>
) : (
) : displayGallery.kind === 'movement' ? (
<VirtualGallery
key={`movement-${displayGallery.movementId}-${galleryRevision}`}
mode="movement"
@@ -689,6 +836,16 @@ export default function HomePage() {
onPaintingClick={handlePaintingClick}
onBack={goToTimelineHome}
/>
) : (
<VirtualGallery
key={`tour-${displayGallery.tourId}-${galleryRevision}`}
mode="tour"
data={displayGallery.data}
imageRevisions={imageRevisions}
active={galleryActive}
onPaintingClick={handlePaintingClick}
onBack={goToTimelineHome}
/>
)}
</div>
)}
@@ -700,6 +857,8 @@ export default function HomePage() {
data={view.data}
artistPaintings={sortedDetailArtistPaintings}
backLabel={view.returnTo.type === 'timeline' ? t('backToTimeline') : t('backToGallery')}
tourTitle={tourOverlay?.title ?? null}
tourText={tourOverlay ? tourOverlay.text : null}
onBack={() => {
if (view.returnTo.type === 'timeline') {
goToTimelineHome();
@@ -718,10 +877,18 @@ export default function HomePage() {
gallerySession.movementId === returnTo.movementId
) {
openMovementGallery(gallerySession.movementId, gallerySession.data);
} else if (
returnTo.type === 'tour-gallery' &&
gallerySession?.kind === 'tour' &&
gallerySession.tourId === returnTo.tourId
) {
openTourGallery(gallerySession.tourId, gallerySession.data);
} else if (returnTo.type === 'gallery') {
openArtistGallery(returnTo.artistId, returnTo.data);
} else if (returnTo.type === 'movement-gallery') {
openMovementGallery(returnTo.movementId, returnTo.data);
} else if (returnTo.type === 'tour-gallery') {
openTourGallery(returnTo.tourId, returnTo.data);
} else {
setView(returnTo);
}
@@ -778,6 +945,25 @@ export default function HomePage() {
)
)}
{view.type === 'tours' && (
isCurator ? (
<ToursPage onBack={goToTimelineHome} />
) : (
<div className="curator-login-gate">
<h2>{t('curatorRequiredTitle')}</h2>
<p>{t('curatorRequiredBody')}</p>
<div className="curator-login-gate-actions">
<button type="button" className="checkup-link-btn" onClick={() => openCuratorLogin('tours')}>
{t('curatorLogin')}
</button>
<button type="button" className="debug-mode-toggle" onClick={goToTimelineHome}>
{t('backToGalleryBtn')}
</button>
</div>
</div>
)
)}
{view.type === 'translations' && (
isCurator ? (
<TranslationsPage onBack={goToTimelineHome} />
@@ -828,6 +1014,12 @@ export default function HomePage() {
onLogin={handleCuratorLogin}
/>
<ToursPopup
open={toursPopupOpen}
onClose={() => setToursPopupOpen(false)}
onSelectTour={(tourId) => void handleSelectPublishedTour(tourId)}
/>
{view.type === 'timeline' && (
<div className="home-page">
<header className="site-header">
@@ -864,6 +1056,14 @@ export default function HomePage() {
>
{t('influences')}
</button>
<button
type="button"
className="checkup-link-btn"
onClick={openToursEditor}
title="Create and edit guided tours"
>
{t('toursEditor')}
</button>
<button
type="button"
className="checkup-link-btn"
@@ -899,6 +1099,14 @@ export default function HomePage() {
{t('curatorLogin')}
</button>
)}
<button
type="button"
className="checkup-link-btn"
onClick={() => setToursPopupOpen(true)}
title={t('tours')}
>
{t('tours')}
</button>
<LocaleSwitcher onLocaleChange={handleLocaleChange} />
</div>
<h1>{t('title')}</h1>
+180
View File
@@ -0,0 +1,180 @@
.tours-page {
max-width: 1200px;
margin: 0 auto;
padding: 1.25rem 1.5rem 3rem;
color: #e8d5b5;
min-height: 100vh;
}
.tours-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.tours-header h1 {
margin: 0;
font-size: 1.5rem;
color: #e8d5b5;
}
.tours-back {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.75rem;
cursor: pointer;
}
.tours-error {
background: rgba(139, 0, 0, 0.3);
border: 1px solid rgba(255, 170, 170, 0.4);
color: #ffaaaa;
padding: 0.65rem 0.85rem;
border-radius: 6px;
margin-bottom: 1rem;
}
.tours-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 1rem;
}
.tours-list-panel,
.tours-editor {
border: 1px solid rgba(201, 169, 110, 0.25);
border-radius: 8px;
padding: 0.85rem;
background: rgba(15, 15, 26, 0.45);
}
.tours-create {
display: flex;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.tours-create input,
.tours-meta input,
.tours-meta textarea,
.tours-meta select,
.tours-stops-toolbar input,
.tours-stops textarea {
border: 1px solid rgba(201, 169, 110, 0.35);
border-radius: 6px;
padding: 0.45rem 0.65rem;
background: rgba(15, 15, 26, 0.85);
color: #e8d5b5;
font: inherit;
width: 100%;
}
.tours-create button,
.tours-meta-actions button,
.tours-stops-toolbar button,
.tours-stop-move button,
.tours-hits button,
.tours-list button {
border: 1px solid rgba(201, 169, 110, 0.35);
background: rgba(15, 15, 26, 0.85);
color: #c9a96e;
border-radius: 6px;
padding: 0.4rem 0.65rem;
cursor: pointer;
font: inherit;
}
.tours-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
max-height: 70vh;
overflow: auto;
}
.tours-list button {
width: 100%;
text-align: left;
display: grid;
gap: 0.15rem;
}
.tours-list button.active {
border-color: #e8a040;
color: #e8d5b5;
background: rgba(232, 160, 64, 0.15);
}
.tours-meta {
display: grid;
gap: 0.65rem;
margin-bottom: 1rem;
}
.tours-meta label {
display: grid;
gap: 0.3rem;
font-size: 0.9rem;
}
.tours-meta-actions {
display: flex;
gap: 0.5rem;
}
.tours-stops-toolbar {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.tours-hits {
list-style: none;
margin: 0 0 0.75rem;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tours-stops {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.85rem;
}
.tours-stop-head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.35rem;
}
.tours-stop-move {
display: flex;
gap: 0.25rem;
}
.muted {
color: rgba(201, 169, 110, 0.65);
font-size: 0.85rem;
}
.danger {
color: #ffaaaa !important;
border-color: rgba(255, 170, 170, 0.4) !important;
}
@media (max-width: 900px) {
.tours-layout {
grid-template-columns: 1fr;
}
}
+363
View File
@@ -0,0 +1,363 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api, type TourSummary } from '../api/client';
import type { Painting } from '../types';
import './ToursPage.css';
interface Props {
onBack: () => void;
}
interface StopDraft {
paintingId: number;
title: string;
artistName: string;
year: number | null;
thumbnailPath: string | null;
body: string;
}
export default function ToursPage({ onBack }: Props) {
const { t } = useTranslation('tours');
const [tours, setTours] = useState<TourSummary[]>([]);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [status, setStatus] = useState<'draft' | 'published'>('draft');
const [stops, setStops] = useState<StopDraft[]>([]);
const [searchQ, setSearchQ] = useState('');
const [searchHits, setSearchHits] = useState<
Array<{ id: number; title: string; artistName: string; year: number | null; thumbnailPath: string | null }>
>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [newTitle, setNewTitle] = useState('');
const loadList = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.listAdminTours();
setTours(data.tours);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void loadList();
}, [loadList]);
const openTour = async (id: number) => {
setSelectedId(id);
setError(null);
try {
const data = await api.getTour(id);
setTitle(data.tour.title);
setDescription(data.tour.description || '');
setStatus(data.tour.status);
setStops(
data.paintings.map((p: Painting) => ({
paintingId: p.id,
title: p.title,
artistName: p.artist_name || '',
year: p.year ?? null,
thumbnailPath: p.thumbnail_path || null,
body: data.stopBodies[p.id] || '',
})),
);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
}
};
useEffect(() => {
const handle = setTimeout(() => {
void (async () => {
if (searchQ.trim().length < 2) {
setSearchHits([]);
return;
}
try {
const data = await api.search(searchQ.trim(), { types: 'painting', limit: 12 });
setSearchHits(
data.results
.filter((r) => r.type === 'painting')
.map((r) => ({
id: r.id,
title: r.title,
artistName: r.artist_name,
year: r.year,
thumbnailPath: r.thumbnail_path,
})),
);
} catch {
setSearchHits([]);
}
})();
}, 250);
return () => clearTimeout(handle);
}, [searchQ]);
const createTour = async () => {
const name = newTitle.trim();
if (!name) return;
setSaving(true);
setError(null);
try {
const { tour } = await api.createTour({ title: name, status: 'draft' });
setNewTitle('');
await loadList();
await openTour(tour.id);
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const saveMeta = async () => {
if (!selectedId) return;
setSaving(true);
setError(null);
try {
await api.updateTour(selectedId, {
title: title.trim(),
description,
status,
coverPaintingId: stops[0]?.paintingId ?? null,
});
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const saveStops = async () => {
if (!selectedId) return;
setSaving(true);
setError(null);
try {
await api.saveTourStops(
selectedId,
stops.map((s) => ({ paintingId: s.paintingId, body: s.body })),
);
await api.updateTour(selectedId, {
coverPaintingId: stops[0]?.paintingId ?? null,
});
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const deleteTour = async () => {
if (!selectedId) return;
if (!window.confirm(t('confirmDelete'))) return;
setSaving(true);
try {
await api.deleteTour(selectedId);
setSelectedId(null);
setStops([]);
await loadList();
} catch (err) {
setError(err instanceof Error ? err.message : t('loadFailed'));
} finally {
setSaving(false);
}
};
const addStop = (hit: {
id: number;
title: string;
artistName: string;
year: number | null;
thumbnailPath: string | null;
}) => {
if (stops.some((s) => s.paintingId === hit.id)) return;
setStops((prev) => [
...prev,
{
paintingId: hit.id,
title: hit.title,
artistName: hit.artistName,
year: hit.year,
thumbnailPath: hit.thumbnailPath,
body: '',
},
]);
setSearchQ('');
setSearchHits([]);
};
const moveStop = (index: number, dir: -1 | 1) => {
const next = index + dir;
if (next < 0 || next >= stops.length) return;
setStops((prev) => {
const copy = [...prev];
const tmp = copy[index];
copy[index] = copy[next];
copy[next] = tmp;
return copy;
});
};
return (
<div className="tours-page">
<header className="tours-header">
<button type="button" className="tours-back" onClick={onBack}>
{t('back')}
</button>
<h1>{t('title')}</h1>
</header>
{error && <div className="tours-error">{error}</div>}
<div className="tours-layout">
<aside className="tours-list-panel">
<div className="tours-create">
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder={t('newTourPlaceholder')}
/>
<button type="button" disabled={saving || !newTitle.trim()} onClick={() => void createTour()}>
{t('create')}
</button>
</div>
{loading ? (
<p className="muted">{t('loading')}</p>
) : (
<ul className="tours-list">
{tours.map((tour) => (
<li key={tour.id}>
<button
type="button"
className={selectedId === tour.id ? 'active' : ''}
onClick={() => void openTour(tour.id)}
>
<strong>{tour.title}</strong>
<span className="muted">
{tour.status} · {t('stopCount', { count: tour.stopCount })}
</span>
</button>
</li>
))}
{tours.length === 0 && <li className="muted">{t('noTours')}</li>}
</ul>
)}
</aside>
<section className="tours-editor">
{!selectedId ? (
<p className="muted">{t('selectTour')}</p>
) : (
<>
<div className="tours-meta">
<label>
{t('tourTitle')}
<input value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<label>
{t('tourDescription')}
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
</label>
<label>
{t('status')}
<select
value={status}
onChange={(e) => setStatus(e.target.value as 'draft' | 'published')}
>
<option value="draft">{t('draft')}</option>
<option value="published">{t('published')}</option>
</select>
</label>
<div className="tours-meta-actions">
<button type="button" disabled={saving} onClick={() => void saveMeta()}>
{t('saveMeta')}
</button>
<button type="button" className="danger" disabled={saving} onClick={() => void deleteTour()}>
{t('delete')}
</button>
</div>
</div>
<div className="tours-stops-toolbar">
<input
type="search"
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
placeholder={t('searchPainting')}
/>
<button type="button" disabled={saving} onClick={() => void saveStops()}>
{t('saveStops')}
</button>
</div>
{searchHits.length > 0 && (
<ul className="tours-hits">
{searchHits.map((h) => (
<li key={h.id}>
<button type="button" onClick={() => addStop(h)}>
{h.artistName} {h.title}
</button>
</li>
))}
</ul>
)}
<ol className="tours-stops">
{stops.map((stop, index) => (
<li key={stop.paintingId}>
<div className="tours-stop-head">
<span>
{index + 1}. {stop.artistName} {stop.title}
{stop.year != null ? ` (${stop.year})` : ''}
</span>
<div className="tours-stop-move">
<button type="button" onClick={() => moveStop(index, -1)} disabled={index === 0}>
</button>
<button
type="button"
onClick={() => moveStop(index, 1)}
disabled={index === stops.length - 1}
>
</button>
<button
type="button"
className="danger"
onClick={() => setStops((prev) => prev.filter((_, i) => i !== index))}
>
{t('remove')}
</button>
</div>
</div>
<textarea
value={stop.body}
onChange={(e) =>
setStops((prev) =>
prev.map((s, i) => (i === index ? { ...s, body: e.target.value } : s)),
)
}
rows={4}
placeholder={t('stopBodyPlaceholder')}
/>
</li>
))}
{stops.length === 0 && <li className="muted">{t('noStops')}</li>}
</ol>
</>
)}
</section>
</div>
</div>
);
}
+19
View File
@@ -133,6 +133,25 @@ export interface MovementGalleryDetail {
paintings: Painting[];
}
export interface TourSummary {
id: number;
title: string;
description: string;
status: 'draft' | 'published';
coverPaintingId: number | null;
coverThumbnailPath: string | null;
coverImagePath: string | null;
stopCount: number;
createdAt?: string;
updatedAt?: string;
}
export interface TourGalleryDetail {
tour: TourSummary;
paintings: Painting[];
stopBodies: Record<number, string>;
}
export interface TimelineData {
eras: HistoricalEra[];
movements: ArtMovement[];
+29 -24
View File
@@ -1,6 +1,5 @@
import type { Painting } from '../types';
import type { GalleryWindowSpec, MovementInteriorStyle } from '../data/movement-interior-styles';
import { comparePaintingsChronological } from './paintingUtils';
/** Target capacity per movement wing (5060 works). */
export const MOVEMENT_PAINTINGS_PER_HALL = 55;
@@ -92,10 +91,6 @@ function layoutRow(paintings: Painting[], span: number) {
return { slots, spanNeeded: Math.max(span, rowWidth + WALL_PADDING) };
}
function orderForWall(paintings: Painting[]) {
return [...paintings].sort(comparePaintingsChronological).reverse();
}
function formatYearLabel(paintings: Painting[]) {
const years = paintings.map((p) => p.year).filter((y): y is number => y != null);
if (years.length === 0) return 'Undated works';
@@ -104,22 +99,27 @@ function formatYearLabel(paintings: Painting[]) {
return min === max ? `${min}` : `${min} ${max}`;
}
/** Preserve caller order (chrono for movements, stop order for tours). */
export function splitPaintingsIntoMovementHalls(paintings: Painting[]): Painting[][] {
const sorted = [...paintings].sort(comparePaintingsChronological);
if (sorted.length === 0) return [[]];
if (paintings.length === 0) return [[]];
const chunks: Painting[][] = [];
for (let i = 0; i < sorted.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
chunks.push(sorted.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
for (let i = 0; i < paintings.length; i += MOVEMENT_PAINTINGS_PER_HALL) {
chunks.push(paintings.slice(i, i + MOVEMENT_PAINTINGS_PER_HALL));
}
return chunks;
}
/**
* First half left wall, second half right.
* Callers pass paintings already in visit order; first work hangs near the
* entrance on the left, last work near the entrance on the right.
*/
function distributeToSideWalls(paintings: Painting[]) {
const left: Painting[] = [];
const right: Painting[] = [];
const sorted = [...paintings].sort(comparePaintingsChronological);
sorted.forEach((p, i) => (i % 2 === 0 ? left : right).push(p));
return { left: orderForWall(left), right: orderForWall(right) };
const mid = Math.ceil(paintings.length / 2);
return {
left: paintings.slice(0, mid),
right: paintings.slice(mid),
};
}
function layoutSideSlots(
@@ -132,16 +132,21 @@ function layoutSideSlots(
if (paintings.length === 0) return [];
const { slots: rowSlots } = layoutRow(paintings, span);
const y = EYE_HEIGHT;
return rowSlots.map((s) => ({
maxW: s.maxW,
maxH: s.maxH,
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
side,
position:
side === 'left'
? ([-halfW + inset + WALL_STANDOFF, y, s.offset] as [number, number, number])
: ([halfW - inset - WALL_STANDOFF, y, s.offset] as [number, number, number]),
}));
return rowSlots.map((s) => {
// layoutRow places index 0 at negative offset. Flip on the left wall so
// the first painting sits at the entrance (+Z), left of the starting view.
const alongWall = side === 'left' ? -s.offset : s.offset;
return {
maxW: s.maxW,
maxH: s.maxH,
rotationY: side === 'left' ? Math.PI / 2 : -Math.PI / 2,
side,
position:
side === 'left'
? ([-halfW + inset + WALL_STANDOFF, y, alongWall] as [number, number, number])
: ([halfW - inset - WALL_STANDOFF, y, alongWall] as [number, number, number]),
};
});
}
export function buildMovementHallLayout(