From 76ec3025269d3514c6a726899e7dc32dfdcb2530 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 20:48:33 +0200 Subject: [PATCH] feat(tours): kept tours are rows of a collection's list, on the phone too A tour kept in a collection was a card in a box of its own above the list, and its button lost its look to the collections page's button reset. It is now a row of the list like a saved place: its grade or type tile, its name, type and the trip it came from, its measures, and Add to a trip, with removing dimmed beside it. The list's search narrows the tours too. The phone shows the same rows and takes a tour into a trip from a sheet, on the same hooks as the desktop. --- client/scripts/feature-pairs.json | 9 + .../collection/CollectionToursSection.tsx | 204 ++++++------ .../collection/TourSaveToCollectionButton.tsx | 4 +- .../Tours/collection/collectionTours.test.tsx | 14 +- .../Tours/collection/keptTourText.ts | 7 + .../Tours/collection/useCollectionTours.ts | 24 ++ .../Tours/library/tourMeasureBadges.ts | 33 ++ .../Tours/library/tourPreviewParts.tsx | 36 +- .../screens/collections/MCollTours.test.tsx | 132 ++++++++ .../mobile/screens/collections/MCollTours.tsx | 312 ++++++++++++++++++ .../screens/collections/MCollections.tsx | 3 + client/src/pages/CollectionsPage.tsx | 6 +- client/src/styles/collections.css | 24 ++ 13 files changed, 670 insertions(+), 138 deletions(-) create mode 100644 client/src/components/Tours/collection/keptTourText.ts create mode 100644 client/src/components/Tours/library/tourMeasureBadges.ts create mode 100644 client/src/mobile/screens/collections/MCollTours.test.tsx create mode 100644 client/src/mobile/screens/collections/MCollTours.tsx diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 2e5784a0a..74d26cc5f 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -223,6 +223,15 @@ "desktop": ["src/pages/CollectionsPage.tsx"], "mobile": ["src/mobile/screens/collections/MCollections.tsx"] }, + { + "feature": "Collection tours (LIB-03): useCollectionTours, useCollectionTourTakeOver", + "hooks": [ + "src/components/Tours/collection/useCollectionTours.ts", + "src/components/Tours/collection/useCollectionTourTakeOver.ts" + ], + "desktop": ["src/components/Tours/collection/CollectionToursSection.tsx"], + "mobile": ["src/mobile/screens/collections/MCollTours.tsx"] + }, { "feature": "Copy collection places to a trip", "hooks": ["src/components/Collections/useTripCopyPicker.ts"], diff --git a/client/src/components/Tours/collection/CollectionToursSection.tsx b/client/src/components/Tours/collection/CollectionToursSection.tsx index 6548d2c52..7c5be10d5 100644 --- a/client/src/components/Tours/collection/CollectionToursSection.tsx +++ b/client/src/components/Tours/collection/CollectionToursSection.tsx @@ -1,26 +1,24 @@ import type { CollectionTourItem } from '@trek/shared'; import { AlertTriangle, CopyPlus, Gauge, Moon, Route, Trash2, Waypoints, WifiOff } from 'lucide-react'; +import type { MouseEvent } from 'react'; import { useTranslation } from '../../../i18n'; -import { useAuthStore } from '../../../store/authStore'; -import { useCollectionStore } from '../../../store/collectionStore'; import { useSettingsStore } from '../../../store/settingsStore'; -import { canRemoveFromCollection, collectionRoleOf } from '../../../utils/collectionAccess'; import { WhiteBadge } from '../../Planner/planParts'; import ConfirmDialog from '../../shared/ConfirmDialog'; -import { fs } from '../../shared/DialogShell'; import { Tooltip } from '../../shared/Tooltip'; import { TourMeasurePills } from '../library/tourPreviewParts'; import { runPaceText } from '../run/runPaceText'; -import { CardAction, TourGradeTile, TourNotice } from '../tourParts'; +import { tourDayRowIcon } from '../tourDayRowIcon'; +import { TourGradeTile, TourNotice } from '../tourParts'; import { CollectionTourTakeOverDialog } from './CollectionTourTakeOverDialog'; +import { sourceText } from './keptTourText'; +import { + toursMatching, + useActiveCollectionTourList, + useCollectionTours, + type CollectionTours, +} from './useCollectionTours'; import { useCollectionTourTakeOver } from './useCollectionTourTakeOver'; -import { useCollectionTours, type CollectionTours } from './useCollectionTours'; - -/** Where a kept tour came from and who kept it, as one tooltip. */ -function sourceText(tour: CollectionTourItem, t: (key: string, params?: Record) => string) { - const source = t('tours.collection.source', { tour: tour.source.tourName, trip: tour.source.tripTitle }); - return tour.saved_by ? `${source}. ${t('tours.collection.keptBy', { name: tour.saved_by })}` : source; -} /** * What the trip's list shows of a kept tour beyond its measures: a run's pace @@ -51,12 +49,13 @@ function KeptTourTypeBadges({ tour }: { tour: CollectionTourItem }) { } /** - * One kept tour as a card: its grade tile with where it came from, its name - * and type, its measures and waypoints as badges, the trip it was saved from, - * and the way into a trip. Removing it sits quietly in its corner, dimmed - * but always there (a phone has no hover), raised on hover or focus. + * One kept tour as a row of the collection's list, the way a saved place is: + * its grade tile where a place has its photo, its name with its type and the + * trip it was kept from, then its measures, the way into a trip and, for its + * owner or an admin, removing it. The whole row takes it into a trip, as the + * whole row of a place opens the place. */ -function KeptTourCard({ +function KeptTourRow({ tour, canRemove, onTake, @@ -69,68 +68,82 @@ function KeptTourCard({ }) { const { t } = useTranslation(); const tile = { ...tour, has_waypoints: tour.waypoint_count > 0 }; + const TypeIcon = tourDayRowIcon(tour.tour_type); + const points = t('tours.collection.points', { count: tour.waypoint_count }); + const stop = (act: () => void) => (event: MouseEvent) => { + event.stopPropagation(); + act(); + }; return ( -
{ + if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) return; + event.preventDefault(); + onTake(); + }} + className="col-lrow col-lrow-tour group" > -
+ -
-

- {tour.name} -

- - {t(`tourTypes.${tour.tour_type}`)} - + +
+
{tour.name}
+
+ + {t(`tourTypes.${tour.tour_type}`)} + + + + {tour.source.tripTitle} + +
+
+
+ + + + {tour.waypoint_count > 0 && ( + + + }> + {tour.waypoint_count} + + + + )} + + + + + {canRemove && ( )}
-
- - - {tour.waypoint_count > 0 && ( - - - }> - {tour.waypoint_count} - - - - )} -
-
- - - - {tour.source.tripTitle} - - - - - } onClick={onTake}> - {t('tours.collection.add')} - - - -
-
+ ); } -/** What stands above the cards while they cannot be shown: offline, or failed with a retry. */ +/** What stands above the rows while they cannot be shown: offline, or failed with a retry. */ function SectionProblem({ kept }: { kept: CollectionTours }) { const { t } = useTranslation(); if (kept.status === 'offline') @@ -149,41 +162,38 @@ function SectionProblem({ kept }: { kept: CollectionTours }) { } /** - * LIB-03 on a collection's page: the whole tours it keeps, above its places, - * each ready to be taken into a trip. Nothing shows while it keeps none, or - * while the Tours addon is off. + * LIB-03 on a collection's page: the whole tours it keeps, as the first rows + * of its list, each ready to be taken into a trip. The list's search narrows + * them by name as it narrows the places. Nothing shows while it keeps none, + * or while the Tours addon is off. */ -export function CollectionToursSection({ collectionId, canRemove }: { collectionId: number; canRemove: boolean }) { +export function CollectionToursSection({ + collectionId, + canRemove, + search = '', +}: { + collectionId: number; + canRemove: boolean; + search?: string; +}) { const { t } = useTranslation(); const kept = useCollectionTours(collectionId); const takeOver = useCollectionTourTakeOver(collectionId); const problem = kept.status === 'error' || kept.status === 'offline'; - if (!kept.enabled || (kept.tours.length === 0 && !problem)) return null; + const shown = toursMatching(kept.tours, search); + if (!kept.enabled || (shown.length === 0 && !problem)) return null; return ( -
-
- - - -

- {t('tours.title')} -

- {kept.tours.length > 0 && {kept.tours.length}} -
+
- {kept.tours.length > 0 && ( -
- {kept.tours.map((tour) => ( - takeOver.show(tour)} - onRemove={() => kept.askRemove(tour)} - /> - ))} -
- )} + {shown.map((tour) => ( + takeOver.show(tour)} + onRemove={() => kept.askRemove(tour)} + /> + ))} state.activeId); - const collections = useCollectionStore((state) => state.collections); - const members = useCollectionStore((state) => state.members); - const userId = useAuthStore((state) => state.user?.id); - if (typeof activeId !== 'number') return null; - const isOwner = collections.find((collection) => collection.id === activeId)?.is_owner ?? false; - const canRemove = canRemoveFromCollection(collectionRoleOf(isOwner, members, userId)); - return ; +/** The tours of the list open on the collections page; nothing on "All saved" or before a list opens. */ +export function ActiveCollectionTours({ search = '' }: { search?: string }) { + const { collectionId, canRemove } = useActiveCollectionTourList(); + if (collectionId === null) return null; + return ; } diff --git a/client/src/components/Tours/collection/TourSaveToCollectionButton.tsx b/client/src/components/Tours/collection/TourSaveToCollectionButton.tsx index 125857201..9a8939a12 100644 --- a/client/src/components/Tours/collection/TourSaveToCollectionButton.tsx +++ b/client/src/components/Tours/collection/TourSaveToCollectionButton.tsx @@ -20,7 +20,9 @@ function KeepButton({ target }: { target: TourSaveTarget }) { aria-label={t('tours.collection.save')} data-testid="tour-keep-in-collection" icon={} - /> + > + {t('tours.collection.list')} + diff --git a/client/src/components/Tours/collection/collectionTours.test.tsx b/client/src/components/Tours/collection/collectionTours.test.tsx index f816d3869..463a2fbfc 100644 --- a/client/src/components/Tours/collection/collectionTours.test.tsx +++ b/client/src/components/Tours/collection/collectionTours.test.tsx @@ -18,7 +18,7 @@ import { TourSaveToCollectionBarButton, TourSaveToCollectionButton } from './Tou import { useCollectionTourTakeOver } from './useCollectionTourTakeOver'; import { useSaveTourToCollection } from './useSaveTourToCollection'; -// FE-COLLECTION-TOURS-001 to FE-COLLECTION-TOURS-016 +// FE-COLLECTION-TOURS-001 to FE-COLLECTION-TOURS-017 vi.mock('../../Map/MapViewAuto', () => ({ MapViewAuto: ({ route }: { route: Array> }) => ( @@ -433,4 +433,16 @@ describe('CollectionToursSection', () => { useCollectionStore.setState(before, true); } }); + + it('FE-COLLECTION-TOURS-017: the rows follow the list search by name, and a click on the row itself starts the take-over', async () => { + vi.spyOn(collectionToursRepo, 'list').mockResolvedValue({ tours: [kept(), kept({ id: 5, name: 'Lake loop' })] }); + const { rerender } = render(); + expect(await screen.findByTestId('kept-tour-5')).toBeInTheDocument(); + expect(screen.queryByTestId('kept-tour-4')).toBeNull(); + rerender(); + expect(screen.queryByTestId('collection-tours')).toBeNull(); + rerender(); + fireEvent.click(await screen.findByTestId('kept-tour-4')); + expect(await screen.findByTestId('kept-tour-preview')).toBeInTheDocument(); + }); }); diff --git a/client/src/components/Tours/collection/keptTourText.ts b/client/src/components/Tours/collection/keptTourText.ts new file mode 100644 index 000000000..0cfc2c97b --- /dev/null +++ b/client/src/components/Tours/collection/keptTourText.ts @@ -0,0 +1,7 @@ +import type { CollectionTourItem } from '@trek/shared'; + +/** Where a kept tour came from and who kept it, as one tooltip. */ +export function sourceText(tour: CollectionTourItem, t: (key: string, params?: Record) => string) { + const source = t('tours.collection.source', { tour: tour.source.tourName, trip: tour.source.tripTitle }); + return tour.saved_by ? `${source}. ${t('tours.collection.keptBy', { name: tour.saved_by })}` : source; +} diff --git a/client/src/components/Tours/collection/useCollectionTours.ts b/client/src/components/Tours/collection/useCollectionTours.ts index 46c9e650b..6354bf12f 100644 --- a/client/src/components/Tours/collection/useCollectionTours.ts +++ b/client/src/components/Tours/collection/useCollectionTours.ts @@ -4,7 +4,10 @@ import { useShallow } from 'zustand/react/shallow'; import { useTranslation } from '../../../i18n'; import { OnlineOnlyError } from '../../../repo/onlineOnly'; import { useAddonStore } from '../../../store/addonStore'; +import { useAuthStore } from '../../../store/authStore'; +import { useCollectionStore } from '../../../store/collectionStore'; import { useCollectionTourStore, watchCollectionTours } from '../../../store/collectionTourStore'; +import { canRemoveFromCollection, collectionRoleOf } from '../../../utils/collectionAccess'; import { useToast } from '../../shared/Toast'; /** @@ -54,3 +57,24 @@ export function useCollectionTours(collectionId: number) { } export type CollectionTours = ReturnType; + +/** The kept tours whose name holds the list's search, all of them without one. */ +export function toursMatching(tours: readonly CollectionTourItem[], search: string): CollectionTourItem[] { + const query = search.trim().toLocaleLowerCase(); + return query ? tours.filter((tour) => tour.name.toLocaleLowerCase().includes(query)) : [...tours]; +} + +/** + * The list open on the collections page and whether this user may remove its + * tours (its owner or an admin of it), read from the list itself; no list on + * "All saved" or before one opens. Desktop and phone both ask here. + */ +export function useActiveCollectionTourList(): { collectionId: number | null; canRemove: boolean } { + const { activeId, collections, members } = useCollectionStore( + useShallow((state) => ({ activeId: state.activeId, collections: state.collections, members: state.members })) + ); + const userId = useAuthStore((state) => state.user?.id); + if (typeof activeId !== 'number') return { collectionId: null, canRemove: false }; + const isOwner = collections.find((collection) => collection.id === activeId)?.is_owner ?? false; + return { collectionId: activeId, canRemove: canRemoveFromCollection(collectionRoleOf(isOwner, members, userId)) }; +} diff --git a/client/src/components/Tours/library/tourMeasureBadges.ts b/client/src/components/Tours/library/tourMeasureBadges.ts new file mode 100644 index 000000000..030ab4ea6 --- /dev/null +++ b/client/src/components/Tours/library/tourMeasureBadges.ts @@ -0,0 +1,33 @@ +import { ArrowUp, Clock, Ruler } from 'lucide-react'; +import { formatDistance, formatElevation } from '../../../utils/units'; +import { plannedTotalText, tourPlannedTimes } from '../tourPresentation'; +import type { MeasuredTour, Translate, Unit } from './tourPreviewFacts'; + +/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */ +export function measureBadges(tour: MeasuredTour, unit: Unit, t: Translate) { + const times = tourPlannedTimes(tour); + const total = plannedTotalText(times, t); + const badge = plannedTotalText(times, t, true); + return [ + total != null && + badge != null && { + key: 'time', + icon: Clock, + value: badge, + label: `${t('tours.planner.plannedTotalDuration')}: ${total}`, + }, + 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); +} diff --git a/client/src/components/Tours/library/tourPreviewParts.tsx b/client/src/components/Tours/library/tourPreviewParts.tsx index c8575ae2f..00a36d894 100644 --- a/client/src/components/Tours/library/tourPreviewParts.tsx +++ b/client/src/components/Tours/library/tourPreviewParts.tsx @@ -1,44 +1,14 @@ -import { ArrowUp, Clock, Loader2, Ruler, Spline } from 'lucide-react'; +import { Loader2, Spline } from 'lucide-react'; import { 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 { fs } from '../../shared/DialogShell'; import { TourDayRowPill } from '../TourDayRowFacts'; -import { plannedTotalText, tourPlannedTimes } from '../tourPresentation'; -import type { MeasuredTour, Translate, Unit } from './tourPreviewFacts'; - -/** A tour's own measures as badges; one it does not have is left out, never shown as zero. */ -function measureBadges(tour: MeasuredTour, unit: Unit, t: Translate) { - const times = tourPlannedTimes(tour); - const total = plannedTotalText(times, t); - const badge = plannedTotalText(times, t, true); - return [ - total != null && - badge != null && { - key: 'time', - icon: Clock, - value: badge, - label: `${t('tours.planner.plannedTotalDuration')}: ${total}`, - }, - 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); -} +import { measureBadges } from './tourMeasureBadges'; +import type { MeasuredTour } from './tourPreviewFacts'; /** The measures of a listed tour as small white badges, each named in full by its tooltip. */ export function TourMeasurePills({ tour, testPrefix }: { tour: MeasuredTour; testPrefix: string }) { diff --git a/client/src/mobile/screens/collections/MCollTours.test.tsx b/client/src/mobile/screens/collections/MCollTours.test.tsx new file mode 100644 index 000000000..c33a20096 --- /dev/null +++ b/client/src/mobile/screens/collections/MCollTours.test.tsx @@ -0,0 +1,132 @@ +import type { CollectionTourDetailResponse, CollectionTourItem, TourCreateResponse } from '@trek/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor, within } from '../../../../tests/helpers/render'; +import { collectionToursRepo } from '../../../repo/collectionToursRepo'; +import { useAddonStore } from '../../../store/addonStore'; +import { useAuthStore } from '../../../store/authStore'; +import { ALL_SAVED, useCollectionStore } from '../../../store/collectionStore'; +import { useCollectionTourStore } from '../../../store/collectionTourStore'; +import MCollTours from './MCollTours'; + +// FE-M-COLLECTION-TOURS-001 to FE-M-COLLECTION-TOURS-004 + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('../../../components/shared/Toast', () => ({ useToast: () => toast })); + +function kept(over: Partial = {}): CollectionTourItem { + return { + id: 4, + collection_id: 3, + name: 'Ridge', + description: null, + tour_type: 'hike', + distance: 8.2, + elevation_gain: 600, + elevation_loss: 590, + duration: 180, + planned_duration_minutes: null, + break_additional_minutes: null, + max_hiking_difficulty: 2, + wanderer_ref: null, + origin: 'trek', + has_line: true, + waypoint_count: 4, + source: { tripTitle: 'Alps', tourName: 'Ridge' }, + saved_by: 'mia', + saved_at: '2026-10-20 10:00:00', + ...over, + } as CollectionTourItem; +} + +const DETAIL: CollectionTourDetailResponse = { + tour: kept(), + line: [ + [47, 11], + [47.1, 11.1], + ], + points: [ + { role: 'start', name: null, shaping: false }, + { role: 'end', name: null, shaping: false }, + ], + trips: [ + { id: 6, title: 'Coast', start_date: '2026-07-01', end_date: '2026-07-08', can_add: true }, + { id: 7, title: 'Away', start_date: null, end_date: null, can_add: false }, + ], +}; + +const before = useCollectionStore.getState(); + +beforeEach(() => { + useAuthStore.setState({ user: { id: 7 } as never }); + useAddonStore.setState({ + addons: [ + { id: 'tours', enabled: true }, + { id: 'collections', enabled: true }, + ] as never, + }); + useCollectionTourStore.getState().clear(); + useCollectionStore.setState({ activeId: 3, collections: [{ id: 3, is_owner: true }] as never, members: [] }); + vi.spyOn(collectionToursRepo, 'list').mockResolvedValue({ + tours: [kept(), kept({ id: 5, name: 'Lake loop', tour_type: 'kayak', max_hiking_difficulty: null })], + }); + vi.spyOn(collectionToursRepo, 'detail').mockResolvedValue(DETAIL); +}); +afterEach(() => { + useCollectionStore.setState(before, true); + vi.restoreAllMocks(); +}); + +describe('MCollTours', () => { + it('FE-M-COLLECTION-TOURS-001: lists the kept tours as rows with their grade or type and their measures', async () => { + render(); + const ridge = await screen.findByTestId('m-kept-tour-4'); + expect(ridge).toHaveTextContent('Ridge'); + expect(ridge).toHaveTextContent('T2'); + expect(within(ridge).getByLabelText(/8\.2 km/)).toBeInTheDocument(); + expect(screen.getByTestId('m-kept-tour-5')).toHaveTextContent('Lake loop'); + }); + + it('FE-M-COLLECTION-TOURS-002: the list search narrows the tours by name, and "All saved" shows none', async () => { + const { rerender } = render(); + expect(await screen.findByTestId('m-kept-tour-5')).toBeInTheDocument(); + expect(screen.queryByTestId('m-kept-tour-4')).toBeNull(); + rerender(); + expect(screen.queryByTestId('m-collection-tours')).toBeNull(); + useCollectionStore.setState({ activeId: ALL_SAVED }); + rerender(); + expect(screen.queryByTestId('m-collection-tours')).toBeNull(); + }); + + it('FE-M-COLLECTION-TOURS-003: a tap opens the sheet, a trip the user may not add to is dimmed, and the tour goes in with its points', async () => { + const takeOver = vi + .spyOn(collectionToursRepo, 'takeOver') + .mockResolvedValue({ tour: { name: 'Ridge' } } as TourCreateResponse); + render(); + fireEvent.click(within(await screen.findByTestId('m-kept-tour-4')).getByText('Ridge')); + const coast = await screen.findByRole('radio', { name: /Coast/ }); + expect(screen.getByRole('radio', { name: /Away/ })).toBeDisabled(); + fireEvent.click(coast); + fireEvent.click(screen.getByRole('radio', { name: /4 waypoints/ })); + fireEvent.click(screen.getByRole('button', { name: /Add to a trip/ })); + await waitFor(() => + expect(takeOver).toHaveBeenCalledWith(6, { + collection_id: 3, + collection_tour_id: 4, + include_waypoints: true, + name: 'Ridge', + }) + ); + expect(await screen.findByRole('button', { name: /Open trip/ })).toBeInTheDocument(); + }); + + it('FE-M-COLLECTION-TOURS-004: its owner removes a tour from the sheet after the question', async () => { + const remove = vi.spyOn(collectionToursRepo, 'remove').mockResolvedValue(undefined); + render(); + fireEvent.click(within(await screen.findByTestId('m-kept-tour-4')).getByText('Ridge')); + await screen.findByRole('radio', { name: /Coast/ }); + fireEvent.click(screen.getByRole('button', { name: 'Remove from the collection' })); + const confirms = await screen.findAllByRole('button', { name: 'Remove from the collection' }); + fireEvent.click(confirms[confirms.length - 1] as HTMLElement); + await waitFor(() => expect(remove).toHaveBeenCalledWith(3, 4)); + }); +}); diff --git a/client/src/mobile/screens/collections/MCollTours.tsx b/client/src/mobile/screens/collections/MCollTours.tsx new file mode 100644 index 000000000..899bb41a8 --- /dev/null +++ b/client/src/mobile/screens/collections/MCollTours.tsx @@ -0,0 +1,312 @@ +import type { CollectionTourItem, CollectionTourTarget } from '@trek/shared'; +import { ArrowRight, Check, CopyPlus, Loader2, Route, Trash2 } from 'lucide-react'; +import { createElement } from 'react'; +import ConfirmDialog from '../../../components/shared/ConfirmDialog'; +import { fs } from '../../../components/shared/DialogShell'; +import { sourceText } from '../../../components/Tours/collection/keptTourText'; +import { + toursMatching, + useActiveCollectionTourList, + useCollectionTours, +} from '../../../components/Tours/collection/useCollectionTours'; +import { + useCollectionTourTakeOver, + type CollectionTourTakeOver, + type PointsChoice, +} from '../../../components/Tours/collection/useCollectionTourTakeOver'; +import { measureBadges } from '../../../components/Tours/library/tourMeasureBadges'; +import { tourDayRowIcon } from '../../../components/Tours/tourDayRowIcon'; +import { useTranslation } from '../../../i18n'; +import { useSettingsStore } from '../../../store/settingsStore'; +import MSheet from '../../components/MSheet'; +import { CancelPill, Eyebrow, PrimaryPill, SheetFooter, SheetHeader } from './MCollSheetKit'; + +/** A kept tour's measures as the small pills of the phone's rows. */ +function MeasurePills({ tour }: { tour: CollectionTourItem }) { + const { t } = useTranslation(); + const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric'; + return ( + <> + {measureBadges(tour, unit, t) + .slice(0, 2) + .map((badge) => ( + + {createElement(badge.icon, { size: 9, strokeWidth: 2.6, className: 'flex-none text-m-muted' })} + {badge.value} + + ))} + + ); +} + +/** + * A kept tour on the phone, shaped like a saved place: the type on a tinted + * tile (a hike's grade where it has one), its name and the trip it was kept + * from, and its time and length where a place has its status and category. + * A tap opens the sheet that takes it into a trip. + */ +function MCollTourRow({ tour, onOpen }: { tour: CollectionTourItem; onOpen: () => void }) { + const { t } = useTranslation(); + const grade = tour.max_hiking_difficulty; + return ( +
+ +
+ +
+
+ ); +} + +/** One trip the tour can go into, chosen like a radio row; one the user may not add to stays, dimmed, with the reason. */ +function TripRow({ trip, chosen, onChoose }: { trip: CollectionTourTarget; chosen: boolean; onChoose: () => void }) { + const { t } = useTranslation(); + return ( + + ); +} + +/** With its points or the line alone; a tour without a line can only bring its points. */ +function PointsChips({ takeOver }: { takeOver: CollectionTourTakeOver }) { + const { t } = useTranslation(); + const tour = takeOver.tour; + if (!tour) return null; + const chips: Array<{ value: PointsChoice; label: string; disabled: boolean }> = [ + { value: 'with', label: t('tours.collection.withPoints', { count: tour.waypoint_count }), disabled: false }, + { value: 'without', label: t('tours.collection.lineOnly'), disabled: !tour.has_line }, + ]; + return ( +
+ {chips.map((chip) => ( + + ))} +
+ ); +} + +/** The sheet's body while it asks: the trips, the points and the name in the trip. */ +function TakeOverForm({ takeOver }: { takeOver: CollectionTourTakeOver }) { + const { t } = useTranslation(); + if (takeOver.detailStatus === 'loading') + return ( +
+ +
+ ); + if (takeOver.detailStatus === 'offline' || takeOver.detailStatus === 'error') + return ( +
+ {t(takeOver.detailStatus === 'offline' ? 'tours.collection.offline' : 'tours.library.previewFailed')} + {takeOver.detailStatus === 'error' && ( + + )} +
+ ); + return ( + <> + {t('tours.collection.intoTrip')} + {takeOver.trips.length === 0 ? ( +
+ {t('tours.collection.noTrips')} +
+ ) : ( + takeOver.trips.map((trip) => ( + takeOver.chooseTrip(trip.id)} + /> + )) + )} + {t('tours.library.controlPoints')} + + {t('tours.collection.nameInTrip')} + takeOver.setName(event.target.value)} + className="h-9 w-full rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] font-geist text-m-ink outline-none" + style={fs(12.5)} + /> + + ); +} + +/** + * Taking a kept tour into a trip on the phone: the same choices as the + * desktop dialog, from the same hook, as a sheet. Removing it from the list + * sits at its foot for the list's owner or an admin. + */ +function MCollTourSheet({ + takeOver, + canRemove, + onRemove, +}: { + takeOver: CollectionTourTakeOver; + canRemove: boolean; + onRemove: (tour: CollectionTourItem) => void; +}) { + const { t } = useTranslation(); + const tour = takeOver.tour; + const title = tour?.name ?? t('tours.collection.add'); + return ( + + +
+ {tour && ( +

+ {sourceText(tour, t)} +

+ )} + {takeOver.done ? ( +

+ {t('tours.collection.taken', { name: takeOver.done.tourName, trip: takeOver.done.tripTitle })} +

+ ) : ( + + )} +
+ + {canRemove && tour && !takeOver.done && ( + + )} + + {takeOver.done ? ( + + {t('tours.collection.openTrip')} + + ) : ( + <> + + {t('common.cancel')} + + void takeOver.confirm()} disabled={!takeOver.canConfirm}> + {takeOver.taking ? ( + + ) : ( + + )} + {t('tours.collection.add')} + + + )} + +
+ ); +} + +/** + * LIB-03 on the phone: the whole tours the open list keeps, as its first + * rows, narrowed by the list's search like its places. Nothing while it keeps + * none, on "All saved", or while the Tours addon is off. + */ +export default function MCollTours({ search }: { search: string }) { + const { collectionId, canRemove } = useActiveCollectionTourList(); + if (collectionId === null) return null; + return ; +} + +function MCollToursOf({ + collectionId, + canRemove, + search, +}: { + collectionId: number; + canRemove: boolean; + search: string; +}) { + const { t } = useTranslation(); + const kept = useCollectionTours(collectionId); + const takeOver = useCollectionTourTakeOver(collectionId); + const shown = toursMatching(kept.tours, search); + if (shown.length === 0 && !takeOver.open && kept.removing === null) return null; + return ( +
+ {shown.map((tour) => ( + takeOver.show(tour)} /> + ))} + + void kept.confirmRemove()} + title={t('tours.collection.removeTitle')} + message={t('tours.collection.removeBody')} + confirmLabel={t('tours.collection.remove')} + cancelLabel={t('common.cancel')} + danger + /> +
+ ); +} diff --git a/client/src/mobile/screens/collections/MCollections.tsx b/client/src/mobile/screens/collections/MCollections.tsx index bb3d81ada..4c10fb091 100644 --- a/client/src/mobile/screens/collections/MCollections.tsx +++ b/client/src/mobile/screens/collections/MCollections.tsx @@ -21,6 +21,7 @@ import MCollEditSheet from './MCollEditSheet' import MCollLabelsSheet from './MCollLabelsSheet' import MCollTripPickerSheet from './MCollTripPickerSheet' import MCollListPickerSheet from './MCollListPickerSheet' +import MCollTours from './MCollTours' import MCollInvites from './MCollInvites' import { STATUS_SPEC } from './collectionsMobileModel' import { CancelPill, PrimaryPill } from './MCollSheetKit' @@ -412,6 +413,8 @@ export default function MCollections() { {/* List view */} + {/* The whole tours the list keeps (LIB-03) open it, as rows like its places. */} + {c.view === 'list' && } {c.view === 'list' && ( c.placesLoading && !hasPlaces ? (
diff --git a/client/src/pages/CollectionsPage.tsx b/client/src/pages/CollectionsPage.tsx index 30eab96e8..4e9e616be 100644 --- a/client/src/pages/CollectionsPage.tsx +++ b/client/src/pages/CollectionsPage.tsx @@ -146,9 +146,12 @@ function CollectionsPageDesktop(): React.ReactElement { t={t} /> ) : null + // The whole tours the list keeps (LIB-03) open its list, as rows like its places. + const tourRows = isRealList ? : null const listColumn = ( <> {filterBar} + {tourRows} {c.visiblePlaces.length > 0 ? listEl : } @@ -161,6 +164,7 @@ function CollectionsPageDesktop(): React.ReactElement { } else if (!hasPlaces) { body = (
+ {tourRows &&
{tourRows}
} {canAddPlace && (
@@ -316,8 +320,6 @@ function CollectionsPageDesktop(): React.ReactElement {
)} - {/* The whole tours the list keeps (LIB-03), above its places. */} - {isRealList && } {body} )} diff --git a/client/src/styles/collections.css b/client/src/styles/collections.css index 22bb9dc89..9533458b7 100644 --- a/client/src/styles/collections.css +++ b/client/src/styles/collections.css @@ -276,6 +276,30 @@ .trek-dash .col-lrow-cat svg { flex-shrink: 0; } .trek-dash .col-lrow-div { width: 1px; height: 18px; flex-shrink: 0; background: var(--line-2); } +/* A kept tour (LIB-03) is a row of the same list: its grade tile stands where + a place has its photo, its measures where a place has its labels, and its + way into a trip where a place has its status. The page's button reset strips + every button here, so the two buttons carry their whole look themselves. */ +.trek-dash .col-tourrows { margin-bottom: 7px; } +.trek-dash .col-lrow-tile { display: grid; place-items: center; width: 40px; height: 40px; } +.trek-dash .col-lrow-tour .li .s { gap: 4px; } +.trek-dash .col-lrow-from { display: inline-flex; align-items: center; gap: 4px; min-width: 0; margin-inline-start: 6px; padding-inline-start: 8px; border-inline-start: 1px solid var(--line-2); } +.trek-dash .col-lrow-measures { display: none; align-items: center; gap: 5px; } +@media (min-width: 1100px) { .trek-dash .col-lrow-measures { display: inline-flex; } } +.trek-dash .col-lrow-act { + display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 12px; border-radius: 999px; + font-size: 12px; font-weight: 600; white-space: nowrap; color: var(--ink); + background: var(--surface); border: 1px solid var(--line-2); box-shadow: var(--sh-xs); + transition: background .15s, color .15s, border-color .15s; +} +.trek-dash .col-lrow-act:hover, .trek-dash .col-lrow-act:focus-visible { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } +.trek-dash .col-lrow-act:focus-visible, .trek-dash .col-lrow-remove:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.trek-dash .col-lrow-remove { + display: grid; place-items: center; width: 30px; height: 30px; border-radius: 999px; color: var(--ink-3); + transition: opacity .15s, background .15s, color .15s; +} +.trek-dash .col-lrow-remove:hover { background: var(--surface-2); color: var(--ink); } + /* Per-collection label chips — list rows, filter bar and detail read view */ .trek-dash .col-lrow-label { display: inline-flex; align-items: center; gap: 5px; max-width: 120px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; color: var(--label, var(--accent)); background: color-mix(in oklch, var(--label, var(--accent)) 14%, transparent); } .trek-dash .col-lrow-label.more { color: var(--ink-3); background: var(--surface-2); }