import { useCallback, useEffect, useMemo, useState } from "react"; import { Layers, Menu, PanelRightClose, PanelRightOpen } from "lucide-react"; import { useTranslation } from "react-i18next"; import { AppHeader } from "@/components/layout/AppHeader"; import { MapViewport } from "@/components/map/MapViewport"; import { ActivityPanel } from "@/components/panels/ActivityPanel"; import { HotspotStatsPanel } from "@/components/panels/HotspotStatsPanel"; import { OverviewPanel } from "@/components/panels/OverviewPanel"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Toaster } from "@/components/ui/toaster"; import { useToast } from "@/components/ui/use-toast"; import { useHotspotFeed } from "@/hooks/useHotspotFeed"; import { useLeafletHeatmap, type TileProvider } from "@/hooks/useLeafletHeatmap"; import { useUserLocation } from "@/hooks/useUserLocation"; import { cn, distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from "@/lib/utils"; import { useAppStore } from "@/store/useAppStore"; import type { Point } from "@/types/api"; const RADIUS_KM = 1; 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( () => new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2, }), [locale] ); const tileOptions = useMemo( () => [ { value: "openstreetmap" as TileProvider, label: t("map.tiles.openstreetmap") }, { value: "mapbox" as TileProvider, label: t("map.tiles.mapbox") }, ], [t] ); const userLocation = useAppStore(state => state.userLocation); const locationError = useAppStore(state => state.locationError); const isRequestingLocation = useAppStore(state => state.isRequestingLocation); const { refresh: refreshLocation } = useUserLocation(); const { status, error, submitPoint, fetchSnapshot, selectVisibleDensity, selectVisiblePoints, selectVisibleLatestByUser, myLatestPoint, lastUpdated, } = useHotspotFeed({ userLocation: userLocation ?? null }); const visibleDensity = useMemo( () => selectVisibleDensity(userLocation ?? null), [selectVisibleDensity, userLocation] ); const visiblePoints = useMemo(() => selectVisiblePoints(userLocation ?? null), [selectVisiblePoints, userLocation]); const visibleLatestByUser = useMemo( () => selectVisibleLatestByUser(userLocation ?? null), [selectVisibleLatestByUser, userLocation] ); const localTotals = useMemo(() => { const uniqueUsers = new Set(); visibleLatestByUser.forEach(point => uniqueUsers.add(point.userKey)); return { points: visiblePoints.length, contributors: uniqueUsers.size }; }, [visibleLatestByUser, visiblePoints]); const myVisibleSignal = useMemo(() => { if (!myLatestPoint) { return null; } if (userLocation && distanceInKm(userLocation, myLatestPoint.signalLocation) > RADIUS_KM) { return null; } return myLatestPoint; }, [myLatestPoint, userLocation]); const statusLabel = t(`status.${status}`); const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated, locale) : t("common.never"); const isLoading = status === "loading"; const isPosting = status === "posting"; const isRefreshing = status === "refreshing"; const hasLocation = Boolean(userLocation); const showLocationCta = !hasLocation || Boolean(locationError); const translatedLocationError = locationError ? t(locationError) : null; const locationHint = translatedLocationError ? translatedLocationError : hasLocation ? t("location.hint.showing", { radius: RADIUS_KM }) : isRequestingLocation ? t("location.hint.requesting") : t("location.hint.allow"); const { mapContainerRef, focusOn, fitToHeat } = useLeafletHeatmap({ heatCells: visibleDensity, userLocation: userLocation ?? null, onRequestSpot: position => { setPendingSpot(position); setIsConfirmOpen(true); }, tileProvider, }); const { toast } = useToast(); useEffect(() => { if (!error) { return; } const description = error.detail ?? t(error.key, error.values ?? {}); toast({ variant: "destructive", title: t("errors.title"), description, duration: 6000, }); }, [error, t, toast]); const handleConfirmSignal = useCallback(async () => { if (!pendingSpot) { return; } setIsConfirming(true); const result = await submitPoint(pendingSpot); setIsConfirming(false); if (result.success) { setIsConfirmOpen(false); setPendingSpot(null); } }, [pendingSpot, submitPoint]); const handleRefresh = useCallback(() => { fetchSnapshot().catch(() => undefined); }, [fetchSnapshot]); const handleFocusHeat = useCallback(() => { fitToHeat(); }, [fitToHeat]); const handleLocateUser = useCallback(() => { if (userLocation) { focusOn(userLocation, 14); } else { refreshLocation(); } }, [focusOn, refreshLocation, userLocation]); const handleFocusMySignal = useCallback(() => { if (myVisibleSignal) { focusOn({ lat: myVisibleSignal.signalLocation.lat, lng: myVisibleSignal.signalLocation.lng }, 15); } }, [focusOn, myVisibleSignal]); const handleManualReport = useCallback(() => { if (!userLocation) { return; } setPendingSpot(userLocation); setIsConfirmOpen(true); }, [userLocation]); const dangerCells = useMemo( () => [...visibleDensity] .sort((a, b) => b.intensity - a.intensity) .slice(0, 5) .map((cell, index) => { const coordinates = t("common.coordinates", { lat: formatCoordinate(cell.lat, locale), lng: formatCoordinate(cell.lng, locale), }); const distanceLabel = userLocation ? `${distanceFormatter.format(distanceInKm(userLocation, cell))} km` : null; return { id: `${cell.lat}-${cell.lng}-${index}`, title: t("hotspots.itemTitle", { index: index + 1 }), subtitle: distanceLabel !== null ? t("hotspots.itemSubtitleWithDistance", { distance: distanceLabel, coordinates }) : t("hotspots.itemSubtitle", { coordinates }), intensity: cell.intensity, onFocus: () => focusOn({ lat: cell.lat, lng: cell.lng }, 15), }; }), [visibleDensity, userLocation, focusOn, distanceFormatter, t, locale] ); const recentActivity = useMemo( () => [...visiblePoints] .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, 8) .map(point => { const coordinates = t("common.coordinates", { lat: formatCoordinate(point.signalLocation.lat, locale), lng: formatCoordinate(point.signalLocation.lng, locale), }); const distanceLabel = userLocation ? t("activityItem.distance", { distance: `${distanceFormatter.format(distanceInKm(userLocation, point.signalLocation))} km`, }) : formatTimestamp(point.createdAt, locale); return { id: point.id, title: coordinates, subtitle: t("activityItem.user", { id: point.userKey.slice(0, 4).toUpperCase() }), timestampLabel: formatRelativeTime(point.createdAt, locale), distanceLabel, onFocus: () => focusOn({ lat: point.signalLocation.lat, lng: point.signalLocation.lng }, 15), }; }), [visiblePoints, userLocation, focusOn, distanceFormatter, t, locale] ); 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 && (
)} { setIsConfirmOpen(nextOpen); if (!nextOpen) { setPendingSpot(null); setIsConfirming(false); } }} > {t("dialog.confirmSignal.title")} {t("dialog.confirmSignal.description")}
{t("dialog.confirmSignal.latitude")} {confirmationLat}°
{t("dialog.confirmSignal.longitude")} {confirmationLng}°

{t("dialog.confirmSignal.reach", { radius: RADIUS_KM })}

{t("dialog.confirmSignal.cancel")} {isConfirming ? t("dialog.confirmSignal.sending") : t("dialog.confirmSignal.confirm")}
); }