From 9ae2f3dc5ba702f74a33f65126761338d3fccf29 Mon Sep 17 00:00:00 2001 From: Alex Vanderpot Date: Fri, 29 May 2026 04:48:22 -0400 Subject: [PATCH] query-params: migrate search & map state to nuqs Replace the hand-rolled useQueryParams hook for the search and map-position features with nuqs type-safe parsers. Adds the NuqsAdapter provider, rewrites useSearchQuery on useQueryStates, switches MapView lat/lng/zoom to nuqs, and fixes node-link URLs to emit exact=true/is_repeater=true so parseAsBoolean reads them. The generic useQueryParams hook stays for ChatBox. Co-Authored-By: Claude Opus 4.8 (1M context) --- meshexplorer/package-lock.json | 44 +++++++++++++++++++ meshexplorer/package.json | 1 + meshexplorer/src/app/(app)/search/page.tsx | 15 ++----- meshexplorer/src/app/layout.tsx | 13 +++--- meshexplorer/src/components/MapView.tsx | 20 ++++----- .../src/components/NodeLinkWithHover.tsx | 4 +- meshexplorer/src/hooks/useQueryParams.ts | 33 ++++++-------- 7 files changed, 81 insertions(+), 49 deletions(-) diff --git a/meshexplorer/package-lock.json b/meshexplorer/package-lock.json index 7084c47..9025870 100644 --- a/meshexplorer/package-lock.json +++ b/meshexplorer/package-lock.json @@ -25,6 +25,7 @@ "moment": "^2.30.1", "next": "15.3.8", "next-themes": "^0.4.6", + "nuqs": "^2.8.9", "qrcode": "^1.5.4", "react": "^19.0.0", "react-d3-tree": "^3.6.6", @@ -1585,6 +1586,12 @@ "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", "dev": true }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "license": "MIT" + }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -5767,6 +5774,43 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/nuqs": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-2.8.9.tgz", + "integrity": "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/franky47" + }, + "peerDependencies": { + "@remix-run/react": ">=2", + "@tanstack/react-router": "^1", + "next": ">=14.2.0", + "react": ">=18.2.0 || ^19.0.0-0", + "react-router": "^5 || ^6 || ^7", + "react-router-dom": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "next": { + "optional": true + }, + "react-router": { + "optional": true + }, + "react-router-dom": { + "optional": true + } + } + }, "node_modules/oauth-sign": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", diff --git a/meshexplorer/package.json b/meshexplorer/package.json index 8fc4802..c3475df 100644 --- a/meshexplorer/package.json +++ b/meshexplorer/package.json @@ -28,6 +28,7 @@ "moment": "^2.30.1", "next": "15.3.8", "next-themes": "^0.4.6", + "nuqs": "^2.8.9", "qrcode": "^1.5.4", "react": "^19.0.0", "react-d3-tree": "^3.6.6", diff --git a/meshexplorer/src/app/(app)/search/page.tsx b/meshexplorer/src/app/(app)/search/page.tsx index 355dc57..ac58953 100644 --- a/meshexplorer/src/app/(app)/search/page.tsx +++ b/meshexplorer/src/app/(app)/search/page.tsx @@ -8,7 +8,6 @@ import SearchResults from '@/components/SearchResults'; import RegionSelect from '@/components/RegionSelect'; import { LAST_SEEN_OPTIONS } from '@/components/ConfigContext'; import { useState, Suspense, useEffect } from 'react'; -import { useRouter } from 'next/navigation'; import { ChevronDownIcon } from '@heroicons/react/24/outline'; function SearchPageContent() { @@ -16,20 +15,14 @@ function SearchPageContent() { const { query, setQuery, setLimit, setExact, setIsRepeater } = useSearchQuery(); const [showFilters, setShowFilters] = useState(false); - // Helper function to check if exact search is enabled - const isExactEnabled = query.exact === true || (typeof query.exact === 'string' && (query.exact === 'true' || query.exact === '')); - - // Helper function to check if is_repeater search is enabled - const isRepeaterEnabled = query.is_repeater === true || (typeof query.is_repeater === 'string' && (query.is_repeater === 'true' || query.is_repeater === '')); - // Always use config values for region and lastSeen const searchParams = { query: query.q, region: config.selectedRegion, lastSeen: config.lastSeen, limit: query.limit || 50, - exact: isExactEnabled, - is_repeater: isRepeaterEnabled, + exact: query.exact, + is_repeater: query.is_repeater, }; const { data, isLoading, error } = useMeshcoreSearch({ @@ -144,7 +137,7 @@ function SearchPageContent() { setExact(e.target.checked)} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" /> @@ -163,7 +156,7 @@ function SearchPageContent() { setIsRepeater(e.target.checked)} className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" /> diff --git a/meshexplorer/src/app/layout.tsx b/meshexplorer/src/app/layout.tsx index 1bb4ce2..dad11b2 100644 --- a/meshexplorer/src/app/layout.tsx +++ b/meshexplorer/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ConfigProvider } from "@/components/ConfigContext"; import { QueryProvider } from "@/components/QueryProvider"; +import { NuqsAdapter } from "nuqs/adapters/next/app"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -42,11 +43,13 @@ export default function RootLayout({ style={{ '--header-height': '64px' } as React.CSSProperties} >
- - - {children} - - + + + + {children} + + +
diff --git a/meshexplorer/src/components/MapView.tsx b/meshexplorer/src/components/MapView.tsx index 6a4f694..9a63970 100644 --- a/meshexplorer/src/components/MapView.tsx +++ b/meshexplorer/src/components/MapView.tsx @@ -1,7 +1,6 @@ "use client"; import React, { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { MapContainer, TileLayer, useMapEvents, Marker, Popup, MapContainerProps, useMap, Polyline } from "react-leaflet"; -import { useRouter, useSearchParams } from "next/navigation"; import 'leaflet/dist/leaflet.css'; import L from "leaflet"; import 'leaflet.markercluster/dist/leaflet.markercluster.js'; @@ -17,16 +16,9 @@ import { buildApiUrl } from "@/lib/api"; import { NodePosition } from "@/types/map"; import { useNeighbors, type Neighbor } from "@/hooks/useNeighbors"; import { type AllNeighborsConnection } from "@/hooks/useAllNeighbors"; -import { useQueryParams } from "@/hooks/useQueryParams"; +import { useQueryStates, parseAsFloat, parseAsInteger } from "nuqs"; import { useMapPosition } from "@/hooks/useMapPosition"; -interface MapQuery { - lat?: number; - lng?: number; - zoom?: number; -} - - type ClusteredMarkersProps = { nodes: NodePosition[]; @@ -505,7 +497,11 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) { const [mapPosition, setMapPosition] = useMapPosition(); // Use query params for map position (for sharing URLs) - const { query: mapQuery, updateQuery: updateMapQuery } = useQueryParams({}); + const [mapQuery, setMapQuery] = useQueryStates({ + lat: parseAsFloat, + lng: parseAsFloat, + zoom: parseAsInteger, + }); // Determine map center and zoom: query params take priority over localStorage const mapCenter: [number, number] = [ @@ -667,7 +663,7 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) { }); // Update URL with new map position for sharing - updateMapQuery({ + setMapQuery({ lat: Math.round(center.lat * 100000) / 100000, // Round to 5 decimal places lng: Math.round(center.lng * 100000) / 100000, zoom: zoom @@ -709,7 +705,7 @@ export default function MapView({ target = '_self' }: MapViewProps = {}) { }); // Update URL with new map position for sharing - updateMapQuery({ + setMapQuery({ lat: Math.round(center.lat * 100000) / 100000, // Round to 5 decimal places lng: Math.round(center.lng * 100000) / 100000, zoom: zoom diff --git a/meshexplorer/src/components/NodeLinkWithHover.tsx b/meshexplorer/src/components/NodeLinkWithHover.tsx index b13034e..9f66fb7 100644 --- a/meshexplorer/src/components/NodeLinkWithHover.tsx +++ b/meshexplorer/src/components/NodeLinkWithHover.tsx @@ -56,8 +56,8 @@ export default function NodeLinkWithHover({ // If no results or multiple results, link to search page const searchUrl = `/search?q=${encodeURIComponent(nodeName)}`; const params = []; - if (exact) params.push('exact'); - if (is_repeater) params.push('is_repeater'); + if (exact) params.push('exact=true'); + if (is_repeater) params.push('is_repeater=true'); return searchUrl + (params.length > 0 ? '&' + params.join('&') : ''); })(); diff --git a/meshexplorer/src/hooks/useQueryParams.ts b/meshexplorer/src/hooks/useQueryParams.ts index 5e35daf..58079cf 100644 --- a/meshexplorer/src/hooks/useQueryParams.ts +++ b/meshexplorer/src/hooks/useQueryParams.ts @@ -2,6 +2,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useMemo, useState, useEffect, useRef } from 'react'; +import { useQueryStates, parseAsString, parseAsInteger, parseAsBoolean } from 'nuqs'; export function useQueryParams>(defaultValues: T = {} as T) { const router = useRouter(); @@ -104,27 +105,21 @@ export function useQueryParams>(defaultValues: T = }; } -// Legacy hook for backward compatibility - now uses the generic hook -export interface SearchQuery { - q: string; - limit?: number; - exact?: boolean; - is_repeater?: boolean; -} - +// Search query params, backed by nuqs for type-safe URL state. export function useSearchQuery() { - const { query, setParam } = useQueryParams({ q: '', limit: 50, exact: false, is_repeater: false }); - + const [query, setQuery] = useQueryStates({ + q: parseAsString.withDefault(''), + limit: parseAsInteger.withDefault(50), + exact: parseAsBoolean.withDefault(false), + is_repeater: parseAsBoolean.withDefault(false), + }); + return { query, - setQuery: (q: string) => setParam('q', q), - setLimit: (limit: number) => setParam('limit', limit), - setExact: (exact: boolean) => setParam('exact', exact), - setIsRepeater: (is_repeater: boolean) => setParam('is_repeater', is_repeater), - updateQuery: (updates: Partial) => { - Object.entries(updates).forEach(([key, value]) => { - setParam(key as keyof SearchQuery, value as any); - }); - }, + setQuery: (q: string) => setQuery({ q }), + setLimit: (limit: number) => setQuery({ limit }), + setExact: (exact: boolean) => setQuery({ exact }), + setIsRepeater: (is_repeater: boolean) => setQuery({ is_repeater }), + updateQuery: setQuery, }; }