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) <noreply@anthropic.com>
This commit is contained in:
Alex Vanderpot
2026-05-29 04:48:22 -04:00
parent 166ef69f51
commit 9ae2f3dc5b
7 changed files with 81 additions and 49 deletions
+44
View File
@@ -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",
+1
View File
@@ -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",
+4 -11
View File
@@ -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() {
<input
type="checkbox"
id="exact-match"
checked={isExactEnabled}
checked={query.exact}
onChange={(e) => 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() {
<input
type="checkbox"
id="is-repeater"
checked={isRepeaterEnabled}
checked={query.is_repeater}
onChange={(e) => setIsRepeater(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
+8 -5
View File
@@ -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}
>
<div className="flex flex-col min-h-screen w-full">
<QueryProvider>
<ConfigProvider>
{children}
</ConfigProvider>
</QueryProvider>
<NuqsAdapter>
<QueryProvider>
<ConfigProvider>
{children}
</ConfigProvider>
</QueryProvider>
</NuqsAdapter>
</div>
</body>
</html>
+8 -12
View File
@@ -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<MapQuery>({});
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
@@ -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('&') : '');
})();
+14 -19
View File
@@ -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<T extends Record<string, any>>(defaultValues: T = {} as T) {
const router = useRouter();
@@ -104,27 +105,21 @@ export function useQueryParams<T extends Record<string, any>>(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<SearchQuery>({ 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<SearchQuery>) => {
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,
};
}