[frontend] restructure components

This commit is contained in:
2025-10-12 05:06:13 +02:00
parent 01b84042ab
commit 9b20b08ab2
24 changed files with 830 additions and 248 deletions
+74 -212
View File
@@ -1,63 +1,29 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { AppHeader } from "@/components/layout/app-header";
import { ConfirmSignalDialog } from "@/components/app/confirm-signal-dialog";
import { HeaderOverlay } from "@/components/app/header-overlay";
import { SidebarPanels } from "@/components/app/sidebar-panels";
import { MapViewport } from "@/components/map/map-viewport";
import { ActivityPanel } from "@/components/panels/activity-panel";
import { HotspotStatsPanel } from "@/components/panels/hotspot-stats-panel";
import { OverviewPanel } from "@/components/panels/overview-panel";
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 {
SidebarProvider,
Sidebar,
SidebarHeader,
SidebarContent,
SidebarTrigger,
SidebarInset,
} from "@/components/ui/sidebar";
// Alert dialog moved into a dedicated component
import { SidebarProvider, Sidebar, SidebarContent, SidebarInset } from "@/components/ui/sidebar";
import { Toaster } from "@/components/ui/sonner";
import { useFeedDerivations } from "@/hooks/use-feed-derivations";
import { useHotspotFeed } from "@/hooks/use-hotsop-feed";
import { useLeafletHeatmap, type TileProvider } from "@/hooks/use-leaflet-heatmap";
import { useLeafletHeatmap } from "@/hooks/use-leaflet-heatmap";
import { useUserLocation } from "@/hooks/use-user-location";
import { distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from "@/lib/utils";
import { formatCoordinate, formatRelativeTime } from "@/lib/utils";
import { useAppStore } from "@/store/use-app-store";
import type { Point } from "@/types/api";
import { MapTilesPanel } from "./components/panels/map-tiles-panel";
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";
@@ -73,6 +39,14 @@ export default function App() {
const userLocation = useAppStore(state => state.userLocation);
const locationError = useAppStore(state => state.locationError);
const isRequestingLocation = useAppStore(state => state.isRequestingLocation);
const isDetailsOpen = useAppStore(state => state.isSidebarOpen);
const setIsDetailsOpen = useAppStore(state => state.setIsSidebarOpen);
const isHeaderCollapsed = useAppStore(state => state.isHeaderCollapsed);
const setIsHeaderCollapsed = useAppStore(state => state.setIsHeaderCollapsed);
const isMobileHeaderOpen = useAppStore(state => state.isMobileHeaderOpen);
const setIsMobileHeaderOpen = useAppStore(state => state.setIsMobileHeaderOpen);
const tileProvider = useAppStore(state => state.tileProvider);
const setTileProvider = useAppStore(state => state.setTileProvider);
const { refresh: refreshLocation } = useUserLocation();
const {
@@ -99,21 +73,7 @@ export default function App() {
[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]);
// Derived lists depend on focusOn; initialize map first, then derive.
const statusLabel = t(`status.${status}`);
const lastUpdatedLabel = lastUpdated ? formatRelativeTime(lastUpdated, locale) : t("common.never");
@@ -142,6 +102,19 @@ export default function App() {
tileProvider,
});
const { localTotals, myVisibleSignal, dangerCells, recentActivity } = useFeedDerivations({
visibleDensity,
visiblePoints,
visibleLatestByUser,
userLocation: userLocation ?? null,
focusOn,
distanceFormatter,
t,
locale,
myLatestPoint: myLatestPoint ?? null,
radiusKm: RADIUS_KM,
});
useEffect(() => {
if (!error) {
return;
@@ -193,60 +166,7 @@ export default function App() {
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]
);
// recentActivity and dangerCells returned above
const confirmationLat = pendingSpot ? formatCoordinate(pendingSpot.lat, locale) : "--";
const confirmationLng = pendingSpot ? formatCoordinate(pendingSpot.lng, locale) : "--";
const isDialogDisabled = !pendingSpot || isConfirming;
@@ -256,38 +176,23 @@ export default function App() {
<>
<SidebarProvider open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
<Sidebar side="left">
<SidebarHeader>
<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>
</SidebarHeader>
<SidebarContent>
<ScrollArea className="h-full">
<div className="flex flex-col gap-4 p-4 pb-6">
<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}
/>
<MapTilesPanel tileProvider={tileProvider} setTileProvider={setTileProvider} />
<HotspotStatsPanel
hasLocation={hasLocation}
radiusKm={RADIUS_KM}
locationHint={locationHint}
cells={dangerCells}
/>
<ActivityPanel items={recentActivity} emptyMessage={t("activity.empty")} />
</div>
</ScrollArea>
<SidebarPanels
localTotals={localTotals}
lastUpdatedLabel={lastUpdatedLabel}
myVisibleSignalCreatedAt={myVisibleSignal ? myVisibleSignal.createdAt : null}
error={error}
onReport={handleManualReport}
onRetry={handleRefresh}
isBusy={isPosting || isConfirming}
locationHint={locationHint}
showLocationCta={showLocationCta}
hasLocation={hasLocation}
tileProvider={tileProvider}
setTileProvider={setTileProvider}
dangerCells={dangerCells}
recentActivity={recentActivity}
/>
</SidebarContent>
</Sidebar>
@@ -302,50 +207,27 @@ export default function App() {
className="min-h-screen"
/>
{/* Top overlay: left = sidebar trigger, right = AppHeader */}
<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 hidden sm:flex">
<SidebarTrigger aria-label={detailsToggleLabel} aria-expanded={isDetailsOpen}>
<Menu className="h-4 w-4" />
</SidebarTrigger>
</div>
<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>
</div>
<HeaderOverlay
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}
isDetailsOpen={isDetailsOpen}
detailsToggleLabel={detailsToggleLabel}
isHeaderCollapsed={isHeaderCollapsed}
setIsHeaderCollapsed={setIsHeaderCollapsed}
isMobileHeaderOpen={isMobileHeaderOpen}
setIsMobileHeaderOpen={setIsMobileHeaderOpen}
/>
{!isDetailsOpen && (
<div className="pointer-events-auto fixed bottom-4 left-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>
)}
<AlertDialog
<ConfirmSignalDialog
open={isConfirmOpen}
onOpenChange={nextOpen => {
setIsConfirmOpen(nextOpen);
@@ -354,33 +236,13 @@ export default function App() {
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>
latitudeLabel={confirmationLat}
longitudeLabel={confirmationLng}
radiusKm={RADIUS_KM}
isConfirming={isConfirming}
isDisabled={isDialogDisabled}
onConfirm={handleConfirmSignal}
/>
</div>
</SidebarInset>
</SidebarProvider>
@@ -0,0 +1,64 @@
import { useTranslation } from "react-i18next";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface ConfirmSignalDialogProps {
open: boolean;
onOpenChange: (next: boolean) => void;
latitudeLabel: string;
longitudeLabel: string;
radiusKm: number;
isConfirming: boolean;
isDisabled: boolean;
onConfirm: () => void;
}
export function ConfirmSignalDialog({
open,
onOpenChange,
latitudeLabel,
longitudeLabel,
radiusKm,
isConfirming,
isDisabled,
onConfirm,
}: ConfirmSignalDialogProps) {
const { t } = useTranslation();
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<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">{latitudeLabel}°</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">{longitudeLabel}°</span>
</div>
<p className="mt-4 text-xs text-muted-foreground">{t("dialog.confirmSignal.reach", { radius: radiusKm })}</p>
</div>
<AlertDialogFooter>
<AlertDialogCancel disabled={isConfirming}>{t("dialog.confirmSignal.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm} disabled={isDisabled}>
{isConfirming ? t("dialog.confirmSignal.sending") : t("dialog.confirmSignal.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,162 @@
import { Menu } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AppHeader } from "@/components/layout/app-header";
import { Badge } from "@/components/ui/badge";
import { SidebarTrigger } from "@/components/ui/sidebar";
import type { FeedStatus } from "@/types/api";
interface HeaderOverlayProps {
status: FeedStatus;
statusLabel: string;
lastUpdatedLabel: string;
onRefresh: () => void;
onFocusHeat: () => void;
onLocateUser: () => void;
onFocusMySignal: () => void;
disableRefresh: boolean;
disableHeat: boolean;
disableLocate: boolean;
disableMySignal: boolean;
isDetailsOpen: boolean;
detailsToggleLabel: string;
isHeaderCollapsed: boolean;
setIsHeaderCollapsed: (collapsed: boolean) => void;
isMobileHeaderOpen: boolean;
setIsMobileHeaderOpen: (open: boolean) => void;
}
export function HeaderOverlay({
status,
statusLabel,
lastUpdatedLabel,
onRefresh,
onFocusHeat,
onLocateUser,
onFocusMySignal,
disableRefresh,
disableHeat,
disableLocate,
disableMySignal,
isDetailsOpen,
detailsToggleLabel,
isHeaderCollapsed,
setIsHeaderCollapsed,
isMobileHeaderOpen,
setIsMobileHeaderOpen,
}: HeaderOverlayProps) {
const { t } = useTranslation();
return (
<div className="pointer-events-none absolute inset-0 flex flex-col">
{/* Desktop variant */}
<div className="hidden items-start justify-between gap-3 p-2 sm:flex sm:p-6">
<div className="pointer-events-auto">
<SidebarTrigger aria-label={detailsToggleLabel} aria-expanded={isDetailsOpen}>
<Menu className="h-4 w-4" />
</SidebarTrigger>
</div>
<div className="pointer-events-auto">
{isHeaderCollapsed ? (
<button
type="button"
onClick={() => setIsHeaderCollapsed(false)}
className="rounded-3xl border border-border/60 bg-background/90 p-2 shadow-xl backdrop-blur transition hover:bg-background"
aria-label={t("header.actions.expand")}
aria-expanded={!isHeaderCollapsed}
>
<Badge
variant="secondary"
className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/60 px-3 py-2 text-xs font-medium uppercase tracking-wide"
>
<span
className={`flex items-center gap-2 ${status === "error" ? "text-destructive" : "text-success"}`}
aria-live="polite"
>
<span className="relative block h-2.5 w-2.5 rounded-full bg-current">
<span className="status-dot relative block h-2.5 w-2.5 rounded-full bg-current" />
</span>
{statusLabel}
</span>
<span className="text-[10px] uppercase text-muted-foreground">
{t("header.badge.updated", { time: lastUpdatedLabel })}
</span>
</Badge>
</button>
) : (
<div className="transition-all duration-300 ease-out">
<AppHeader
status={status}
statusLabel={statusLabel}
lastUpdatedLabel={lastUpdatedLabel}
onRefresh={onRefresh}
onFocusHeat={onFocusHeat}
onLocateUser={onLocateUser}
onFocusMySignal={onFocusMySignal}
disableRefresh={disableRefresh}
disableHeat={disableHeat}
disableLocate={disableLocate}
disableMySignal={disableMySignal}
collapsed={isHeaderCollapsed}
onToggleCollapse={() => setIsHeaderCollapsed(true)}
/>
</div>
)}
</div>
</div>
{/* Mobile variant */}
<div className="pointer-events-none sm:hidden">
<div className="pointer-events-auto px-4 pt-4">
{isMobileHeaderOpen ? (
<div className="transition-all duration-300 ease-out">
<AppHeader
status={status}
statusLabel={statusLabel}
lastUpdatedLabel={lastUpdatedLabel}
onRefresh={onRefresh}
onFocusHeat={onFocusHeat}
onLocateUser={onLocateUser}
onFocusMySignal={onFocusMySignal}
disableRefresh={disableRefresh}
disableHeat={disableHeat}
disableLocate={disableLocate}
disableMySignal={disableMySignal}
collapsed={false}
onToggleCollapse={() => setIsMobileHeaderOpen(false)}
/>
</div>
) : (
<div className="flex items-center justify-between">
<SidebarTrigger aria-label={detailsToggleLabel} aria-expanded={isDetailsOpen} />
<button
type="button"
onClick={() => setIsMobileHeaderOpen(true)}
className="rounded-3xl border border-border/60 bg-background/90 p-2 shadow-xl backdrop-blur transition hover:bg-background"
aria-label={t("header.actions.expand")}
>
<Badge
variant="secondary"
className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/60 px-3 py-2 text-xs font-medium uppercase tracking-wide"
>
<span
className={`flex items-center gap-2 ${status === "error" ? "text-destructive" : "text-success"}`}
aria-live="polite"
>
<span className="relative block h-2.5 w-2.5 rounded-full bg-current">
<span className="status-dot relative block h-2.5 w-2.5 rounded-full bg-current" />
</span>
{statusLabel}
</span>
<span className="text-[10px] uppercase text-muted-foreground">
{t("header.badge.updated", { time: lastUpdatedLabel })}
</span>
</Badge>
</button>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { useTranslation } from "react-i18next";
import { ActivityPanel } from "@/components/panels/activity-panel";
import { HotspotStatsPanel } from "@/components/panels/hotspot-stats-panel";
import { MapTilesPanel } from "@/components/panels/map-tiles-panel";
import { OverviewPanel } from "@/components/panels/overview-panel";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { FeedError } from "@/hooks/use-hotsop-feed";
import type { TileProvider } from "@/hooks/use-leaflet-heatmap";
import { formatRelativeTime } from "@/lib/utils";
type DangerCell = {
id: string;
title: string;
subtitle: string;
intensity: number;
onFocus: () => void;
};
type ActivityItem = {
id: string;
title: string;
subtitle: string;
timestampLabel: string;
distanceLabel: string;
onFocus: () => void;
};
interface SidebarPanelsProps {
localTotals: { points: number; contributors: number };
lastUpdatedLabel: string;
myVisibleSignalCreatedAt: string | null;
error: FeedError | null;
onReport: () => void;
onRetry: () => void;
isBusy: boolean;
locationHint: string;
showLocationCta: boolean;
hasLocation: boolean;
tileProvider: TileProvider;
setTileProvider: (p: TileProvider) => void;
dangerCells: DangerCell[];
recentActivity: ActivityItem[];
}
export function SidebarPanels({
localTotals,
lastUpdatedLabel,
myVisibleSignalCreatedAt,
error,
onReport,
onRetry,
isBusy,
locationHint,
showLocationCta,
hasLocation,
tileProvider,
setTileProvider,
dangerCells,
recentActivity,
}: SidebarPanelsProps) {
const { i18n, t } = useTranslation();
const locale = i18n.language === "fr" ? "fr-FR" : "en-US";
return (
<ScrollArea className="h-full">
<div className="flex flex-col gap-4 p-4 pb-6">
<OverviewPanel
nearbySignals={localTotals.points}
uniqueContributors={localTotals.contributors}
lastUpdatedLabel={lastUpdatedLabel}
mySignalLabel={myVisibleSignalCreatedAt ? formatRelativeTime(myVisibleSignalCreatedAt, locale) : null}
error={error}
onReport={onReport}
onRetry={onRetry}
isPosting={isBusy}
locationHint={locationHint}
showLocationCta={showLocationCta}
disableReport={!hasLocation}
/>
<HotspotStatsPanel hasLocation={hasLocation} radiusKm={1} locationHint={locationHint} cells={dangerCells} />
<ActivityPanel items={recentActivity} emptyMessage={t("activity.empty")} />
<MapTilesPanel tileProvider={tileProvider} setTileProvider={setTileProvider} />
</div>
</ScrollArea>
);
}
+8 -10
View File
@@ -48,14 +48,12 @@ export function AppHeader({
<Badge
variant="secondary"
className={cn(
"inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/60 px-3 py-1 text-xs font-medium uppercase tracking-wide",
"inline-flex items-center gap-2 rounded-full border border-border/60 bg-muted/60 px-3 py-2 text-xs font-medium uppercase tracking-wide",
collapsed && "px-2 py-1 text-[11px] font-semibold"
)}
>
<span className={`flex items-center gap-2 ${isError ? "text-destructive" : "text-primary"}`} aria-live="polite">
<span className="relative block h-2.5 w-2.5 rounded-full bg-current">
<span className="absolute inset-[-0.35rem] rounded-full border border-current opacity-40 animate-status-pulse" />
</span>
<span className={`flex items-center gap-2 ${isError ? "text-destructive" : "text-success"}`} aria-live="polite">
<span className="status-dot relative block h-2.5 w-2.5 rounded-full bg-current" />
{statusLabel}
</span>
{!collapsed && (
@@ -69,7 +67,7 @@ export function AppHeader({
return (
<header
className={cn(
"flex w-full xl:max-w-[420px] flex-col gap-3 rounded-3xl border border-border/60 bg-background/90 p-4 text-sm shadow-xl backdrop-blur transition-all",
"flex w-full xl:max-w-[420px] flex-col gap-3 rounded-xl border border-border/60 bg-sidebar p-4 text-sm shadow-xl backdrop-blur transition-all",
collapsed && "max-w-[240px] bg-background/80 p-3",
className
)}
@@ -82,7 +80,7 @@ export function AppHeader({
</div>
</div>
<Button
variant="ghost"
variant="secondary"
size="icon"
onClick={onToggleCollapse}
aria-label={collapsed ? t("header.actions.expand") : t("header.actions.collapse")}
@@ -95,7 +93,7 @@ export function AppHeader({
{!collapsed && (
<div className="flex flex-wrap items-center gap-2">
<Button
variant="ghost"
variant="secondary"
size="icon"
onClick={onRefresh}
disabled={disableRefresh}
@@ -113,7 +111,7 @@ export function AppHeader({
<Focus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
variant="secondary"
size="icon"
onClick={onLocateUser}
disabled={disableLocate}
@@ -122,7 +120,7 @@ export function AppHeader({
<LocateFixed className="h-4 w-4" />
</Button>
<Button
variant="ghost"
variant="secondary"
size="icon"
onClick={onFocusMySignal}
disabled={disableMySignal}
@@ -11,13 +11,13 @@ export function LanguageToggle() {
return (
<Button
variant="ghost"
variant="secondary"
size="sm"
onClick={() => {
i18n.changeLanguage(next).catch(() => undefined);
}}
aria-label={t("common.aria.language", { language: nextLabel })}
className="h-9 rounded-full border border-border/60 bg-background/80 px-3 backdrop-blur"
className="h-9 px-3"
>
<span className="sr-only">{t("language.label")}</span>
<Globe className="h-4 w-4" />
@@ -10,11 +10,10 @@ export function ThemeToggle() {
return (
<Button
variant="ghost"
variant="secondary"
size="icon"
onClick={toggleTheme}
aria-label={isDark ? t("common.aria.theme.light") : t("common.aria.theme.dark")}
className="rounded-full border border-border/60 bg-background/80 backdrop-blur"
>
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
@@ -38,7 +38,7 @@ export function ActivityPanel({ items, emptyMessage }: ActivityPanelProps) {
<ScrollArea className="max-h-[280px] pr-2">
<ul className="space-y-3">
{items.map(item => (
<li key={item.id} className="rounded-2xl border border-border/60 bg-muted/50 p-3">
<li key={item.id} className="rounded-xl border border-border/60 bg-muted/50 p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">{item.title}</span>
@@ -40,7 +40,7 @@ export function HotspotStatsPanel({ hasLocation, radiusKm, locationHint, cells }
<ScrollArea className="max-h-[280px] pr-2">
<ul className="space-y-3">
{cells.map(cell => (
<li key={cell.id} className="rounded-2xl border border-border/60 bg-muted/50 p-3">
<li key={cell.id} className="rounded-xl border border-border/60 bg-muted/50 p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{cell.title}</span>
@@ -37,10 +37,9 @@ export function MapTilesPanel({ tileProvider, setTileProvider }: MapTilesPanelPr
<Button
key={option.value}
type="button"
variant={tileProvider === option.value ? "default" : "outline"}
variant={tileProvider === option.value ? "default" : "secondary"}
size="sm"
onClick={() => setTileProvider(option.value)}
className="w-full"
>
{option.label}
</Button>
@@ -49,11 +49,11 @@ export function OverviewPanel({
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-border/60 bg-muted/50 p-3">
<div className="rounded-xl border border-border/60 bg-muted/50 p-3">
<span className="text-xs uppercase text-muted-foreground">{t("overview.stats.signals")}</span>
<p className="text-xl font-semibold">{nearbySignals}</p>
</div>
<div className="rounded-2xl border border-border/60 bg-muted/50 p-3">
<div className="rounded-xl border border-border/60 bg-muted/50 p-3">
<span className="text-xs uppercase text-muted-foreground">{t("overview.stats.contributors")}</span>
<p className="text-xl font-semibold">{uniqueContributors}</p>
</div>
+4 -1
View File
@@ -243,7 +243,10 @@ function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<t
data-slot="sidebar-trigger"
variant="default"
size="icon"
className={cn("size-10 rounded-full", className)}
className={cn(
"size-10 bg-sidebar rounded-full shadow-xl text-sidebar-foreground hover:bg-sidebar/90 cursor-pointer",
className
)}
onClick={event => {
onClick?.(event);
toggleSidebar();
+119
View File
@@ -0,0 +1,119 @@
import { useMemo } from "react";
import type { TFunction } from "i18next";
import { distanceInKm, formatCoordinate, formatRelativeTime, formatTimestamp } from "@/lib/utils";
import type { Point, Signal, SignalDensityCell } from "@/types/api";
type DangerCell = {
id: string;
title: string;
subtitle: string;
intensity: number;
onFocus: () => void;
};
type ActivityItem = {
id: string;
title: string;
subtitle: string;
timestampLabel: string;
distanceLabel: string;
onFocus: () => void;
};
interface UseFeedDerivationsArgs {
visibleDensity: SignalDensityCell[];
visiblePoints: Signal[];
visibleLatestByUser: Signal[];
userLocation: Point | null;
focusOn: (position: Point, zoom?: number) => void;
distanceFormatter: Intl.NumberFormat;
t: TFunction;
locale: string;
myLatestPoint: Signal | null;
radiusKm: number;
}
export function useFeedDerivations({
visibleDensity,
visiblePoints,
visibleLatestByUser,
userLocation,
focusOn,
distanceFormatter,
t,
locale,
myLatestPoint,
radiusKm,
}: UseFeedDerivationsArgs) {
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) > radiusKm) {
return null;
}
return myLatestPoint;
}, [myLatestPoint, userLocation, radiusKm]);
const dangerCells: DangerCell[] = 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: ActivityItem[] = 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: string = userLocation
? (t("activityItem.distance", {
distance: `${distanceFormatter.format(distanceInKm(userLocation, point.signalLocation))} km`,
}) as string)
: 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]
);
return { localTotals, myVisibleSignal, dangerCells, recentActivity };
}
+35 -3
View File
@@ -103,7 +103,7 @@ export function useLeafletHeatmap({
worldCopyJump: true,
minZoom: 4,
zoomControl: false,
attributionControl: false,
attributionControl: true,
})
.setView([INITIAL_VIEW.lat, INITIAL_VIEW.lng], DEFAULT_ZOOM);
@@ -163,6 +163,38 @@ export function useLeafletHeatmap({
return () => {
map.off("click", handleClick);
window.removeEventListener("resize", onResize);
// Be explicit when tearing down layers to avoid animation-queued redraws
try {
const hl = heatLayerRef.current ?? heatLayer;
if (hl) {
// Cancel any pending requestAnimationFrame used internally by the heat layer
const anyL = L as unknown as { Util: { cancelAnimFrame: (id: any) => void } };
const anyHl = hl as any;
if (anyHl._frame) {
anyL.Util.cancelAnimFrame(anyHl._frame);
}
if (typeof anyHl.remove === "function") {
anyHl.remove();
} else if (typeof anyHl.removeFrom === "function") {
anyHl.removeFrom(map);
}
}
} catch {
// noop
}
try {
userLayer.remove();
} catch {
// noop
}
try {
tileLayer.remove();
} catch {
// noop
}
map.remove();
mapRef.current = null;
heatLayerRef.current = null;
@@ -205,8 +237,8 @@ export function useLeafletHeatmap({
}, [createTileLayer, tileProvider]);
useEffect(() => {
const heatLayer = heatLayerRef.current;
if (!heatLayer) {
const heatLayer = heatLayerRef.current as any;
if (!heatLayer || !heatLayer._map) {
return;
}
+27 -2
View File
@@ -19,6 +19,8 @@
--secondary-foreground: oklch(0.205 0 0);
--warning: oklch(0.84 0.16 84);
--warning-foreground: oklch(0.28 0.07 46);
--success: oklch(0.7395 0.2268 142.8504);
--success-foreground: oklch(0 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
@@ -55,6 +57,8 @@
--secondary-foreground: oklch(0.985 0 0);
--warning: oklch(0.41 0.11 46);
--warning-foreground: oklch(0.99 0.02 95);
--success: oklch(0.7395 0.2268 142.8504);
--success-foreground: oklch(0 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
@@ -93,6 +97,8 @@
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
--color-warning: var(--warning);
--color-warning-foreground: var(--warning-foreground);
--color-muted: var(--muted);
@@ -125,6 +131,10 @@
body {
@apply bg-background text-foreground;
}
button {
@apply cursor-pointer;
}
}
@layer components {
@@ -154,13 +164,28 @@
}
@layer utilities {
@keyframes status-pulse {
0% {
transform: scale(0.8);
opacity: 0.5;
}
70% {
transform: scale(1.8);
opacity: 0;
}
100% {
opacity: 0;
}
}
.status-dot::after {
content: "";
position: absolute;
inset: -0.35rem;
border-radius: 9999px;
border: 1px solid currentColor;
border: 2px solid currentColor;
opacity: 0.35;
animation: animate-pulse 2.4s ease-out infinite;
animation: status-pulse 2s ease-out infinite;
will-change: transform, opacity;
}
}
+18
View File
@@ -1,5 +1,6 @@
import { create } from "zustand";
import type { TileProvider } from "@/hooks/use-leaflet-heatmap";
import type { Point } from "@/types/api";
type Nullable<T> = T | null;
@@ -8,16 +9,33 @@ interface AppState {
userLocation: Nullable<Point>;
locationError: Nullable<string>;
isRequestingLocation: boolean;
// UI state
isSidebarOpen: boolean;
isHeaderCollapsed: boolean;
isMobileHeaderOpen: boolean;
tileProvider: TileProvider;
setUserLocation: (location: Nullable<Point>) => void;
setLocationError: (error: Nullable<string>) => void;
setIsRequestingLocation: (isRequesting: boolean) => void;
setIsSidebarOpen: (open: boolean) => void;
setIsHeaderCollapsed: (collapsed: boolean) => void;
setIsMobileHeaderOpen: (open: boolean) => void;
setTileProvider: (provider: TileProvider) => void;
}
export const useAppStore = create<AppState>(set => ({
userLocation: null,
locationError: null,
isRequestingLocation: false,
isSidebarOpen: typeof window !== "undefined" ? window.innerWidth >= 1024 : false,
isHeaderCollapsed: typeof window !== "undefined" ? window.innerWidth < 768 : false,
isMobileHeaderOpen: false,
tileProvider: "openstreetmap",
setUserLocation: userLocation => set({ userLocation }),
setLocationError: locationError => set({ locationError }),
setIsRequestingLocation: isRequestingLocation => set({ isRequestingLocation }),
setIsSidebarOpen: isSidebarOpen => set({ isSidebarOpen }),
setIsHeaderCollapsed: isHeaderCollapsed => set({ isHeaderCollapsed }),
setIsMobileHeaderOpen: isMobileHeaderOpen => set({ isMobileHeaderOpen }),
setTileProvider: tileProvider => set({ tileProvider }),
}));