mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-05-12 16:10:11 +02:00
fix(UI): Country Picker UX polish + auto-refresh stored files (#817)
Three UX issues from manual testing of #780 on NOMAD3. 1. Slider was unusable for multi-step zoom changes `setLoading(true)` fired immediately on every selection or maxzoom change, which disabled the slider until the request returned. Even with the 400ms debounce delaying the network call, the UI was locked the whole time. User couldn't drag through zoom levels to find the right one. Fix: bump debounce to 1500ms, move `setLoading(true)` inside the setTimeout so it only flips after the debounce expires. Slider stays interactive throughout the wait. Slider `disabled` now only ties to `downloading` (active extract dispatch), not `loading` (preflight in flight). The existing requestId stale-safe pattern handles concurrent changes. 2. Newly-downloaded maps didn't show in Stored Map Files until manual refresh `props.maps.regionFiles` is rendered server-side and passed through Inertia props; without a partial reload it stayed stale until the user navigated away and back. Fix: watch `useDownloads({ filetype: 'map' })` count via a ref. When the count drops (a download finished), trigger `router.reload({ only: ['maps'] })` to refresh just the maps prop. Existing pattern from elsewhere in the codebase. 3. Country picker didn't surface already-downloaded countries When a user re-opened "Choose Countries" after downloading UK, UK appeared unchecked with no indication it was already on disk. Fix: pass installed pmtiles filenames into the modal as a prop; parse with regex `^([a-z]{2})_[\w-]+_z\d+\.pmtiles$` to extract country codes from single-country extracts (matching MapService.buildRegionSlug's iso2 lowercase slug pattern). Render an "Installed" badge on those countries with a tooltip explaining they're re-selectable for redownload at a different zoom. Group / custom multi-country extracts don't reverse-map cleanly from filename and are skipped here. Could be a follow-up if useful. Files: admin/inertia/components/CountryPickerModal.tsx - SINGLE_COUNTRY_FILENAME_RE: iso2 + flexible date + zoom - installedFilenames prop with default [] - installedCountrySet derivation via useMemo - "Installed" badge rendering on country list rows - Debounce: 400ms -> 1500ms; setLoading inside setTimeout - Slider disabled: only on `downloading` admin/inertia/pages/settings/maps.tsx - import useEffect/useRef - destructure activeMapDownloads from useDownloads - useEffect on download count drop -> router.reload({ only: ['maps'] }) - pass installedFilenames to CountryPickerModal All three fixes tested end-to-end on NOMAD3.
This commit is contained in:
parent
27cd803090
commit
822b94629c
|
|
@ -30,10 +30,20 @@ export type CountryPickerModalProps = Omit<
|
|||
| 'large'
|
||||
> & {
|
||||
onDownloadStart?: () => void
|
||||
/** Filenames of pmtiles already on disk; used to badge already-installed countries. */
|
||||
installedFilenames?: string[]
|
||||
}
|
||||
|
||||
// Single-country extracts use the slug `{iso2 lowercase}_{dateSlug}_z{maxzoom}.pmtiles`,
|
||||
// matching MapService.buildRegionSlug (which lowercases the alpha-2 country code).
|
||||
// dateSlug comes from the upstream pmtiles key with `.pmtiles` stripped — currently
|
||||
// YYYYMMDD but we accept any digits/dashes. Group / custom filenames don't reverse-map
|
||||
// to country codes, so we skip them here.
|
||||
const SINGLE_COUNTRY_FILENAME_RE = /^([a-z]{2})_[\w-]+_z\d+\.pmtiles$/
|
||||
|
||||
const CountryPickerModal: React.FC<CountryPickerModalProps> = ({
|
||||
onDownloadStart,
|
||||
installedFilenames = [],
|
||||
...modalProps
|
||||
}) => {
|
||||
const [selected, setSelected] = useState<Set<CountryCode>>(new Set())
|
||||
|
|
@ -78,6 +88,15 @@ const CountryPickerModal: React.FC<CountryPickerModalProps> = ({
|
|||
[countries, selected]
|
||||
)
|
||||
|
||||
const installedCountrySet = useMemo(() => {
|
||||
const set = new Set<CountryCode>()
|
||||
for (const filename of installedFilenames) {
|
||||
const match = SINGLE_COUNTRY_FILENAME_RE.exec(filename)
|
||||
if (match) set.add(match[1].toUpperCase() as CountryCode)
|
||||
}
|
||||
return set
|
||||
}, [installedFilenames])
|
||||
|
||||
function toggleCountry(code: CountryCode) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
|
|
@ -105,8 +124,10 @@ const CountryPickerModal: React.FC<CountryPickerModalProps> = ({
|
|||
}
|
||||
|
||||
// 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.
|
||||
// so rapid multi-select clicks and slider drags collapse into a single CDN
|
||||
// round-trip. Loading state only flips after the debounce expires so the UI
|
||||
// stays interactive during the wait. Stale-safe via requestId so an earlier
|
||||
// slow response can't clobber a later one.
|
||||
useEffect(() => {
|
||||
if (selected.size === 0) {
|
||||
setPreflight(null)
|
||||
|
|
@ -116,10 +137,10 @@ const CountryPickerModal: React.FC<CountryPickerModalProps> = ({
|
|||
return
|
||||
}
|
||||
|
||||
const requestId = ++preflightRequestIdRef.current
|
||||
setLoading(true)
|
||||
setErrorMessage(null)
|
||||
const timer = setTimeout(async () => {
|
||||
const requestId = ++preflightRequestIdRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.extractMapPreflight({
|
||||
countries: [...selected],
|
||||
|
|
@ -135,7 +156,7 @@ const CountryPickerModal: React.FC<CountryPickerModalProps> = ({
|
|||
} finally {
|
||||
if (requestId === preflightRequestIdRef.current) setLoading(false)
|
||||
}
|
||||
}, 400)
|
||||
}, 1500)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [selected, maxzoom])
|
||||
|
|
@ -253,6 +274,7 @@ const CountryPickerModal: React.FC<CountryPickerModalProps> = ({
|
|||
<ul>
|
||||
{list.map((country) => {
|
||||
const isSelected = selected.has(country.code)
|
||||
const isInstalled = installedCountrySet.has(country.code)
|
||||
return (
|
||||
<li key={country.code}>
|
||||
<button
|
||||
|
|
@ -276,6 +298,14 @@ const CountryPickerModal: React.FC<CountryPickerModalProps> = ({
|
|||
{isSelected && <IconCheck className="w-3 h-3 text-white" />}
|
||||
</span>
|
||||
<span className="flex-1 text-text-primary">{country.name}</span>
|
||||
{isInstalled && (
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wide font-semibold px-1.5 py-0.5 rounded bg-desert-green/15 text-desert-green border border-desert-green/30"
|
||||
title="Already downloaded — re-select to update with a different zoom"
|
||||
>
|
||||
Installed
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-mono text-text-muted">
|
||||
{country.code}
|
||||
</span>
|
||||
|
|
@ -327,7 +357,7 @@ const CountryPickerModal: React.FC<CountryPickerModalProps> = ({
|
|||
value={maxzoom}
|
||||
onChange={(e) => setMaxzoom(parseInt(e.target.value, 10))}
|
||||
className="w-full accent-desert-green"
|
||||
disabled={loading || downloading}
|
||||
disabled={downloading}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-text-muted mt-1 font-mono">
|
||||
<span>z{EXTRACT_MIN_ZOOM} (world)</span>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useModals } from '~/context/ModalContext'
|
|||
import StyledModal from '~/components/StyledModal'
|
||||
import { FileEntry } from '../../../types/files'
|
||||
import { useNotifications } from '~/context/NotificationContext'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import api from '~/lib/api'
|
||||
import DownloadURLModal from '~/components/DownloadURLModal'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
|
@ -38,11 +38,22 @@ export default function MapsManager(props: {
|
|||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const { invalidate: invalidateDownloads } = useDownloads({
|
||||
const { data: activeMapDownloads = [], invalidate: invalidateDownloads } = useDownloads({
|
||||
filetype: 'map',
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
// Refresh the Stored Map Files list when a map download finishes. We pass props.maps.regionFiles
|
||||
// straight through from the server-side render, so without an Inertia partial reload it stays stale
|
||||
// until the user navigates away and back.
|
||||
const prevMapDownloadCountRef = useRef(activeMapDownloads.length)
|
||||
useEffect(() => {
|
||||
if (activeMapDownloads.length < prevMapDownloadCountRef.current) {
|
||||
router.reload({ only: ['maps'] })
|
||||
}
|
||||
prevMapDownloadCountRef.current = activeMapDownloads.length
|
||||
}, [activeMapDownloads.length])
|
||||
|
||||
const { data: globalMapInfo } = useQuery({
|
||||
queryKey: [GLOBAL_MAP_INFO_KEY],
|
||||
queryFn: () => api.getGlobalMapInfo(),
|
||||
|
|
@ -226,6 +237,7 @@ export default function MapsManager(props: {
|
|||
openModal(
|
||||
<CountryPickerModal
|
||||
onCancel={closeAllModals}
|
||||
installedFilenames={(props.maps.regionFiles ?? []).map((f) => f.name)}
|
||||
onDownloadStart={() => {
|
||||
invalidateDownloads()
|
||||
addNotification({
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user