feat: browser graph
This commit is contained in:
245
backend/browse.py
Normal file
245
backend/browse.py
Normal file
@@ -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
|
||||||
@@ -7,17 +7,20 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from pages import router as pages_router, ensure_default_pages
|
from pages import router as pages_router, ensure_default_pages
|
||||||
from docker_utils import router as docker_router
|
from docker_utils import router as docker_router
|
||||||
from converter import router as converter_router
|
from converter import router as converter_router
|
||||||
|
from browse import router as browse_router, start_browser
|
||||||
|
|
||||||
app = FastAPI(title="µFrame Editor")
|
app = FastAPI(title="µFrame Editor")
|
||||||
|
|
||||||
app.include_router(converter_router, prefix="/api")
|
app.include_router(converter_router, prefix="/api")
|
||||||
app.include_router(pages_router, prefix="/api")
|
app.include_router(pages_router, prefix="/api")
|
||||||
app.include_router(docker_router, prefix="/api")
|
app.include_router(docker_router, prefix="/api")
|
||||||
|
app.include_router(browse_router, prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup():
|
async def startup():
|
||||||
ensure_default_pages()
|
ensure_default_pages()
|
||||||
|
start_browser()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
6
backend/package-lock.json
generated
Normal file
6
backend/package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
@@ -4,3 +4,4 @@ docker>=7.0
|
|||||||
Pillow>=10.0
|
Pillow>=10.0
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
md2txt @ git+https://codeberg.org/randogoth/md2txt
|
md2txt @ git+https://codeberg.org/randogoth/md2txt
|
||||||
|
rns>=0.9.3
|
||||||
|
|||||||
132
frontend/package-lock.json
generated
132
frontend/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"@codemirror/search": "^6.6.0",
|
"@codemirror/search": "^6.6.0",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/view": "^6.40.0",
|
"@codemirror/view": "^6.40.0",
|
||||||
|
"@cosmos.gl/graph": "^2.6.4",
|
||||||
"@dagrejs/dagre": "^3.0.0",
|
"@dagrejs/dagre": "^3.0.0",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
@@ -663,6 +664,31 @@
|
|||||||
"w3c-keyname": "^2.2.4"
|
"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": {
|
"node_modules/@dagrejs/dagre": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
|
||||||
@@ -3290,6 +3316,18 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/d3-color": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
@@ -3330,6 +3368,15 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-interpolate": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
@@ -3342,6 +3389,22 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-selection": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
@@ -3351,6 +3414,30 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-timer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
@@ -4389,6 +4476,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -4607,6 +4706,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||||
@@ -6069,6 +6177,18 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
@@ -6178,6 +6298,12 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"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==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@codemirror/search": "^6.6.0",
|
"@codemirror/search": "^6.6.0",
|
||||||
"@codemirror/state": "^6.6.0",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/view": "^6.40.0",
|
"@codemirror/view": "^6.40.0",
|
||||||
|
"@cosmos.gl/graph": "^2.6.4",
|
||||||
"@dagrejs/dagre": "^3.0.0",
|
"@dagrejs/dagre": "^3.0.0",
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||||
|
|||||||
@@ -113,6 +113,33 @@ export async function restartNode(): Promise<void> {
|
|||||||
if (!res.ok) throw new Error(await res.text());
|
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<NetworkNode[]> {
|
||||||
|
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
|
// Images
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
|||||||
{!isEditor && (
|
{!isEditor && (
|
||||||
<div
|
<div
|
||||||
className="absolute z-20"
|
className="absolute z-20"
|
||||||
style={{ top: 200, left: -210 }}
|
style={{ top: 150, left: -210 }}
|
||||||
>
|
>
|
||||||
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<string, ThemeColors> = {
|
||||||
|
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() {
|
export default function BrowseView() {
|
||||||
|
const [nodes, setNodes] = useState<NetworkNode[]>([]);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [selectedNode, setSelectedNode] = useState<NetworkNode | null>(null);
|
||||||
|
const [pageHtml, setPageHtml] = useState<string | null>(null);
|
||||||
|
const [pageLoading, setPageLoading] = useState(false);
|
||||||
|
const [pageError, setPageError] = useState<string | null>(null);
|
||||||
|
const [labelPositions, setLabelPositions] = useState<{ x: number; y: number }[]>([]);
|
||||||
|
const [themeId, setThemeId] = useState(getThemeId);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const graphRef = useRef<Graph | null>(null);
|
||||||
|
const nodesRef = useRef<NetworkNode[]>([]);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | 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 (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex flex-col" style={{ height: "100%" }}>
|
||||||
Browse
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 border-b-2 border-border shrink-0">
|
||||||
|
<h1 className="text-sm font-semibold">Browse</h1>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground uppercase tracking-wider whitespace-nowrap">
|
||||||
|
{filteredNodes.length}/{nodes.length} node{nodes.length !== 1 && "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Graph + labels */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative shrink-0 bg-background overflow-hidden"
|
||||||
|
style={{ height: 500 }}
|
||||||
|
>
|
||||||
|
{nodesRef.current.map((node, i) => {
|
||||||
|
const lp = labelPositions[i];
|
||||||
|
if (!lp) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={node.hash}
|
||||||
|
className="absolute text-[10px] font-mono pointer-events-none select-none whitespace-nowrap"
|
||||||
|
style={{
|
||||||
|
left: lp.x,
|
||||||
|
top: lp.y - 12,
|
||||||
|
transform: "translate(-50%, -100%)",
|
||||||
|
color: node.is_self ? colors.primary : colors.muted,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{node.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{nodes.length === 0 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
Listening for nodes on the Reticulum network...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nodes.length > 0 && filteredNodes.length === 0 && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
No nodes match "{filter}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page viewer */}
|
||||||
|
<div className="flex-1 min-h-0 border-t-2 border-border overflow-auto">
|
||||||
|
<div className="flex items-center px-4 py-2 border-b border-border sticky top-0 bg-background z-10">
|
||||||
|
<span className="text-xs font-semibold flex-1 truncate">
|
||||||
|
{selectedNode ? (
|
||||||
|
<>
|
||||||
|
{selectedNode.name}
|
||||||
|
<span className="ml-2 text-[10px] text-muted-foreground font-normal">
|
||||||
|
{selectedNode.hash.slice(0, 12)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground font-normal">Page</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3">
|
||||||
|
{!selectedNode && (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Click a node to view its page
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{pageLoading && (
|
||||||
|
<span className="text-muted-foreground text-xs animate-pulse">
|
||||||
|
Requesting page...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{pageError && (
|
||||||
|
<span className="text-destructive text-xs">{pageError}</span>
|
||||||
|
)}
|
||||||
|
{pageHtml && (
|
||||||
|
<div
|
||||||
|
className="font-mono text-[11px] leading-tight"
|
||||||
|
dangerouslySetInnerHTML={{ __html: pageHtml }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user