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>
99 lines
3.1 KiB
TypeScript
99 lines
3.1 KiB
TypeScript
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>
|
||
);
|
||
}
|