mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
add roadtrip warning areas and tidy driving settings
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
import { GeoJSON } from 'react-leaflet'
|
||||
import type { RoadtripHazard } from '@trek/shared'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
import { hazardPopup, hazardFeature } from './hazardPopup'
|
||||
|
||||
export default function HazardLayers({ hazards }: { hazards: RoadtripHazard[] }) {
|
||||
const { t } = useTranslation()
|
||||
return <>{hazards.map(hazard => <GeoJSON key={`${hazard.id}-${hazard.updatedAt}`} data={hazardFeature(hazard)}
|
||||
style={{ color: 'var(--warning)', weight: 2, fillOpacity: 0.16 }}
|
||||
pointToLayer={(_, point) => L.circleMarker(point, { radius: 7, color: 'var(--warning)', fillOpacity: 0.65 })}
|
||||
onEachFeature={(_, layer) => layer.bindPopup(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point')), { className: 'map-tooltip', maxWidth: 320 })}
|
||||
/>)}</>
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import HazardLayers from './HazardLayers'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
@@ -666,6 +667,7 @@ export const MapView = memo(function MapView({
|
||||
tripId,
|
||||
routeVias = [],
|
||||
dayBoundaryControls,
|
||||
hazards,
|
||||
accessLines = [],
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
@@ -1153,6 +1155,7 @@ export const MapView = memo(function MapView({
|
||||
roadRoutes={transportRoutes}
|
||||
/>
|
||||
|
||||
{hazards?.length > 0 && <HazardLayers hazards={hazards} />}
|
||||
<ClusteredPois pois={pois} enabled={clusterLoosely} onPoiClick={onPoiClick}>{poiMarkers}</ClusteredPois>
|
||||
{/* Charging stops / rest areas a plugin route places on the drawn day route.
|
||||
Host-vetted data (server-normalized), rendered as plain tone dots. */}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Suspense } from 'react'
|
||||
import { AlertTriangle, Clock, Info } from 'lucide-react'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapView } from './MapView'
|
||||
import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
|
||||
import { useRoadtripHazards } from './useRoadtripHazards'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||
@@ -14,6 +18,30 @@ import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
|
||||
// them online (see the GL tile rules in vite.config.js), not prefetched.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function MapViewAuto(props: any) {
|
||||
const { t } = useTranslation()
|
||||
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
|
||||
const mapProps = { ...props, hazards: hazards.feed?.hazards }
|
||||
const status = hazards.enabled ? <div role="status" className="absolute bottom-10 left-1/2 z-[500] flex max-w-[calc(100%-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-x-3 gap-y-2 rounded-xl border border-edge-faint bg-surface-card px-3 py-2 text-caption text-content shadow-md">
|
||||
{hazards.status !== 'ready' ? t(`roadtrip.hazards.${hazards.status}`) : <>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap font-medium">
|
||||
<AlertTriangle size={14} className="text-content-muted" aria-hidden />
|
||||
{t('roadtrip.hazards.current')}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 whitespace-nowrap tabular-nums text-content-muted">
|
||||
<Clock size={12} aria-hidden />
|
||||
{new Date(hazards.feed!.fetchedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{hazards.feed!.sources.map(source => <Tooltip key={source.source} placement="top"
|
||||
label={source.status === 'ok' ? source.source : `${source.source}: ${t(`roadtrip.hazards.${source.status}`)}`}>
|
||||
<span tabIndex={0} className={`inline-flex items-center gap-1 rounded-md bg-surface-secondary px-2 py-1 font-medium ${source.status === 'ok' ? 'text-content-secondary' : 'text-warning'}`}>
|
||||
{source.source}
|
||||
{source.status !== 'ok' && <Info size={12} aria-label={t(`roadtrip.hazards.${source.status}`)} />}
|
||||
</span>
|
||||
</Tooltip>)}
|
||||
</span>
|
||||
</>}
|
||||
</div> : null
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||
// Fall back to Leaflet when Mapbox is selected but no token is set,
|
||||
@@ -33,12 +61,12 @@ export function MapViewAuto(props: any) {
|
||||
// to Leaflet keeps a usable map instead of an error card.
|
||||
// resetKeys: with two engine chunks, a failure under one provider must not
|
||||
// keep showing Leaflet after the user switches to the other.
|
||||
<ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...props} />}>
|
||||
<Suspense fallback={<MapView {...props} />}>
|
||||
<MapViewGL {...props} glProvider={glProvider} />
|
||||
<><ErrorBoundary boundaryId="map:gl" resetKeys={[glProvider]} fallback={<MapView {...mapProps} />}>
|
||||
<Suspense fallback={<MapView {...mapProps} />}>
|
||||
<MapViewGL {...mapProps} glProvider={glProvider} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</ErrorBoundary>{status}</>
|
||||
)
|
||||
}
|
||||
return <MapView {...props} />
|
||||
return <><MapView {...mapProps} />{status}</>
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import type { Day, Place, Reservation, RouteVia } from '../../types'
|
||||
import type { MapHoverInfo } from './mapHover'
|
||||
import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
|
||||
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
|
||||
import type { RoadtripHazard } from '@trek/shared'
|
||||
import { useHazardLayerGL } from './useHazardLayerGL'
|
||||
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
|
||||
import NightPauseTooltip from './NightPauseTooltip'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
@@ -187,6 +189,7 @@ interface Props {
|
||||
* motorway are the shape of the day, and merging them into one dot hides it.
|
||||
*/
|
||||
clusterLoosely?: boolean
|
||||
hazards?: RoadtripHazard[]
|
||||
/** Via points to draw as draggable handles, keyed by day (#1797). */
|
||||
roadtripVias?: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
@@ -631,6 +634,7 @@ export function MapViewGL({
|
||||
fitKey = 0,
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
hazards,
|
||||
dayOrderMap = NO_DAY_ORDER,
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
@@ -706,6 +710,8 @@ export function MapViewGL({
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mapRef = useRef<any | null>(null)
|
||||
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip', maxWidth: '320px' }), [gl])
|
||||
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, it } from 'vitest'
|
||||
import { hazardPopup } from './hazardPopup'
|
||||
|
||||
it('renders provider text without HTML and identifies point-only notices', () => {
|
||||
const popup = hazardPopup({ id: 'test', source: 'DWD', title: '<img src=x>', description: '<script>bad()</script>',
|
||||
updatedAt: '2026-09-12T08:00:00Z', validUntil: null, url: 'https://www.dwd.de/',
|
||||
geometry: { type: 'Point', coordinates: [10, 50] },
|
||||
}, 'Not a closure', 'Location only')
|
||||
expect(popup.querySelector('img,script')).toBeNull()
|
||||
expect(popup.textContent).toContain('Location only Not a closure')
|
||||
expect(popup.querySelector('a')?.rel).toBe('noopener noreferrer')
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { RoadtripHazard } from '@trek/shared'
|
||||
import type { Feature, Geometry } from 'geojson'
|
||||
|
||||
export function hazardFeature(hazard: RoadtripHazard): Feature {
|
||||
return { type: 'Feature', properties: { id: hazard.id }, geometry: hazard.geometry as Geometry }
|
||||
}
|
||||
|
||||
export function hazardPopup(hazard: RoadtripHazard, note: string, pointNote: string): HTMLDivElement {
|
||||
const box = document.createElement('div')
|
||||
box.className = 'flex max-w-xs flex-col gap-2 text-caption text-content'
|
||||
const title = document.createElement('strong')
|
||||
title.textContent = hazard.title
|
||||
const details = document.createElement('p')
|
||||
details.textContent = hazard.description
|
||||
const timestamp = document.createElement('p')
|
||||
timestamp.textContent = `${hazard.source}: ${new Date(hazard.updatedAt).toLocaleString()}`
|
||||
const caution = document.createElement('p')
|
||||
caution.textContent = hazard.geometry.type === 'Point' ? `${pointNote} ${note}` : note
|
||||
const source = document.createElement('a')
|
||||
source.textContent = hazard.source
|
||||
source.href = hazard.url
|
||||
source.target = '_blank'
|
||||
source.rel = 'noopener noreferrer'
|
||||
source.className = 'text-accent-on underline'
|
||||
box.append(title, details, timestamp, caution, source)
|
||||
return box
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { Map, Popup, MapMouseEvent } from 'mapbox-gl'
|
||||
import type { RoadtripHazard } from '@trek/shared'
|
||||
import { hazardPopup, hazardFeature } from './hazardPopup'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
export function useHazardLayerGL(map: Map | null, ready: boolean, hazards: RoadtripHazard[] | undefined, popup: () => Popup) {
|
||||
const { t } = useTranslation()
|
||||
useEffect(() => {
|
||||
if (!map || !ready || !hazards?.length) return
|
||||
const source = 'roadtrip-hazards'
|
||||
const layers = [`${source}-fill`, `${source}-line`, `${source}-point`]
|
||||
let detail: Popup | undefined
|
||||
const draw = () => {
|
||||
if (!map.isStyleLoaded() || map.getSource(source)) return
|
||||
const color = getComputedStyle(document.documentElement).getPropertyValue('--warning').trim()
|
||||
map.addSource(source, { type: 'geojson', data: { type: 'FeatureCollection', features: hazards.map(hazardFeature) } })
|
||||
map.addLayer({ id: layers[0], type: 'fill', source, filter: ['!=', ['geometry-type'], 'Point'], paint: { 'fill-color': color, 'fill-opacity': 0.16 } })
|
||||
map.addLayer({ id: layers[1], type: 'line', source, filter: ['!=', ['geometry-type'], 'Point'], paint: { 'line-color': color, 'line-width': 2 } })
|
||||
map.addLayer({ id: layers[2], type: 'circle', source, filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-color': color, 'circle-radius': 7 } })
|
||||
}
|
||||
const click = (event: MapMouseEvent) => {
|
||||
if (!map.getLayer(layers[0])) return
|
||||
const hit = map.queryRenderedFeatures(event.point, { layers })[0]
|
||||
const hazard = hazards.find(item => item.id === hit?.properties?.id)
|
||||
if (!hazard) return
|
||||
detail?.remove()
|
||||
detail = popup().setLngLat(event.lngLat).setDOMContent(hazardPopup(hazard, t('roadtrip.hazards.note'), t('roadtrip.hazards.point'))).addTo(map)
|
||||
}
|
||||
draw()
|
||||
map.on('style.load', draw)
|
||||
map.on('click', click)
|
||||
return () => {
|
||||
detail?.remove()
|
||||
map.off('style.load', draw)
|
||||
map.off('click', click)
|
||||
for (const layer of layers) if (map.getLayer(layer)) map.removeLayer(layer)
|
||||
if (map.getSource(source)) map.removeSource(source)
|
||||
}
|
||||
}, [map, ready, hazards, popup, t])
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RoadtripHazards } from '@trek/shared'
|
||||
import { useRoadtripSettings } from '../../hooks/useRoadtripSettings'
|
||||
import { roadtripHazardsRepo } from '../../repo/roadtripHazardsRepo'
|
||||
import { isEffectivelyOffline, onNetworkModeChange } from '../../sync/networkMode'
|
||||
|
||||
export function useRoadtripHazards(tripId: number | undefined, roadtrip: boolean) {
|
||||
const enabled = useRoadtripSettings(s => s.roadtrip_show_hazards === true, tripId) && roadtrip && !!tripId
|
||||
const [feed, setFeed] = useState<RoadtripHazards | null>(null)
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'unavailable' | 'offline'>('loading')
|
||||
useEffect(() => {
|
||||
if (!enabled || !tripId) return
|
||||
let controller: AbortController
|
||||
const load = () => {
|
||||
controller?.abort()
|
||||
controller = new AbortController()
|
||||
const signal = controller.signal
|
||||
setFeed(null)
|
||||
if (isEffectivelyOffline()) { setStatus('offline'); return }
|
||||
setStatus('loading')
|
||||
roadtripHazardsRepo.read(tripId, signal).then(saved => {
|
||||
if (!signal.aborted) { setFeed(saved); setStatus('ready') }
|
||||
}).catch(() => { if (!signal.aborted) setStatus('unavailable') })
|
||||
}
|
||||
load()
|
||||
const timer = window.setInterval(load, 600000)
|
||||
const unsubscribe = onNetworkModeChange(load)
|
||||
window.addEventListener('online', load)
|
||||
return () => { controller.abort(); window.clearInterval(timer); unsubscribe(); window.removeEventListener('online', load) }
|
||||
}, [enabled, tripId])
|
||||
return { enabled, feed: enabled ? feed : null, status }
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Sunrise, Sunset } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { dayWindow } from './dayWindow'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import SettingsHint from './SettingsHint'
|
||||
|
||||
export default function DayWindowFields({ start, end, endMode = 'route', onSave }: {
|
||||
start?: string
|
||||
@@ -25,7 +26,7 @@ export default function DayWindowFields({ start, end, endMode = 'route', onSave
|
||||
}
|
||||
}
|
||||
return (
|
||||
<fieldset className="flex flex-col gap-3">
|
||||
<fieldset className="flex min-w-0 flex-col gap-3">
|
||||
<legend className="sr-only">{t('roadtrip.window.title')}</legend>
|
||||
{(['start', 'end'] as const).map(field => {
|
||||
const Icon = field === 'start' ? Sunrise : Sunset
|
||||
@@ -45,10 +46,10 @@ export default function DayWindowFields({ start, end, endMode = 'route', onSave
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p id="roadtrip-window-hint" role={invalid ? 'alert' : undefined} className={`text-caption ${invalid ? 'text-danger' : 'text-content-faint'}`}>
|
||||
{t(invalid ? 'roadtrip.window.invalid' : 'roadtrip.window.hint')}
|
||||
</p>
|
||||
<fieldset className="flex flex-col gap-2 border-t border-edge-faint pt-3">
|
||||
{invalid
|
||||
? <p id="roadtrip-window-hint" role="alert" className="text-caption text-danger">{t('roadtrip.window.invalid')}</p>
|
||||
: <SettingsHint id="roadtrip-window-hint">{t('roadtrip.window.hint')}</SettingsHint>}
|
||||
<fieldset className="flex min-w-0 flex-col gap-2 border-t border-edge-faint pt-3">
|
||||
<legend className="sr-only">{t('roadtrip.window.endAt')}</legend>
|
||||
<span className="text-body text-content-secondary">{t('roadtrip.window.endAt')}</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -61,7 +62,7 @@ export default function DayWindowFields({ start, end, endMode = 'route', onSave
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p id="roadtrip-end-mode-hint" className="text-caption text-content-faint">{t(`roadtrip.window.${endMode === 'stop' ? 'stopHint' : 'routeHint'}`)}</p>
|
||||
<SettingsHint id="roadtrip-end-mode-hint">{t(`roadtrip.window.${endMode === 'stop' ? 'stopHint' : 'routeHint'}`)}</SettingsHint>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
import type { DistanceUnit, RouteAvoidClass } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
import DayWindowFields from './DayWindowFields'
|
||||
import SettingsHint from './SettingsHint'
|
||||
import { dayWindow } from './dayWindow'
|
||||
|
||||
/**
|
||||
@@ -193,10 +194,10 @@ function Panel({ icon: Icon, title, note, children }: {
|
||||
children: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-4 py-3.5">
|
||||
<section className="flex min-w-0 flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-4 py-3.5">
|
||||
<SectionTitle icon={Icon}>{title}</SectionTitle>
|
||||
{children}
|
||||
{note ? <p className="text-caption leading-snug text-content-faint">{note}</p> : null}
|
||||
{note ? <SettingsHint>{note}</SettingsHint> : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -429,7 +430,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
{t('roadtrip.limit.title')}
|
||||
</span>
|
||||
}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
{/* Two columns, kept. One column reads more tidily but turns the dialog into
|
||||
something that has to be scrolled, and a setting you cannot see while you
|
||||
change another is worse than a seam down the middle.
|
||||
@@ -439,7 +440,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
ends on the same right edge, and the panels are stacked to one rhythm. The
|
||||
left column holds trip settings, the right holds the car and route appearance. */}
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Panel icon={Clock} title={t('roadtrip.limit.sectionDriving')} note={t('roadtrip.limit.hint')}>
|
||||
<LimitRow
|
||||
icon={Clock}
|
||||
@@ -484,7 +485,7 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<Panel
|
||||
icon={Car}
|
||||
title={t('roadtrip.limit.sectionVehicle')}
|
||||
@@ -641,6 +642,10 @@ export default function RoadtripLimitsCard({ onSave, onResetDayBoundaries, loadi
|
||||
onToggle={() => onSave?.('roadtrip_day_colors', !settings.roadtrip_day_colors)}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel icon={Signpost} title={t('roadtrip.hazards.current')} note={t('roadtrip.hazards.note')}>
|
||||
<AvoidRow icon={Signpost} label={t('roadtrip.hazards.show')} on={settings.roadtrip_show_hazards === true} disabled={!onSave}
|
||||
onToggle={() => onSave?.('roadtrip_show_hazards', !settings.roadtrip_show_hazards)} />
|
||||
</Panel>
|
||||
<Panel icon={Signpost} title={t('roadtrip.stops.section')} note={t('roadtrip.stops.daysHint')}>
|
||||
<AvoidRow icon={Link2} label={t('roadtrip.stops.inDays')}
|
||||
on={settings.roadtrip_service_stops_in_days !== false}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
|
||||
export default function SettingsHint({ children, id }: { children: string; id?: string }) {
|
||||
return <Tooltip label={children} placement="top">
|
||||
<p id={id} tabIndex={0} className="min-w-0 truncate text-caption leading-snug text-content-faint">{children}</p>
|
||||
</Tooltip>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { roadtripHazardsSchema } from '@trek/shared'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
export const roadtripHazardsRepo = {
|
||||
async read(tripId: number, signal: AbortSignal) {
|
||||
const fetched = await apiClient.get(`/trips/${tripId}/roadtrip/hazards`, { signal })
|
||||
return roadtripHazardsSchema.parse(fetched.data)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user