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
@@ -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