import { plannableTourTypeSchema, type TourLibraryItem, type TourTypeKey } from '@trek/shared'; import { AlertTriangle, ArrowUp, Clock, CopyPlus, Library, Loader2, Ruler, Search, WifiOff } from 'lucide-react'; import { useId, useMemo } from 'react'; import { OFM_POSITRON } from '../../../constants/mapDefaults'; import { useTileUrl } from '../../../hooks/useTileUrl'; import { useTranslation } from '../../../i18n'; import { useSettingsStore } from '../../../store/settingsStore'; import { formatDistance, formatElevation } from '../../../utils/units'; import { MapViewAuto } from '../../Map/MapViewAuto'; import { EditorField, INPUT, PillSelect } from '../../shared/dialogParts'; import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, fs, NEUTRAL_TINT, } from '../../shared/DialogShell'; import { TourDayRowPill } from '../TourDayRowFacts'; import { RailFacts, TourNotice, type TourMetricField } from '../tourParts'; import { formatPlannedTourDuration, tourPlannedTimes } from '../tourPresentation'; import { type TourLibrary } from './useTourLibrary'; type Translate = (key: string, params?: Record) => string; type Unit = Parameters[1]; const ALL = 'all'; /** A tour's own measures as badges; one it does not have is left out, never shown as zero. */ function measureBadges(tour: TourLibraryItem, unit: Unit, t: Translate) { const minutes = tourPlannedTimes(tour).plannedTotalMinutes; return [ minutes != null && { key: 'time', icon: Clock, value: formatPlannedTourDuration(minutes), label: `${t('tours.planner.plannedTotalDuration')}: ${formatPlannedTourDuration(minutes)}`, }, tour.distance != null && { key: 'distance', icon: Ruler, value: formatDistance(tour.distance, unit), label: `${t('tours.detail.distance')}: ${formatDistance(tour.distance, unit)}`, }, tour.elevation_gain != null && tour.tour_type !== 'kayak' && { key: 'ascent', icon: ArrowUp, value: formatElevation(tour.elevation_gain, unit), label: `${t('tours.detail.ascent')}: ${formatElevation(tour.elevation_gain, unit)}`, }, ].filter((badge) => !!badge); } /** One tour of the list: its name, the trip it is in, its measures. */ function LibraryRow({ tour, selected, onChoose }: { tour: TourLibraryItem; selected: boolean; onChoose: () => void }) { const { t } = useTranslation(); const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; return (
  • ); } /** A line of the list's state: loading, offline, failed with a retry, empty, or cut short. */ function ListState({ library }: { library: TourLibrary }) { const { t } = useTranslation(); const { listStatus, list } = library; if (listStatus === 'offline') return }>{t('tours.library.offline')}; if (listStatus === 'error') return ( }> {t('tours.library.loadFailed')} ); if (listStatus === 'loading' && !list) return (

    {t('common.loading')}

    ); if (list && list.tours.length === 0) return (

    {list.trips.length === 0 ? t('tours.library.noTrips') : t('tours.library.empty')}

    ); if (list?.truncated) return {t('tours.library.truncated')}; return null; } /** The search box and the two filters over the list. */ function ListFilters({ library }: { library: TourLibrary }) { const { t } = useTranslation(); const searchId = useId(); const trips = library.list?.trips ?? []; const tripOptions = [ { value: ALL, label: t('tours.library.allTrips') }, ...trips.map((trip) => ({ value: String(trip.id), label: trip.title, hint: String(trip.tour_count) })), ]; const typeOptions = [ { value: ALL, label: t('tours.library.allTypes') }, ...plannableTourTypeSchema.options.map((type) => ({ value: type, label: t(`tourTypes.${type}`) })), ]; return (
    library.filter({ query: event.target.value })} placeholder={t('tours.library.search')} className="min-w-0 flex-1 border-0 bg-transparent text-content outline-none placeholder:text-content-faint" style={fs(12.5, 'body')} />
    library.filter({ tripFilter: value === ALL ? null : Number(value) })} /> library.filter({ typeFilter: value === ALL ? null : (value as TourTypeKey) })} />
    ); } /** The facts of the chosen tour as the preview shows them, each only where the tour has it. */ function previewFacts(tour: TourLibraryItem, waypoints: number, unit: Unit, t: Translate): TourMetricField[] { const minutes = tourPlannedTimes(tour).plannedTotalMinutes; const fact = (show: boolean, label: string, value: () => string) => (show ? { label, value: value() } : null); return [ fact(true, t('tours.library.fromTrip'), () => tour.trip.title), fact(true, t('tours.filter.type'), () => t(`tourTypes.${tour.tour_type}`)), fact(tour.distance != null, t('tours.detail.distance'), () => formatDistance(tour.distance ?? 0, unit)), fact(minutes != null, t('tours.planner.plannedTotalDuration'), () => formatPlannedTourDuration(minutes ?? 0)), fact(tour.elevation_gain != null && tour.tour_type !== 'kayak', t('tours.detail.ascent'), () => formatElevation(tour.elevation_gain ?? 0, unit) ), fact(tour.elevation_loss != null && tour.tour_type !== 'kayak', t('tours.detail.descent'), () => formatElevation(tour.elevation_loss ?? 0, unit) ), fact(waypoints > 0, t('tours.library.controlPoints'), () => String(waypoints)), ].filter((field): field is TourMetricField => field !== null); } /** The chosen tour: on a map, its facts, and the name the copy gets. */ function Preview({ library }: { library: TourLibrary }) { const { t } = useTranslation(); const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; const tileUrl = useTileUrl(OFM_POSITRON); const nameId = useId(); const line = library.preview?.line; const route = useMemo( // The client reads the contract's tuples loosely; the points are spelled out as pairs here. () => line && line.length > 1 ? [line.map((point): [number, number] => [Number(point[0]), Number(point[1])])] : [], [line] ); const { selected } = library; if (!selected) return (

    {t('tours.library.chooseHint')}

    ); return (
    {route.length > 0 && ( )} {library.previewStatus === 'loading' && ( )} {(library.previewStatus === 'error' || library.previewStatus === 'offline') && ( {t(library.previewStatus === 'offline' ? 'tours.library.offline' : 'tours.library.previewFailed')} )}
    library.setName(event.target.value)} />
    ); } /** * LIB-02: the dialog to take a tour of another trip over into this one. The * list on the left, the chosen tour on the right; the copy is made only by the * button in its foot. */ /** LIB-02: the dialog that takes a tour of another trip into this one, opened from "Add tour". */ export function TourLibraryDialog({ library }: { library: TourLibrary }) { const { t } = useTranslation(); const labelId = useId(); if (!library.open) return null; return ( } tint={NEUTRAL_TINT} labelId={labelId} onClose={library.close} eyebrow={t('tours.library.eyebrow')} title={t('tours.library.title')} sub={t('tours.library.sub')} subWraps /> } footer={ {t('common.cancel')} ) : ( ) } disabled={!library.canConfirm} onClick={() => void library.confirm()} > {t('tours.library.confirm')} } >
    {library.list && library.list.tours.length > 0 && (
      {library.list.tours.map((tour) => ( library.choose(tour)} /> ))}
    )}
    ); }