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.
This commit is contained in:
Maurice
2026-10-11 14:01:18 +02:00
parent 0c554e319b
commit e8fd41b9b4
3 changed files with 151 additions and 131 deletions
@@ -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<AbortController | null>(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 <>
<Tooltip label={t('roadtrip.import.title')}>
@@ -60,29 +63,48 @@ export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; d
</button>
</Tooltip>
<ContextMenu menu={menu.menu} onClose={menu.close} />
<Modal isOpen={open} onClose={() => { if (!busy) setOpen(false) }} title={t('roadtrip.import.title')} size="lg">
<div className="space-y-4">
<p className="text-caption text-content-muted">{t('roadtrip.import.note')}</p>
{!preview ? <input type="url" value={url} disabled={busy} onChange={e => 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" /> : <>
<DialogShell
open={open}
onClose={close}
blocked={busy}
labelledBy={labelId}
width="narrow"
header={(
<DialogHeader
tile={<DialogTile><Link2 size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
tint={NEUTRAL_TINT}
labelId={labelId}
onClose={close}
title={t('roadtrip.import.title')}
sub={t('roadtrip.import.note')}
subWraps
/>
)}
footer={(
<DialogFooter>
<FooterSpacer />
<DialogButton disabled={busy} onClick={close}>{t('common.cancel')}</DialogButton>
<DialogButton variant="primary" disabled={busy || saved || (!preview ? !url.trim() : resolved.length < 2 || !target)} onClick={() => void (preview ? save() : inspect())}>
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
</DialogButton>
</DialogFooter>
)}
>
{!preview ? <input type="url" value={url} disabled={busy} onChange={e => 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')} /> : <>
<DialogSection label={t('roadtrip.import.day')}>
<CustomSelect value={target} onChange={value => { 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} />
<ol className="max-h-64 space-y-2 overflow-auto">
{preview.stops.map((stop, index) => <li key={index} className="flex items-baseline gap-3 text-body text-content">
<span className="text-caption tabular-nums text-content-muted">{index + 1}</span>
<span className="min-w-0">{stop.name}{stop.name !== `${stop.lat}, ${stop.lng}` && <span className="block text-caption text-content-muted">{stop.lat == null || stop.lng == null ? t('roadtrip.import.unresolved') : `${stop.lat.toFixed(5)}, ${stop.lng.toFixed(5)}`}</span>}</span>
</li>)}
</ol>
</>}
{failure && <p role="alert" className="text-caption text-danger">{saved ? t('common.saved') + ': ' : ''}{failure}</p>}
<div className="flex justify-end gap-2">
<button type="button" disabled={busy} className="rounded-lg px-3 py-2 text-body text-content-secondary" onClick={() => setOpen(false)}>{t('common.cancel')}</button>
<button type="button" disabled={busy || saved || (!preview ? !url.trim() : resolved.length < 2 || !target)} onClick={() => void (preview ? save() : inspect())}
className="rounded-lg bg-accent px-4 py-2 text-body font-medium text-accent-text disabled:opacity-50">
{busy ? t('common.loading') : t(preview ? 'common.import' : 'common.preview')}
</button>
</div>
</div>
</Modal>
</DialogSection>
<ol className="m-0 flex max-h-64 list-none flex-col gap-2.5 overflow-auto rounded-[14px] border border-edge-faint bg-surface-secondary p-3.5">
{preview.stops.map((stop, index) => <li key={index} className="flex items-baseline gap-3 text-content" style={fs(13, 'body')}>
<span className="grid h-5 w-5 flex-none place-items-center rounded-full bg-surface-card font-semibold tabular-nums text-content-muted shadow-sm" style={fs(10.5)}>{index + 1}</span>
<span className="min-w-0 break-words">{stop.name}{stop.name !== `${stop.lat}, ${stop.lng}` && <span className="block font-geist text-content-muted" style={fs(11.5)}>{stop.lat == null || stop.lng == null ? t('roadtrip.import.unresolved') : `${stop.lat.toFixed(5)}, ${stop.lng.toFixed(5)}`}</span>}</span>
</li>)}
</ol>
</>}
{failure && <p role="alert" className="m-0 text-caption text-danger">{saved ? t('common.saved') + ': ' : ''}{failure}</p>}
</DialogShell>
</>
}
@@ -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
? <RotateCw size={15} className="shrink-0 animate-spin" aria-hidden />
: <Search size={15} strokeWidth={2} className="shrink-0" aria-hidden />}
? <RotateCw size={13} className="shrink-0 animate-spin" aria-hidden />
: <Search size={13} strokeWidth={2.2} className="shrink-0" aria-hidden />}
{/* 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')}
>
<Plus size={15} strokeWidth={2.2} className="shrink-0" aria-hidden />
<Plus size={13} strokeWidth={2.2} className="shrink-0" aria-hidden />
{narrow ? null : <span className="min-w-0 truncate">{t('roadtrip.poi.addManualShort')}</span>}
</button>
) : null}
@@ -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
/>
<span className="min-w-0 flex-1">
<span className="block truncate font-semibold text-content" style={{ fontSize: FS.name }}>
<span className="block truncate font-semibold text-content" style={fs(13, 'body')}>
{track.name || t('roadtrip.track.untitled')}
</span>
<span className="block truncate tabular-nums text-content-faint" style={{ fontSize: FS.meta }}>
<span className="block truncate font-geist tabular-nums text-content-faint" style={fs(11)}>
{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) })}
</span>
</span>
{picked ? <Check size={15} className="shrink-0 text-content" aria-hidden /> : null}
</button>
</li>
)
@@ -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<number | null>(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 (
<Modal
isOpen
<DialogShell
onClose={follow.close}
size="sm"
title={
<span className="flex items-center gap-2">
<Spline size={15} className="text-content-faint" aria-hidden />
{t('roadtrip.track.title', { number: dayNumber })}
</span>
}
>
<div className="flex flex-col gap-3">
{/* 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 ? (
<p className="rounded-xl bg-surface-secondary px-3 py-2 text-caption text-content-secondary">
{t('roadtrip.track.current', { name: current.name })}
</p>
) : null}
{tracks.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-2">
<EmptyState scene="transport" size={96} compact title={t('roadtrip.track.empty')} />
<p className="text-center text-caption text-content-faint">{t('roadtrip.track.emptyHint')}</p>
</div>
) : (
<ul className="-mx-2 max-h-[280px] overflow-y-auto">
{tracks.map(track => (
<TrackRow
key={track.id}
track={track}
disabled={busy}
onPick={() => setPicked(track.id === picked ? null : track.id)}
/>
))}
</ul>
)}
{/* 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 ? (
<p className="rounded-xl bg-surface-secondary px-3 py-2 text-caption text-content-secondary">
{t('roadtrip.track.explain')}
</p>
) : null}
{busy ? (
<p className="flex items-center gap-2 text-caption text-content-secondary" role="status">
<RotateCw size={13} className="animate-spin shrink-0" aria-hidden />
{t('roadtrip.track.working', { round })}
</p>
) : null}
{error ? (
<p className="flex items-start gap-1.5 text-caption text-danger" role="alert">
<AlertTriangle size={13} className="mt-px shrink-0" aria-hidden />
{t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')}
</p>
) : null}
{outcome && !busy && !error ? (
<p className="flex items-start gap-1.5 text-caption text-content-secondary" role="status">
<Check size={13} className="mt-px shrink-0 text-success" aria-hidden />
{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),
})}
</p>
) : null}
<div className="flex items-center gap-2">
labelledBy={labelId}
width="narrow"
header={(
<DialogHeader
tile={<DialogTile><Spline size={20} strokeWidth={1.9} className="text-content-muted" /></DialogTile>}
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={(
<DialogFooter>
{/* 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 ? (
<button
type="button"
disabled={busy}
onClick={() => { void follow.clear() }}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
>
<Trash2 size={13} aria-hidden />
<DialogButton disabled={busy} onClick={() => { void follow.clear() }} icon={<Trash2 size={13} aria-hidden />}>
{t('roadtrip.track.clear', { count: viaCount })}
</button>
</DialogButton>
) : null}
<button
type="button"
disabled={!chosen || busy}
onClick={() => { if (chosen) void follow.apply(chosen.id) }}
className="ms-auto rounded-lg bg-accent px-3 py-2 text-caption font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-40"
>
<FooterSpacer />
<DialogButton variant="primary" disabled={!chosen || busy} onClick={() => { if (chosen) void follow.apply(chosen.id) }}>
{t('roadtrip.track.action')}
</button>
</DialogButton>
</DialogFooter>
)}
>
{tracks.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-[14px] border border-edge-faint bg-surface-secondary px-4 py-5">
<EmptyState scene="transport" size={96} compact title={t('roadtrip.track.empty')} />
<p className="m-0 text-center text-content-faint" style={fs(12, 'body')}>{t('roadtrip.track.emptyHint')}</p>
</div>
</div>
</Modal>
) : (
<ul className="m-0 flex max-h-[280px] list-none flex-col gap-0.5 overflow-y-auto rounded-[14px] border border-edge-faint bg-surface-secondary p-1.5">
{tracks.map(track => (
<TrackRow
key={track.id}
track={track}
picked={track.id === picked}
disabled={busy}
onPick={() => setPicked(track.id === picked ? null : track.id)}
/>
))}
</ul>
)}
{/* 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 ? (
<p className="m-0 rounded-[12px] bg-surface-secondary px-3 py-2.5 text-content-secondary" style={fs(12, 'body')}>
{t('roadtrip.track.explain')}
</p>
) : null}
{busy ? (
<p className="m-0 flex items-center gap-2 text-content-secondary" style={fs(12, 'body')} role="status">
<RotateCw size={13} className="animate-spin shrink-0" aria-hidden />
{t('roadtrip.track.working', { round })}
</p>
) : null}
{error ? (
<p className="m-0 flex items-start gap-1.5 rounded-[12px] bg-danger-soft px-3 py-2.5 text-danger" style={fs(12, 'body')} role="alert">
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden />
{t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')}
</p>
) : null}
{outcome && !busy && !error ? (
<p className="m-0 flex items-start gap-1.5 text-content-secondary" style={fs(12, 'body')} role="status">
<Check size={13} className="mt-0.5 shrink-0 text-success" aria-hidden />
{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),
})}
</p>
) : null}
</DialogShell>
)
}