diff --git a/src/app/(default)/components/SharePageURLButton.tsx b/src/app/(default)/components/SharePageURLButton.tsx index dc02c1b42..43500a25b 100644 --- a/src/app/(default)/components/SharePageURLButton.tsx +++ b/src/app/(default)/components/SharePageURLButton.tsx @@ -10,7 +10,9 @@ import { ControlledTooltip } from '@/components/ui/Tooltip' */ export const SharePageURLButton: React.FC<{ path: string, name: string }> = ({ path, name }) => { const slug = getFriendlySlug(name) - const url = `https://openbeta.io${path}/${slug}` + const baseUrl = process.env.NEXT_PUBLIC_BASE_URL != null ? process.env.NEXT_PUBLIC_BASE_URL : 'http://localhost:3000' + const optionalSlug = slug !== '' ? `/${slug}` : '' + const url = `${baseUrl}${path}${optionalSlug}` const [clicked, setClicked] = useState(false) diff --git a/src/app/(default)/layout.tsx b/src/app/(default)/layout.tsx index bcfa7ed93..da8e33600 100644 --- a/src/app/(default)/layout.tsx +++ b/src/app/(default)/layout.tsx @@ -6,7 +6,7 @@ import '../global.css' import Header from './header' import { PageFooter } from './components/PageFooter' import { NextAuthProvider } from '@/components/auth/NextAuthProvider' -import { ReactToastifyProvider } from './components/ReactToastifyProvider' +import { ReactToastifyProvider } from '@/components/toast/ReactToastifyProvider' import { BlockingAlertUploadingInProgress } from './components/ui/GlobalAlerts' export const metadata: Metadata = { diff --git a/src/app/(maps)/components/FullScreenMap.tsx b/src/app/(maps)/components/FullScreenMap.tsx index 12407e899..c2b37dba6 100644 --- a/src/app/(maps)/components/FullScreenMap.tsx +++ b/src/app/(maps)/components/FullScreenMap.tsx @@ -1,45 +1,114 @@ 'use client' import { useCallback, useEffect, useState } from 'react' import { CameraInfo, GlobalMap } from '@/components/maps/GlobalMap' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useRouter } from 'next/navigation' +import { MapLayerMouseEvent } from 'maplibre-gl' +import { useUrlParams } from '@/js/hooks/useUrlParams' export const FullScreenMap: React.FC = () => { - const [initialCenter, setInitialCenter] = useState<[number, number] | undefined>(undefined) - const [initialZoom, setInitialZoom] = useState(undefined) - const router = useRouter() + const [center, setCenter] = useState<[number, number] | undefined>(undefined) + const [zoom, setZoom] = useState(undefined) + const [areaId, setAreaId] = useState(undefined) + const [isInitialized, setIsInitialized] = useState(false) + const DEFAULT_CENTER: [number, number] = [0, 0] + const DEFAULT_ZOOM = 2 - const cameraParams = useCameraParams() + const router = useRouter() + const urlParams = useUrlParams() + // Handle initial state setup only once useEffect(() => { - const initialStateFromUrl = cameraParams.fromUrl() + if (isInitialized) return - if (initialStateFromUrl != null) { - setInitialCenter([initialStateFromUrl.center.lng, initialStateFromUrl.center.lat]) - setInitialZoom(initialStateFromUrl.zoom) - return + const { camera, areaId: urlAreaId } = urlParams.fromUrl() + + if (urlAreaId != null) { + setAreaId(urlAreaId) } - getVisitorLocation().then((visitorLocation) => { - if (visitorLocation != null) { - setInitialCenter([visitorLocation.longitude, visitorLocation.latitude]) - } - }).catch(() => { - console.log('Unable to determine user\'s location') - }) - }, []) + // If camera params exist in URL, use them + if (camera != null) { + setCenter([camera.center.lng, camera.center.lat]) + setZoom(camera.zoom) + setIsInitialized(true) + return + } - const handleCamerMovement = useCallback((camera: CameraInfo) => { - const url = cameraParams.toUrl(camera) + // If no camera params, get visitor location and set URL + setZoom(DEFAULT_ZOOM) + getVisitorLocation() + .then((visitorLocation) => { + const newCenter: [number, number] = (visitorLocation != null) + ? [visitorLocation.longitude, visitorLocation.latitude] + : DEFAULT_CENTER + + setCenter(newCenter) + + // Always update URL with camera position + const newCamera: CameraInfo = { + center: { + lng: newCenter[0], + lat: newCenter[1] + }, + zoom: DEFAULT_ZOOM + } + + const url = urlParams.toUrl({ + camera: newCamera, + areaId: urlAreaId + }) + router.replace(url, { scroll: false }) + }) + .catch(() => { + console.log('Unable to determine user\'s location') + setCenter(DEFAULT_CENTER) + + // Set URL with default camera position on error + const defaultCamera: CameraInfo = { + center: { + lng: DEFAULT_CENTER[0], + lat: DEFAULT_CENTER[1] + }, + zoom: DEFAULT_ZOOM + } + + const url = urlParams.toUrl({ + camera: defaultCamera, + areaId: urlAreaId + }) + router.replace(url, { scroll: false }) + }) + .finally(() => { + setIsInitialized(true) + }) + }, [urlParams, isInitialized, router]) + + const handleCameraMovement = useCallback( + (camera: CameraInfo) => { + const { areaId } = urlParams.fromUrl() + const url = urlParams.toUrl({ camera, areaId }) + router.replace(url, { scroll: false }) + }, + [urlParams, router] + ) - router.replace(url, { scroll: false }) - }, []) + const handleMapClick = useCallback( + (e: MapLayerMouseEvent) => { + const areaId = e.features?.[0]?.properties?.id ?? null + const { camera } = urlParams.fromUrl() + const url = urlParams.toUrl({ camera: camera ?? null, areaId }) + router.replace(url, { scroll: false }) + }, [urlParams, router] + ) return ( ) } @@ -53,51 +122,3 @@ const getVisitorLocation = async (): Promise<{ longitude: number, latitude: numb return undefined } } - -function useCameraParams (): { toUrl: (camera: CameraInfo) => string, fromUrl: () => CameraInfo | null } { - const pathname = usePathname() - const initialSearchParams = useSearchParams() - - function toUrl (camera: CameraInfo): string { - const params = new URLSearchParams(initialSearchParams) - params.delete('camera') - - const queryParams = [ - params.toString(), - `camera=${cameraInfoToQuery(camera)}` - ] - - return `${pathname}?${queryParams.filter(Boolean).join('&')}` - } - - function fromUrl (): CameraInfo | null { - const cameraParams = initialSearchParams.get('camera') - if (cameraParams == null) { - return null - } - - return queryToCameraInfo(cameraParams) - } - - return { toUrl, fromUrl } -} - -const cameraInfoToQuery = ({ zoom, center }: CameraInfo): string => { - return `${Math.ceil(zoom)}/${center.lat.toFixed(5)}/${center.lng.toFixed(5)}` -} - -const queryToCameraInfo = (cameraParam: string): CameraInfo | null => { - const [zoomRaw, latitude, longitude] = cameraParam.split('/') - const lat = parseFloat(latitude) - const lng = parseFloat(longitude) - const zoom = parseInt(zoomRaw, 10) - - if ([lat, lng, zoom].some(isNaN)) { - return null - } - - return { - center: { lat, lng }, - zoom - } -} diff --git a/src/components/maps/GlobalMap.tsx b/src/components/maps/GlobalMap.tsx index 2076aaa69..0bacd3cc9 100644 --- a/src/components/maps/GlobalMap.tsx +++ b/src/components/maps/GlobalMap.tsx @@ -1,9 +1,8 @@ 'use client' -import { useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Map, FullscreenControl, ScaleControl, NavigationControl, MapLayerMouseEvent, ViewStateChangeEvent, GeolocateControl } from 'react-map-gl/maplibre' import maplibregl, { MapLibreEvent } from 'maplibre-gl' import dynamic from 'next/dynamic' - import { MAP_STYLES, type MapStyles } from './MapSelector' import { Drawer } from './TileHandlers/Drawer' import { HoverCard } from './TileHandlers/HoverCard' @@ -11,9 +10,10 @@ import { OBCustomLayers } from './OBCustomLayers' import { tileToFeature } from './utils' import { ActiveFeature, TileProps } from './TileTypes' import MapLayersSelector from './MapLayersSelector' -import { debounce } from 'underscore' import { MapToolbar } from './MapToolbar' import { SelectedFeature } from './AreaActiveMarker' +import { useRouter } from 'next/navigation' +import { useUrlParams } from '@/js/hooks/useUrlParams' export interface CameraInfo { center: { @@ -27,12 +27,14 @@ interface FeatureState { selected?: boolean hover?: boolean } + export interface DataLayersDisplayState { areaBoundaries: boolean organizations: boolean heatmap: boolean crags: boolean } + interface GlobalMapProps { showFullscreenControl?: boolean initialCenter?: [number, number] @@ -43,25 +45,30 @@ interface GlobalMapProps { } onCameraMovement?: (camera: CameraInfo) => void children?: React.ReactNode + handleOnClick?: (e: MapLayerMouseEvent) => void + initialAreaId?: string } /** * Global map */ export const GlobalMap: React.FC = ({ - showFullscreenControl = true, initialCenter, initialZoom, initialViewState, onCameraMovement, children + showFullscreenControl = true, initialCenter, initialZoom, initialViewState, onCameraMovement, children, handleOnClick, initialAreaId }) => { const [clickInfo, setClickInfo] = useState(null) const [hoverInfo, setHoverInfo] = useState(null) const [mapInstance, setMapInstance] = useState(null) const [cursor, setCursor] = useState('default') const [mapStyle, setMapStyle] = useState(MAP_STYLES.light.style) + const [isSourceLoaded, setIsSourceLoaded] = useState(false) const [dataLayersDisplayState, setDataLayersDisplayState] = useState({ areaBoundaries: false, organizations: false, heatmap: false, crags: true }) + const router = useRouter() + const urlParams = useUrlParams() const setActiveFeatureVisual = (feature: ActiveFeature | null, fState: FeatureState): void => { if (feature == null || mapInstance == null) return @@ -72,39 +79,40 @@ export const GlobalMap: React.FC = ({ }, fState) } - const onMove = useCallback(debounce((e: ViewStateChangeEvent) => { - if (onCameraMovement != null) { - onCameraMovement({ - center: { - lat: e.viewState.latitude, - lng: e.viewState.longitude - }, - zoom: e.viewState.zoom - }) - } - }, 300), []) + const onMoveEnd = useCallback((e: ViewStateChangeEvent) => { + if ((mapInstance == null) || e.viewState == null || (onCameraMovement === undefined)) return + onCameraMovement({ + center: { + lat: e.viewState.latitude, + lng: e.viewState.longitude + }, + zoom: e.viewState.zoom + }) + }, [mapInstance, onCameraMovement]) const onLoad = useCallback((e: MapLibreEvent) => { if (e.target == null) return setMapInstance(e.target) - if (initialCenter != null) { + + // Only apply jumpTo if initial values are defined + if (initialCenter != null && initialZoom != null) { e.target.jumpTo({ center: initialCenter, zoom: initialZoom ?? 6 }) } else if (initialViewState != null) { e.target.fitBounds(initialViewState.bounds, initialViewState.fitBoundsOptions) } - }, [initialCenter, initialZoom]) + }, [initialCenter, initialZoom, initialViewState]) /** - * Handle click event on the map. Place a market on the map and activate the side drawer. + * Handle click event on the map. Place a marker on the map and activate the side drawer. */ const onClick = (event: MapLayerMouseEvent): void => { if (mapInstance == null) return const feature = event?.features?.[0] - if (feature == null) { + handleOnClick?.(event) + if (feature === undefined) { setClickInfo(null) } else { const { layer, geometry, properties } = feature - setClickInfo(prev => { setActiveFeatureVisual(prev, { selected: false, hover: false }) const activeFeature = tileToFeature(layer.id, event.point, geometry, properties as TileProps, mapInstance) @@ -115,9 +123,17 @@ export const GlobalMap: React.FC = ({ } /** - * Handle click event on the popover. Behave as if the user clicked on a feature on the map. + * Handle click event on the popover. Behave as if the user clicked on a feature on the map. */ const onHoverCardClick = (feature: ActiveFeature): void => { + const areaId = feature.data?.id + if (areaId === '') { + return + } + + const { camera } = urlParams.fromUrl() + const url = urlParams.toUrl({ camera: camera ?? null, areaId }) + router.replace(url, { scroll: false }) setClickInfo(prevFeature => { setHoverInfo(null) setActiveFeatureVisual(prevFeature, { selected: false, hover: false }) @@ -129,10 +145,15 @@ export const GlobalMap: React.FC = ({ } /** - * Handle mouseover event on the map. Show the popover with the area info. + * Handle mouseover event on the map. Show the popover with the area info. */ const onHover = (event: MapLayerMouseEvent): void => { - const obLayerId = event.features?.findIndex((f) => f.layer.id === 'crag-markers' || f.layer.id === 'crag-name-labels' || f.layer.id === 'area-boundaries' || f.layer.id === 'area-background') ?? -1 + const obLayerId = event.features?.findIndex((f) => + f.layer.id === 'crag-markers' || + f.layer.id === 'crag-name-labels' || + f.layer.id === 'area-boundaries' || + f.layer.id === 'area-background' + ) ?? -1 if (obLayerId !== -1) { setCursor('pointer') @@ -158,18 +179,47 @@ export const GlobalMap: React.FC = ({ setMapStyle(style.style) } + const findAreaById = useCallback((map: maplibregl.Map, areaId: string) => { + const features = map.querySourceFeatures('crags', { + sourceLayer: 'crags', + filter: ['==', ['get', 'id'], areaId] + }) + return features[0] // return first feature because it could be duplicated by the tileset + }, []) + + useEffect(() => { + if (mapInstance == null) return + + if (!isSourceLoaded) { + mapInstance.on('sourcedata', (e) => { + if (e.sourceId === 'crags' && e.isSourceLoaded) { + setIsSourceLoaded(true) + } + }) + } + + if (isSourceLoaded && initialAreaId !== undefined) { + const feature = findAreaById(mapInstance, initialAreaId) + if (feature != null) { + setClickInfo(prev => { + setActiveFeatureVisual(prev, { selected: false, hover: false }) + + const activeFeature = tileToFeature('crag-name-labels', { x: 0, y: 0 }, feature.geometry, feature.properties as TileProps, mapInstance) + setActiveFeatureVisual(activeFeature, { selected: true, hover: false }) + return activeFeature + }) + } + } + }, [mapInstance, isSourceLoaded, initialAreaId, findAreaById]) + return (
{ - setCursor('move') - }} - onMove={onMove} - onDragEnd={() => { - setCursor('default') - }} + onDragStart={() => setCursor('move')} + onMoveEnd={onMoveEnd} + onDragEnd={() => setCursor('default')} onMouseEnter={onHover} onMouseLeave={() => { setHoverInfo(prev => { @@ -188,25 +238,19 @@ export const GlobalMap: React.FC = ({ - {showFullscreenControl && } - {clickInfo != null && - } + {clickInfo != null && } {hoverInfo != null && ( - )} + + )} {children}
diff --git a/src/components/maps/TileHandlers/CragContent.tsx b/src/components/maps/TileHandlers/CragContent.tsx index 99cf012bf..4363d6323 100644 --- a/src/components/maps/TileHandlers/CragContent.tsx +++ b/src/components/maps/TileHandlers/CragContent.tsx @@ -4,10 +4,14 @@ import { getAreaPageFriendlyUrl } from '@/js/utils' import { EntityIcon } from '@/app/(default)/editArea/[slug]/general/components/AreaItem' import { BaseDrawerContent } from './Drawer' import { MiniCarousel } from '../CardGallery' +import { SharePageURLButton } from '@/app/(default)/components/SharePageURLButton' +import { usePathname } from 'next/navigation' export const CragDrawerContent: React.FC = ({ id, areaName, climbs, content: { description }, media }) => { const friendlyUrl = getAreaPageFriendlyUrl(id, areaName) const editUrl = `/editArea/${id}/general` + const pathname = `${usePathname()}${window.location.search}` + return ( <> = ({ id, areaNam heading={{areaName}} subheading={} cta={Edit area} + share={} >
{description == null || description.trim() === '' diff --git a/src/components/maps/TileHandlers/Drawer.tsx b/src/components/maps/TileHandlers/Drawer.tsx index fe23843af..a4e38ad24 100644 --- a/src/components/maps/TileHandlers/Drawer.tsx +++ b/src/components/maps/TileHandlers/Drawer.tsx @@ -33,7 +33,7 @@ export const Drawer: React.FC<{ feature: ActiveFeature | null, onClose?: () => v ) } -export const BaseDrawerContent: React.FC<{ media: ReactNode, heading: ReactNode, subheading: ReactNode, cta: ReactNode, children: ReactNode }> = ({ media, heading, subheading, cta, children }) => { +export const BaseDrawerContent: React.FC<{ media: ReactNode, heading: ReactNode, subheading: ReactNode, cta: ReactNode, children: ReactNode, share?: ReactNode }> = ({ media, heading, subheading, cta, children, share }) => { return (
@@ -46,8 +46,10 @@ export const BaseDrawerContent: React.FC<{ media: ReactNode, heading: ReactNode,
{subheading}
- - {cta} +
+ {cta} + {share} +

diff --git a/src/app/(default)/components/ReactToastifyProvider.tsx b/src/components/toast/ReactToastifyProvider.tsx similarity index 100% rename from src/app/(default)/components/ReactToastifyProvider.tsx rename to src/components/toast/ReactToastifyProvider.tsx diff --git a/src/js/hooks/useUrlParams.tsx b/src/js/hooks/useUrlParams.tsx new file mode 100644 index 000000000..a18437801 --- /dev/null +++ b/src/js/hooks/useUrlParams.tsx @@ -0,0 +1,64 @@ +import { CameraInfo } from '@/components/maps/GlobalMap' +import { usePathname, useSearchParams } from 'next/navigation' + +interface UrlProps { camera: CameraInfo | null, areaId: string | null } + +interface UseUrlParamsReturn { + toUrl: (props: UrlProps) => string + fromUrl: () => UrlProps +} + +const useUrlParams = (): UseUrlParamsReturn => { + const pathname = usePathname() + const searchParams = useSearchParams() + + const toUrl = ({ camera, areaId }: UrlProps): string => { + const params = new URLSearchParams() + + if (areaId != null) { + params.set('areaId', areaId) + } + + const baseUrl = `${pathname}?` + const cameraParam = camera != null ? `camera=${cameraInfoToQuery(camera)}` : '' + const otherParams = params.toString() + + const query = [cameraParam, otherParams] + .filter(param => param !== '') // Remove empty params + .join('&') // Join non-empty params with `&` + + return query !== '' ? `${baseUrl}${query}` : pathname // Return base URL if query is empty + } + + const fromUrl = (): UrlProps => { + const cameraParam = searchParams.get('camera') + return { + camera: cameraParam != null ? queryToCameraInfo(cameraParam) : null, + areaId: searchParams.get('areaId') + } + } + + return { toUrl, fromUrl } +} + +const cameraInfoToQuery = ({ zoom, center }: CameraInfo): string => { + return `${Math.ceil(zoom)}/${center.lat.toFixed(5)}/${center.lng.toFixed(5)}` +} + +const queryToCameraInfo = (cameraParam: string): CameraInfo | null => { + const [zoomRaw, latitude, longitude] = cameraParam.split('/') + const lat = parseFloat(latitude) + const lng = parseFloat(longitude) + const zoom = parseInt(zoomRaw, 10) + + if ([lat, lng, zoom].some(isNaN)) { + return null + } + + return { + center: { lat, lng }, + zoom + } +} + +export { useUrlParams }