diff --git a/src/app/api/node-positions/route.ts b/src/app/api/map/route.ts similarity index 80% rename from src/app/api/node-positions/route.ts rename to src/app/api/map/route.ts index 0b0decc..03ea42d 100644 --- a/src/app/api/node-positions/route.ts +++ b/src/app/api/map/route.ts @@ -8,7 +8,9 @@ export async function GET(req: Request) { const maxLat = searchParams.get("maxLat"); const minLng = searchParams.get("minLng"); const maxLng = searchParams.get("maxLng"); - const positions = await getNodePositions({ minLat, maxLat, minLng, maxLng }); + const nodeTypes = searchParams.getAll("nodeTypes"); + const lastSeen = searchParams.get("lastSeen"); + const positions = await getNodePositions({ minLat, maxLat, minLng, maxLng, nodeTypes, lastSeen }); return NextResponse.json(positions); } catch (error) { return NextResponse.json({ error: "Failed to fetch node positions" }, { status: 500 }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index bf43a87..251c994 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import Header from "../components/Header"; +import { ConfigProvider } from "../components/ConfigContext"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -30,8 +31,10 @@ export default function RootLayout({ style={{ '--header-height': '64px' } as React.CSSProperties} >
-
-
{children}
+ +
+
{children}
+
diff --git a/src/components/ConfigContext.tsx b/src/components/ConfigContext.tsx index c6c2eb3..47f78fd 100644 --- a/src/components/ConfigContext.tsx +++ b/src/components/ConfigContext.tsx @@ -1,12 +1,187 @@ "use client"; -import React, { ReactNode } from "react"; +import React, { createContext, useContext, useState, useEffect, useRef, useLayoutEffect, ReactNode } from "react"; + +// Config shape +type NodeType = "meshcore" | "meshtastic"; +export type Config = { + nodeTypes: NodeType[]; // which node types to show + lastSeen: number | null; // seconds, or null for forever + tileLayer: string; // add tileLayer selection + clustering?: boolean; // add clustering toggle +}; + +const TILE_LAYERS = [ + { key: "openstreetmap", label: "OpenStreetMap" }, + { key: "opentopomap", label: "OpenTopoMap" }, + { key: "esri", label: "Esri World Imagery" }, +]; + +const DEFAULT_CONFIG: Config = { + nodeTypes: ["meshcore", "meshtastic"], + lastSeen: 86400, // 24h in seconds + tileLayer: "openstreetmap", // default + clustering: true, // default to clustering enabled +}; + +const LAST_SEEN_OPTIONS = [ + { value: 1800, label: "30m" }, + { value: 3600, label: "1h" }, + { value: 7200, label: "2h" }, + { value: 14400, label: "4h" }, + { value: 28800, label: "8h" }, + { value: 86400, label: "24h" }, + { value: 604800, label: "1w" }, + { value: null, label: "Forever (all time)" }, +]; + +const ConfigContext = createContext(null); -// Placeholder ConfigProvider that just renders children export function ConfigProvider({ children }: { children: ReactNode }) { - return <>{children}; + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [open, setOpen] = useState(false); + const configButtonRef = useRef(null); + const firstRender = useRef(true); + + // Load from localStorage + useEffect(() => { + const stored = localStorage.getItem("meshExplorerConfig"); + if (stored) { + try { + setConfig({ ...DEFAULT_CONFIG, ...JSON.parse(stored) }); + } catch {} + } + }, []); + + // Save to localStorage + useEffect(() => { + if (!firstRender.current) { + localStorage.setItem("meshExplorerConfig", JSON.stringify(config)); + } else { + firstRender.current = false; + } + }, [config]); + + // Expose openConfig for header button + const openConfig = () => setOpen(true); + const closeConfig = () => setOpen(false); + + return ( + + {children} + {open && } + + ); } -// Dummy useConfig hook export function useConfig() { - return {}; + return useContext(ConfigContext); +} + +function ConfigPopover({ config, setConfig, onClose, anchorRef }: { config: Config, setConfig: (c: Config) => void, onClose: () => void, anchorRef: React.RefObject }) { + const popoverRef = useRef(null); + + // Click outside to close + useEffect(() => { + function handle(e: MouseEvent) { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) && + anchorRef.current && + !anchorRef.current.contains(e.target as Node) + ) { + onClose(); + } + } + document.addEventListener("mousedown", handle); + return () => document.removeEventListener("mousedown", handle); + }, [onClose, anchorRef]); + + // Use fixed positioning and CSS to keep the popover on screen + return ( +
+ +

Map Filters

+
+
Node Types
+ + +
+
+
Last Seen
+ +
+
+
Tile Layer
+ +
+
+ +
+
+ ); } \ No newline at end of file diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 71d1cee..aeeb662 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -9,7 +9,7 @@ interface HeaderProps { } export default function Header({ configButtonRef }: HeaderProps) { - const { openConfig } = useConfig(); + const { openConfig, configButtonRef: contextButtonRef } = useConfig(); return (