443 lines
17 KiB
TypeScript
443 lines
17 KiB
TypeScript
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<Point | null>(null);
|
|
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
|
const [isConfirming, setIsConfirming] = useState(false);
|
|
const [tileProvider, setTileProvider] = useState<TileProvider>("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<string>();
|
|
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 (
|
|
<>
|
|
<div className="relative min-h-screen w-full overflow-hidden bg-background text-foreground">
|
|
<MapViewport
|
|
containerRef={mapContainerRef}
|
|
isPosting={isPosting || isConfirming}
|
|
isLoading={isLoading}
|
|
confirmationHint={isConfirmOpen ? t("map.confirmationHint") : null}
|
|
className="min-h-screen"
|
|
/>
|
|
|
|
<div className="pointer-events-none absolute inset-0 flex flex-col">
|
|
<div className="flex items-start justify-between gap-3 p-4 sm:p-6">
|
|
<div className="pointer-events-auto">
|
|
<AppHeader
|
|
status={status}
|
|
statusLabel={statusLabel}
|
|
lastUpdatedLabel={lastUpdatedLabel}
|
|
onRefresh={handleRefresh}
|
|
onFocusHeat={handleFocusHeat}
|
|
onLocateUser={handleLocateUser}
|
|
onFocusMySignal={handleFocusMySignal}
|
|
disableRefresh={isLoading || isRefreshing || isPosting}
|
|
disableHeat={visibleDensity.length === 0}
|
|
disableLocate={!hasLocation}
|
|
disableMySignal={!myVisibleSignal}
|
|
collapsed={isHeaderCollapsed}
|
|
onToggleCollapse={() => setIsHeaderCollapsed(prev => !prev)}
|
|
/>
|
|
</div>
|
|
<div className="pointer-events-auto hidden sm:flex">
|
|
<Button
|
|
variant="secondary"
|
|
size="icon"
|
|
onClick={() => setIsDetailsOpen(prev => !prev)}
|
|
aria-label={detailsToggleLabel}
|
|
aria-expanded={isDetailsOpen}
|
|
className="h-11 w-11 rounded-full border border-border/60 bg-background/80 shadow-lg backdrop-blur hover:bg-background"
|
|
>
|
|
{isDetailsOpen ? <PanelRightClose className="h-4 w-4" /> : <PanelRightOpen className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{!isDetailsOpen && (
|
|
<div className="pointer-events-auto fixed bottom-4 right-4 z-30 sm:hidden">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setIsDetailsOpen(true)}
|
|
aria-label={detailsToggleLabel}
|
|
className="flex items-center gap-2 rounded-full border border-border/60 bg-background/85 px-4 py-2 text-xs font-semibold uppercase tracking-wide shadow-lg backdrop-blur"
|
|
>
|
|
<Menu className="h-4 w-4" />
|
|
<span>{t("details.open")}</span>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<aside
|
|
className={detailsPanelClassName}
|
|
role="complementary"
|
|
aria-label={t("details.title")}
|
|
aria-hidden={!isDetailsOpen}
|
|
>
|
|
<header className="flex items-center justify-between gap-2 border-b border-border/60 bg-background/80 px-4 py-3 backdrop-blur">
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-semibold">{t("details.title")}</span>
|
|
<span className="text-xs text-muted-foreground">{t("details.description")}</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setIsDetailsOpen(false)}
|
|
aria-label={t("details.close")}
|
|
className="h-9 w-9 rounded-full border border-border/60 bg-muted/60 backdrop-blur hover:bg-muted"
|
|
>
|
|
<PanelRightClose className="h-4 w-4" />
|
|
</Button>
|
|
</header>
|
|
<ScrollArea className="flex-1">
|
|
<div className="flex flex-col gap-4 p-4 pb-6">
|
|
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 shadow-sm">
|
|
<div className="flex items-start gap-3">
|
|
<span className="flex h-10 w-10 items-center justify-center rounded-2xl border border-border/60 bg-background/80 text-primary">
|
|
<Layers className="h-5 w-5" />
|
|
</span>
|
|
<div className="flex flex-1 flex-col">
|
|
<span className="text-sm font-semibold">{t("map.tiles.title")}</span>
|
|
<span className="text-xs text-muted-foreground">{t("map.tiles.subtitle")}</span>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{tileOptions.map(option => (
|
|
<Button
|
|
key={option.value}
|
|
type="button"
|
|
variant={tileProvider === option.value ? "default" : "outline"}
|
|
size="sm"
|
|
onClick={() => setTileProvider(option.value)}
|
|
className={cn(
|
|
"rounded-full border border-border/60 px-4 py-1 text-xs font-medium transition-colors",
|
|
tileProvider === option.value
|
|
? "shadow-sm"
|
|
: "bg-background/80 text-muted-foreground hover:bg-background"
|
|
)}
|
|
>
|
|
{option.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<OverviewPanel
|
|
nearbySignals={localTotals.points}
|
|
uniqueContributors={localTotals.contributors}
|
|
lastUpdatedLabel={lastUpdatedLabel}
|
|
mySignalLabel={myVisibleSignal ? formatRelativeTime(myVisibleSignal.createdAt, locale) : null}
|
|
error={error}
|
|
onReport={handleManualReport}
|
|
onRetry={handleRefresh}
|
|
isPosting={isPosting || isConfirming}
|
|
locationHint={locationHint}
|
|
showLocationCta={showLocationCta}
|
|
disableReport={!hasLocation}
|
|
/>
|
|
<HotspotStatsPanel
|
|
hasLocation={hasLocation}
|
|
radiusKm={RADIUS_KM}
|
|
locationHint={locationHint}
|
|
cells={dangerCells}
|
|
/>
|
|
<ActivityPanel items={recentActivity} emptyMessage={t("activity.empty")} />
|
|
</div>
|
|
</ScrollArea>
|
|
</aside>
|
|
|
|
<AlertDialog
|
|
open={isConfirmOpen}
|
|
onOpenChange={nextOpen => {
|
|
setIsConfirmOpen(nextOpen);
|
|
if (!nextOpen) {
|
|
setPendingSpot(null);
|
|
setIsConfirming(false);
|
|
}
|
|
}}
|
|
>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t("dialog.confirmSignal.title")}</AlertDialogTitle>
|
|
<AlertDialogDescription>{t("dialog.confirmSignal.description")}</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<div className="rounded-2xl border border-border/60 bg-muted/40 p-4 text-sm">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-muted-foreground">{t("dialog.confirmSignal.latitude")}</span>
|
|
<span className="font-medium text-foreground">{confirmationLat}°</span>
|
|
</div>
|
|
<div className="mt-3 flex items-center justify-between">
|
|
<span className="text-muted-foreground">{t("dialog.confirmSignal.longitude")}</span>
|
|
<span className="font-medium text-foreground">{confirmationLng}°</span>
|
|
</div>
|
|
<p className="mt-4 text-xs text-muted-foreground">
|
|
{t("dialog.confirmSignal.reach", { radius: RADIUS_KM })}
|
|
</p>
|
|
</div>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel disabled={isConfirming}>{t("dialog.confirmSignal.cancel")}</AlertDialogCancel>
|
|
<AlertDialogAction onClick={handleConfirmSignal} disabled={isDialogDisabled}>
|
|
{isConfirming ? t("dialog.confirmSignal.sending") : t("dialog.confirmSignal.confirm")}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
<Toaster />
|
|
</>
|
|
);
|
|
}
|