385 lines
13 KiB
TypeScript
385 lines
13 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { IconCheck, IconSearch, IconX } from '@tabler/icons-react'
|
|
import StyledModal, { StyledModalProps } from './StyledModal'
|
|
import LoadingSpinner from './LoadingSpinner'
|
|
import api from '~/lib/api'
|
|
import { formatBytes } from '~/lib/util'
|
|
import classNames from '~/lib/classNames'
|
|
import {
|
|
EXTRACT_DEFAULT_MAX_ZOOM,
|
|
EXTRACT_MAX_ZOOM,
|
|
EXTRACT_MIN_ZOOM,
|
|
} from '../../constants/map_regions'
|
|
import type {
|
|
Country,
|
|
CountryCode,
|
|
CountryGroup,
|
|
MapExtractPreflight,
|
|
} from '../../types/maps'
|
|
|
|
export type CountryPickerModalProps = Omit<
|
|
StyledModalProps,
|
|
| 'onConfirm'
|
|
| 'open'
|
|
| 'confirmText'
|
|
| 'cancelText'
|
|
| 'confirmVariant'
|
|
| 'children'
|
|
| 'title'
|
|
| 'large'
|
|
> & {
|
|
onDownloadStart?: () => void
|
|
}
|
|
|
|
const CountryPickerModal: React.FC<CountryPickerModalProps> = ({
|
|
onDownloadStart,
|
|
...modalProps
|
|
}) => {
|
|
const [selected, setSelected] = useState<Set<CountryCode>>(new Set())
|
|
const [search, setSearch] = useState('')
|
|
const [maxzoom, setMaxzoom] = useState<number>(EXTRACT_DEFAULT_MAX_ZOOM)
|
|
const [preflight, setPreflight] = useState<MapExtractPreflight | null>(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [downloading, setDownloading] = useState(false)
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
const preflightRequestIdRef = useRef(0)
|
|
|
|
const { data: countries = [], isLoading: countriesLoading } = useQuery({
|
|
queryKey: ['maps-countries'],
|
|
queryFn: () => api.listCountries(),
|
|
staleTime: Infinity,
|
|
})
|
|
|
|
const { data: groups = [] } = useQuery({
|
|
queryKey: ['maps-country-groups'],
|
|
queryFn: () => api.listCountryGroups(),
|
|
staleTime: Infinity,
|
|
})
|
|
|
|
const grouped = useMemo(() => {
|
|
const q = search.trim().toLowerCase()
|
|
const filtered = q
|
|
? countries.filter(
|
|
(c) => c.name.toLowerCase().includes(q) || c.code.toLowerCase().includes(q)
|
|
)
|
|
: countries
|
|
|
|
const buckets: Record<string, Country[]> = {}
|
|
for (const country of filtered) {
|
|
if (!buckets[country.continent]) buckets[country.continent] = []
|
|
buckets[country.continent].push(country)
|
|
}
|
|
return Object.entries(buckets).sort(([a], [b]) => a.localeCompare(b))
|
|
}, [countries, search])
|
|
|
|
const selectedCountries = useMemo(
|
|
() => countries.filter((c) => selected.has(c.code)),
|
|
[countries, selected]
|
|
)
|
|
|
|
function toggleCountry(code: CountryCode) {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(code)) next.delete(code)
|
|
else next.add(code)
|
|
return next
|
|
})
|
|
}
|
|
|
|
function toggleGroup(group: CountryGroup) {
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
const allIn = group.countries.every((c) => next.has(c))
|
|
if (allIn) {
|
|
group.countries.forEach((c) => next.delete(c))
|
|
} else {
|
|
group.countries.forEach((c) => next.add(c))
|
|
}
|
|
return next
|
|
})
|
|
}
|
|
|
|
function clearAll() {
|
|
setSelected(new Set())
|
|
}
|
|
|
|
// Auto-refresh the preflight whenever selection or maxzoom changes. Debounced
|
|
// so rapid multi-select clicks collapse into a single CDN round-trip, and
|
|
// stale-safe via requestId so an earlier slow response can't clobber a later one.
|
|
useEffect(() => {
|
|
if (selected.size === 0) {
|
|
setPreflight(null)
|
|
setErrorMessage(null)
|
|
setLoading(false)
|
|
preflightRequestIdRef.current++
|
|
return
|
|
}
|
|
|
|
const requestId = ++preflightRequestIdRef.current
|
|
setLoading(true)
|
|
setErrorMessage(null)
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const res = await api.extractMapPreflight({
|
|
countries: [...selected],
|
|
maxzoom,
|
|
})
|
|
if (requestId !== preflightRequestIdRef.current) return
|
|
if (!res) throw new Error('Preflight returned no data')
|
|
setPreflight(res)
|
|
} catch (err: any) {
|
|
if (requestId !== preflightRequestIdRef.current) return
|
|
console.error('Preflight failed:', err)
|
|
setErrorMessage(err?.message ?? 'Estimate failed')
|
|
} finally {
|
|
if (requestId === preflightRequestIdRef.current) setLoading(false)
|
|
}
|
|
}, 400)
|
|
|
|
return () => clearTimeout(timer)
|
|
}, [selected, maxzoom])
|
|
|
|
async function startDownload() {
|
|
if (selected.size === 0) {
|
|
setErrorMessage('Pick at least one country before downloading.')
|
|
return
|
|
}
|
|
if (loading || !preflight) {
|
|
setErrorMessage('Still estimating size — hold on a moment.')
|
|
return
|
|
}
|
|
try {
|
|
setDownloading(true)
|
|
setErrorMessage(null)
|
|
await api.extractMapRegion({
|
|
countries: [...selected],
|
|
maxzoom,
|
|
estimatedBytes: preflight?.bytes,
|
|
})
|
|
onDownloadStart?.()
|
|
} catch (err: any) {
|
|
console.error('Extract dispatch failed:', err)
|
|
setErrorMessage(err?.message ?? 'Download failed')
|
|
} finally {
|
|
setDownloading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<StyledModal
|
|
{...modalProps}
|
|
title="Download map by country or region"
|
|
open={true}
|
|
confirmText="Start Download"
|
|
confirmIcon="IconDownload"
|
|
cancelText="Cancel"
|
|
confirmVariant="primary"
|
|
confirmLoading={loading || downloading}
|
|
cancelLoading={loading || downloading}
|
|
onConfirm={startDownload}
|
|
large
|
|
>
|
|
<div className="flex flex-col text-left gap-4 min-h-[60vh]">
|
|
<div className="flex gap-3 items-stretch">
|
|
<div className="relative flex-1">
|
|
<IconSearch className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={`Search ${countries.length} countries...`}
|
|
className="w-full pl-9 pr-3 py-2 rounded-md border border-border-default bg-surface-primary text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-desert-green"
|
|
/>
|
|
</div>
|
|
{selected.size > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={clearAll}
|
|
className="text-sm text-text-muted hover:text-text-primary px-3 cursor-pointer"
|
|
>
|
|
Clear all
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{groups.length > 0 && (
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-text-muted mb-2">
|
|
Quick picks
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{groups.map((group) => {
|
|
const allIn =
|
|
group.countries.length > 0 &&
|
|
group.countries.every((c) => selected.has(c))
|
|
return (
|
|
<button
|
|
key={group.id}
|
|
type="button"
|
|
onClick={() => toggleGroup(group)}
|
|
className={classNames(
|
|
'px-3 py-1.5 rounded-full text-xs font-medium border transition-colors cursor-pointer',
|
|
allIn
|
|
? 'bg-desert-green text-white border-desert-green'
|
|
: 'bg-surface-primary text-text-primary border-border-default hover:border-desert-green'
|
|
)}
|
|
>
|
|
{allIn && <IconCheck className="inline w-3 h-3 mr-1" />}
|
|
{group.name}{' '}
|
|
<span className="opacity-60">({group.countries.length})</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-y-auto max-h-96 border border-border-default rounded-md bg-surface-secondary">
|
|
{countriesLoading ? (
|
|
<div className="flex items-center justify-center h-40">
|
|
<LoadingSpinner />
|
|
</div>
|
|
) : grouped.length === 0 ? (
|
|
<p className="text-text-muted text-sm p-6 text-center">
|
|
No countries match "{search}".
|
|
</p>
|
|
) : (
|
|
grouped.map(([continent, list]) => (
|
|
<div key={continent}>
|
|
<div className="sticky top-0 bg-surface-secondary border-b border-border-default px-4 py-2 text-xs uppercase tracking-wide text-text-muted font-semibold z-10">
|
|
{continent}
|
|
</div>
|
|
<ul>
|
|
{list.map((country) => {
|
|
const isSelected = selected.has(country.code)
|
|
return (
|
|
<li key={country.code}>
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleCountry(country.code)}
|
|
className={classNames(
|
|
'w-full flex items-center gap-3 px-4 py-2 text-left text-sm transition-colors cursor-pointer',
|
|
isSelected
|
|
? 'bg-desert-green/10 hover:bg-desert-green/15'
|
|
: 'hover:bg-surface-primary'
|
|
)}
|
|
>
|
|
<span
|
|
className={classNames(
|
|
'w-4 h-4 rounded border flex items-center justify-center shrink-0',
|
|
isSelected
|
|
? 'bg-desert-green border-desert-green'
|
|
: 'border-border-default'
|
|
)}
|
|
>
|
|
{isSelected && <IconCheck className="w-3 h-3 text-white" />}
|
|
</span>
|
|
<span className="flex-1 text-text-primary">{country.name}</span>
|
|
<span className="text-xs font-mono text-text-muted">
|
|
{country.code}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{selectedCountries.length > 0 && (
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-text-muted mb-2">
|
|
{selectedCountries.length} selected
|
|
</p>
|
|
<div className="flex flex-wrap gap-2 max-h-24 overflow-y-auto">
|
|
{selectedCountries.map((country) => (
|
|
<span
|
|
key={country.code}
|
|
className="inline-flex items-center gap-1 px-2 py-1 rounded bg-desert-green text-white text-xs"
|
|
>
|
|
{country.name}
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleCountry(country.code)}
|
|
className="hover:bg-white/20 rounded cursor-pointer"
|
|
aria-label={`Remove ${country.name}`}
|
|
>
|
|
<IconX className="w-3 h-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="block text-sm text-text-primary font-medium mb-2">
|
|
Max zoom level: <span className="font-mono">{maxzoom}</span>
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min={EXTRACT_MIN_ZOOM}
|
|
max={EXTRACT_MAX_ZOOM}
|
|
step={1}
|
|
value={maxzoom}
|
|
onChange={(e) => setMaxzoom(parseInt(e.target.value, 10))}
|
|
className="w-full accent-desert-green"
|
|
disabled={loading || downloading}
|
|
/>
|
|
<div className="flex justify-between text-xs text-text-muted mt-1 font-mono">
|
|
<span>z{EXTRACT_MIN_ZOOM} (world)</span>
|
|
<span>z{EXTRACT_MAX_ZOOM} (street)</span>
|
|
</div>
|
|
<p className="text-xs text-text-muted mt-2">
|
|
Lower zoom = smaller file, less detail. Zoom 15 shows individual streets;
|
|
zoom 10 shows city-level detail.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-surface-secondary border border-border-default rounded-md p-3 min-h-14 text-sm font-mono">
|
|
<PreflightStatus
|
|
errorMessage={errorMessage}
|
|
loading={loading}
|
|
preflight={preflight}
|
|
hasSelection={selected.size > 0}
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
</StyledModal>
|
|
)
|
|
}
|
|
|
|
type PreflightStatusProps = {
|
|
errorMessage: string | null
|
|
loading: boolean
|
|
preflight: MapExtractPreflight | null
|
|
hasSelection: boolean
|
|
}
|
|
|
|
function PreflightStatus({ errorMessage, loading, preflight, hasSelection }: PreflightStatusProps) {
|
|
if (errorMessage) {
|
|
return <p className="text-desert-red">{errorMessage}</p>
|
|
}
|
|
if (loading) {
|
|
return <p className="text-text-muted">Estimating size…</p>
|
|
}
|
|
if (preflight) {
|
|
return (
|
|
<p className="text-text-primary">
|
|
{preflight.tiles.toLocaleString()} tiles, ~{formatBytes(preflight.bytes, 1)}{' '}
|
|
<span className="text-text-muted">(source build {preflight.source.date})</span>
|
|
</p>
|
|
)
|
|
}
|
|
if (!hasSelection) {
|
|
return <p className="text-text-muted">Pick at least one country to estimate size.</p>
|
|
}
|
|
return <p className="text-text-muted">Estimating size…</p>
|
|
}
|
|
|
|
export default CountryPickerModal
|