mirror of
https://github.com/ajvpot/meshexplorer.git
synced 2026-08-11 02:52:43 +02:00
path display overhaul
This commit is contained in:
@@ -5,7 +5,6 @@ import Link from "next/link";
|
||||
import moment from "moment";
|
||||
import { formatPublicKey } from "@/lib/meshcore";
|
||||
import { getNameIconLabel } from "@/lib/meshcore-map-nodeutils";
|
||||
import PathDisplay from "@/components/PathDisplay";
|
||||
import AdvertDetails from "@/components/AdvertDetails";
|
||||
import ContactQRCode from "@/components/ContactQRCode";
|
||||
import { useConfig, LAST_SEEN_OPTIONS } from "@/components/ConfigContext";
|
||||
|
||||
@@ -13,11 +13,14 @@ import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
function SearchPageContent() {
|
||||
const { config } = useConfig();
|
||||
const { query, setQuery, setLimit, setExact } = useSearchQuery();
|
||||
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 = {
|
||||
@@ -26,6 +29,7 @@ function SearchPageContent() {
|
||||
lastSeen: config.lastSeen,
|
||||
limit: query.limit || 50,
|
||||
exact: isExactEnabled,
|
||||
is_repeater: isRepeaterEnabled,
|
||||
};
|
||||
|
||||
const { data, isLoading, error } = useMeshcoreSearch({
|
||||
@@ -82,7 +86,7 @@ function SearchPageContent() {
|
||||
|
||||
{showFilters && (
|
||||
<div className="mt-4 p-4 bg-white dark:bg-neutral-800 rounded-lg border border-gray-200 dark:border-neutral-700">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
{/* Region Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
@@ -155,6 +159,25 @@ function SearchPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repeater Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Node Type
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is-repeater"
|
||||
checked={isRepeaterEnabled}
|
||||
onChange={(e) => setIsRepeater(e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="is-repeater" className="ml-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
Repeaters only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,9 +30,9 @@ export async function POST(req: Request) {
|
||||
});
|
||||
}
|
||||
|
||||
if (body.queries.length > 50) {
|
||||
if (body.queries.length > 500) {
|
||||
return NextResponse.json({
|
||||
error: "Maximum 50 queries allowed per batch",
|
||||
error: "Maximum 500 queries allowed per batch",
|
||||
code: "TOO_MANY_QUERIES"
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import moment from "moment";
|
||||
import PathVisualization, { PathData } from "./PathVisualization";
|
||||
import PathVisualization from "./PathVisualization";
|
||||
import { PathData } from "@/lib/pathUtils";
|
||||
|
||||
interface AdvertDetailsProps {
|
||||
advert: {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { useConfig } from "./ConfigContext";
|
||||
import { useMessageDecryption } from "@/hooks/useMessageDecryption";
|
||||
import PathVisualization, { PathData } from "./PathVisualization";
|
||||
import PathVisualization from "./PathVisualization";
|
||||
import { PathData } from "@/lib/pathUtils";
|
||||
import NodeLinkWithHover from "./NodeLinkWithHover";
|
||||
import { findNodeMentions } from "@/lib/node-utils";
|
||||
|
||||
@@ -90,6 +91,7 @@ function ChatMessageContent({ text }: { text: string }) {
|
||||
<NodeLinkWithHover
|
||||
key={index}
|
||||
nodeName={nodeName}
|
||||
exact={true}
|
||||
>
|
||||
@{nodeName}
|
||||
</NodeLinkWithHover>
|
||||
@@ -149,6 +151,7 @@ function ChatMessageItem({ msg, showErrorRow }: { msg: ChatMessage, showErrorRow
|
||||
{parsed.sender ? (
|
||||
<NodeLinkWithHover
|
||||
nodeName={parsed.sender}
|
||||
exact={true}
|
||||
>
|
||||
{parsed.sender}
|
||||
</NodeLinkWithHover>
|
||||
|
||||
@@ -11,11 +11,15 @@ import NodeCard from '@/components/NodeCard';
|
||||
interface NodeLinkWithHoverProps {
|
||||
nodeName: string;
|
||||
children: React.ReactNode;
|
||||
exact?: boolean;
|
||||
is_repeater?: boolean;
|
||||
}
|
||||
|
||||
export default function NodeLinkWithHover({
|
||||
nodeName,
|
||||
children
|
||||
children,
|
||||
exact = false,
|
||||
is_repeater = false
|
||||
}: NodeLinkWithHoverProps) {
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
const [isWaitingForSearch, setIsWaitingForSearch] = useState(false);
|
||||
@@ -32,7 +36,8 @@ export default function NodeLinkWithHover({
|
||||
region: config.selectedRegion,
|
||||
lastSeen: config.lastSeen,
|
||||
limit: 10,
|
||||
exact: true,
|
||||
exact: exact,
|
||||
is_repeater: is_repeater,
|
||||
enabled: !!nodeName
|
||||
});
|
||||
|
||||
@@ -49,7 +54,11 @@ export default function NodeLinkWithHover({
|
||||
if (foundNode) return `/meshcore/node/${foundNode.public_key}`;
|
||||
|
||||
// If no results or multiple results, link to search page
|
||||
return `/search?q=${encodeURIComponent(nodeName)}&exact`;
|
||||
const searchUrl = `/search?q=${encodeURIComponent(nodeName)}`;
|
||||
const params = [];
|
||||
if (exact) params.push('exact');
|
||||
if (is_repeater) params.push('is_repeater');
|
||||
return searchUrl + (params.length > 0 ? '&' + params.join('&') : '');
|
||||
})();
|
||||
|
||||
// Handle click behavior
|
||||
@@ -75,11 +84,17 @@ export default function NodeLinkWithHover({
|
||||
// Calculate navigation URL directly here since linkHref might still be "#"
|
||||
const navigationUrl = foundNode
|
||||
? `/meshcore/node/${foundNode.public_key}`
|
||||
: `/search?q=${encodeURIComponent(nodeName)}&exact`;
|
||||
: (() => {
|
||||
const searchUrl = `/search?q=${encodeURIComponent(nodeName)}`;
|
||||
const params = [];
|
||||
if (exact) params.push('exact');
|
||||
if (is_repeater) params.push('is_repeater');
|
||||
return searchUrl + (params.length > 0 ? '&' + params.join('&') : '');
|
||||
})();
|
||||
|
||||
router.push(navigationUrl);
|
||||
}
|
||||
}, [isWaitingForSearch, isSearchLoading, foundNode, router, nodeName, searchData]);
|
||||
}, [isWaitingForSearch, isSearchLoading, foundNode, router, nodeName, searchData, exact, is_repeater]);
|
||||
|
||||
// Popover content component
|
||||
const PopoverContent = () => {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface PathDisplayProps {
|
||||
path: string;
|
||||
origin_pubkey: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PathDisplay({
|
||||
path,
|
||||
origin_pubkey,
|
||||
className = ""
|
||||
}: PathDisplayProps) {
|
||||
// Parse path into 2-character slices
|
||||
const pathSlices = path.match(/.{1,2}/g) || [];
|
||||
const formattedPath = pathSlices.join(' ');
|
||||
|
||||
// Get first 2 characters of the pubkey for display
|
||||
const pubkeyPrefix = origin_pubkey.substring(0, 2);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<span className="font-mono text-sm">{formattedPath}</span>
|
||||
<span className="text-blue-600 dark:text-blue-400 text-sm">({pubkeyPrefix})</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,27 +6,18 @@ import Link from "next/link";
|
||||
import Tree from 'react-d3-tree';
|
||||
import { ArrowsPointingOutIcon, ArrowsPointingInIcon } from "@heroicons/react/24/outline";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import PathDisplay from "./PathDisplay";
|
||||
import NodeLinkWithHover from "./NodeLinkWithHover";
|
||||
import { useMeshcoreSearches } from "@/hooks/useMeshcoreSearch";
|
||||
import type { MeshcoreSearchResult } from "@/hooks/useMeshcoreSearch";
|
||||
import { useConfigWithRegion } from "@/hooks/useConfigWithRegion";
|
||||
|
||||
export interface PathData {
|
||||
origin: string;
|
||||
pubkey: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface PathGroup {
|
||||
path: string;
|
||||
pathSlices: string[];
|
||||
indices: number[];
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
children?: TreeNode[];
|
||||
}
|
||||
import {
|
||||
PathData,
|
||||
PathGroup,
|
||||
TreeNode,
|
||||
groupPathsByStructure,
|
||||
buildTreeFromPathGroups,
|
||||
extractUniquePrefixes
|
||||
} from "@/lib/pathUtils";
|
||||
|
||||
interface PathVisualizationProps {
|
||||
paths: PathData[];
|
||||
@@ -53,77 +44,23 @@ export default function PathVisualization({
|
||||
const { config } = useConfigWithRegion();
|
||||
const pathsCount = paths.length;
|
||||
|
||||
// Group paths by structure
|
||||
const pathGroups = useMemo(() =>
|
||||
groupPathsByStructure(paths),
|
||||
[paths]
|
||||
);
|
||||
|
||||
// Process data for tree visualization
|
||||
const treeData = useMemo(() => {
|
||||
if (!showGraph || pathsCount === 0) return null;
|
||||
|
||||
// Group messages by path similarity
|
||||
const pathGroups: PathGroup[] = [];
|
||||
|
||||
paths.forEach(({ origin, pubkey, path }, index) => {
|
||||
// Parse path into 2-character slices and include pubkey as final hop
|
||||
const pathSlices = path.match(/.{1,2}/g) || [];
|
||||
const pubkeyPrefix = pubkey.substring(0, 2);
|
||||
const fullPathSlices = [...pathSlices, pubkeyPrefix];
|
||||
|
||||
// Find existing group with same path structure
|
||||
const existingGroup = pathGroups.find(group =>
|
||||
group.pathSlices.length === fullPathSlices.length &&
|
||||
group.pathSlices.every((slice, i) => slice === fullPathSlices[i])
|
||||
);
|
||||
|
||||
if (existingGroup) {
|
||||
existingGroup.indices.push(index);
|
||||
} else {
|
||||
pathGroups.push({
|
||||
path: path + pubkeyPrefix,
|
||||
pathSlices: fullPathSlices,
|
||||
indices: [index]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Build tree structure for react-d3-tree
|
||||
const buildTree = (): TreeNode => {
|
||||
const rootName = initiatingNodeKey ? initiatingNodeKey.substring(0, 2) : "??";
|
||||
const root: TreeNode = { name: rootName, children: [] };
|
||||
|
||||
pathGroups.forEach(group => {
|
||||
let currentNode = root;
|
||||
|
||||
group.pathSlices.forEach((slice, level) => {
|
||||
let child = currentNode.children?.find(c => c.name === slice);
|
||||
|
||||
if (!child) {
|
||||
child = { name: slice, children: [] };
|
||||
if (!currentNode.children) currentNode.children = [];
|
||||
currentNode.children.push(child);
|
||||
}
|
||||
|
||||
currentNode = child;
|
||||
});
|
||||
});
|
||||
|
||||
return root;
|
||||
};
|
||||
|
||||
return buildTree();
|
||||
}, [showGraph, paths, pathsCount, initiatingNodeKey]);
|
||||
return buildTreeFromPathGroups(pathGroups, initiatingNodeKey);
|
||||
}, [showGraph, pathsCount, pathGroups, initiatingNodeKey]);
|
||||
|
||||
// Extract unique prefixes from tree data for name lookups
|
||||
const uniquePrefixes = useMemo(() => {
|
||||
if (!treeData) return [];
|
||||
|
||||
const prefixes = new Set<string>();
|
||||
|
||||
const extractPrefixes = (node: TreeNode) => {
|
||||
prefixes.add(node.name);
|
||||
node.children?.forEach(extractPrefixes);
|
||||
};
|
||||
|
||||
extractPrefixes(treeData);
|
||||
return Array.from(prefixes);
|
||||
}, [treeData]);
|
||||
const uniquePrefixes = useMemo(() =>
|
||||
extractUniquePrefixes(treeData),
|
||||
[treeData]
|
||||
);
|
||||
|
||||
// Use the new useMeshcoreSearches hook to handle multiple prefix searches
|
||||
// Filter out "??" prefix and only search for valid hex prefixes
|
||||
@@ -187,23 +124,27 @@ export default function PathVisualization({
|
||||
|
||||
const PathsList = useCallback(() => (
|
||||
<div className="mt-1 p-2 bg-gray-100 dark:bg-neutral-700 rounded text-xs break-all text-gray-800 dark:text-gray-200">
|
||||
{paths.map(({ origin, pubkey, path }, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/meshcore/node/${pubkey}`}
|
||||
className="hover:underline cursor-pointer"
|
||||
>
|
||||
{origin}
|
||||
</Link>
|
||||
<PathDisplay
|
||||
path={path}
|
||||
origin_pubkey={pubkey}
|
||||
className="text-xs"
|
||||
/>
|
||||
{pathGroups.map((group, groupIndex) => (
|
||||
<div key={groupIndex} className="mb-2 last:mb-0">
|
||||
<div className="flex flex-wrap gap-1 ml-2 items-center">
|
||||
{group.pathSlices.map((slice, sliceIndex) => (
|
||||
<NodeLinkWithHover key={sliceIndex} nodeName={slice} exact={false} is_repeater={true}>
|
||||
<span className="text-gray-800 dark:text-gray-200 text-xs">
|
||||
{slice}
|
||||
</span>
|
||||
</NodeLinkWithHover>
|
||||
))}
|
||||
{group.count > 1 && (
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xs ml-1">
|
||||
(x{group.count})
|
||||
{/* TODO: this doesnt work? */}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
), [paths]);
|
||||
), [pathGroups]);
|
||||
|
||||
// Memoize the render function to prevent unnecessary re-renders
|
||||
const renderCustomNodeElement = useCallback(({ nodeDatum, toggleNode }: any) => {
|
||||
@@ -333,7 +274,7 @@ export default function PathVisualization({
|
||||
<div className={className}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{pathsCount} path{pathsCount !== 1 ? 's' : ''}
|
||||
Heard {pathsCount} time{pathsCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{pathsCount > 0 && (
|
||||
<button
|
||||
@@ -368,7 +309,7 @@ export default function PathVisualization({
|
||||
onClick={handleToggle}
|
||||
className="flex items-center gap-1 hover:text-gray-800 dark:hover:text-gray-100 transition-colors"
|
||||
>
|
||||
<span>{pathsCount} path{pathsCount !== 1 ? 's' : ''}</span>
|
||||
<span>Heard {pathsCount} time{pathsCount !== 1 ? 's' : ''}</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
|
||||
@@ -109,16 +109,18 @@ export interface SearchQuery {
|
||||
q: string;
|
||||
limit?: number;
|
||||
exact?: boolean;
|
||||
is_repeater?: boolean;
|
||||
}
|
||||
|
||||
export function useSearchQuery() {
|
||||
const { query, setParam } = useQueryParams<SearchQuery>({ q: '', limit: 50, exact: false });
|
||||
const { query, setParam } = useQueryParams<SearchQuery>({ q: '', limit: 50, exact: false, is_repeater: 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);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
export interface PathData {
|
||||
origin: string;
|
||||
pubkey: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface PathGroup {
|
||||
path: string;
|
||||
pathSlices: string[];
|
||||
indices: number[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
name: string;
|
||||
children?: TreeNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups paths by their structure similarity
|
||||
*/
|
||||
export function groupPathsByStructure(paths: PathData[]): PathGroup[] {
|
||||
const pathGroups: PathGroup[] = [];
|
||||
|
||||
paths.forEach(({ origin, pubkey, path }, index) => {
|
||||
// Parse path into 2-character slices and include pubkey as final hop
|
||||
const pathSlices = path.match(/.{1,2}/g) || [];
|
||||
const pubkeyPrefix = pubkey.substring(0, 2);
|
||||
const fullPathSlices = [...pathSlices, pubkeyPrefix];
|
||||
|
||||
// Find existing group with same path structure
|
||||
const existingGroup = pathGroups.find(group =>
|
||||
group.pathSlices.length === fullPathSlices.length &&
|
||||
group.pathSlices.every((slice, i) => slice === fullPathSlices[i])
|
||||
);
|
||||
|
||||
if (existingGroup) {
|
||||
existingGroup.indices.push(index);
|
||||
existingGroup.count++;
|
||||
} else {
|
||||
pathGroups.push({
|
||||
path: path + pubkeyPrefix,
|
||||
pathSlices: fullPathSlices,
|
||||
indices: [index],
|
||||
count: 1
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return pathGroups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a tree structure from path groups for visualization
|
||||
*/
|
||||
export function buildTreeFromPathGroups(pathGroups: PathGroup[], initiatingNodeKey?: string): TreeNode {
|
||||
const rootName = initiatingNodeKey ? initiatingNodeKey.substring(0, 2) : "??";
|
||||
const root: TreeNode = { name: rootName, children: [] };
|
||||
|
||||
pathGroups.forEach(group => {
|
||||
let currentNode = root;
|
||||
|
||||
group.pathSlices.forEach((slice) => {
|
||||
let child = currentNode.children?.find(c => c.name === slice);
|
||||
|
||||
if (!child) {
|
||||
child = { name: slice, children: [] };
|
||||
if (!currentNode.children) currentNode.children = [];
|
||||
currentNode.children.push(child);
|
||||
}
|
||||
|
||||
currentNode = child;
|
||||
});
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all unique prefixes from a tree structure
|
||||
*/
|
||||
export function extractUniquePrefixes(treeData: TreeNode | null): string[] {
|
||||
if (!treeData) return [];
|
||||
|
||||
const prefixes = new Set<string>();
|
||||
|
||||
const extractPrefixes = (node: TreeNode) => {
|
||||
prefixes.add(node.name);
|
||||
node.children?.forEach(extractPrefixes);
|
||||
};
|
||||
|
||||
extractPrefixes(treeData);
|
||||
return Array.from(prefixes);
|
||||
}
|
||||
Reference in New Issue
Block a user