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;