diff --git a/backend/browse.py b/backend/browse.py new file mode 100644 index 0000000..f3f74ed --- /dev/null +++ b/backend/browse.py @@ -0,0 +1,245 @@ +"""Network browser — discovers NomadNet nodes and fetches pages via Reticulum. + +Starts an RNS transport on FastAPI startup, listens for NomadNet node +announces, and exposes discovered nodes + remote page fetching via API. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import threading +import time +from pathlib import Path + +from fastapi import APIRouter, Query + +router = APIRouter() +log = logging.getLogger("browse") + +# --------------------------------------------------------------------------- +# Module-level state +# --------------------------------------------------------------------------- + +_nodes: dict[str, dict] = {} # hash_hex -> node info +_own_hash: str | None = None +_own_name: str = os.environ.get("NOMADNET_NODE_NAME", "Micronomicon") +_lock = threading.Lock() +_started = False + + +# --------------------------------------------------------------------------- +# RNS lifecycle +# --------------------------------------------------------------------------- + +def start_browser() -> None: + """Initialize RNS and begin listening for NomadNet node announces.""" + global _started, _own_hash + + if _started: + return + + try: + import RNS + + configdir = os.environ.get("RNS_CONFIG_DIR", None) + if configdir: + Path(configdir).mkdir(parents=True, exist_ok=True) + + reticulum = RNS.Reticulum(configdir=configdir) + + # Register handler for NomadNet page-serving node announces + RNS.Transport.register_announce_handler( + _on_announce, + aspect_filter="nomadnetwork.node", + ) + + _started = True + log.info("RNS browser started (v%s)", RNS.__version__) + + except Exception as exc: + log.warning("Failed to start RNS browser: %s", exc) + + +def _on_announce( + destination_hash: bytes, + announced_identity, + app_data: bytes | None, +) -> None: + """Handle an incoming NomadNet node announce.""" + import RNS + + hash_hex = RNS.hexrep(destination_hash, delimit=False) + + name = hash_hex[:12] + if app_data: + try: + name = app_data.decode("utf-8") + except Exception: + pass + + is_self = name == _own_name + + with _lock: + _nodes[hash_hex] = { + "hash": hash_hex, + "name": name, + "last_seen": time.time(), + "is_self": is_self, + } + if is_self: + global _own_hash + _own_hash = hash_hex + + log.info("Node announce: %s (%s)%s", name, hash_hex[:8], " [self]" if is_self else "") + + +# --------------------------------------------------------------------------- +# API endpoints +# --------------------------------------------------------------------------- + +@router.get("/browse/nodes") +async def list_nodes(): + """Return all discovered NomadNet page-serving nodes.""" + with _lock: + nodes = list(_nodes.values()) + + # Ensure the user's own node is always present + if not any(n["is_self"] for n in nodes): + nodes.insert(0, { + "hash": _own_hash or "self", + "name": _own_name, + "last_seen": time.time(), + "is_self": True, + }) + + return nodes + + +@router.get("/browse/page/{hash_hex}") +async def get_remote_page(hash_hex: str, path: str = Query("index.mu")): + """Fetch a Micron page from a node. + + For the user's own node reads from the local pages directory. + For remote nodes establishes an RNS link and requests the page. + """ + # Own node → read from disk + with _lock: + node = _nodes.get(hash_hex) + if (node and node.get("is_self")) or hash_hex == "self": + return _read_local_page(path) + + # Remote node → RNS request + content = await _request_remote_page(hash_hex, path) + if content is None: + return {"content": None, "error": "Could not reach node"} + return {"content": content} + + +# --------------------------------------------------------------------------- +# Local page reader +# --------------------------------------------------------------------------- + +def _read_local_page(path: str) -> dict: + pages_dir = os.environ.get("PAGES_DIR", str(Path.home() / ".nomadnetwork/storage/pages")) + filepath = Path(pages_dir) / path + try: + return {"content": filepath.read_text()} + except FileNotFoundError: + return {"content": None, "error": "Page not found"} + + +# --------------------------------------------------------------------------- +# Remote page fetcher (RNS link + request/response) +# --------------------------------------------------------------------------- + +async def _request_remote_page(hash_hex: str, path: str) -> str | None: + """Establish an RNS link to a remote NomadNet node and request a page.""" + try: + import RNS + except ImportError: + return None + + loop = asyncio.get_event_loop() + future: asyncio.Future[str | None] = loop.create_future() + + def _do_request(): + try: + dest_hash = bytes.fromhex(hash_hex) + + # Ensure path to destination is known + if not RNS.Transport.has_path(dest_hash): + RNS.Transport.request_path(dest_hash) + deadline = time.time() + 10 + while time.time() < deadline: + if RNS.Transport.has_path(dest_hash): + break + time.sleep(0.1) + else: + loop.call_soon_threadsafe(future.set_result, None) + return + + identity = RNS.Identity.recall(dest_hash) + if not identity: + loop.call_soon_threadsafe(future.set_result, None) + return + + dest = RNS.Destination( + identity, + RNS.Destination.OUT, + RNS.Destination.SINGLE, + "nomadnetwork", + "node", + ) + + link = RNS.Link(dest) + + # Wait for link to become active + deadline = time.time() + 15 + while time.time() < deadline: + if link.status == RNS.Link.ACTIVE: + break + time.sleep(0.1) + else: + link.teardown() + loop.call_soon_threadsafe(future.set_result, None) + return + + # Request the page via NomadNet's protocol + def on_response(request_receipt): + try: + resp = request_receipt.response + if resp is not None: + content = resp.decode("utf-8") if isinstance(resp, bytes) else str(resp) + loop.call_soon_threadsafe(future.set_result, content) + else: + loop.call_soon_threadsafe(future.set_result, None) + except Exception: + loop.call_soon_threadsafe(future.set_result, None) + finally: + link.teardown() + + def on_failed(request_receipt): + if not future.done(): + loop.call_soon_threadsafe(future.set_result, None) + link.teardown() + + link.request( + "/page/" + path, + response_callback=on_response, + failed_callback=on_failed, + ) + + except Exception as exc: + log.warning("Remote page request failed: %s", exc) + if not future.done(): + loop.call_soon_threadsafe(future.set_result, None) + + # Run blocking RNS operations in a thread + threading.Thread(target=_do_request, daemon=True).start() + + try: + return await asyncio.wait_for(future, timeout=30.0) + except asyncio.TimeoutError: + return None diff --git a/backend/main.py b/backend/main.py index 9696ad1..b7c30b7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -7,17 +7,20 @@ from fastapi.staticfiles import StaticFiles from pages import router as pages_router, ensure_default_pages from docker_utils import router as docker_router from converter import router as converter_router +from browse import router as browse_router, start_browser app = FastAPI(title="µFrame Editor") app.include_router(converter_router, prefix="/api") app.include_router(pages_router, prefix="/api") app.include_router(docker_router, prefix="/api") +app.include_router(browse_router, prefix="/api") @app.on_event("startup") async def startup(): ensure_default_pages() + start_browser() @app.get("/api/health") diff --git a/backend/package-lock.json b/backend/package-lock.json new file mode 100644 index 0000000..dfb18f1 --- /dev/null +++ b/backend/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "backend", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/backend/requirements.txt b/backend/requirements.txt index ed8310f..3072f30 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,4 @@ docker>=7.0 Pillow>=10.0 python-multipart>=0.0.6 md2txt @ git+https://codeberg.org/randogoth/md2txt +rns>=0.9.3 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 92fd4ff..3377c1c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", + "@cosmos.gl/graph": "^2.6.4", "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", @@ -663,6 +664,31 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@cosmos.gl/graph": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/@cosmos.gl/graph/-/graph-2.6.4.tgz", + "integrity": "sha512-i+N9lSpAjGLTUPelo/bKNbQnKPDqt3k2UnRlfIWe2Lrambc4J3QFgOfpR8AalQ/1tgLRoeNtVBZ1GPpsNqae5w==", + "license": "MIT", + "dependencies": { + "d3-array": "^3.2.0", + "d3-color": "^3.1.0", + "d3-drag": "^3.0.0", + "d3-ease": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1", + "d3-zoom": "^3.0.0", + "dompurify": "^3.2.6", + "gl-bench": "^1.0.42", + "gl-matrix": "^3.4.3", + "random": "^4.1.0", + "regl": "^2.1.0" + }, + "engines": { + "node": ">=12.2.0", + "npm": ">=7.0.0" + } + }, "node_modules/@dagrejs/dagre": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz", @@ -3290,6 +3316,18 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -3330,6 +3368,15 @@ "node": ">=12" } }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -3342,6 +3389,22 @@ "node": ">=12" } }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-selection": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", @@ -3351,6 +3414,30 @@ "node": ">=12" } }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-timer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", @@ -4389,6 +4476,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gl-bench": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/gl-bench/-/gl-bench-1.0.42.tgz", + "integrity": "sha512-zuMsA/NCPmI8dPy6q3zTUH8OUM5cqKg7uVWwqzrtXJPBqoypM0XeFWEc8iFOqbf/1qtXieWOrbmgFEByKTQt4Q==", + "license": "MIT" + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4607,6 +4706,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -6069,6 +6177,18 @@ ], "license": "MIT" }, + "node_modules/random": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/random/-/random-4.1.0.tgz", + "integrity": "sha512-6Ajb7XmMSE9EFAMGC3kg9mvE7fGlBip25mYYuSMzw/uUSrmGilvZo2qwX3RnTRjwXkwkS+4swse9otZ92VjAtQ==", + "license": "MIT", + "dependencies": { + "seedrandom": "^3.0.5" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6178,6 +6298,12 @@ "node": ">= 4" } }, + "node_modules/regl": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/regl/-/regl-2.1.1.tgz", + "integrity": "sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==", + "license": "MIT" + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6355,6 +6481,12 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 03b89c3..f0e32d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", + "@cosmos.gl/graph": "^2.6.4", "@dagrejs/dagre": "^3.0.0", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c999092..370ae54 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -113,6 +113,33 @@ export async function restartNode(): Promise { if (!res.ok) throw new Error(await res.text()); } +// --------------------------------------------------------------------------- +// Browse — network node discovery + remote pages +// --------------------------------------------------------------------------- + +export interface NetworkNode { + hash: string; + name: string; + last_seen: number; + is_self: boolean; +} + +export async function fetchBrowseNodes(): Promise { + const res = await fetch("/api/browse/nodes"); + const data = await res.json(); + return Array.isArray(data) ? data : []; +} + +export async function fetchRemotePage( + hash: string, + path: string = "index.mu", +): Promise<{ content: string | null; error?: string }> { + const res = await fetch( + `/api/browse/page/${hash}?path=${encodeURIComponent(path)}`, + ); + return res.json(); +} + // --------------------------------------------------------------------------- // Images // --------------------------------------------------------------------------- diff --git a/frontend/src/components/shared/AppShell.tsx b/frontend/src/components/shared/AppShell.tsx index d4aa11b..ea60be2 100644 --- a/frontend/src/components/shared/AppShell.tsx +++ b/frontend/src/components/shared/AppShell.tsx @@ -150,7 +150,7 @@ export default function AppShell({ children }: { children: ReactNode }) { {!isEditor && (
diff --git a/frontend/src/routes/BrowseView.tsx b/frontend/src/routes/BrowseView.tsx index f102e65..16bbb39 100644 --- a/frontend/src/routes/BrowseView.tsx +++ b/frontend/src/routes/BrowseView.tsx @@ -1,7 +1,398 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Graph } from "@cosmos.gl/graph"; +import { fetchBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client"; +import { renderMicron } from "@/components/editor/micronRenderer"; + +// --------------------------------------------------------------------------- +// Theme color maps — hex values matching index.css OKLCH definitions +// --------------------------------------------------------------------------- + +interface ThemeColors { + primary: string; + muted: string; + border: string; +} + +const THEME_COLORS: Record = { + dark: { // .dark (terra) + primary: "#c47a32", + muted: "#8a7560", + border: "#6b5a42", + }, + azure: { // .theme-azure + primary: "#5aa0d4", + muted: "#6d8a9e", + border: "#4a6e88", + }, +}; + +function getThemeId(): string { + const cl = document.documentElement.classList; + if (cl.contains("theme-azure")) return "azure"; + return "dark"; +} + +function hexToRgba255(hex: string): [number, number, number, number] { + return [ + parseInt(hex.slice(1, 3), 16), + parseInt(hex.slice(3, 5), 16), + parseInt(hex.slice(5, 7), 16), + 255, + ]; +} + +// --------------------------------------------------------------------------- +// Data builders — convert NetworkNode[] to Float32Arrays for cosmos.gl +// --------------------------------------------------------------------------- + +function buildBuffers(nodes: NetworkNode[], colors: ThemeColors) { + const sorted = [...nodes].sort((a, b) => (a.is_self ? -1 : b.is_self ? 1 : 0)); + + const n = sorted.length; + const positions = new Float32Array(n * 2); + const pointColors = new Float32Array(n * 4); + const sizes = new Float32Array(n); + const primaryRgba = hexToRgba255(colors.primary); + const mutedRgba = hexToRgba255(colors.muted); + + for (let i = 0; i < n; i++) { + if (sorted[i].is_self) { + positions[i * 2] = 0; + positions[i * 2 + 1] = 0; + } else { + const angle = ((i - 1) / Math.max(1, n - 1)) * Math.PI * 2; + positions[i * 2] = Math.cos(angle) * 100; + positions[i * 2 + 1] = Math.sin(angle) * 100; + } + + const rgba = sorted[i].is_self ? primaryRgba : mutedRgba; + pointColors[i * 4] = rgba[0]; + pointColors[i * 4 + 1] = rgba[1]; + pointColors[i * 4 + 2] = rgba[2]; + pointColors[i * 4 + 3] = rgba[3]; + + sizes[i] = sorted[i].is_self ? 14 : 7; + } + + const linkCount = Math.max(0, n - 1); + const links = new Float32Array(linkCount * 2); + const borderRgba = hexToRgba255(colors.border); + const linkColors = new Float32Array(linkCount * 4); + for (let i = 0; i < linkCount; i++) { + links[i * 2] = 0; + links[i * 2 + 1] = i + 1; + linkColors[i * 4] = borderRgba[0]; + linkColors[i * 4 + 1] = borderRgba[1]; + linkColors[i * 4 + 2] = borderRgba[2]; + linkColors[i * 4 + 3] = 180; + } + + return { sorted, positions, pointColors, sizes, links, linkColors }; +} + +/** Re-apply theme colors to an existing graph instance */ +function applyThemeToGraph(graph: Graph, nodes: NetworkNode[], colors: ThemeColors) { + graph.setConfig({ + pointDefaultColor: colors.primary, + linkDefaultColor: colors.border, + hoveredPointRingColor: colors.primary, + }); + + const n = nodes.length; + if (n === 0) return; + + const pointColors = new Float32Array(n * 4); + const primaryRgba = hexToRgba255(colors.primary); + const mutedRgba = hexToRgba255(colors.muted); + for (let i = 0; i < n; i++) { + const rgba = nodes[i].is_self ? primaryRgba : mutedRgba; + pointColors[i * 4] = rgba[0]; + pointColors[i * 4 + 1] = rgba[1]; + pointColors[i * 4 + 2] = rgba[2]; + pointColors[i * 4 + 3] = rgba[3]; + } + graph.setPointColors(pointColors); + + const linkCount = Math.max(0, n - 1); + if (linkCount > 0) { + const borderRgba = hexToRgba255(colors.border); + const linkColors = new Float32Array(linkCount * 4); + for (let i = 0; i < linkCount; i++) { + linkColors[i * 4] = borderRgba[0]; + linkColors[i * 4 + 1] = borderRgba[1]; + linkColors[i * 4 + 2] = borderRgba[2]; + linkColors[i * 4 + 3] = 180; + } + graph.setLinkColors(linkColors); + } + + graph.render(); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + export default function BrowseView() { + const [nodes, setNodes] = useState([]); + const [filter, setFilter] = useState(""); + const [selectedNode, setSelectedNode] = useState(null); + const [pageHtml, setPageHtml] = useState(null); + const [pageLoading, setPageLoading] = useState(false); + const [pageError, setPageError] = useState(null); + const [labelPositions, setLabelPositions] = useState<{ x: number; y: number }[]>([]); + const [themeId, setThemeId] = useState(getThemeId); + + const containerRef = useRef(null); + const graphRef = useRef(null); + const nodesRef = useRef([]); + const pollRef = useRef | null>(null); + const rafRef = useRef(0); + + const colors = THEME_COLORS[themeId] ?? THEME_COLORS.dark; + + const filteredNodes = useMemo(() => { + if (!filter) return nodes; + const q = filter.toLowerCase(); + return nodes.filter( + (n) => n.name.toLowerCase().includes(q) || n.hash.toLowerCase().includes(q), + ); + }, [nodes, filter]); + + // ── Watch for theme changes ── + useEffect(() => { + const observer = new MutationObserver(() => { + const id = getThemeId(); + setThemeId(id); + const graph = graphRef.current; + if (graph) { + applyThemeToGraph(graph, nodesRef.current, THEME_COLORS[id] ?? THEME_COLORS.dark); + } + }); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); + return () => observer.disconnect(); + }, []); + + // ── Initialize cosmos.gl graph ── + useEffect(() => { + if (!containerRef.current) return; + + const graph = new Graph(containerRef.current, { + backgroundColor: [0, 0, 0, 0], + pointDefaultColor: colors.primary, + pointDefaultSize: 8, + linkDefaultColor: colors.border, + linkDefaultWidth: 1, + linkOpacity: 0.5, + enableSimulation: true, + enableDrag: true, + enableZoom: true, + fitViewOnInit: false, + spaceSize: 1024, + simulationGravity: 0.15, + simulationRepulsion: 0.6, + simulationLinkSpring: 0.3, + simulationLinkDistance: 60, + simulationFriction: 0.85, + simulationDecay: 8000, + renderHoveredPointRing: true, + hoveredPointRingColor: colors.primary, + hoveredPointCursor: "pointer", + onPointClick: (index: number) => { + const node = nodesRef.current[index]; + if (node) handleNodeClick(node); + }, + onSimulationTick: () => updateLabels(), + onZoom: () => updateLabels(), + }); + + graphRef.current = graph; + + return () => { + cancelAnimationFrame(rafRef.current); + graph.destroy(); + graphRef.current = null; + }; + }, []); + + // ── Update labels from graph positions ── + const updateLabels = useCallback(() => { + const graph = graphRef.current; + if (!graph || nodesRef.current.length === 0) return; + + const positions = graph.getPointPositions(); + const next: { x: number; y: number }[] = []; + for (let i = 0; i < nodesRef.current.length; i++) { + const sx = positions[i * 2]; + const sy = positions[i * 2 + 1]; + if (sx === undefined) break; + const [px, py] = graph.spaceToScreenPosition([sx, sy]); + next.push({ x: px, y: py }); + } + setLabelPositions(next); + }, []); + + // ── Feed node data into graph when nodes change ── + useEffect(() => { + const graph = graphRef.current; + if (!graph) return; + if (filteredNodes.length === 0) { + nodesRef.current = []; + setLabelPositions([]); + graph.setPointPositions(new Float32Array(0)); + graph.setPointColors(new Float32Array(0)); + graph.setPointSizes(new Float32Array(0)); + graph.setLinks(new Float32Array(0)); + graph.setLinkColors(new Float32Array(0)); + graph.render(); + return; + } + + const c = THEME_COLORS[getThemeId()] ?? THEME_COLORS.dark; + const { sorted, positions, pointColors, sizes, links, linkColors } = buildBuffers(filteredNodes, c); + nodesRef.current = sorted; + + graph.setPointPositions(positions); + graph.setPointColors(pointColors); + graph.setPointSizes(sizes); + if (links.length > 0) { + graph.setLinks(links); + graph.setLinkColors(linkColors); + } + graph.setPinnedPoints([0]); + graph.render(); + graph.start(); + + setTimeout(() => { + graph.fitView(400, 0.4); + updateLabels(); + }, 200); + }, [filteredNodes, updateLabels]); + + // ── Poll for nodes ── + useEffect(() => { + const load = () => { + fetchBrowseNodes().then(setNodes).catch(() => { }); + }; + load(); + pollRef.current = setInterval(load, 30_000); + return () => { + if (pollRef.current) clearInterval(pollRef.current); + }; + }, []); + + // ── Node click → fetch page ── + const handleNodeClick = useCallback((node: NetworkNode) => { + setSelectedNode(node); + setPageHtml(null); + setPageError(null); + setPageLoading(true); + + fetchRemotePage(node.hash) + .then((res) => { + if (res.content) { + setPageHtml(renderMicron(res.content, true)); + } else { + setPageError(res.error ?? "No content"); + } + }) + .catch((e) => setPageError(String(e))) + .finally(() => setPageLoading(false)); + }, []); + return ( -
- Browse +
+ {/* Header */} +
+

Browse

+ setFilter(e.target.value)} + placeholder="Filter nodes..." + className="flex-1 h-8 px-2 text-xs bg-muted/50 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary" + /> + + {filteredNodes.length}/{nodes.length} node{nodes.length !== 1 && "s"} + +
+ + {/* Graph + labels */} +
+ {nodesRef.current.map((node, i) => { + const lp = labelPositions[i]; + if (!lp) return null; + return ( + + {node.name} + + ); + })} + + {nodes.length === 0 && ( +
+ Listening for nodes on the Reticulum network... +
+ )} + {nodes.length > 0 && filteredNodes.length === 0 && ( +
+ No nodes match "{filter}" +
+ )} +
+ + {/* Page viewer */} +
+
+ + {selectedNode ? ( + <> + {selectedNode.name} + + {selectedNode.hash.slice(0, 12)} + + + ) : ( + Page + )} + +
+ +
+ {!selectedNode && ( + + Click a node to view its page + + )} + {pageLoading && ( + + Requesting page... + + )} + {pageError && ( + {pageError} + )} + {pageHtml && ( +
+ )} +
+
); }