feat: refactoring

This commit is contained in:
2026-04-05 10:24:01 +02:00
parent e1db06104e
commit ad89f409bf
15 changed files with 662 additions and 756 deletions

View File

@@ -1,198 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Graph } from "@cosmos.gl/graph";
import { subscribeBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client";
import { renderMicron } from "@/components/editor/micronRenderer";
import FloatingWindow, { DITHERED_SHADOW } from "@/components/shared/FloatingWindow";
import { useWindowManager } from "@/hooks/useWindowManager";
// ---------------------------------------------------------------------------
// Constants — matching cosmos.gl clusters-with-labels example
// ---------------------------------------------------------------------------
const SPACE_SIZE = 4096;
const CENTER = SPACE_SIZE / 2;
// ---------------------------------------------------------------------------
// Theme-aware status colors — reads CSS variables, returns 01 RGBA
// ---------------------------------------------------------------------------
function cssVarToRGBA(varName: string): [number, number, number, number] {
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
if (!raw) return [0.5, 0.5, 0.5, 1];
const ctx = document.createElement("canvas").getContext("2d")!;
ctx.fillStyle = raw;
// ctx.fillStyle normalizes to #rrggbb
const hex = ctx.fillStyle;
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
return [r, g, b, 1];
}
function lerpRGBA(
a: [number, number, number, number],
b: [number, number, number, number],
t: number,
): [number, number, number, number] {
return [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
a[3] + (b[3] - a[3]) * t,
];
}
function brighten(c: [number, number, number, number], amount: number): [number, number, number, number] {
return [
Math.min(1, c[0] + amount),
Math.min(1, c[1] + amount),
Math.min(1, c[2] + amount),
c[3],
];
}
function getThemeStatusColors() {
const primary = brighten(cssVarToRGBA("--primary"), 0.15);
const muted = cssVarToRGBA("--muted-foreground");
return {
online: primary, // --primary brightened
stale: lerpRGBA(primary, muted, 0.4), // blend, closer to primary
offline: muted, // --muted-foreground
};
}
function statusRGBA(entry: NetworkNode, theme: ReturnType<typeof getThemeStatusColors>): [number, number, number, number] {
const age = Date.now() / 1000 - (entry.last_seen ?? 0);
if (age < 300) return theme.online;
if (age < 3600) return theme.stale;
return theme.offline;
}
// ---------------------------------------------------------------------------
// Build flat arrays for cosmos — positions around spaceSize/2
// ---------------------------------------------------------------------------
interface BuiltGraph {
entries: NetworkNode[];
positions: Float32Array;
colors: Float32Array;
sizes: Float32Array;
clusterIndices: (number | undefined)[];
clusterPositions: (number | undefined)[];
clusterStrength: Float32Array;
clusterNames: string[];
hashToIndex: Map<string, number>;
}
function findParentIface(
entry: NetworkNode,
interfaces: NetworkNode[],
peerIndex: number,
): string | undefined {
if (entry.interface) {
const iface = interfaces.find(i => i.name === entry.interface);
if (iface) return iface.hash;
}
if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length].hash;
return undefined;
}
function buildGraphArrays(
rawNodes: NetworkNode[],
prevHashToIndex: Map<string, number>,
prevPositions: number[],
theme: ReturnType<typeof getThemeStatusColors>,
): BuiltGraph {
const interfaces = rawNodes.filter(e => e.type === "interface").sort((a, b) => a.name.localeCompare(b.name));
const selfNode = rawNodes.find(e => e.is_self && e.type !== "interface");
const peers = rawNodes.filter(e => !e.is_self && e.type !== "interface");
// Peers + self node are points — interfaces are clusters, not nodes
// Self node goes first so it's easy to find by index
const entries = selfNode ? [selfNode, ...peers] : peers;
const selfIndex = selfNode ? 0 : -1;
const n = entries.length;
const nClusters = interfaces.length;
const hashToIndex = new Map<string, number>();
const positions = new Float32Array(n * 2);
const colors = new Float32Array(n * 4);
const sizes = new Float32Array(n);
const clusterIndices: (number | undefined)[] = [];
const clusterStrength = new Float32Array(n);
// Cluster map: interface name → cluster index
// Self node gets its own cluster at the end
const clusterMap = new Map<string, number>();
interfaces.forEach((iface, i) => clusterMap.set(iface.name, i));
const selfClusterIndex = interfaces.length;
// Cluster names for labels
const clusterNames = [...interfaces.map(i => i.name), ...(selfNode ? [selfNode.name] : [])];
// No explicit cluster positions — let cosmos use centermass
const clusterPositions: (number | undefined)[] = [];
entries.forEach((entry, i) => {
hashToIndex.set(entry.hash, i);
// Position: preserve existing, or jitter near center for new points
const prevIdx = prevHashToIndex.get(entry.hash);
if (prevIdx !== undefined && prevPositions.length >= (prevIdx + 1) * 2) {
positions[i * 2] = prevPositions[prevIdx * 2]!;
positions[i * 2 + 1] = prevPositions[prevIdx * 2 + 1]!;
} else {
positions[i * 2] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5;
positions[i * 2 + 1] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5;
}
// Colors — self node uses bright white, others by status
const rgba = i === selfIndex
? [1, 1, 1, 1] as [number, number, number, number]
: statusRGBA(entry, theme);
colors[i * 4 + 0] = rgba[0];
colors[i * 4 + 1] = rgba[1];
colors[i * 4 + 2] = rgba[2];
colors[i * 4 + 3] = rgba[3];
sizes[i] = 3;
// Cluster assignment — self node gets its own cluster, peers map to parent interface
if (i === selfIndex) {
clusterIndices.push(selfClusterIndex);
} else {
const pi = peers.indexOf(entry);
const parentHash = findParentIface(entry, interfaces, pi);
const parentName = parentHash ? interfaces.find(f => f.hash === parentHash)?.name : undefined;
clusterIndices.push(parentName !== undefined ? clusterMap.get(parentName) : undefined);
}
// Cluster strength
clusterStrength[i] = nClusters > 1 ? (nClusters - (i % nClusters)) / nClusters : 1;
});
return { entries, positions, colors, sizes, clusterIndices, clusterPositions, clusterStrength, clusterNames, hashToIndex };
}
// ---------------------------------------------------------------------------
// Browse window data
// ---------------------------------------------------------------------------
interface HistoryEntry {
path: string;
html: string | null;
error: string | null;
}
interface BrowseWinData {
node: NetworkNode;
pageHtml: string | null;
pageLoading: boolean;
pageError: string | null;
currentPath: string;
history: HistoryEntry[];
historyIndex: number;
}
import { getThemeStatusColors } from "@/components/browse/graphColors";
import { buildGraphArrays, SPACE_SIZE } from "@/components/browse/buildGraph";
import type { BrowseWinData, HistoryEntry } from "@/components/browse/types";
import BrowseNodeWindow from "@/components/browse/BrowseNodeWindow";
import BrowseSearchBar from "@/components/browse/BrowseSearchBar";
// ---------------------------------------------------------------------------
// Main component
@@ -214,7 +29,6 @@ export default function BrowseView() {
}, []);
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const graphRef = useRef<Graph | null>(null);
const entriesRef = useRef<NetworkNode[]>([]);
const nodesMapRef = useRef<Map<string, NetworkNode>>(new Map());
@@ -257,11 +71,6 @@ export default function BrowseView() {
.slice(0, 8);
}, [filter, graphData]);
const [selectedSuggestion, setSelectedSuggestion] = useState(-1);
// Reset selection when suggestions change
useEffect(() => { setSelectedSuggestion(-1); }, [suggestions]);
// ── Cluster labels — direct DOM like the example's create-cluster-labels.ts ──
const labelDivsRef = useRef<HTMLDivElement[]>([]);
const updateClusterLabels = useCallback(() => {
@@ -299,7 +108,6 @@ export default function BrowseView() {
}
}
// Update positions
for (let i = 0; i < nClusters; i++) {
const x = positions[i * 2];
const y = positions[i * 2 + 1];
@@ -311,97 +119,7 @@ export default function BrowseView() {
}
}, []);
// ── Init Cosmos — matching the example's create-cosmos.ts ──
useEffect(() => {
if (!containerRef.current) return;
const graph = new Graph(containerRef.current, {
spaceSize: SPACE_SIZE,
backgroundColor: "transparent",
pointDefaultColor: "#888888",
pointDefaultSize: 10,
renderLinks: false,
fitViewOnInit: true,
fitViewDelay: 1500,
fitViewPadding: 0.2,
renderHoveredPointRing: true,
hoveredPointRingColor: "#ffffff",
scalePointsOnZoom: true,
pointGreyoutOpacity: 0.1,
// Simulation defaults — dynamically adjusted by node count in data update
simulationGravity: 0.5,
simulationRepulsion: 1,
simulationCluster: 0.5,
simulationDecay: 5000,
simulationFriction: 0.85,
simulationLinkSpring: 0,
simulationLinkDistance: 1,
// Events
onClick: (index, _pos, _event) => {
if (index === undefined) return;
const entry = entriesRef.current[index];
if (entry && entry.type !== "interface") handleNodeClick(entry);
},
onMouseMove: (index, pointPosition) => {
if (index === undefined || !pointPosition || !graphRef.current) {
setHoveredLabel(null);
return;
}
const entry = entriesRef.current[index];
if (!entry) return;
const screen = graphRef.current.spaceToScreenPosition(pointPosition);
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
},
onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); },
onZoom: () => { updateClusterLabels(); updateSearchLabels(); },
});
graphRef.current = graph;
return () => {
labelDivsRef.current.forEach(d => d.remove());
labelDivsRef.current = [];
graph.destroy();
graphRef.current = null;
};
}, []);
// ── Update data — following the example's exact call order ──
const isFirstLoadRef = useRef(true);
useEffect(() => {
const graph = graphRef.current;
if (!graph || graphData.entries.length === 0) return;
// Scale simulation params by node count
// Reference: 10k nodes → repulsion 10, cluster 0.25, gravity 2
// Scale logarithmically so it works from 10 to 10k nodes
const n = graphData.entries.length;
const scale = Math.log10(Math.max(10, n)) / Math.log10(10000); // 0..1
graph.setConfig({
simulationRepulsion: 0.5 + scale * 9.5, // 0.5 → 10
simulationCluster: 1.0 - scale * 0.75, // 1.0 → 0.25
simulationGravity: 0.25 + scale * 1.75, // 0.25 → 2
});
graph.setPointPositions(graphData.positions);
graph.setPointColors(graphData.colors);
graph.setPointSizes(graphData.sizes);
graph.setPointClusters(graphData.clusterIndices);
graph.setClusterPositions(graphData.clusterPositions);
graph.setPointClusterStrength(graphData.clusterStrength);
graph.setLinks(new Float32Array(0));
if (isFirstLoadRef.current) {
graph.render(1);
isFirstLoadRef.current = false;
} else {
graph.render(0.1);
}
updateClusterLabels();
}, [graphData]);
// ── Search highlighting + labels ──
// ── Search highlighting labels ──
const searchLabelDivsRef = useRef<Map<number, HTMLDivElement>>(new Map());
const searchIndicesRef = useRef<number[] | null>(null);
@@ -411,7 +129,6 @@ export default function BrowseView() {
searchIndicesRef.current = null;
}, []);
// Reposition search labels (called on tick/zoom alongside cluster labels)
const updateSearchLabels = useCallback(() => {
const graph = graphRef.current;
const indices = searchIndicesRef.current;
@@ -454,6 +171,92 @@ export default function BrowseView() {
}
}, []);
// ── Init Cosmos ──
useEffect(() => {
if (!containerRef.current) return;
const graph = new Graph(containerRef.current, {
spaceSize: SPACE_SIZE,
backgroundColor: "transparent",
pointDefaultColor: "#888888",
pointDefaultSize: 10,
renderLinks: false,
fitViewOnInit: true,
fitViewDelay: 1500,
fitViewPadding: 0.2,
renderHoveredPointRing: true,
hoveredPointRingColor: "#ffffff",
scalePointsOnZoom: true,
pointGreyoutOpacity: 0.1,
simulationGravity: 0.5,
simulationRepulsion: 1,
simulationCluster: 0.5,
simulationDecay: 5000,
simulationFriction: 0.85,
simulationLinkSpring: 0,
simulationLinkDistance: 1,
onClick: (index, _pos, _event) => {
if (index === undefined) return;
const entry = entriesRef.current[index];
if (entry && entry.type !== "interface") handleNodeClick(entry);
},
onMouseMove: (index, pointPosition) => {
if (index === undefined || !pointPosition || !graphRef.current) {
setHoveredLabel(null);
return;
}
const entry = entriesRef.current[index];
if (!entry) return;
const screen = graphRef.current.spaceToScreenPosition(pointPosition);
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
},
onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); },
onZoom: () => { updateClusterLabels(); updateSearchLabels(); },
});
graphRef.current = graph;
return () => {
labelDivsRef.current.forEach(d => d.remove());
labelDivsRef.current = [];
graph.destroy();
graphRef.current = null;
};
}, []);
// ── Update graph data ──
const isFirstLoadRef = useRef(true);
useEffect(() => {
const graph = graphRef.current;
if (!graph || graphData.entries.length === 0) return;
const n = graphData.entries.length;
const scale = Math.log10(Math.max(10, n)) / Math.log10(10000);
graph.setConfig({
simulationRepulsion: 0.5 + scale * 9.5,
simulationCluster: 1.0 - scale * 0.75,
simulationGravity: 0.25 + scale * 1.75,
});
graph.setPointPositions(graphData.positions);
graph.setPointColors(graphData.colors);
graph.setPointSizes(graphData.sizes);
graph.setPointClusters(graphData.clusterIndices);
graph.setClusterPositions(graphData.clusterPositions);
graph.setPointClusterStrength(graphData.clusterStrength);
graph.setLinks(new Float32Array(0));
if (isFirstLoadRef.current) {
graph.render(1);
isFirstLoadRef.current = false;
} else {
graph.render(0.1);
}
updateClusterLabels();
}, [graphData]);
// ── Search highlighting ──
useEffect(() => {
const graph = graphRef.current;
if (!graph) return;
@@ -466,7 +269,6 @@ export default function BrowseView() {
if (searchMatchIndices.length <= 10) {
graph.fitViewByPointIndices(searchMatchIndices, 500);
}
// Initial position — will be continuously updated on tick/zoom
updateSearchLabels();
} else {
graph.unselectPoints();
@@ -510,7 +312,6 @@ export default function BrowseView() {
const error = res.content ? null : (res.error ?? "No content");
const entry: HistoryEntry = { path, html, error };
// Build new history: truncate any forward entries, push new
const prevHistory = loading.history.slice(0, loading.historyIndex + 1);
const newHistory = [...prevHistory, entry];
const newIndex = newHistory.length - 1;
@@ -562,11 +363,8 @@ export default function BrowseView() {
const dest = anchor.getAttribute("data-destination") ?? anchor.getAttribute("href") ?? "";
if (!dest) return;
// Strip nomadnetwork:// prefix if present, normalize path
let path = dest.replace(/^nomadnetwork:\/\//, "").replace(/^\/+/, "");
// If it looks like a hash (hex, 32 chars), it's a node link — not a page path
if (/^[0-9a-f]{32}$/i.test(path)) return;
// Ensure .mu extension
if (!path.endsWith(".mu")) path += ".mu";
navigateTo(winId, data.node, path, data);
@@ -579,41 +377,6 @@ export default function BrowseView() {
updateClusterLabels();
}, [updateClusterLabels]);
// ── Capture typing into search when no window is focused ──
useEffect(() => { searchInputRef.current?.focus(); }, []);
useEffect(() => {
if (windows.length === 0) searchInputRef.current?.focus();
}, [windows.length]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
// Skip if a window is focused, or already in the search input, or modifier keys
if (focusedWinId) return;
if (document.activeElement === searchInputRef.current) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key.length !== 1) return; // only printable characters
searchInputRef.current?.focus();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [focusedWinId]);
// ── Draggable search bar ──
const [searchPos, setSearchPos] = useState<{ x: number; y: number } | null>(null);
const searchDragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
useEffect(() => { setSearchPos({ x: Math.round(window.innerWidth / 2 - 200), y: window.innerHeight - 307 }); }, []);
const onSearchDragStart = useCallback((e: React.MouseEvent) => {
if ((e.target as HTMLElement).tagName === "INPUT") return;
e.preventDefault();
const pos = searchPos ?? { x: 0, y: 0 };
searchDragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
const onMove = (ev: MouseEvent) => { if (!searchDragRef.current) return; setSearchPos({ x: searchDragRef.current.origX + (ev.clientX - searchDragRef.current.startX), y: Math.max(0, searchDragRef.current.origY + (ev.clientY - searchDragRef.current.startY)) }); };
const onUp = () => { searchDragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
}, [searchPos]);
return (
<div className="relative overflow-hidden" style={{ height: "100%" }}>
<div ref={containerRef} className="absolute inset-0" />
@@ -626,60 +389,15 @@ export default function BrowseView() {
</div>
)}
{/* Search bar */}
{searchPos && createPortal(
<div onMouseDown={onSearchDragStart}
className="fixed z-999 flex items-center gap-3 px-3 py-1.5 bg-popover border-2 border-border focus-within:border-primary rounded-lg cursor-grab active:cursor-grabbing transition-[border-color] duration-150"
style={{ left: searchPos.x, top: searchPos.y, width: 400, boxShadow: DITHERED_SHADOW }}>
<input ref={searchInputRef} type="text" value={filter}
onChange={(e) => setFilter(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") { clearSearch(); e.currentTarget.blur(); return; }
if (e.key === "ArrowDown") { e.preventDefault(); setSelectedSuggestion(i => Math.min(i + 1, suggestions.length - 1)); return; }
if (e.key === "ArrowUp") { e.preventDefault(); setSelectedSuggestion(i => Math.max(i - 1, -1)); return; }
if (e.key === "Enter") {
e.preventDefault();
const entry = selectedSuggestion >= 0 ? suggestions[selectedSuggestion] : suggestions[0];
if (entry && entry.type !== "interface") { handleNodeClick(entry); clearSearch(); }
return;
}
}}
placeholder="Search nodes..."
className="flex-1 h-7 px-2 text-xs bg-background/60 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary cursor-text" />
{filter && (
<button onClick={clearSearch} className="text-muted-foreground hover:text-foreground transition-colors text-xs leading-none px-1" title="Clear search (Esc)">
&times;
</button>
)}
{/* Autocomplete dropdown */}
{suggestions.length > 0 && (
<div className="absolute left-0 right-0 top-full mt-1 bg-popover border border-border rounded-lg overflow-hidden"
style={{ boxShadow: "0 4px 12px rgba(0,0,0,0.4)" }}>
{suggestions.map((entry, i) => (
<button
key={entry.hash}
onMouseDown={(e) => { e.preventDefault(); if (entry.type !== "interface") { handleNodeClick(entry); clearSearch(); } }}
onMouseEnter={() => setSelectedSuggestion(i)}
className={`w-full text-left px-3 py-1.5 text-xs font-mono flex items-center gap-2 transition-colors ${i === selectedSuggestion ? "bg-accent text-accent-foreground" : "text-foreground hover:bg-accent/50"
}`}
>
<span className="w-2 h-2 rounded-full shrink-0" style={{
backgroundColor: (() => {
const age = Date.now() / 1000 - (entry.last_seen ?? 0);
if (age < 300) return "var(--primary)";
if (age < 3600) return "var(--muted-foreground)";
return "var(--border)";
})(),
}} />
<span className="truncate">{entry.name}</span>
<span className="ml-auto text-[9px] text-muted-foreground uppercase shrink-0">
{entry.interface ?? "peer"}
</span>
</button>
))}
</div>
)}
</div>, document.body)}
<BrowseSearchBar
filter={filter}
onFilterChange={setFilter}
onClear={clearSearch}
suggestions={suggestions}
onSelectNode={handleNodeClick}
focusedWinId={focusedWinId}
windowCount={windows.length}
/>
{nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm pointer-events-none">
@@ -687,63 +405,20 @@ export default function BrowseView() {
</div>
)}
{windows.map((win) => {
const d = win.data;
const canBack = d.historyIndex > 0;
const canFwd = d.historyIndex < d.history.length - 1;
return (
<FloatingWindow
key={win.id}
id={win.id}
title={win.data.node.name}
x={win.x} y={win.y} w={win.w} h={win.h}
zIndex={win.zIndex}
focused={focusedWinId === win.id}
onUpdate={updateWindow}
onClose={closeWindowById}
onFocus={focusWindow}
addressBar={
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border shrink-0 bg-muted/15">
{/* Nav buttons */}
<button onClick={() => navBack(win.id, d)} disabled={!canBack || d.pageLoading}
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Back">
&#9664;
</button>
<button onClick={() => navForward(win.id, d)} disabled={!canFwd || d.pageLoading}
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Forward">
&#9654;
</button>
<button onClick={() => navReload(win.id, d)} disabled={d.pageLoading}
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Reload">
&#8635;
</button>
{/* Address */}
<div className="flex-1 flex items-center h-5 px-2 bg-background/60 border border-border rounded text-[10px] font-mono text-foreground/80 truncate">
<span className="text-muted-foreground/60 truncate">{d.node.hash.slice(0, 12)}/</span>{d.currentPath}
</div>
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
{d.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
: d.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
: d.pageHtml ? <><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /><span className="text-muted-foreground">ok</span></> : null}
</span>
</div>
}
footer={
<div className="flex items-center gap-3 px-3 py-1 border-t border-border shrink-0 bg-muted/15 rounded-b-lg">
<span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">{d.node.type ?? "peer"}</span>
{d.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {d.node.interface}</span>}
</div>
}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="p-3 h-full overflow-auto" onClick={(e) => handleContentClick(e, win.id, d)}>
{d.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>}
{d.pageError && <span className="text-destructive text-xs">{d.pageError}</span>}
{d.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: d.pageHtml }} />}
</div>
</FloatingWindow>
);
})}
{windows.map((win) => (
<BrowseNodeWindow
key={win.id}
win={win}
focused={focusedWinId === win.id}
onUpdate={updateWindow}
onClose={closeWindowById}
onFocus={focusWindow}
onNavBack={navBack}
onNavForward={navForward}
onNavReload={navReload}
onContentClick={handleContentClick}
/>
))}
</div>
);
}