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
+227
-19
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user