[frontend] restructure components
This commit is contained in:
+74
-212
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user