From e8fd41b9b4ca6f52aa8989b3ffb934ecd2cff5ec Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 11 Oct 2026 14:01:18 +0200 Subject: [PATCH] feat(roadtrip): the Google route import and the track dialog use the shared dialog frame, and Search and Manual match the day plan's buttons Both dialogs now open in the same frame as the stay and stop dialogs: an icon tile and title in the head, the content in a card, and the actions in the footer. Closing stays blocked while an import runs. In the right column, Search and Manual take the size, text and icon of the day plan's toolbar buttons. --- .../components/Roadtrip/GoogleRouteImport.tsx | 70 +++--- .../Roadtrip/RoadtripCorridorPanel.tsx | 13 +- .../Roadtrip/RoadtripTrackModal.tsx | 199 +++++++++--------- 3 files changed, 151 insertions(+), 131 deletions(-) diff --git a/client/src/components/Roadtrip/GoogleRouteImport.tsx b/client/src/components/Roadtrip/GoogleRouteImport.tsx index 19082888a..2841a454e 100644 --- a/client/src/components/Roadtrip/GoogleRouteImport.tsx +++ b/client/src/components/Roadtrip/GoogleRouteImport.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { MoreHorizontal, Link2 } from 'lucide-react' import { useTranslation } from '../../i18n/TranslationContext' import { useTripStore } from '../../store/tripStore' @@ -6,7 +6,7 @@ import { googleRouteRepo } from '../../repo/googleRouteRepo' import { generateUUID } from '../../sync/mutationQueue' import { getApiErrorMessage } from '../../types' import type { GoogleRoutePreview } from '@trek/shared' -import Modal from '../shared/Modal' +import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import CustomSelect from '../shared/CustomSelect' import { ContextMenu, useContextMenu } from '../shared/ContextMenu' import { Tooltip } from '../shared/Tooltip' @@ -24,6 +24,7 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d const controller = useRef(null) const requestId = useRef('') const menu = useContextMenu() + const labelId = useId() useEffect(() => () => controller.current?.abort(), []) const inspect = async () => { controller.current?.abort() @@ -51,6 +52,8 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d setTarget(String(dayId ?? days[0]?.id ?? '')) requestId.current = generateUUID(); setOpen(true) } + // Every way out is held while a preview or an import is in flight, as before. + const close = () => { if (!busy) setOpen(false) } const resolved = preview?.stops.filter(s => s.lat != null && s.lng != null) ?? [] return <> @@ -60,29 +63,48 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d - { if (!busy) setOpen(false) }} title={t('roadtrip.import.title')} size="lg"> -
-

{t('roadtrip.import.note')}

- {!preview ? setUrl(e.target.value)} aria-label="Google Maps URL" placeholder="https://maps.app.goo.gl/…" - className="w-full rounded-xl border border-edge bg-surface-input p-3 text-body text-content" /> : <> + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={close} + title={t('roadtrip.import.title')} + sub={t('roadtrip.import.note')} + subWraps + /> + )} + footer={( + + + {t('common.cancel')} + void (preview ? save() : inspect())}> + {busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')} + + + )} + > + {!preview ? setUrl(e.target.value)} aria-label="Google Maps URL" placeholder="https://maps.app.goo.gl/…" + className="w-full rounded-[10px] border border-edge bg-surface-input px-3 py-2.5 text-content outline-none placeholder:text-content-faint focus:ring-2 focus:ring-[color:var(--text-primary)] disabled:opacity-50" + style={fs(13, 'body')} /> : <> + { setTarget(String(value)); requestId.current = generateUUID() }} options={days.map(day => ({ value: String(day.id), label: day.title || `${t('roadtrip.import.day')} ${day.day_number}` }))} disabled={busy || saved} /> -
    - {preview.stops.map((stop, index) =>
  1. - {index + 1} - {stop.name}{stop.name !== `${stop.lat}, ${stop.lng}` && {stop.lat == null || stop.lng == null ? t('roadtrip.import.unresolved') : `${stop.lat.toFixed(5)}, ${stop.lng.toFixed(5)}`}} -
  2. )} -
- } - {failure &&

{saved ? t('common.saved') + ': ' : ''}{failure}

} -
- - -
-
-
+ +
    + {preview.stops.map((stop, index) =>
  1. + {index + 1} + {stop.name}{stop.name !== `${stop.lat}, ${stop.lng}` && {stop.lat == null || stop.lng == null ? t('roadtrip.import.unresolved') : `${stop.lat.toFixed(5)}, ${stop.lng.toFixed(5)}`}} +
  2. )} +
+ } + {failure &&

{saved ? t('common.saved') + ': ' : ''}{failure}

} + } diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index 07018c197..9611f3394 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -16,6 +16,7 @@ import { serviceColor } from './roadtripModel' import { alongLabel, offRouteLabel } from './corridorSearchModel' import { CORRIDOR_CATEGORY_BY_KEY, manualStopKindFor } from './stopKinds' import { FS } from './typeScale' +import { fs } from '../shared/DialogShell' import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor' import type { RoadtripStopType } from '@trek/shared' import type { CorridorPoi } from './useCorridorPois' @@ -457,11 +458,12 @@ export default function RoadtripCorridorPanel({ disabled={!canSearch} aria-label={t('roadtrip.poi.search')} title={narrow ? t('roadtrip.poi.search') : undefined} - className="flex h-[32px] min-w-0 flex-1 items-center justify-center gap-2 rounded-lg bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50" + className="flex h-8 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[10px] bg-accent px-3 font-semibold text-accent-text transition-opacity enabled:hover:opacity-90 disabled:opacity-50" + style={fs(12, 'body')} > {search.loading - ? - : } + ? + : } {/* The word stays put while a run is on, and the spinner in front of it says the run is on. It used to become "Searching 3 of 12", which is both the longest label in the panel and the very sentence the progress row right @@ -481,9 +483,10 @@ export default function RoadtripCorridorPanel({ // the button carries the one word that fits beside "Search". aria-label={t('roadtrip.poi.addManual')} title={t('roadtrip.poi.addManual')} - className="flex h-[32px] min-w-0 flex-1 items-center justify-center gap-2 rounded-lg border border-edge bg-surface-card text-body font-semibold text-content transition-colors hover:bg-surface-hover" + className="flex h-8 min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[10px] border border-edge bg-surface-card px-3 font-semibold text-content transition-colors hover:bg-surface-hover" + style={fs(12, 'body')} > - + {narrow ? null : {t('roadtrip.poi.addManualShort')}} ) : null} diff --git a/client/src/components/Roadtrip/RoadtripTrackModal.tsx b/client/src/components/Roadtrip/RoadtripTrackModal.tsx index 4ca4a9d6c..a4156a83c 100644 --- a/client/src/components/Roadtrip/RoadtripTrackModal.tsx +++ b/client/src/components/Roadtrip/RoadtripTrackModal.tsx @@ -1,11 +1,10 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useId, useState } from 'react' import { AlertTriangle, Check, RotateCw, Spline, Trash2 } from 'lucide-react' -import Modal from '../shared/Modal' +import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import EmptyState from '../shared/EmptyState' import { useTranslation } from '../../i18n/TranslationContext' import { useSettingsStore } from '../../store/settingsStore' import { formatDistance } from '../../utils/units' -import { FS } from './typeScale' import type { FollowTrack, TrackChoice } from './useFollowTrack' /** @@ -24,8 +23,9 @@ import type { FollowTrack, TrackChoice } from './useFollowTrack' /** A track close enough that it is plainly about this day rather than another one. */ const ON_THIS_DAY_KM = 2 -function TrackRow({ track, disabled, onPick }: { +function TrackRow({ track, picked, disabled, onPick }: { track: TrackChoice + picked: boolean disabled: boolean onPick: () => void }): React.ReactElement { @@ -39,7 +39,10 @@ function TrackRow({ track, disabled, onPick }: { type="button" disabled={disabled} onClick={onPick} - className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-start transition-colors hover:bg-surface-hover disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" + aria-pressed={picked} + className={`flex w-full items-center gap-3 rounded-[10px] px-3 py-2.5 text-start transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent ${ + picked ? 'bg-surface-card shadow-sm ring-1 ring-edge-faint' : 'hover:bg-surface-card' + }`} > {/* The line's own colour, as a short stroke rather than a dot: it is a line on the map, and a dot beside a name reads as a place. */} @@ -50,10 +53,10 @@ function TrackRow({ track, disabled, onPick }: { aria-hidden /> - + {track.name || t('roadtrip.track.untitled')} - + {t('roadtrip.track.length', { distance: formatDistance(track.lengthKm, distanceUnit) })} {' · '} {onDay @@ -61,6 +64,7 @@ function TrackRow({ track, disabled, onPick }: { : t('roadtrip.track.gap', { distance: formatDistance(track.gapKm, distanceUnit) })} + {picked ? : null} ) @@ -74,6 +78,7 @@ export default function RoadtripTrackModal({ follow, dayNumber }: { const { t } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const [picked, setPicked] = useState(null) + const labelId = useId() const { dayId, busy, round, error, outcome, tracks, viaCount, current } = follow @@ -85,109 +90,99 @@ export default function RoadtripTrackModal({ follow, dayNumber }: { const chosen = tracks.find(track => track.id === picked) ?? null return ( - - - {t('roadtrip.track.title', { number: dayNumber })} - - } - > -
- {/* What the day follows today, before anything is picked. Read back from the day - rather than remembered from the run that applied it, which is the whole reason - it is stored: it has to survive a reload. */} - {current ? ( -

- {t('roadtrip.track.current', { name: current.name })} -

- ) : null} - {tracks.length === 0 ? ( -
- -

{t('roadtrip.track.emptyHint')}

-
- ) : ( -
    - {tracks.map(track => ( - setPicked(track.id === picked ? null : track.id)} - /> - ))} -
- )} - - {/* What the pick will do, before it is done. The count is the honest part: a track - becomes a dozen waypoints on the day, and a traveller who later wonders where - they came from should have been told here. */} - {chosen ? ( -

- {t('roadtrip.track.explain')} -

- ) : null} - - {busy ? ( -

- - {t('roadtrip.track.working', { round })} -

- ) : null} - - {error ? ( -

- - {t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')} -

- ) : null} - - {outcome && !busy && !error ? ( -

- - {outcome.vias === 0 - ? t('roadtrip.track.already') - : outcome.capped - ? t('roadtrip.track.capped', { - count: outcome.vias, - distance: formatDistance(outcome.strayKm, distanceUnit), - }) - : t('roadtrip.track.done', { - count: outcome.vias, - distance: formatDistance(outcome.strayKm, distanceUnit), - })} -

- ) : null} - -
+ labelledBy={labelId} + width="narrow" + header={( + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={follow.close} + title={t('roadtrip.track.title', { number: dayNumber })} + // What the day follows today, before anything is picked. Read back from the day + // rather than remembered from the run that applied it, which is the whole reason + // it is stored: it has to survive a reload. + sub={current ? t('roadtrip.track.current', { name: current.name }) : undefined} + subWraps + /> + )} + footer={( + {/* Offered whenever the day carries vias, whether this dialog put them there or a dragged handle did: "put it back the way the router had it" is the same wish either way. */} {viaCount > 0 ? ( - + ) : null} - + + + )} + > + {tracks.length === 0 ? ( +
+ +

{t('roadtrip.track.emptyHint')}

-
- + ) : ( +
    + {tracks.map(track => ( + setPicked(track.id === picked ? null : track.id)} + /> + ))} +
+ )} + + {/* What the pick will do, before it is done. The count is the honest part: a track + becomes a dozen waypoints on the day, and a traveller who later wonders where + they came from should have been told here. */} + {chosen ? ( +

+ {t('roadtrip.track.explain')} +

+ ) : null} + + {busy ? ( +

+ + {t('roadtrip.track.working', { round })} +

+ ) : null} + + {error ? ( +

+ + {t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')} +

+ ) : null} + + {outcome && !busy && !error ? ( +

+ + {outcome.vias === 0 + ? t('roadtrip.track.already') + : outcome.capped + ? t('roadtrip.track.capped', { + count: outcome.vias, + distance: formatDistance(outcome.strayKm, distanceUnit), + }) + : t('roadtrip.track.done', { + count: outcome.vias, + distance: formatDistance(outcome.strayKm, distanceUnit), + })} +

+ ) : null} + ) }