diff --git a/client/src/App.tsx b/client/src/App.tsx index d47e072..4f006f2 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState } from 'react' +import { Layers, Menu, PanelRightClose, PanelRightOpen } from 'lucide-react' import { useTranslation } from 'react-i18next' import { AppHeader } from '@/components/layout/AppHeader' @@ -16,10 +17,12 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { ScrollArea } from '@/components/ui/scroll-area' import { useHotspotFeed } from '@/hooks/useHotspotFeed' -import { useLeafletHeatmap } from '@/hooks/useLeafletHeatmap' +import { useLeafletHeatmap, type TileProvider } from '@/hooks/useLeafletHeatmap' import { useUserLocation } from '@/hooks/useUserLocation' -import { distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils' +import { cn, distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from '@/lib/utils' import type { LatLng } from '@/types/api' const RADIUS_KM = 1 @@ -28,6 +31,19 @@ export default function App() { const [pendingSpot, setPendingSpot] = useState(null) const [isConfirmOpen, setIsConfirmOpen] = useState(false) const [isConfirming, setIsConfirming] = useState(false) + const [tileProvider, setTileProvider] = useState('openstreetmap') + const [isDetailsOpen, setIsDetailsOpen] = useState(() => { + if (typeof window === 'undefined') { + return false + } + return window.innerWidth >= 1024 + }) + const [isHeaderCollapsed, setIsHeaderCollapsed] = useState(() => { + if (typeof window === 'undefined') { + return false + } + return window.innerWidth < 768 + }) const { t, i18n } = useTranslation() const locale = i18n.language === 'fr' ? 'fr-FR' : 'en-US' const distanceFormatter = useMemo( @@ -39,6 +55,14 @@ export default function App() { [locale], ) + const tileOptions = useMemo( + () => [ + { value: 'openstreetmap' as TileProvider, label: t('map.tiles.openstreetmap') }, + { value: 'mapbox' as TileProvider, label: t('map.tiles.mapbox') }, + ], + [t], + ) + const { status, error, @@ -108,6 +132,7 @@ export default function App() { setPendingSpot(position) setIsConfirmOpen(true) }, + tileProvider, }) const handleConfirmSignal = useCallback(async () => { @@ -207,26 +232,127 @@ export default function App() { const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : '--' const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : '--' const isDialogDisabled = !pendingSpot || isConfirming + const detailsToggleLabel = isDetailsOpen ? t('details.close') : t('details.open') + const detailsPanelClassName = cn( + 'pointer-events-auto fixed inset-x-0 bottom-0 z-40 flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-3xl border border-border/70 bg-background/95 shadow-2xl backdrop-blur transition-transform duration-300 sm:left-auto sm:right-6 sm:top-24 sm:bottom-6 sm:max-h-[calc(100vh-8rem)] sm:w-[min(380px,calc(100vw-4rem))] sm:rounded-3xl', + isDetailsOpen + ? 'translate-y-0 sm:translate-x-0' + : 'translate-y-[calc(100%+1rem)] sm:translate-x-[calc(100%+2rem)]', + ) return ( -
- + -
-
-
+
+
+
+ setIsHeaderCollapsed((prev) => !prev)} + /> +
+
+ +
+
+
+ + {!isDetailsOpen && ( +
+ +
+ )} + +
-
+ + void + className?: string } export function AppHeader({ @@ -33,42 +37,69 @@ export function AppHeader({ disableHeat, disableLocate, disableMySignal, + collapsed, + onToggleCollapse, + className, }: AppHeaderProps) { const { t } = useTranslation() const isError = status === 'error' + const statusBadge = ( + + + + + + {statusLabel} + + {!collapsed && ( + + {t('header.badge.updated', { time: lastUpdatedLabel })} + + )} + + ) + return ( -
-
+
+
- {t('app.name')} - {t('app.tagline')} + {t('app.name')} + {!collapsed && {t('app.tagline')}}
-
- - - - - - {statusLabel} - - - {t('header.badge.updated', { time: lastUpdatedLabel })} - - + +
+
{statusBadge}
+ {!collapsed && ( +
-
+ )}
) } diff --git a/client/src/components/map/MapViewport.tsx b/client/src/components/map/MapViewport.tsx index bc65f4d..83b1e79 100644 --- a/client/src/components/map/MapViewport.tsx +++ b/client/src/components/map/MapViewport.tsx @@ -8,13 +8,25 @@ interface MapViewportProps { isPosting: boolean isLoading: boolean confirmationHint?: string | null + className?: string } -export function MapViewport({ containerRef, isPosting, isLoading, confirmationHint }: MapViewportProps) { +export function MapViewport({ + containerRef, + isPosting, + isLoading, + confirmationHint, + className, +}: MapViewportProps) { const { t } = useTranslation() return ( -
+
diff --git a/client/src/hooks/useLeafletHeatmap.ts b/client/src/hooks/useLeafletHeatmap.ts index f8de559..6daed31 100644 --- a/client/src/hooks/useLeafletHeatmap.ts +++ b/client/src/hooks/useLeafletHeatmap.ts @@ -1,6 +1,12 @@ import { useCallback, useEffect, useRef } from 'react' import type { MutableRefObject } from 'react' -import L, { type LeafletMouseEvent, type Map as LeafletMap, type LayerGroup } from 'leaflet' +import L, { + type LeafletMouseEvent, + type Map as LeafletMap, + type LayerGroup, + type TileLayer, + type TileLayerOptions, +} from 'leaflet' import 'leaflet.heat' import type { ApiDensityCell, LatLng } from '@/types/api' @@ -20,6 +26,7 @@ interface UseLeafletHeatmapParams { heatCells: ApiDensityCell[] userLocation: LatLng | null onRequestSpot?: (position: LatLng) => void + tileProvider: TileProvider } interface UseLeafletHeatmapResult { @@ -29,15 +36,54 @@ interface UseLeafletHeatmapResult { map: LeafletMap | null } +const TILE_SOURCES = { + openstreetmap: { + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap contributors', + }, + mapbox: { + url: 'https://api.mapbox.com/styles/v1/mapbox/navigation-day-v1/tiles/{z}/{x}/{y}?access_token=pk.eyJ1IjoiMWNhbnNhIiwiYSI6ImNsdzZ5cHp3bTFheWUydHJ6dHA4empteWEifQ.a3bODguIOY5HqhsVIvW48Q', + attribution: '© Mapbox, © OpenStreetMap contributors', + options: { + tileSize: 512, + zoomOffset: -1, + } satisfies TileLayerOptions, + }, +} as const + +export type TileProvider = keyof typeof TILE_SOURCES + const INITIAL_VIEW: LatLng = { lat: 20, lng: 0 } const DEFAULT_ZOOM = 3 -export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: UseLeafletHeatmapParams): UseLeafletHeatmapResult { +export function useLeafletHeatmap({ + heatCells, + userLocation, + onRequestSpot, + tileProvider, +}: UseLeafletHeatmapParams): UseLeafletHeatmapResult { const mapRef = useRef(null) const heatLayerRef = useRef(null) const userLayerRef = useRef(null) + const tileLayerRef = useRef(null) const hasCenteredOnUserRef = useRef(false) const mapContainerRef = useRef(null) + const onRequestSpotRef = useRef(onRequestSpot) + const tileProviderRef = useRef(tileProvider) + + const createTileLayer = useCallback( + (provider: TileProvider) => { + const leaflet = L as LeafletWithHeat + const source = TILE_SOURCES[provider] + return leaflet.tileLayer(source.url, { + attribution: source.attribution, + crossOrigin: true, + maxZoom: 19, + ...(source.options ?? {}), + }) + }, + [], + ) const initialiseMap = useCallback(() => { if (mapRef.current || !mapContainerRef.current) { @@ -56,13 +102,8 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us }) .setView([INITIAL_VIEW.lat, INITIAL_VIEW.lng], DEFAULT_ZOOM) - leaflet - .tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - crossOrigin: true, - maxZoom: 19, - }) - .addTo(map) + const tileLayer = createTileLayer(tileProviderRef.current) + tileLayer.addTo(map) const heatLayer = typeof leaflet.heatLayer === 'function' @@ -89,7 +130,7 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us const handleClick = (event: LeafletMouseEvent) => { const { lat, lng } = event.latlng if (typeof lat === 'number' && typeof lng === 'number') { - onRequestSpot?.({ lat, lng }) + onRequestSpotRef.current?.({ lat, lng }) } } @@ -112,6 +153,7 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us mapRef.current = map heatLayerRef.current = heatLayer userLayerRef.current = userLayer + tileLayerRef.current = tileLayer return () => { map.off('click', handleClick) @@ -120,8 +162,13 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us mapRef.current = null heatLayerRef.current = null userLayerRef.current = null + tileLayerRef.current = null hasCenteredOnUserRef.current = false } + }, [createTileLayer]) + + useEffect(() => { + onRequestSpotRef.current = onRequestSpot }, [onRequestSpot]) useEffect(() => { @@ -134,6 +181,24 @@ export function useLeafletHeatmap({ heatCells, userLocation, onRequestSpot }: Us } }, [initialiseMap]) + useEffect(() => { + tileProviderRef.current = tileProvider + const map = mapRef.current + if (!map) { + return + } + + const nextLayer = createTileLayer(tileProvider) + const currentLayer = tileLayerRef.current + + if (currentLayer) { + currentLayer.removeFrom(map) + } + + nextLayer.addTo(map) + tileLayerRef.current = nextLayer + }, [createTileLayer, tileProvider]) + useEffect(() => { const heatLayer = heatLayerRef.current if (!heatLayer) { diff --git a/client/src/locales/en/common.json b/client/src/locales/en/common.json index 535f815..d9a55fb 100644 --- a/client/src/locales/en/common.json +++ b/client/src/locales/en/common.json @@ -18,7 +18,9 @@ "refresh": "Refresh now", "focusHeat": "Focus heatmap", "locate": "Locate me", - "mySignal": "My last signal" + "mySignal": "My last signal", + "collapse": "Hide toolbar", + "expand": "Show toolbar" } }, "overview": { @@ -59,7 +61,19 @@ "map": { "posting": "Sending your signal…", "loading": "Syncing map…", - "confirmationHint": "Confirm the new signal in the dialog to send it." + "confirmationHint": "Confirm the new signal in the dialog to send it.", + "tiles": { + "title": "Base map", + "subtitle": "Choose the background layer you prefer for navigation.", + "openstreetmap": "OpenStreetMap", + "mapbox": "Mapbox Navigation" + } + }, + "details": { + "title": "Live feed details", + "description": "View stats, switch tiles and review nearby activity.", + "open": "Details", + "close": "Close details" }, "dialog": { "confirmSignal": { diff --git a/client/src/locales/fr/common.json b/client/src/locales/fr/common.json index 9435c7a..e8d4978 100644 --- a/client/src/locales/fr/common.json +++ b/client/src/locales/fr/common.json @@ -18,7 +18,9 @@ "refresh": "Actualiser", "focusHeat": "Centrer la chaleur", "locate": "Me localiser", - "mySignal": "Mon dernier signal" + "mySignal": "Mon dernier signal", + "collapse": "Masquer la barre", + "expand": "Afficher la barre" } }, "overview": { @@ -59,7 +61,19 @@ "map": { "posting": "Envoi de votre signal…", "loading": "Synchronisation de la carte…", - "confirmationHint": "Confirmez le nouveau signal dans la fenêtre pour l'envoyer." + "confirmationHint": "Confirmez le nouveau signal dans la fenêtre pour l'envoyer.", + "tiles": { + "title": "Fond de carte", + "subtitle": "Choisissez la couche de fond adaptée à votre navigation.", + "openstreetmap": "OpenStreetMap", + "mapbox": "Navigation Mapbox" + } + }, + "details": { + "title": "Détails du flux en direct", + "description": "Consultez les statistiques, changez de fond de carte et suivez l'activité proche.", + "open": "Détails", + "close": "Fermer les détails" }, "dialog": { "confirmSignal": {