mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
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.
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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, unknown>) => 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 (
|
||||
<article
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-no-press
|
||||
data-testid={`kept-tour-${tour.id}`}
|
||||
className="group flex min-w-0 flex-col gap-2.5 rounded-2xl border border-edge-faint bg-surface-card p-3 shadow-sm transition-shadow hover:shadow-md"
|
||||
onClick={onTake}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) return;
|
||||
event.preventDefault();
|
||||
onTake();
|
||||
}}
|
||||
className="col-lrow col-lrow-tour group"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="col-lrow-tile">
|
||||
<TourGradeTile tour={tile} />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5 pt-0.5">
|
||||
<h3 className="m-0 truncate font-semibold text-content" style={fs(13.5, 'body')}>
|
||||
{tour.name}
|
||||
</h3>
|
||||
<span className="truncate text-content-faint" style={fs(11, 'body')}>
|
||||
{t(`tourTypes.${tour.tour_type}`)}
|
||||
</span>
|
||||
</span>
|
||||
<div className="li">
|
||||
<div className="t">{tour.name}</div>
|
||||
<div className="s">
|
||||
<TypeIcon size={11} />
|
||||
<span>{t(`tourTypes.${tour.tour_type}`)}</span>
|
||||
<Tooltip label={sourceText(tour, t)}>
|
||||
<span className="col-lrow-from">
|
||||
<Route size={11} />
|
||||
<span>{tour.source.tripTitle}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-lrow-end">
|
||||
<span className="col-lrow-measures">
|
||||
<TourMeasurePills tour={tour} testPrefix="kept-tour" />
|
||||
<KeptTourTypeBadges tour={tour} />
|
||||
{tour.waypoint_count > 0 && (
|
||||
<Tooltip label={points}>
|
||||
<span className="inline-flex" aria-label={points}>
|
||||
<WhiteBadge icon={<Waypoints size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
{tour.waypoint_count}
|
||||
</WhiteBadge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
<span className="col-lrow-div" aria-hidden />
|
||||
<Tooltip label={t('tours.collection.addHint')}>
|
||||
<button type="button" className="col-lrow-act" onClick={stop(onTake)}>
|
||||
<CopyPlus size={13} strokeWidth={2} />
|
||||
{t('tours.collection.add')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{canRemove && (
|
||||
<Tooltip label={t('tours.collection.remove')}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
onClick={stop(onRemove)}
|
||||
aria-label={t('tours.collection.remove')}
|
||||
data-testid={`kept-tour-remove-${tour.id}`}
|
||||
className="grid h-7 w-7 flex-none place-items-center rounded-full text-content-faint opacity-50 transition-opacity hover:bg-danger-soft hover:text-danger focus-visible:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent group-hover:opacity-100"
|
||||
className="col-lrow-remove opacity-50 focus-visible:opacity-100 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
<Trash2 size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<TourMeasurePills tour={tour} testPrefix="kept-tour" />
|
||||
<KeptTourTypeBadges tour={tour} />
|
||||
{tour.waypoint_count > 0 && (
|
||||
<Tooltip label={t('tours.collection.points', { count: tour.waypoint_count })}>
|
||||
<span className="inline-flex" aria-label={t('tours.collection.points', { count: tour.waypoint_count })}>
|
||||
<WhiteBadge icon={<Waypoints size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
|
||||
{tour.waypoint_count}
|
||||
</WhiteBadge>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-auto flex min-w-0 items-center gap-2 border-t border-edge-faint pt-2.5">
|
||||
<Tooltip label={sourceText(tour, t)}>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-content-muted" style={fs(11, 'body')}>
|
||||
<Route size={12} strokeWidth={2} className="flex-none text-content-faint" />
|
||||
<span className="truncate">{tour.source.tripTitle}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('tours.collection.addHint')}>
|
||||
<span className="inline-flex">
|
||||
<CardAction icon={<CopyPlus size={12} strokeWidth={2} />} onClick={onTake}>
|
||||
{t('tours.collection.add')}
|
||||
</CardAction>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<section aria-label={t('tours.title')} className="mb-5 flex flex-col gap-2.5" data-testid="collection-tours">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-6 w-6 place-items-center rounded-full bg-accent-subtle text-accent-on">
|
||||
<Route size={13} strokeWidth={2.2} />
|
||||
</span>
|
||||
<h2 className="m-0 font-semibold text-content" style={fs(13, 'body')}>
|
||||
{t('tours.title')}
|
||||
</h2>
|
||||
{kept.tours.length > 0 && <WhiteBadge>{kept.tours.length}</WhiteBadge>}
|
||||
</div>
|
||||
<section aria-label={t('tours.title')} className="col-listview col-tourrows" data-testid="collection-tours">
|
||||
<SectionProblem kept={kept} />
|
||||
{kept.tours.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{kept.tours.map((tour) => (
|
||||
<KeptTourCard
|
||||
key={tour.id}
|
||||
tour={tour}
|
||||
canRemove={canRemove}
|
||||
onTake={() => takeOver.show(tour)}
|
||||
onRemove={() => kept.askRemove(tour)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{shown.map((tour) => (
|
||||
<KeptTourRow
|
||||
key={tour.id}
|
||||
tour={tour}
|
||||
canRemove={canRemove}
|
||||
onTake={() => takeOver.show(tour)}
|
||||
onRemove={() => kept.askRemove(tour)}
|
||||
/>
|
||||
))}
|
||||
<CollectionTourTakeOverDialog takeOver={takeOver} />
|
||||
<ConfirmDialog
|
||||
isOpen={kept.removing !== null}
|
||||
@@ -199,17 +209,9 @@ export function CollectionToursSection({ collectionId, canRemove }: { collection
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tours of the list open on the collections page, with what the user may
|
||||
* do there read from the list itself; nothing on "All saved" or before a list opens.
|
||||
*/
|
||||
export function ActiveCollectionTours() {
|
||||
const activeId = useCollectionStore((state) => 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 <CollectionToursSection collectionId={activeId} canRemove={canRemove} />;
|
||||
/** 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 <CollectionToursSection collectionId={collectionId} canRemove={canRemove} search={search} />;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ function KeepButton({ target }: { target: TourSaveTarget }) {
|
||||
aria-label={t('tours.collection.save')}
|
||||
data-testid="tour-keep-in-collection"
|
||||
icon={<BookmarkPlus size={14} strokeWidth={2} />}
|
||||
/>
|
||||
>
|
||||
<span className="max-sm:hidden">{t('tours.collection.list')}</span>
|
||||
</DialogButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<SaveTourToCollectionDialog keep={keep} tourName={target.name} />
|
||||
|
||||
@@ -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<Array<[number, number]>> }) => (
|
||||
@@ -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(<CollectionToursSection collectionId={3} canRemove search="lake" />);
|
||||
expect(await screen.findByTestId('kept-tour-5')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('kept-tour-4')).toBeNull();
|
||||
rerender(<CollectionToursSection collectionId={3} canRemove search="nothing" />);
|
||||
expect(screen.queryByTestId('collection-tours')).toBeNull();
|
||||
rerender(<CollectionToursSection collectionId={3} canRemove search="" />);
|
||||
fireEvent.click(await screen.findByTestId('kept-tour-4'));
|
||||
expect(await screen.findByTestId('kept-tour-preview')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, unknown>) => 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;
|
||||
}
|
||||
@@ -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<typeof useCollectionTours>;
|
||||
|
||||
/** 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)) };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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> = {}): 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(<MCollTours search="" />);
|
||||
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(<MCollTours search="lake" />);
|
||||
expect(await screen.findByTestId('m-kept-tour-5')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('m-kept-tour-4')).toBeNull();
|
||||
rerender(<MCollTours search="nothing" />);
|
||||
expect(screen.queryByTestId('m-collection-tours')).toBeNull();
|
||||
useCollectionStore.setState({ activeId: ALL_SAVED });
|
||||
rerender(<MCollTours search="" />);
|
||||
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(<MCollTours search="" />);
|
||||
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(<MCollTours search="" />);
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -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) => (
|
||||
<span
|
||||
key={badge.key}
|
||||
aria-label={badge.label}
|
||||
className="box-border inline-flex w-[78px] items-center justify-center gap-1 truncate rounded-full bg-[color:var(--m-ic)] py-[5px] font-geist font-extrabold text-m-ink"
|
||||
style={fs(9)}
|
||||
>
|
||||
{createElement(badge.icon, { size: 9, strokeWidth: 2.6, className: 'flex-none text-m-muted' })}
|
||||
<span className="truncate">{badge.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="mt-2 flex items-stretch gap-2" data-testid={`m-kept-tour-${tour.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="flex min-w-0 flex-1 items-center gap-[11px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[11px] text-start"
|
||||
>
|
||||
<span
|
||||
className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-[color:var(--m-ic)] font-geist font-extrabold text-m-ink"
|
||||
style={fs(12)}
|
||||
>
|
||||
{grade != null ? `T${grade}` : createElement(tourDayRowIcon(tour.tour_type), { size: 17, strokeWidth: 2.2 })}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-bold text-m-ink" style={fs(12.5)}>
|
||||
{tour.name}
|
||||
</span>
|
||||
<span className="mt-[2px] flex items-center gap-[3px] truncate font-geist text-m-muted" style={fs(10)}>
|
||||
<Route size={9} strokeWidth={2.2} className="flex-none" />
|
||||
<span className="truncate">{t(`tourTypes.${tour.tour_type}`)}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className="flex flex-none flex-col justify-center gap-[5px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-[10px] py-2">
|
||||
<MeasurePills tour={tour} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chosen}
|
||||
disabled={!trip.can_add}
|
||||
onClick={onChoose}
|
||||
className={`mb-2 flex w-full items-center gap-[11px] rounded-[14px] border px-3 py-[10px] text-start disabled:opacity-50 ${chosen ? 'border-[color:var(--m-act)] bg-m-sheetop' : 'border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)]'}`}
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-m-ink" style={fs(13)}>
|
||||
{trip.title}
|
||||
</span>
|
||||
{!trip.can_add && (
|
||||
<span className="block truncate font-geist text-m-muted" style={fs(11)}>
|
||||
{t('tours.collection.noPermission')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`flex h-5 w-5 flex-none items-center justify-center rounded-full ${chosen ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)]'}`}
|
||||
>
|
||||
{chosen && <Check size={12} strokeWidth={3} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex flex-wrap gap-2" role="radiogroup">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={chip.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={takeOver.points === chip.value}
|
||||
disabled={chip.disabled}
|
||||
onClick={() => takeOver.choosePoints(chip.value)}
|
||||
className={`rounded-full px-[13px] py-[7px] font-semibold disabled:opacity-40 ${takeOver.points === chip.value ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'}`}
|
||||
style={fs(12)}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="flex justify-center py-10 text-m-faint">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
if (takeOver.detailStatus === 'offline' || takeOver.detailStatus === 'error')
|
||||
return (
|
||||
<div className="py-6 text-center font-geist text-m-muted" style={fs(12.5)}>
|
||||
{t(takeOver.detailStatus === 'offline' ? 'tours.collection.offline' : 'tours.library.previewFailed')}
|
||||
{takeOver.detailStatus === 'error' && (
|
||||
<button type="button" onClick={takeOver.retry} className="ms-2 font-semibold text-m-ink underline">
|
||||
{t('tours.planner.retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Eyebrow className="mb-2">{t('tours.collection.intoTrip')}</Eyebrow>
|
||||
{takeOver.trips.length === 0 ? (
|
||||
<div className="py-4 font-geist text-m-muted" style={fs(12.5)}>
|
||||
{t('tours.collection.noTrips')}
|
||||
</div>
|
||||
) : (
|
||||
takeOver.trips.map((trip) => (
|
||||
<TripRow
|
||||
key={trip.id}
|
||||
trip={trip}
|
||||
chosen={takeOver.tripId === trip.id}
|
||||
onChoose={() => takeOver.chooseTrip(trip.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
<Eyebrow className="mb-2 mt-4">{t('tours.library.controlPoints')}</Eyebrow>
|
||||
<PointsChips takeOver={takeOver} />
|
||||
<Eyebrow className="mb-2 mt-4">{t('tours.collection.nameInTrip')}</Eyebrow>
|
||||
<input
|
||||
value={takeOver.name}
|
||||
onChange={(event) => 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 (
|
||||
<MSheet open={takeOver.open} onClose={takeOver.close} material="opaque" ariaLabel={title}>
|
||||
<SheetHeader title={title} onClose={takeOver.close} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{tour && (
|
||||
<p className="mb-3 mt-0 font-geist text-m-muted" style={fs(11)}>
|
||||
{sourceText(tour, t)}
|
||||
</p>
|
||||
)}
|
||||
{takeOver.done ? (
|
||||
<p className="m-0 text-m-ink" style={fs(13)}>
|
||||
{t('tours.collection.taken', { name: takeOver.done.tourName, trip: takeOver.done.tripTitle })}
|
||||
</p>
|
||||
) : (
|
||||
<TakeOverForm takeOver={takeOver} />
|
||||
)}
|
||||
</div>
|
||||
<SheetFooter>
|
||||
{canRemove && tour && !takeOver.done && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
takeOver.close();
|
||||
onRemove(tour);
|
||||
}}
|
||||
aria-label={t('tours.collection.remove')}
|
||||
className="flex h-9 w-9 items-center justify-center rounded-full text-m-muted"
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{takeOver.done ? (
|
||||
<PrimaryPill onClick={takeOver.openTrip}>
|
||||
{t('tours.collection.openTrip')} <ArrowRight size={14} strokeWidth={2.2} className="rtl:rotate-180" />
|
||||
</PrimaryPill>
|
||||
) : (
|
||||
<>
|
||||
<CancelPill onClick={takeOver.close} disabled={takeOver.taking}>
|
||||
{t('common.cancel')}
|
||||
</CancelPill>
|
||||
<PrimaryPill onClick={() => void takeOver.confirm()} disabled={!takeOver.canConfirm}>
|
||||
{takeOver.taking ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<CopyPlus size={14} strokeWidth={2.2} />
|
||||
)}
|
||||
{t('tours.collection.add')}
|
||||
</PrimaryPill>
|
||||
</>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</MSheet>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <MCollToursOf collectionId={collectionId} canRemove={canRemove} search={search} />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div data-testid="m-collection-tours">
|
||||
{shown.map((tour) => (
|
||||
<MCollTourRow key={tour.id} tour={tour} onOpen={() => takeOver.show(tour)} />
|
||||
))}
|
||||
<MCollTourSheet takeOver={takeOver} canRemove={canRemove} onRemove={kept.askRemove} />
|
||||
<ConfirmDialog
|
||||
isOpen={kept.removing !== null}
|
||||
onClose={kept.cancelRemove}
|
||||
onConfirm={() => void kept.confirmRemove()}
|
||||
title={t('tours.collection.removeTitle')}
|
||||
message={t('tours.collection.removeBody')}
|
||||
confirmLabel={t('tours.collection.remove')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
{/* List view */}
|
||||
{/* The whole tours the list keeps (LIB-03) open it, as rows like its places. */}
|
||||
{c.view === 'list' && <MCollTours search={c.search} />}
|
||||
{c.view === 'list' && (
|
||||
c.placesLoading && !hasPlaces ? (
|
||||
<div className="flex items-center justify-center py-16 text-m-faint">
|
||||
|
||||
@@ -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 ? <ActiveCollectionTours search={c.search} /> : null
|
||||
const listColumn = (
|
||||
<>
|
||||
{filterBar}
|
||||
{tourRows}
|
||||
{c.visiblePlaces.length > 0
|
||||
? listEl
|
||||
: <EmptyState scene="search" title={t('collections.empty.noMatchTitle')} />}
|
||||
@@ -161,6 +164,7 @@ function CollectionsPageDesktop(): React.ReactElement {
|
||||
} else if (!hasPlaces) {
|
||||
body = (
|
||||
<div className="flex flex-col items-center">
|
||||
{tourRows && <div className="w-full pb-2">{tourRows}</div>}
|
||||
<EmptyState scene="collections" title={t('collections.empty.title')} className={canAddPlace ? 'pb-4' : undefined} />
|
||||
{canAddPlace && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
@@ -316,8 +320,6 @@ function CollectionsPageDesktop(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The whole tours the list keeps (LIB-03), above its places. */}
|
||||
{isRealList && <ActiveCollectionTours />}
|
||||
{body}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user