Compare commits

..

3 Commits

Author SHA1 Message Date
914945279f feat: editor in popover 2026-04-05 00:31:47 +02:00
3eec7f316e feat: final graph 2026-04-04 23:24:44 +02:00
3132d40391 feat: graph browser 2026-04-04 16:18:21 +02:00
30 changed files with 1685 additions and 511 deletions

View File

@@ -7,6 +7,7 @@ announces, and exposes discovered nodes + remote page fetching via API.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import os import os
import threading import threading
@@ -14,6 +15,7 @@ import time
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from starlette.responses import StreamingResponse
router = APIRouter() router = APIRouter()
log = logging.getLogger("browse") log = logging.getLogger("browse")
@@ -27,19 +29,136 @@ _own_hash: str | None = None
_own_name: str = os.environ.get("NOMADNET_NODE_NAME", "Micronomicon") _own_name: str = os.environ.get("NOMADNET_NODE_NAME", "Micronomicon")
_lock = threading.Lock() _lock = threading.Lock()
_started = False _started = False
_subscribers: list[asyncio.Queue] = []
_sub_lock = threading.Lock()
_loop: asyncio.AbstractEventLoop | None = None
# ---------------------------------------------------------------------------
# RNS announce handler (must be an object with aspect_filter + method)
# ---------------------------------------------------------------------------
def _push_node(node_data: dict) -> None:
"""Push a node update to all SSE subscribers (thread-safe)."""
with _sub_lock:
for q in list(_subscribers):
if _loop and _loop.is_running():
_loop.call_soon_threadsafe(q.put_nowait, node_data)
else:
try:
q.put_nowait(node_data)
except asyncio.QueueFull:
pass
class _AnnounceHandler:
"""RNS-compatible announce handler.
RNS.Transport.register_announce_handler() requires an object with:
- aspect_filter: str attribute
- received_announce(dest_hash, identity, app_data, ...): callable
"""
aspect_filter = "nomadnetwork.node"
def received_announce(
self,
destination_hash: bytes,
announced_identity,
app_data: bytes | None,
**kwargs,
) -> None:
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
# Determine which interface this announce arrived on
iface_name = None
try:
path_entry = RNS.Transport.path_table.get(destination_hash)
if path_entry and path_entry[5]: # IDX_PT_RVCD_IF = 5
iface_name = getattr(path_entry[5], "name", None)
except Exception:
pass
with _lock:
_nodes[hash_hex] = {
"hash": hash_hex,
"name": name,
"last_seen": time.time(),
"is_self": is_self,
"type": "node",
"interface": iface_name,
}
if is_self:
global _own_hash
_own_hash = hash_hex
log.info(
"Node announce: %s (%s) via %s%s",
name, hash_hex[:8], iface_name or "?",
" [self]" if is_self else "",
)
_push_node(_nodes[hash_hex])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# RNS lifecycle # RNS lifecycle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_reticulum = None
def _collect_interfaces() -> list[dict]:
"""Read active RNS interfaces and return them as node-like dicts."""
try:
import RNS
except ImportError:
return []
ifaces = []
for iface in RNS.Transport.interfaces:
name = getattr(iface, "name", str(iface))
iface_id = f"iface_{name}"
target = getattr(iface, "target_ip", None) or getattr(iface, "target_host", None)
port = getattr(iface, "target_port", None) or getattr(iface, "bind_port", None)
ifaces.append({
"hash": iface_id,
"name": name,
"last_seen": time.time(),
"is_self": False,
"type": "interface",
"online": getattr(iface, "online", False),
"target": f"{target}:{port}" if target and port else None,
"txb": getattr(iface, "txb", 0),
"rxb": getattr(iface, "rxb", 0),
"bitrate": getattr(iface, "bitrate", 0),
"clients": len(getattr(iface, "clients", None) or []) if hasattr(iface, "clients") else None,
})
return ifaces
def start_browser() -> None: def start_browser() -> None:
"""Initialize RNS and begin listening for NomadNet node announces.""" """Initialize RNS and begin listening for NomadNet node announces."""
global _started, _own_hash global _started, _loop, _reticulum
if _started: if _started:
return return
try:
_loop = asyncio.get_event_loop()
except RuntimeError:
_loop = None
try: try:
import RNS import RNS
@@ -47,13 +166,9 @@ def start_browser() -> None:
if configdir: if configdir:
Path(configdir).mkdir(parents=True, exist_ok=True) Path(configdir).mkdir(parents=True, exist_ok=True)
reticulum = RNS.Reticulum(configdir=configdir) _reticulum = RNS.Reticulum(configdir=configdir)
# Register handler for NomadNet page-serving node announces RNS.Transport.register_announce_handler(_AnnounceHandler())
RNS.Transport.register_announce_handler(
_on_announce,
aspect_filter="nomadnetwork.node",
)
_started = True _started = True
log.info("RNS browser started (v%s)", RNS.__version__) log.info("RNS browser started (v%s)", RNS.__version__)
@@ -62,61 +177,70 @@ def start_browser() -> None:
log.warning("Failed to start RNS browser: %s", 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 # API endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/browse/nodes") def _build_snapshot() -> list[dict]:
async def list_nodes(): """Build a full snapshot: self node + interfaces + discovered nodes."""
"""Return all discovered NomadNet page-serving nodes."""
with _lock: with _lock:
nodes = list(_nodes.values()) nodes = list(_nodes.values())
# Ensure the user's own node is always present # Ensure type field on all nodes
for n in nodes:
n.setdefault("type", "node")
if not any(n["is_self"] for n in nodes): if not any(n["is_self"] for n in nodes):
nodes.insert(0, { nodes.insert(0, {
"hash": _own_hash or "self", "hash": _own_hash or "self",
"name": _own_name, "name": _own_name,
"last_seen": time.time(), "last_seen": time.time(),
"is_self": True, "is_self": True,
"type": "node",
}) })
# Add interfaces
nodes.extend(_collect_interfaces())
return nodes return nodes
@router.get("/browse/nodes")
async def list_nodes():
"""Return all discovered NomadNet nodes and interfaces."""
return _build_snapshot()
@router.get("/browse/nodes/stream")
async def stream_nodes():
"""SSE stream — pushes full snapshot then live node announces."""
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
with _sub_lock:
_subscribers.append(queue)
async def event_generator():
try:
# Send full snapshot (self + interfaces + known nodes)
for entry in _build_snapshot():
yield f"data: {json.dumps(entry)}\n\n"
# Then stream new announces as they arrive
while True:
node = await queue.get()
yield f"data: {json.dumps(node)}\n\n"
except asyncio.CancelledError:
pass
finally:
with _sub_lock:
_subscribers.remove(queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/browse/page/{hash_hex}") @router.get("/browse/page/{hash_hex}")
async def get_remote_page(hash_hex: str, path: str = Query("index.mu")): async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
"""Fetch a Micron page from a node. """Fetch a Micron page from a node.
@@ -124,13 +248,11 @@ async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
For the user's own node reads from the local pages directory. For the user's own node reads from the local pages directory.
For remote nodes establishes an RNS link and requests the page. For remote nodes establishes an RNS link and requests the page.
""" """
# Own node → read from disk
with _lock: with _lock:
node = _nodes.get(hash_hex) node = _nodes.get(hash_hex)
if (node and node.get("is_self")) or hash_hex == "self": if (node and node.get("is_self")) or hash_hex == "self":
return _read_local_page(path) return _read_local_page(path)
# Remote node → RNS request
content = await _request_remote_page(hash_hex, path) content = await _request_remote_page(hash_hex, path)
if content is None: if content is None:
return {"content": None, "error": "Could not reach node"} return {"content": None, "error": "Could not reach node"}
@@ -168,7 +290,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
try: try:
dest_hash = bytes.fromhex(hash_hex) dest_hash = bytes.fromhex(hash_hex)
# Ensure path to destination is known
if not RNS.Transport.has_path(dest_hash): if not RNS.Transport.has_path(dest_hash):
RNS.Transport.request_path(dest_hash) RNS.Transport.request_path(dest_hash)
deadline = time.time() + 10 deadline = time.time() + 10
@@ -195,7 +316,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
link = RNS.Link(dest) link = RNS.Link(dest)
# Wait for link to become active
deadline = time.time() + 15 deadline = time.time() + 15
while time.time() < deadline: while time.time() < deadline:
if link.status == RNS.Link.ACTIVE: if link.status == RNS.Link.ACTIVE:
@@ -206,7 +326,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
loop.call_soon_threadsafe(future.set_result, None) loop.call_soon_threadsafe(future.set_result, None)
return return
# Request the page via NomadNet's protocol
def on_response(request_receipt): def on_response(request_receipt):
try: try:
resp = request_receipt.response resp = request_receipt.response
@@ -236,7 +355,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
if not future.done(): if not future.done():
loop.call_soon_threadsafe(future.set_result, None) loop.call_soon_threadsafe(future.set_result, None)
# Run blocking RNS operations in a thread
threading.Thread(target=_do_request, daemon=True).start() threading.Thread(target=_do_request, daemon=True).start()
try: try:

View File

@@ -28,7 +28,16 @@ async def health():
return {"status": "ok"} return {"status": "ok"}
# Serve built frontend as static files (SPA fallback) # Serve built frontend as static files with SPA fallback
static_dir = Path(__file__).parent / "static" static_dir = Path(__file__).parent / "static"
if static_dir.is_dir(): if static_dir.is_dir():
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") from fastapi.responses import FileResponse
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
@app.get("/{path:path}")
async def spa_fallback(path: str):
file = static_dir / path
if file.is_file():
return FileResponse(file)
return FileResponse(static_dir / "index.html")

View File

@@ -9,10 +9,12 @@ services:
- PAGES_DIR=/data/pages - PAGES_DIR=/data/pages
- SOURCES_DIR=/data/sources - SOURCES_DIR=/data/sources
- NOMADNET_CONTAINER=nomadnet - NOMADNET_CONTAINER=nomadnet
- LOG_LEVEL=DEBUG
volumes: volumes:
- pages:/data/pages - pages:/data/pages
- sources:/data/sources - sources:/data/sources
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- ./reticulum-client.conf:/root/.reticulum/config:ro
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- nomadnet - nomadnet

14
deploy.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "==> Building frontend..."
(cd frontend && npm run build)
echo "==> Deploying with docker compose..."
docker compose -f compose.yml up -d --build
echo "==> Done!"
echo " Web IDE: http://localhost:8080"
echo " Reticulum: tcp://0.0.0.0:4242"

View File

@@ -16,15 +16,19 @@
"@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",
"@cosmograph/cosmos": "^1.6.1",
"@cosmos.gl/graph": "^2.6.4", "@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",
"@sigma/node-square": "^3.0.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"graphology": "^0.26.0",
"graphology-layout-force": "^0.2.4",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"micron-parser": "^1.0.3", "micron-parser": "^1.0.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -33,6 +37,7 @@
"react-resizable-panels": "^4.8.0", "react-resizable-panels": "^4.8.0",
"react-router-dom": "^7.13.2", "react-router-dom": "^7.13.2",
"shadcn": "^4.1.1", "shadcn": "^4.1.1",
"sigma": "^3.0.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
@@ -664,6 +669,30 @@
"w3c-keyname": "^2.2.4" "w3c-keyname": "^2.2.4"
} }
}, },
"node_modules/@cosmograph/cosmos": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@cosmograph/cosmos/-/cosmos-1.6.1.tgz",
"integrity": "sha512-A91YabqDxCRqYZXmlOs5ykqkDw1pui0TnuXA65sYFRHwskOJ+BNy7z2IFb/vVIBlsnl3Jdyzm9G93A/xipbHIA==",
"deprecated": "This package has been moved to @cosmos.gl/graph. If you're using version >2, please update",
"license": "CC-BY-NC-4.0",
"dependencies": {
"d3-array": "^3.2.0",
"d3-color": "^3.1.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",
"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/@cosmos.gl/graph": { "node_modules/@cosmos.gl/graph": {
"version": "2.6.4", "version": "2.6.4",
"resolved": "https://registry.npmjs.org/@cosmos.gl/graph/-/graph-2.6.4.tgz", "resolved": "https://registry.npmjs.org/@cosmos.gl/graph/-/graph-2.6.4.tgz",
@@ -1836,6 +1865,15 @@
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@sigma/node-square": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@sigma/node-square/-/node-square-3.0.0.tgz",
"integrity": "sha512-hPX2oWo7WeaSe6M3D56AXsrLyg3F+7N/YsodaJh4Sw3KTce0GAFVWWPZZklu9CITz0xi3kEmlCGulqAH0cVG2w==",
"license": "MIT",
"peerDependencies": {
"sigma": ">=3.0.0-beta.17"
}
},
"node_modules/@sindresorhus/merge-streams": { "node_modules/@sindresorhus/merge-streams": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
@@ -3979,6 +4017,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/eventsource": { "node_modules/eventsource": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -4532,6 +4579,46 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/graphology": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz",
"integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==",
"license": "MIT",
"dependencies": {
"events": "^3.3.0"
},
"peerDependencies": {
"graphology-types": ">=0.24.0"
}
},
"node_modules/graphology-layout-force": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/graphology-layout-force/-/graphology-layout-force-0.2.4.tgz",
"integrity": "sha512-NYZz0YAnDkn5pkm30cvB0IScFoWGtbzJMrqaiH070dYlYJiag12Oc89dbVfaMaVR/w8DMIKxn/ix9Bqj+Umm9Q==",
"license": "MIT",
"dependencies": {
"graphology-utils": "^2.4.2"
},
"peerDependencies": {
"graphology-types": ">=0.19.0"
}
},
"node_modules/graphology-types": {
"version": "0.24.8",
"resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz",
"integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==",
"license": "MIT",
"peer": true
},
"node_modules/graphology-utils": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz",
"integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==",
"license": "MIT",
"peerDependencies": {
"graphology-types": ">=0.23.0"
}
},
"node_modules/graphql": { "node_modules/graphql": {
"version": "16.13.2", "version": "16.13.2",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz",
@@ -6700,6 +6787,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/sigma": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
"integrity": "sha512-/BUbeOwPGruiBOm0YQQ6ZMcLIZ6tf/W+Jcm7dxZyAX0tK3WP9/sq7/NAWBxPIxVahdGjCJoGwej0Gdrv0DxlQQ==",
"license": "MIT",
"dependencies": {
"events": "^3.3.0",
"graphology-utils": "^2.5.2"
}
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",

View File

@@ -18,15 +18,19 @@
"@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",
"@cosmograph/cosmos": "^1.6.1",
"@cosmos.gl/graph": "^2.6.4", "@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",
"@sigma/node-square": "^3.0.0",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@xyflow/react": "^12.10.2", "@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
"graphology": "^0.26.0",
"graphology-layout-force": "^0.2.4",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"micron-parser": "^1.0.3", "micron-parser": "^1.0.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -35,6 +39,7 @@
"react-resizable-panels": "^4.8.0", "react-resizable-panels": "^4.8.0",
"react-router-dom": "^7.13.2", "react-router-dom": "^7.13.2",
"shadcn": "^4.1.1", "shadcn": "^4.1.1",
"sigma": "^3.0.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",

View File

@@ -2,7 +2,6 @@ import { Routes, Route } from "react-router-dom";
import AppShell from "./components/shared/AppShell"; import AppShell from "./components/shared/AppShell";
import ComposeView from "./routes/ComposeView"; import ComposeView from "./routes/ComposeView";
import BrowseView from "./routes/BrowseView"; import BrowseView from "./routes/BrowseView";
import EditorView from "./routes/EditorView";
import SettingsView from "./routes/SettingsView"; import SettingsView from "./routes/SettingsView";
export default function App() { export default function App() {
@@ -12,8 +11,6 @@ export default function App() {
<Route path="/" element={<ComposeView />} /> <Route path="/" element={<ComposeView />} />
<Route path="/browse" element={<BrowseView />} /> <Route path="/browse" element={<BrowseView />} />
<Route path="/settings" element={<SettingsView />} /> <Route path="/settings" element={<SettingsView />} />
<Route path="/editor/new" element={<EditorView />} />
<Route path="/editor/:name" element={<EditorView />} />
</Routes> </Routes>
</AppShell> </AppShell>
); );

View File

@@ -122,6 +122,14 @@ export interface NetworkNode {
name: string; name: string;
last_seen: number; last_seen: number;
is_self: boolean; is_self: boolean;
type?: "node" | "interface";
interface?: string | null;
online?: boolean;
target?: string | null;
txb?: number;
rxb?: number;
bitrate?: number;
clients?: number | null;
} }
export async function fetchBrowseNodes(): Promise<NetworkNode[]> { export async function fetchBrowseNodes(): Promise<NetworkNode[]> {
@@ -130,6 +138,18 @@ export async function fetchBrowseNodes(): Promise<NetworkNode[]> {
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
} }
export function subscribeBrowseNodes(
onNode: (node: NetworkNode) => void,
): () => void {
const es = new EventSource("/api/browse/nodes/stream");
es.onmessage = (e) => {
try {
onNode(JSON.parse(e.data));
} catch { /* ignore parse errors */ }
};
return () => es.close();
}
export async function fetchRemotePage( export async function fetchRemotePage(
hash: string, hash: string,
path: string = "index.mu", path: string = "index.mu",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -1,12 +1,13 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import pointerSvg from "@/assets/pointer.min.svg"; import pointerSvg from "@/assets/pointer.min.svg";
import { useLazyEyes } from "@/hooks/useLazyEyes";
/** /**
* Floating pointer that tracks the CodeMirror cursor with smooth lerp animation. * Floating pointer that tracks the CodeMirror cursor vertically,
* Positions itself right next to the left border of the editor container. * pinned to the left edge of the editor. Positions relative to
* Uses a ResizeObserver to keep horizontal position synced on window resize. * containerRef (for floating windows).
*/ */
export default function EditorPointer() { export default function EditorPointer({ containerRef, focused = true }: { containerRef?: React.RefObject<HTMLElement | null>; focused?: boolean }) {
const [y, setY] = useState<number | null>(null); const [y, setY] = useState<number | null>(null);
const [editorLeft, setEditorLeft] = useState<number | null>(null); const [editorLeft, setEditorLeft] = useState<number | null>(null);
const targetRef = useRef(0); const targetRef = useRef(0);
@@ -16,35 +17,24 @@ export default function EditorPointer() {
const [clickKey, setClickKey] = useState(0); const [clickKey, setClickKey] = useState(0);
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined); const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const cmRef = useRef<Element | null>(null); const cmRef = useRef<Element | null>(null);
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
const eyeTargetRef = useRef({ x: 0, y: 0 }); // Eye anchor must be in viewport coords (useLazyEyes compares against e.clientX/Y)
const eyeCurrentRef = useRef({ x: 0, y: 0 }); const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null);
const eyeOffset = useLazyEyes({ anchorRef: eyeAnchorRef });
useEffect(() => { useEffect(() => {
const ease = 0.09; const ease = 0.09;
const eyeEase = 0.06;
const getContainerRect = () =>
containerRef?.current?.getBoundingClientRect() ?? { left: 0, top: 0 };
const updateLeft = () => { const updateLeft = () => {
if (cmRef.current) { if (!cmRef.current || !containerRef?.current) return;
setEditorLeft(cmRef.current.getBoundingClientRect().left); const cmLeft = cmRef.current.getBoundingClientRect().left;
} const containerLeft = containerRef.current.getBoundingClientRect().left;
setEditorLeft(cmLeft - containerLeft);
}; };
const onMouseMove = (e: MouseEvent) => {
const pointerX = (cmRef.current?.getBoundingClientRect().left ?? 0) - 100;
const pointerY = currentRef.current;
const dx = e.clientX - pointerX;
const dy = e.clientY - pointerY;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const maxShift = 1.5;
eyeTargetRef.current = {
x: (dx / dist) * maxShift,
y: (dy / dist) * maxShift,
};
};
window.addEventListener("mousemove", onMouseMove);
const onEditorClick = () => { const onEditorClick = () => {
clearTimeout(clickTimerRef.current); clearTimeout(clickTimerRef.current);
setClickKey((k) => k + 1); setClickKey((k) => k + 1);
@@ -53,22 +43,26 @@ export default function EditorPointer() {
const onCursorMove = (e: Event) => { const onCursorMove = (e: Event) => {
const { top } = (e as CustomEvent).detail; const { top } = (e as CustomEvent).detail;
targetRef.current = top;
// Find editor container and attach click listener lazily // Find editor container lazily, scoped to our window
if (!cmRef.current) { if (!cmRef.current) {
const cm = document.querySelector(".cm-editor"); const scope = containerRef?.current ?? document;
const cm = scope.querySelector(".cm-editor");
if (cm) { if (cm) {
cmRef.current = cm; cmRef.current = cm;
cm.addEventListener("mousedown", onEditorClick); cm.addEventListener("mousedown", onEditorClick);
ro.observe(cm); ro.observe(cm);
} }
} }
// Convert viewport top to container-relative top
const containerTop = getContainerRect().top;
targetRef.current = top - containerTop;
updateLeft(); updateLeft();
if (!activeRef.current) { if (!activeRef.current) {
currentRef.current = top; currentRef.current = targetRef.current;
setY(top); setY(targetRef.current);
activeRef.current = true; activeRef.current = true;
} }
}; };
@@ -82,13 +76,12 @@ export default function EditorPointer() {
currentRef.current += diff * ease; currentRef.current += diff * ease;
} }
setY(currentRef.current); setY(currentRef.current);
// Eye anchor in viewport coords for useLazyEyes
const cmLeft = cmRef.current?.getBoundingClientRect().left ?? 0;
const containerTop = getContainerRect().top;
eyeAnchorRef.current = { x: cmLeft - 100, y: containerTop + currentRef.current };
} }
// Lerp eyes toward target
const ec = eyeCurrentRef.current;
const et = eyeTargetRef.current;
ec.x += (et.x - ec.x) * eyeEase;
ec.y += (et.y - ec.y) * eyeEase;
setEyeOffset({ x: ec.x, y: ec.y });
rafRef.current = requestAnimationFrame(tick); rafRef.current = requestAnimationFrame(tick);
}; };
@@ -97,7 +90,8 @@ export default function EditorPointer() {
// Keep left position updated on resize // Keep left position updated on resize
const ro = new ResizeObserver(() => updateLeft()); const ro = new ResizeObserver(() => updateLeft());
const existingCm = document.querySelector(".cm-editor"); const scope = containerRef?.current ?? document;
const existingCm = scope.querySelector(".cm-editor");
if (existingCm) { if (existingCm) {
cmRef.current = existingCm; cmRef.current = existingCm;
ro.observe(existingCm); ro.observe(existingCm);
@@ -106,15 +100,14 @@ export default function EditorPointer() {
return () => { return () => {
window.removeEventListener("cm-cursor-move", onCursorMove); window.removeEventListener("cm-cursor-move", onCursorMove);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("resize", updateLeft); window.removeEventListener("resize", updateLeft);
cmRef.current?.removeEventListener("mousedown", onEditorClick); cmRef.current?.removeEventListener("mousedown", onEditorClick);
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
ro.disconnect(); ro.disconnect();
}; };
}, []); }, [containerRef]);
if (y === null || editorLeft === null) return null; if (!focused || y === null || editorLeft === null) return null;
return ( return (
<div <div

View File

@@ -0,0 +1,11 @@
import { createContext, useContext } from "react";
import { useStore, type StoreApi } from "zustand";
import type { EditorStore } from "@/stores/editorStore";
export const EditorStoreContext = createContext<StoreApi<EditorStore> | null>(null);
export function useEditorCtx<T>(selector: (s: EditorStore) => T): T {
const store = useContext(EditorStoreContext);
if (!store) throw new Error("useEditorCtx must be used within EditorStoreContext.Provider");
return useStore(store, selector);
}

View File

@@ -0,0 +1,255 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { BookOpen, Upload } from "lucide-react";
import { autocompletion } from "@codemirror/autocomplete";
import type { Extension } from "@codemirror/state";
import type { StoreApi } from "zustand";
import { useStore } from "zustand";
import * as api from "@/api/client";
import { createEditorStore, type EditorStore } from "@/stores/editorStore";
import { usePagesStore } from "@/stores/pagesStore";
import { useCompile } from "@/hooks/useCompile";
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
import { uframeHighlight } from "./uframeHighlight";
import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "./uframeCommands";
import { keywordHoverTooltip } from "./uframeHover";
import { EditorStoreContext } from "./EditorStoreContext";
import EditorPane from "./EditorPane";
import EditorPointer from "./EditorPointer";
import PreviewPane from "./PreviewPane";
import ToolBar from "./ToolBar";
import { EXAMPLES } from "./examples";
import FloatingWindow from "@/components/shared/FloatingWindow";
import type { ManagedWindow } from "@/hooks/useWindowManager";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverHeader,
PopoverTitle,
} from "@/components/ui/popover";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
export interface EditorWinData {
pageName: string;
isNew: boolean;
}
interface EditorWindowProps {
win: ManagedWindow<EditorWinData>;
focused: boolean;
onUpdate: (id: string, patch: Partial<ManagedWindow<EditorWinData>>) => void;
onClose: (id: string) => void;
onFocus: (id: string) => void;
}
export default function EditorWindow({ win, focused, onUpdate, onClose, onFocus }: EditorWindowProps) {
const storeRef = useRef<StoreApi<EditorStore>>(null);
if (!storeRef.current) storeRef.current = createEditorStore();
const store = storeRef.current;
const windowRef = useRef<HTMLDivElement>(null);
const ufSource = useStore(store, (s) => s.ufSource);
const isDirty = useStore(store, (s) => s.isDirty);
const setSource = useStore(store, (s) => s.setSource);
const setDirty = useStore(store, (s) => s.setDirty);
const setCurrentPage = useStore(store, (s) => s.setCurrentPage);
const { fetchPages } = usePagesStore();
const [pageName, setPageName] = useState(win.data.pageName);
const [saving, setSaving] = useState(false);
const extensions = useMemo(
() => [
...uframeHighlight(),
autocompletion({
override: [uframeCommandSource, uframeValueHintSource],
icons: false,
activateOnTyping: true,
}),
keywordHoverTooltip,
],
[],
);
useEffect(() => { loadCommandsFromApi(); }, []);
useCompile(store);
useUnsavedGuard(store);
// Load page on mount
useEffect(() => {
if (!win.data.isNew && win.data.pageName) {
api.fetchPage(win.data.pageName).then((data) => {
if (data.source != null) {
store.setState({ ufSource: data.source, isDirty: false });
}
});
}
return () => store.getState().reset();
}, []);
const handleSave = useCallback(
async (publish: boolean) => {
const slug = pageName.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
if (!slug) { toast.error("Enter a page name."); return; }
setSaving(true);
try {
const meta = await api.savePage(slug, ufSource, publish);
setCurrentPage(meta);
setDirty(false);
fetchPages();
toast.success(publish ? "Published" : "Draft saved");
if (win.data.isNew) {
setPageName(slug);
onUpdate(win.id, { data: { pageName: slug, isNew: false } });
}
} catch (e) {
toast.error(`Save failed: ${e}`);
} finally {
setSaving(false);
}
},
[pageName, ufSource, win.data.isNew, win.id],
);
// Keyboard shortcuts — only fire when this window is focused
useEffect(() => {
if (!focused) return;
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); handleSave(false); }
if ((e.metaKey || e.ctrlKey) && e.key === "p") { e.preventDefault(); handleSave(true); }
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [focused, handleSave]);
// Confirm close if dirty
const handleClose = useCallback((id: string) => {
if (isDirty) {
if (!window.confirm("You have unsaved changes. Close anyway?")) return;
}
onClose(id);
}, [isDirty, onClose]);
return (
<FloatingWindow
id={win.id}
title={pageName || "new page"}
x={win.x} y={win.y} w={win.w} h={win.h}
zIndex={win.zIndex}
focused={focused}
onUpdate={onUpdate}
onClose={handleClose}
onFocus={onFocus}
minW={480} minH={300}
containerRef={windowRef}
>
<EditorStoreContext.Provider value={store}>
<EditorPointer containerRef={windowRef} focused={focused} />
<div className="flex flex-col h-full">
<ToolBar
pageName={pageName}
onNameChange={win.data.isNew ? setPageName : undefined}
onSaveDraft={() => handleSave(false)}
onPublish={() => handleSave(true)}
saving={saving}
isDirty={isDirty}
/>
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0">
<ResizablePanel defaultSize={50} minSize={20}>
<SourcePane ufSource={ufSource} setSource={setSource} extensions={extensions} />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={50} minSize={20}>
<PreviewPane />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</EditorStoreContext.Provider>
</FloatingWindow>
);
}
function SourcePane({
ufSource,
setSource,
extensions,
}: {
ufSource: string;
setSource: (s: string) => void;
extensions: Extension[];
}) {
const [examplesOpen, setExamplesOpen] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const data = await api.uploadImage(file);
toast.success(`Uploaded ${data.filename}`);
setSource(`image "${data.path}" braille 30\n align center`);
} catch (err) {
toast.error(`Upload failed: ${err}`);
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
return (
<div className="flex flex-col h-full">
<div className="flex items-center px-4 py-2 border-b-2 border-border shrink-0 gap-2">
<span className="font-medium text-foreground flex-1">Source</span>
<Popover open={examplesOpen} onOpenChange={setExamplesOpen}>
<PopoverTrigger
render={
<button className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 cursor-pointer">
<BookOpen className="h-3 w-3" />
Examples
</button>
}
/>
<PopoverContent side="bottom" align="end" sideOffset={8}>
<PopoverHeader>
<PopoverTitle>Insert Example</PopoverTitle>
</PopoverHeader>
<div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto -mx-1">
{EXAMPLES.map((ex) => (
<button
key={ex.name}
onClick={() => { setSource(ex.source); setExamplesOpen(false); }}
className="flex flex-col items-start px-2 py-1.5 text-left hover:bg-accent transition-colors cursor-pointer"
>
<span className="text-sm font-medium">{ex.name}</span>
<span className="text-xs text-muted-foreground leading-tight">{ex.description}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleUpload} />
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 disabled:opacity-50 cursor-pointer"
>
<Upload className="h-3 w-3" />
{uploading ? "Uploading…" : "Image"}
</button>
</div>
<div className="flex-1 overflow-auto min-h-0">
<EditorPane value={ufSource} onChange={setSource} extensions={extensions} />
</div>
</div>
);
}

View File

@@ -1,17 +1,17 @@
import { useEditorStore } from "@/stores/editorStore"; import { useEditorCtx } from "./EditorStoreContext";
import { renderMicron } from "./micronRenderer"; import { renderMicron } from "./micronRenderer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
type PreviewMode = "micron" | "raw" | "script"; type PreviewMode = "micron" | "raw" | "script";
export default function PreviewPane() { export default function PreviewPane() {
const previewMode = useEditorStore((s) => s.previewMode); const previewMode = useEditorCtx((s) => s.previewMode);
const setPreviewMode = useEditorStore((s) => s.setPreviewMode); const setPreviewMode = useEditorCtx((s) => s.setPreviewMode);
const compiledMicron = useEditorStore((s) => s.compiledMicron); const compiledMicron = useEditorCtx((s) => s.compiledMicron);
const compiledScript = useEditorStore((s) => s.compiledScript); const compiledScript = useEditorCtx((s) => s.compiledScript);
const isDynamic = useEditorStore((s) => s.isDynamic); const isDynamic = useEditorCtx((s) => s.isDynamic);
const isCompiling = useEditorStore((s) => s.isCompiling); const isCompiling = useEditorCtx((s) => s.isCompiling);
const compileError = useEditorStore((s) => s.compileError); const compileError = useEditorCtx((s) => s.compileError);
const tabs: { value: PreviewMode; label: string; show: boolean }[] = [ const tabs: { value: PreviewMode; label: string; show: boolean }[] = [
{ value: "micron", label: "Micron", show: true }, { value: "micron", label: "Micron", show: true },

View File

@@ -1,6 +1,5 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { Pencil } from "lucide-react";
import { ChevronLeft, Pencil } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -21,20 +20,8 @@ export default function ToolBar({
saving, saving,
isDirty, isDirty,
}: Props) { }: Props) {
const navigate = useNavigate();
return ( return (
<div className="flex items-center gap-2 px-4 py-2 border-b-2 border-border shrink-0"> <div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-border shrink-0">
<button
onClick={() => navigate("/")}
className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1 text-xs uppercase tracking-wider"
>
<ChevronLeft className="h-3.5 w-3.5" />
Pages
</button>
<span className="text-muted-foreground/30 text-xs"></span>
<PageNameField pageName={pageName} onNameChange={onNameChange} /> <PageNameField pageName={pageName} onNameChange={onNameChange} />
{isDirty && ( {isDirty && (
@@ -43,10 +30,10 @@ export default function ToolBar({
<div className="flex-1" /> <div className="flex-1" />
<Button variant="outline" onClick={onSaveDraft} disabled={saving}> <Button variant="outline" size="sm" onClick={onSaveDraft} disabled={saving}>
Save Draft Save Draft
</Button> </Button>
<Button onClick={onPublish} disabled={saving}> <Button size="sm" onClick={onPublish} disabled={saving}>
Publish Publish
</Button> </Button>
</div> </div>

View File

@@ -1,9 +1,10 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import frameSvg from "@/assets/frame.themed.svg"; import browserSvg from "@/assets/browser.min.svg";
import NavMenu from "./NavMenu"; import NavMenu from "./NavMenu";
import LazyEyes from "./LazyEyes";
const TITLE = ` const TITLE = `
@@ -80,11 +81,34 @@ function RomanClock() {
); );
} }
// ---------------------------------------------------------------------------
// Per-frame layout configs
// ---------------------------------------------------------------------------
interface FrameLayout {
svg: string;
frameHeight: number;
containerMaxW: string;
containerMinW: string;
title: { paddingTop: number; height: number; paddingLeft: number; paddingRight: number; paddingBottom: number };
content: { marginLeft: number; marginTop: number; marginRight: number; width: number; height: number; paddingTop: number; paddingBottom: number };
nav: { top: number; left: number };
}
const frameLayout: FrameLayout = {
svg: browserSvg,
frameHeight: 884,
containerMaxW: "max-w-5xl",
containerMinW: "min-w-5xl",
title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 },
content: { marginLeft: 268, marginTop: 63, width: 476, height: 377, paddingTop: 0, paddingBottom: 0 },
nav: { top: 150, left: -210 },
};
export default function AppShell({ children }: { children: ReactNode }) { export default function AppShell({ children }: { children: ReactNode }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [theme, setTheme] = useState<Theme>(getStoredTheme); const [theme, setTheme] = useState<Theme>(getStoredTheme);
const isEditor = location.pathname.startsWith("/editor"); const layout = frameLayout;
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
@@ -104,17 +128,17 @@ export default function AppShell({ children }: { children: ReactNode }) {
<TooltipProvider> <TooltipProvider>
<div className="flex flex-col h-screen bg-background text-foreground"> <div className="flex flex-col h-screen bg-background text-foreground">
<main className="flex-1 overflow-auto" data-scroll-root> <main className="flex-1 overflow-auto" data-scroll-root>
<div className="relative max-w-5xl min-w-5xl mx-auto min-h-full"> <div className={`relative ${layout.containerMaxW} ${layout.containerMinW} mx-auto min-h-full`}>
{/* Frame SVG — background */} {/* Frame SVG — background */}
<div <div
aria-hidden aria-hidden
className="absolute inset-0 w-full min-w-full pointer-events-none select-none" className="absolute inset-0 w-full min-w-full pointer-events-none select-none"
style={{ style={{
zIndex: 0, zIndex: 0,
height: "1315px", height: `${layout.frameHeight}px`,
background: "var(--primary)", background: "var(--primary)",
WebkitMaskImage: `url(${frameSvg})`, WebkitMaskImage: `url(${layout.svg})`,
maskImage: `url(${frameSvg})`, maskImage: `url(${layout.svg})`,
WebkitMaskSize: "cover", WebkitMaskSize: "cover",
maskSize: "cover", maskSize: "cover",
WebkitMaskRepeat: "no-repeat", WebkitMaskRepeat: "no-repeat",
@@ -123,43 +147,50 @@ export default function AppShell({ children }: { children: ReactNode }) {
}} }}
/> />
{/* Lazy eyes on the frame figure */}
<LazyEyes
eyes={[
{ top: 81, left: 554, size: 5, irisSize: 2 },
{ top: 85, left: 562, size: 5, irisSize: 2 },
]}
maxShift={2}
/>
{/* Top hole — ASCII title */} {/* Top hole — ASCII title */}
<div <div
className="relative z-10 flex flex-col cursor-pointer overflow-hidden" className="relative z-10 flex flex-col overflow-hidden"
style={{ paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 }} style={layout.title}
onClick={() => navigate("/")}
> >
<pre <pre
className="select-none text-primary/70 hover:text-primary transition-colors whitespace-pre origin-center" className="select-none text-primary/70 hover:text-primary transition-colors whitespace-pre origin-center cursor-pointer bg-background"
style={{ height: 60, fontSize: 6, lineHeight: 1.05, transform: "scale(0.50)", transformOrigin: "left center", alignContent: "center" }} style={{ height: 60, fontSize: 6, lineHeight: 1.05, transform: "scale(0.50)", transformOrigin: "left center", alignContent: "center" }}
onClick={() => navigate("/")}
> >
{TITLE} {TITLE}
</pre> </pre>
<span className="font-mono text-[10px] text-muted-foreground"><RomanClock /></span> <span className="font-mono text-[10px] w-fit text-muted-foreground bg-background"><RomanClock /></span>
</div> </div>
{/* Bottom hole — main content */} {/* Bottom hole — main content */}
<div <div
className="relative z-10 overflow-auto" className="relative z-10 overflow-auto"
style={{ marginLeft: 12, marginRight: 68, width: 843, height: 1030, paddingTop: 8, paddingBottom: 20 }} style={layout.content}
> >
{children} {children}
</div> </div>
{/* Nav menu — anchored below the frame, left side (hidden on editor) */} {/* Nav menu — anchored below the frame, left side */}
{!isEditor && ( <div
<div className="absolute z-20"
className="absolute z-20" style={layout.nav}
style={{ top: 150, left: -210 }} >
> <NavMenu theme={theme} onToggleTheme={toggleTheme} />
<NavMenu theme={theme} onToggleTheme={toggleTheme} /> </div>
</div>
)}
</div> </div>
</main> </main>
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase"> <footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">
<span>Becoming with hubris · MMXXVI</span> <span>Created with hubris · MMXXVI</span>
</footer> </footer>
</div> </div>
<Toaster /> <Toaster />

View File

@@ -0,0 +1,86 @@
import { useCallback, useEffect, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
export const DITHERED_SHADOW = `
3px 3px 0 0 var(--border), 5px 3px 0 0 transparent, 7px 3px 0 0 var(--border),
4px 4px 0 0 transparent, 6px 4px 0 0 var(--border),
3px 5px 0 0 var(--border), 5px 5px 0 0 transparent, 7px 5px 0 0 var(--border),
4px 6px 0 0 var(--border), 6px 6px 0 0 transparent
`;
export interface FloatingWindowProps {
id: string;
title: string;
x: number;
y: number;
w: number;
h: number;
zIndex: number;
focused: boolean;
onUpdate: (id: string, patch: { x?: number; y?: number; w?: number; h?: number }) => void;
onClose: (id: string) => void;
onFocus: (id: string) => void;
minW?: number;
minH?: number;
addressBar?: ReactNode;
footer?: ReactNode;
containerRef?: React.RefObject<HTMLDivElement | null>;
children: ReactNode;
}
export default function FloatingWindow({
id, title, x, y, w, h, zIndex, focused,
onUpdate, onClose, onFocus,
minW = 320, minH = 200,
addressBar, footer, containerRef, children,
}: FloatingWindowProps) {
const internalRef = useRef<HTMLDivElement>(null);
const ref = containerRef ?? internalRef;
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
useEffect(() => { if (focused) ref.current?.focus(); }, [focused]);
const onDragStart = useCallback((e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault(); onFocus(id);
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: x, origY: y };
const onMove = (ev: MouseEvent) => { if (!dragRef.current) return; onUpdate(id, { x: dragRef.current.origX + (ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + (ev.clientY - dragRef.current.startY)) }); };
const onUp = () => { dragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
}, [id, x, y, onUpdate, onFocus]);
const onResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault(); e.stopPropagation(); onFocus(id);
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: w, origH: h };
const onMove = (ev: MouseEvent) => { if (!resizeRef.current) return; onUpdate(id, { w: Math.max(minW, resizeRef.current.origW + (ev.clientX - resizeRef.current.startX)), h: Math.max(minH, resizeRef.current.origH + (ev.clientY - resizeRef.current.startY)) }); };
const onUp = () => { resizeRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
}, [id, w, h, minW, minH, onUpdate, onFocus]);
return createPortal(
<div ref={ref} tabIndex={-1} onKeyDown={(e) => { if (e.key === "Escape") onClose(id); }} onMouseDown={() => onFocus(id)}
className="fixed z-999 flex flex-col bg-popover text-popover-foreground border-2 rounded-lg outline-none transition-[border-color,opacity] duration-150"
style={{ left: x, top: y, width: w, height: h, zIndex: 999 + zIndex, borderColor: focused ? "var(--primary)" : "var(--border)", opacity: focused ? 1 : 0.85, boxShadow: DITHERED_SHADOW }}>
{/* Title bar */}
<div onMouseDown={onDragStart} className="flex items-center gap-2 px-3 py-1.5 border-b-2 border-border cursor-grab active:cursor-grabbing select-none shrink-0 bg-muted/30 rounded-t-lg">
<div className="flex items-center gap-1.5">
<button onClick={() => onClose(id)} className="w-2.5 h-2.5 rounded-full bg-destructive hover:brightness-125 transition-all" />
<span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" /><span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" />
</div>
<span className="flex-1 text-[10px] font-semibold uppercase tracking-wider truncate text-center">{title}</span>
</div>
{/* Optional address bar */}
{addressBar}
{/* Content */}
<div className="flex-1 min-h-0">
{children}
</div>
{/* Optional footer */}
{footer}
{/* Resize handle */}
<div onMouseDown={onResizeStart} className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize" style={{ touchAction: "none" }}>
<svg viewBox="0 0 16 16" className="w-full h-full text-muted-foreground/50"><path d="M14 14L8 14L14 8Z" fill="currentColor" /><path d="M14 14L11 14L14 11Z" fill="currentColor" opacity="0.5" /></svg>
</div>
</div>, document.body);
}

View File

@@ -0,0 +1,79 @@
import { useRef, useEffect } from "react";
import { useLazyEyes } from "@/hooks/useLazyEyes";
interface EyeSpec {
top: number;
left: number;
size?: number;
irisSize?: number;
}
interface LazyEyesProps {
eyes: EyeSpec[];
/** Viewport-relative anchor the eyes "live" at. If omitted, uses the component's own position. */
anchor?: { x: number; y: number };
maxShift?: number;
ease?: number;
className?: string;
}
export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: LazyEyesProps) {
const containerRef = useRef<HTMLDivElement>(null);
const anchorRef = useRef<{ x: number; y: number } | null>(anchor ?? null);
// Keep anchorRef in sync with prop or auto-compute from DOM
useEffect(() => {
if (anchor) {
anchorRef.current = anchor;
return;
}
const update = () => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
anchorRef.current = { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
};
update();
window.addEventListener("scroll", update, true);
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update, true);
window.removeEventListener("resize", update);
};
}, [anchor]);
const offset = useLazyEyes({ anchorRef, maxShift, ease });
return (
<div ref={containerRef} className={className} style={{ position: "absolute", pointerEvents: "none" }}>
{eyes.map((eye, i) => {
const size = eye.size ?? 5;
const irisSize = eye.irisSize ?? 2;
// Clamp iris within eye circle
const maxR = (size - irisSize) / 2;
const dist = Math.sqrt(offset.x * offset.x + offset.y * offset.y) || 0;
const scale = dist > maxR && dist > 0 ? maxR / dist : 1;
const cx = offset.x * scale;
const cy = offset.y * scale;
return (
<div
key={i}
className="editor-pointer-eye"
style={{ top: eye.top, left: eye.left, width: size, height: size }}
>
<div
className="editor-pointer-iris"
style={{
width: irisSize,
height: irisSize,
marginTop: -(irisSize / 2) - 0.5,
marginLeft: -(irisSize / 2) - 0.5,
transform: `translate(${cx}px, ${cy}px)`,
}}
/>
</div>
);
})}
</div>
);
}

View File

@@ -30,7 +30,7 @@ function PopoverContent({
alignOffset={alignOffset} alignOffset={alignOffset}
side={side} side={side}
sideOffset={sideOffset} sideOffset={sideOffset}
className="isolate z-50" className="isolate z-9999"
> >
<PopoverPrimitive.Popup <PopoverPrimitive.Popup
data-slot="popover-content" data-slot="popover-content"

View File

@@ -68,7 +68,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
<th <th
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0", "h-7 px-1.5 text-left align-middle font-medium whitespace-nowrap text-foreground text-xs [&:has([role=checkbox])]:pr-0",
className className
)} )}
{...props} {...props}
@@ -81,7 +81,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
<td <td
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", "px-1.5 py-1 align-middle whitespace-nowrap text-xs [&:has([role=checkbox])]:pr-0",
className className
)} )}
{...props} {...props}

View File

@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { useStore, type StoreApi } from "zustand";
import { useEditorStore } from "@/stores/editorStore"; import { useEditorStore } from "@/stores/editorStore";
import type { EditorStore } from "@/stores/editorStore";
import { compile } from "@/api/client"; import { compile } from "@/api/client";
const DEBOUNCE_MS = 400; const DEBOUNCE_MS = 400;
@@ -7,12 +9,23 @@ const DEBOUNCE_MS = 400;
/** /**
* Debounced hook that compiles µFrame source via the API. * Debounced hook that compiles µFrame source via the API.
* Automatically triggers on ufSource changes. * Automatically triggers on ufSource changes.
* Accepts an optional store API for per-window instances; falls back to the global singleton.
*/ */
export function useCompile() { export function useCompile(storeApi?: StoreApi<EditorStore>) {
const ufSource = useEditorStore((s) => s.ufSource); const globalSource = useEditorStore((s) => s.ufSource);
const setCompileResult = useEditorStore((s) => s.setCompileResult); const globalSetResult = useEditorStore((s) => s.setCompileResult);
const setCompiling = useEditorStore((s) => s.setCompiling); const globalSetCompiling = useEditorStore((s) => s.setCompiling);
const setCompileError = useEditorStore((s) => s.setCompileError); const globalSetError = useEditorStore((s) => s.setCompileError);
const localSource = useStore(storeApi ?? useEditorStore, (s) => s.ufSource);
const localSetResult = useStore(storeApi ?? useEditorStore, (s) => s.setCompileResult);
const localSetCompiling = useStore(storeApi ?? useEditorStore, (s) => s.setCompiling);
const localSetError = useStore(storeApi ?? useEditorStore, (s) => s.setCompileError);
const ufSource = storeApi ? localSource : globalSource;
const setCompileResult = storeApi ? localSetResult : globalSetResult;
const setCompiling = storeApi ? localSetCompiling : globalSetCompiling;
const setCompileError = storeApi ? localSetError : globalSetError;
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);

View File

@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from "react";
interface LazyEyesOptions {
/** Reference point the eyes "live" at (viewport coords). Eyes look away from this toward the mouse. */
anchorRef: React.RefObject<{ x: number; y: number } | null>;
/** Max pixel shift for the iris (default 1.5) */
maxShift?: number;
/** Lerp ease factor 01 for slow drift (default 0.04) */
ease?: number;
/** Saccade threshold — when target jumps more than this, snap fast (default 0.8) */
saccadeThreshold?: number;
/** Fast ease for saccade snap (default 0.35) */
saccadeEase?: number;
}
/**
* Returns a smoothly-interpolated { x, y } offset for positioning irises
* that lazily track the mouse cursor relative to an anchor point.
*
* Movement model:
* - Small mouse moves → slow, lazy drift (ease)
* - Large jumps → quick saccade snap (saccadeEase), then settle
* - Tiny random micro-drift to avoid perfectly still eyes
*/
export function useLazyEyes({
anchorRef,
maxShift = 1.5,
ease = 0.04,
saccadeThreshold = 0.8,
saccadeEase = 0.35,
}: LazyEyesOptions) {
const [offset, setOffset] = useState({ x: 0, y: 0 });
const targetRef = useRef({ x: 0, y: 0 });
const currentRef = useRef({ x: 0, y: 0 });
const velocityRef = useRef({ x: 0, y: 0 });
useEffect(() => {
const onMouseMove = (e: MouseEvent) => {
const anchor = anchorRef.current;
if (!anchor) return;
const dx = e.clientX - anchor.x;
const dy = e.clientY - anchor.y;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
targetRef.current = {
x: (dx / dist) * maxShift,
y: (dy / dist) * maxShift,
};
};
window.addEventListener("mousemove", onMouseMove);
let raf = 0;
const tick = () => {
const ec = currentRef.current;
const et = targetRef.current;
const vel = velocityRef.current;
// Distance to target
const dx = et.x - ec.x;
const dy = et.y - ec.y;
const dist = Math.sqrt(dx * dx + dy * dy);
// Saccade: snap fast when target jumps significantly
const e_ = dist > saccadeThreshold ? saccadeEase : ease;
// Apply eased movement
vel.x = vel.x * 0.6 + dx * e_ * 0.4;
vel.y = vel.y * 0.6 + dy * e_ * 0.4;
ec.x += vel.x;
ec.y += vel.y;
// Micro-drift: tiny organic tremor when nearly still
if (dist < 0.1) {
ec.x += (Math.random() - 0.5) * 0.02;
ec.y += (Math.random() - 0.5) * 0.02;
}
setOffset({ x: ec.x, y: ec.y });
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
window.removeEventListener("mousemove", onMouseMove);
cancelAnimationFrame(raf);
};
}, [anchorRef, maxShift, ease, saccadeThreshold, saccadeEase]);
return offset;
}

View File

@@ -1,8 +1,12 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useStore, type StoreApi } from "zustand";
import { useEditorStore } from "@/stores/editorStore"; import { useEditorStore } from "@/stores/editorStore";
import type { EditorStore } from "@/stores/editorStore";
export function useUnsavedGuard() { export function useUnsavedGuard(storeApi?: StoreApi<EditorStore>) {
const isDirty = useEditorStore((s) => s.isDirty); const globalDirty = useEditorStore((s) => s.isDirty);
const localDirty = useStore(storeApi ?? useEditorStore, (s) => s.isDirty);
const isDirty = storeApi ? localDirty : globalDirty;
useEffect(() => { useEffect(() => {
const handler = (e: BeforeUnloadEvent) => { const handler = (e: BeforeUnloadEvent) => {

View File

@@ -0,0 +1,63 @@
import { useCallback, useState } from "react";
let nextWinZ = 1;
export interface ManagedWindow<T> {
id: string;
x: number;
y: number;
w: number;
h: number;
zIndex: number;
data: T;
}
export function useWindowManager<T>(defaults?: { w: number; h: number }) {
const defaultW = defaults?.w ?? 480;
const defaultH = defaults?.h ?? 400;
const [windows, setWindows] = useState<ManagedWindow<T>[]>([]);
const [focusedId, setFocusedId] = useState<string | null>(null);
const open = useCallback((id: string, data: T, size?: { w: number; h: number }) => {
setWindows((prev) => {
const existing = prev.find((w) => w.id === id);
if (existing) {
const z = ++nextWinZ;
setFocusedId(existing.id);
return prev.map((w) => w.id === existing.id ? { ...w, zIndex: z } : w);
}
const winW = size?.w ?? defaultW;
const winH = size?.h ?? defaultH;
const margin = 20;
const z = ++nextWinZ;
const win: ManagedWindow<T> = {
id, data,
x: Math.round(margin + Math.random() * (Math.max(margin, window.innerWidth - winW - margin) - margin)),
y: Math.round(margin + Math.random() * (Math.max(margin, window.innerHeight - winH - margin) - margin)),
w: winW, h: winH, zIndex: z,
};
setFocusedId(win.id);
return [...prev, win];
});
}, [defaultW, defaultH]);
const update = useCallback((id: string, patch: Partial<ManagedWindow<T>>) => {
setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, ...patch } : w)));
}, []);
const close = useCallback((id: string) => {
setWindows((prev) => prev.filter((w) => w.id !== id));
setFocusedId((cur) => cur === id ? null : cur);
}, []);
const focus = useCallback((id: string) => {
setFocusedId((cur) => {
if (cur === id) return cur;
const z = ++nextWinZ;
setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, zIndex: z } : w)));
return id;
});
}, []);
return { windows, focusedId, open, update, close, focus };
}

View File

@@ -405,8 +405,8 @@
/* ── Editor Pointer ── */ /* ── Editor Pointer ── */
.editor-pointer { .editor-pointer {
position: fixed; position: absolute;
z-index: 50; z-index: 9999;
pointer-events: none; pointer-events: none;
will-change: top; will-change: top;
transform: translateX(-100%); transform: translateX(-100%);
@@ -441,7 +441,6 @@
-webkit-mask-size: contain; -webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat; -webkit-mask-repeat: no-repeat;
-webkit-mask-position: center; -webkit-mask-position: center;
opacity: 1;
} }
.editor-pointer-eye { .editor-pointer-eye {

View File

@@ -1,413 +1,640 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Graph } from "@cosmos.gl/graph"; import { Graph } from "@cosmos.gl/graph";
import { fetchBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client"; import { subscribeBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client";
import { renderMicron } from "@/components/editor/micronRenderer"; import { renderMicron } from "@/components/editor/micronRenderer";
import axisMundiUrl from "@/assets/axis-mundi.min.svg"; import FloatingWindow, { DITHERED_SHADOW } from "@/components/shared/FloatingWindow";
import { useWindowManager } from "@/hooks/useWindowManager";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Theme color maps — hex values matching index.css OKLCH definitions // Constants — matching cosmos.gl clusters-with-labels example
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface ThemeColors { const SPACE_SIZE = 4096;
primary: string; const CENTER = SPACE_SIZE / 2;
muted: string;
border: string; // ---------------------------------------------------------------------------
// 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];
} }
const THEME_COLORS: Record<string, ThemeColors> = { function lerpRGBA(
dark: { // .dark (terra) a: [number, number, number, number],
primary: "#c47a32", b: [number, number, number, number],
muted: "#8a7560", t: number,
border: "#6b5a42", ): [number, number, number, number] {
},
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 [ return [
parseInt(hex.slice(1, 3), 16), a[0] + (b[0] - a[0]) * t,
parseInt(hex.slice(3, 5), 16), a[1] + (b[1] - a[1]) * t,
parseInt(hex.slice(5, 7), 16), a[2] + (b[2] - a[2]) * t,
255, a[3] + (b[3] - a[3]) * t,
]; ];
} }
// --------------------------------------------------------------------------- function brighten(c: [number, number, number, number], amount: number): [number, number, number, number] {
// Data builders — convert NetworkNode[] to Float32Arrays for cosmos.gl return [
// --------------------------------------------------------------------------- Math.min(1, c[0] + amount),
Math.min(1, c[1] + amount),
function buildBuffers(nodes: NetworkNode[], colors: ThemeColors) { Math.min(1, c[2] + amount),
const sorted = [...nodes].sort((a, b) => (a.is_self ? -1 : b.is_self ? 1 : 0)); c[3],
];
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 getThemeStatusColors() {
function applyThemeToGraph(graph: Graph, nodes: NetworkNode[], colors: ThemeColors) { const primary = brighten(cssVarToRGBA("--primary"), 0.15);
graph.setConfig({ const muted = cssVarToRGBA("--muted-foreground");
pointDefaultColor: colors.primary, return {
linkDefaultColor: colors.border, online: primary, // --primary brightened
hoveredPointRingColor: colors.primary, 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 peers = rawNodes.filter(e => !e.is_self && e.type !== "interface");
// Only peers are points — interfaces are clusters, not nodes
const entries = peers;
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
const clusterMap = new Map<string, number>();
interfaces.forEach((iface, i) => clusterMap.set(iface.name, i));
// Cluster names for labels
const clusterNames = interfaces.map(i => i.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 — by status
const rgba = 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 — map to parent interface
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;
}); });
const n = nodes.length; return { entries, positions, colors, sizes, clusterIndices, clusterPositions, clusterStrength, clusterNames, hashToIndex };
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 // Browse window data
// ---------------------------------------------------------------------------
interface BrowseWinData {
node: NetworkNode;
pageHtml: string | null;
pageLoading: boolean;
pageError: string | null;
}
// ---------------------------------------------------------------------------
// Main component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function BrowseView() { export default function BrowseView() {
const [nodes, setNodes] = useState<NetworkNode[]>([]); const [nodes, setNodes] = useState<NetworkNode[]>([]);
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
const [selectedNode, setSelectedNode] = useState<NetworkNode | null>(null); const { windows, focusedId: focusedWinId, open: openWindow, update: updateWindow, close: closeWindowById, focus: focusWindow } = useWindowManager<BrowseWinData>();
const [pageHtml, setPageHtml] = useState<string | null>(null); const [hoveredLabel, setHoveredLabel] = useState<{ text: string; x: number; y: number } | 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 [themeRev, setThemeRev] = useState(0);
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; // Watch for theme changes (class on <html>)
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(() => { useEffect(() => {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => setThemeRev(r => r + 1));
const id = getThemeId();
setThemeId(id);
const graph = graphRef.current;
if (graph) {
const c = THEME_COLORS[id] ?? THEME_COLORS.dark;
applyThemeToGraph(graph, nodesRef.current, c);
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// ── Initialize cosmos.gl graph ── 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());
const hashToIndexRef = useRef<Map<string, number>>(new Map());
const clusterNamesRef = useRef<string[]>([]);
// Build graph data, preserving existing positions
const graphData = useMemo(() => {
const graph = graphRef.current;
const prevPositions = graph ? graph.getPointPositions() : [];
return buildGraphArrays(nodes, hashToIndexRef.current, prevPositions, getThemeStatusColors());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodes, themeRev]);
useEffect(() => {
const map = new Map<string, NetworkNode>();
for (const n of nodes) map.set(n.hash, n);
nodesMapRef.current = map;
entriesRef.current = graphData.entries;
hashToIndexRef.current = graphData.hashToIndex;
clusterNamesRef.current = graphData.clusterNames;
}, [nodes, graphData]);
// Search — matches + autocomplete suggestions
const searchMatchIndices = useMemo(() => {
if (!filter.trim()) return null;
const q = filter.trim().toLowerCase();
const indices: number[] = [];
graphData.entries.forEach((entry, i) => {
if (entry.name.toLowerCase().includes(q)) indices.push(i);
});
return indices.length > 0 ? indices : null;
}, [filter, graphData]);
const suggestions = useMemo(() => {
if (!filter.trim()) return [];
const q = filter.trim().toLowerCase();
return graphData.entries
.filter(e => e.name.toLowerCase().includes(q))
.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(() => {
const graph = graphRef.current;
const container = containerRef.current;
if (!graph || !container) return;
const positions = graph.getClusterPositions();
const names = clusterNamesRef.current;
const nClusters = Math.min(names.length, positions.length / 2);
// Rebuild label divs if count changed
if (labelDivsRef.current.length !== nClusters) {
labelDivsRef.current.forEach(d => d.remove());
labelDivsRef.current = [];
for (let i = 0; i < nClusters; i++) {
const div = document.createElement("div");
div.style.position = "absolute";
div.style.pointerEvents = "none";
div.style.whiteSpace = "nowrap";
div.style.transform = "translate(-50%, -100%)";
div.style.padding = "2px 8px";
div.style.borderRadius = "4px";
div.style.background = "var(--popover)";
div.style.border = "1px solid var(--border)";
div.style.color = "var(--foreground)";
div.style.fontFamily = "JetBrains Mono, monospace";
div.style.fontSize = "11px";
div.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)";
div.textContent = names[i] ?? "";
container.appendChild(div);
labelDivsRef.current.push(div);
}
}
// Update positions
for (let i = 0; i < nClusters; i++) {
const x = positions[i * 2];
const y = positions[i * 2 + 1];
if (x === undefined || y === undefined) continue;
const screen = graph.spaceToScreenPosition([x, y]);
const div = labelDivsRef.current[i]!;
div.style.left = `${screen[0]}px`;
div.style.top = `${screen[1]}px`;
}
}, []);
// ── Init Cosmos — matching the example's create-cosmos.ts ──
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
const graph = new Graph(containerRef.current, { const graph = new Graph(containerRef.current, {
backgroundColor: [0, 0, 0, 0], spaceSize: SPACE_SIZE,
pointDefaultColor: colors.primary, backgroundColor: "transparent",
pointDefaultSize: 12, pointDefaultColor: "#888888",
linkDefaultColor: colors.border, pointDefaultSize: 10,
linkDefaultWidth: 1, renderLinks: false,
linkOpacity: 0.5, fitViewOnInit: true,
enableSimulation: true, fitViewDelay: 1500,
enableDrag: true, fitViewPadding: 0.2,
enableZoom: true,
fitViewOnInit: false,
spaceSize: 1024,
simulationGravity: 0.15,
simulationRepulsion: 0.6,
simulationLinkSpring: 0.3,
simulationLinkDistance: 60,
simulationFriction: 0.85,
simulationDecay: 8000,
renderHoveredPointRing: true, renderHoveredPointRing: true,
hoveredPointRingColor: colors.primary, hoveredPointRingColor: "#ffffff",
hoveredPointCursor: "pointer", scalePointsOnZoom: true,
onPointClick: (index: number) => { pointGreyoutOpacity: 0.1,
const node = nodesRef.current[index]; // Simulation defaults — dynamically adjusted by node count in data update
if (node) handleNodeClick(node); 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);
}, },
onClick: () => { onMouseMove: (index, pointPosition) => {
setSelectedNode(null); if (index === undefined || !pointPosition || !graphRef.current) {
setPageHtml(null); setHoveredLabel(null);
setPageError(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: () => updateLabels(), onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
onZoom: () => updateLabels(), onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); },
onZoom: () => { updateClusterLabels(); updateSearchLabels(); },
}); });
graphRef.current = graph; graphRef.current = graph;
return () => { return () => {
cancelAnimationFrame(rafRef.current); labelDivsRef.current.forEach(d => d.remove());
labelDivsRef.current = [];
graph.destroy(); graph.destroy();
graphRef.current = null; graphRef.current = null;
}; };
}, []); }, []);
// ── Update labels from graph positions ── // ── Update data — following the example's exact call order ──
const updateLabels = useCallback(() => { const isFirstLoadRef = useRef(true);
useEffect(() => {
const graph = graphRef.current; const graph = graphRef.current;
if (!graph || nodesRef.current.length === 0) return; if (!graph || graphData.entries.length === 0) return;
const positions = graph.getPointPositions(); // Scale simulation params by node count
const next: { x: number; y: number }[] = []; // Reference: 10k nodes → repulsion 10, cluster 0.25, gravity 2
for (let i = 0; i < nodesRef.current.length; i++) { // Scale logarithmically so it works from 10 to 10k nodes
const sx = positions[i * 2]; const n = graphData.entries.length;
const sy = positions[i * 2 + 1]; const scale = Math.log10(Math.max(10, n)) / Math.log10(10000); // 0..1
if (sx === undefined) break; graph.setConfig({
const [px, py] = graph.spaceToScreenPosition([sx, sy]); simulationRepulsion: 0.5 + scale * 9.5, // 0.5 → 10
next.push({ x: px, y: py }); 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 ──
const searchLabelDivsRef = useRef<Map<number, HTMLDivElement>>(new Map());
const searchIndicesRef = useRef<number[] | null>(null);
const clearSearchLabels = useCallback(() => {
searchLabelDivsRef.current.forEach(d => d.remove());
searchLabelDivsRef.current.clear();
searchIndicesRef.current = null;
}, []);
// Reposition search labels (called on tick/zoom alongside cluster labels)
const updateSearchLabels = useCallback(() => {
const graph = graphRef.current;
const indices = searchIndicesRef.current;
if (!graph || !indices || indices.length === 0) return;
const positions = graph.getPointPositions();
const entries = entriesRef.current;
const containerEl = containerRef.current;
if (!containerEl) return;
const rect = containerEl.getBoundingClientRect();
for (const idx of indices) {
const x = positions[idx * 2];
const y = positions[idx * 2 + 1];
if (x === undefined || y === undefined) continue;
const screen = graph.spaceToScreenPosition([x, y]);
let div = searchLabelDivsRef.current.get(idx);
if (!div) {
div = document.createElement("div");
div.style.position = "fixed";
div.style.pointerEvents = "none";
div.style.padding = "2px 8px";
div.style.borderRadius = "4px";
div.style.background = "var(--popover)";
div.style.border = "1px solid var(--primary)";
div.style.color = "var(--primary)";
div.style.fontFamily = "JetBrains Mono, monospace";
div.style.fontWeight = "bold";
div.style.fontSize = "11px";
div.style.boxShadow = "0 2px 8px rgba(0,0,0,0.4)";
div.style.whiteSpace = "nowrap";
div.style.zIndex = "998";
div.textContent = entries[idx]?.name ?? "";
document.body.appendChild(div);
searchLabelDivsRef.current.set(idx, div);
}
div.style.left = `${rect.left + screen[0] + 10}px`;
div.style.top = `${rect.top + screen[1] - 8}px`;
} }
setLabelPositions(next);
}, []); }, []);
// ── Feed node data into graph when nodes change ──
useEffect(() => { useEffect(() => {
const graph = graphRef.current; const graph = graphRef.current;
if (!graph) return; if (!graph) return;
if (filteredNodes.length === 0) {
nodesRef.current = []; clearSearchLabels();
setLabelPositions([]);
graph.setPointPositions(new Float32Array(0)); if (searchMatchIndices) {
graph.setPointColors(new Float32Array(0)); searchIndicesRef.current = searchMatchIndices;
graph.setPointSizes(new Float32Array(0)); graph.selectPointsByIndices(searchMatchIndices);
graph.setLinks(new Float32Array(0)); if (searchMatchIndices.length <= 10) {
graph.setLinkColors(new Float32Array(0)); graph.fitViewByPointIndices(searchMatchIndices, 500);
graph.render(); }
return; // Initial position — will be continuously updated on tick/zoom
updateSearchLabels();
} else {
graph.unselectPoints();
graph.fitView(300, 0.2);
} }
const c = THEME_COLORS[getThemeId()] ?? THEME_COLORS.dark; return () => clearSearchLabels();
const { sorted, positions, pointColors, sizes, links, linkColors } = buildBuffers(filteredNodes, c); }, [searchMatchIndices, clearSearchLabels, updateSearchLabels]);
nodesRef.current = sorted;
graph.setPointPositions(positions); // ── SSE stream ──
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(() => { useEffect(() => {
const load = () => { let pending: NetworkNode[] = [];
fetchBrowseNodes().then(setNodes).catch(() => { }); let batchTimer: ReturnType<typeof setTimeout> | null = null;
}; const flush = () => {
load(); batchTimer = null;
pollRef.current = setInterval(load, 30_000); if (pending.length === 0) return;
return () => { const batch = pending; pending = [];
if (pollRef.current) clearInterval(pollRef.current); setNodes((prev) => {
const map = new Map(prev.map((n) => [n.hash, n]));
for (const node of batch) map.set(node.hash, node);
return Array.from(map.values());
});
}; };
const unsub = subscribeBrowseNodes((node) => { pending.push(node); if (!batchTimer) batchTimer = setTimeout(flush, 200); });
return () => { unsub(); if (batchTimer) clearTimeout(batchTimer); flush(); };
}, []); }, []);
// ── Node click → fetch page ── // ── Node click ──
const handleNodeClick = useCallback((node: NetworkNode) => { const handleNodeClick = useCallback((node: NetworkNode) => {
setSelectedNode(node); const id = node.hash;
setPageHtml(null); const data: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null };
setPageError(null); openWindow(id, data);
setPageLoading(true);
fetchRemotePage(node.hash) fetchRemotePage(node.hash)
.then((res) => { .then((res) => updateWindow(id, { data: { node, pageHtml: res.content ? renderMicron(res.content, true) : null, pageError: res.content ? null : (res.error ?? "No content"), pageLoading: false } }))
if (res.content) { .catch((e) => updateWindow(id, { data: { node, pageError: String(e), pageLoading: false, pageHtml: null } }));
setPageHtml(renderMicron(res.content, true)); }, [openWindow, updateWindow]);
} else {
setPageError(res.error ?? "No content"); const clearSearch = useCallback(() => {
} setFilter("");
}) graphRef.current?.unselectPoints();
.catch((e) => setPageError(String(e))) graphRef.current?.fitView(300, 0.2);
.finally(() => setPageLoading(false)); updateClusterLabels();
}, []); }, [updateClusterLabels]);
const nodeCount = nodes.filter(n => n.type !== "interface").length;
const ifaceCount = nodes.filter(n => n.type === "interface").length;
// ── 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 ( return (
<div className="flex flex-col" style={{ height: "100%" }}> <div className="relative overflow-hidden" style={{ height: "100%" }}>
{/* Header */} <div ref={containerRef} className="absolute inset-0" />
<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 */} {/* Hover label */}
<div {hoveredLabel && (
ref={containerRef} <div className="absolute pointer-events-none px-2 py-0.5 bg-popover border border-border rounded text-xs font-mono text-foreground whitespace-nowrap"
className="relative shrink-0 bg-background overflow-hidden" style={{ left: hoveredLabel.x + 12, top: hoveredLabel.y - 10, boxShadow: "0 2px 8px rgba(0,0,0,0.3)" }}>
style={{ height: 500 }} {hoveredLabel.text}
>
{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 - (node.is_self ? 24 : 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 flex flex-col">
<div className="flex items-center px-4 py-2 border-b border-border bg-background shrink-0">
<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>
)}
<div className="flex-1 min-h-0 overflow-auto p-3"> {/* Search bar */}
{!selectedNode && !pageLoading && ( {searchPos && createPortal(
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground"> <div onMouseDown={onSearchDragStart}
<div 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"
className="w-80 h-64" style={{ left: searchPos.x, top: searchPos.y, width: 400, boxShadow: DITHERED_SHADOW }}>
style={{ <input ref={searchInputRef} type="text" value={filter}
backgroundColor: colors.primary, onChange={(e) => setFilter(e.target.value)}
mask: `url(${axisMundiUrl}) center/contain no-repeat`, onKeyDown={(e) => {
WebkitMask: `url(${axisMundiUrl}) center/contain no-repeat`, 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; }
<span className="text-xs">Click a node to view its page</span> 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>
)} )}
{pageLoading && ( </div>, document.body)}
<span className="text-muted-foreground text-xs animate-pulse">
Requesting page... {nodes.length === 0 && (
</span> <div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm pointer-events-none">
)} Listening for nodes on the Reticulum network...
{pageError && (
<span className="text-destructive text-xs">{pageError}</span>
)}
{pageHtml && (
<div
className="font-mono text-[11px] leading-tight"
dangerouslySetInnerHTML={{ __html: pageHtml }}
/>
)}
</div> </div>
</div> )}
{windows.map((win) => (
<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-2 px-3 py-1 border-b border-border shrink-0 bg-muted/15">
<span className="text-[9px] text-muted-foreground uppercase tracking-wider shrink-0">addr</span>
<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">{win.data.node.hash}</div>
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
{win.data.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
: win.data.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
: win.data.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">{win.data.node.type ?? "peer"}</span>
{win.data.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {win.data.node.interface}</span>}
</div>
}
>
<div className="p-3 h-full overflow-auto">
{win.data.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>}
{win.data.pageError && <span className="text-destructive text-xs">{win.data.pageError}</span>}
{win.data.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: win.data.pageHtml }} />}
</div>
</FloatingWindow>
))}
</div> </div>
); );
} }

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { MoreVertical, Plus, RotateCcw } from "lucide-react"; import { MoreVertical, Plus, RotateCcw } from "lucide-react";
import { usePagesStore } from "@/stores/pagesStore"; import { usePagesStore } from "@/stores/pagesStore";
@@ -29,13 +28,20 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { useWindowManager } from "@/hooks/useWindowManager";
import EditorWindow, { type EditorWinData } from "@/components/editor/EditorWindow";
export default function ComposeView() { export default function ComposeView() {
const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } = const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } =
usePagesStore(); usePagesStore();
const navigate = useNavigate();
const [pageToDelete, setPageToDelete] = useState<string | null>(null); const [pageToDelete, setPageToDelete] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false); const [restarting, setRestarting] = useState(false);
const { windows, focusedId, open, update, close, focus } = useWindowManager<EditorWinData>({ w: 720, h: 520 });
const openEditor = (name: string, isNew: boolean) => {
const id = isNew ? `new-${Date.now()}` : name;
open(id, { pageName: isNew ? "" : name, isNew });
};
useEffect(() => { useEffect(() => {
fetchPages(); fetchPages();
@@ -89,15 +95,15 @@ export default function ComposeView() {
<div> <div>
<div> <div>
{/* Header row */} {/* Header row */}
<div className="flex items-center px-4 py-2 border-b-2 border-border"> <div className="flex items-center px-2 py-1.5 border-b-2 border-border">
<h1 className="text-sm font-semibold flex-1">Compose</h1> <h1 className="text-xs font-semibold flex-1">Compose</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" onClick={handleRestart} disabled={restarting}> <Button variant="outline" size="sm" onClick={handleRestart} disabled={restarting}>
<RotateCcw className="w-4 h-4 mr-2" /> <RotateCcw className="w-3 h-3 mr-1.5" />
Restart Restart
</Button> </Button>
<Button onClick={() => navigate("/editor/new")}> <Button size="sm" onClick={() => openEditor("", true)}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-3 h-3 mr-1.5" />
New Page New Page
</Button> </Button>
</div> </div>
@@ -119,7 +125,7 @@ export default function ComposeView() {
<TableRow <TableRow
key={p.name} key={p.name}
className="cursor-pointer" className="cursor-pointer"
onClick={() => navigate(`/editor/${p.name}`)} onClick={() => openEditor(p.name, false)}
> >
<TableCell className="font-mono"> <TableCell className="font-mono">
{p.name} {p.name}
@@ -138,8 +144,8 @@ export default function ComposeView() {
</TableCell> </TableCell>
<TableCell className="text-right w-8"> <TableCell className="text-right w-8">
<PageActions <PageActions
name={p.name}
published={p.published} published={p.published}
onEdit={() => openEditor(p.name, false)}
onPublish={() => handlePublish(p.name)} onPublish={() => handlePublish(p.name)}
onUnpublish={() => handleUnpublish(p.name)} onUnpublish={() => handleUnpublish(p.name)}
onDelete={() => setPageToDelete(p.name)} onDelete={() => setPageToDelete(p.name)}
@@ -184,6 +190,18 @@ export default function ComposeView() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* Floating editor windows */}
{windows.map((win) => (
<EditorWindow
key={win.id}
win={win}
focused={focusedId === win.id}
onUpdate={update}
onClose={close}
onFocus={focus}
/>
))}
</div> </div>
); );
} }
@@ -191,20 +209,18 @@ export default function ComposeView() {
/** Per-row action menu for a page. */ /** Per-row action menu for a page. */
function PageActions({ function PageActions({
name,
published, published,
onEdit,
onPublish, onPublish,
onUnpublish, onUnpublish,
onDelete, onDelete,
}: { }: {
name: string;
published: boolean; published: boolean;
onEdit: () => void;
onPublish: () => void; onPublish: () => void;
onUnpublish: () => void; onUnpublish: () => void;
onDelete: () => void; onDelete: () => void;
}) { }) {
const navigate = useNavigate();
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
@@ -219,7 +235,7 @@ function PageActions({
/> />
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-36 p-1"> <PopoverContent side="bottom" align="end" sideOffset={4} className="w-36 p-1">
<button <button
onClick={(e) => { e.stopPropagation(); navigate(`/editor/${name}`); }} onClick={(e) => { e.stopPropagation(); onEdit(); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer" className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Edit</button> >Edit</button>
{published ? ( {published ? (

View File

@@ -1,7 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import type { PageMeta } from "@/api/client"; import type { PageMeta } from "@/api/client";
interface EditorStore { export interface EditorStore {
// Source // Source
ufSource: string; ufSource: string;
isDirty: boolean; isDirty: boolean;
@@ -30,6 +30,41 @@ interface EditorStore {
reset: () => void; reset: () => void;
} }
const initialState = {
ufSource: "",
isDirty: false,
currentPage: null as PageMeta | null,
compiledAscii: "",
compiledMicron: "",
compiledScript: "",
isDynamic: false,
compileWarnings: [] as string[],
isCompiling: false,
compileError: null as string | null,
previewMode: "micron" as const,
};
function makeActions(set: (partial: Partial<EditorStore>) => void) {
return {
setSource: (s: string) => set({ ufSource: s, isDirty: true }),
setCurrentPage: (p: PageMeta | null) => set({ currentPage: p }),
setDirty: (v: boolean) => set({ isDirty: v }),
setCompileResult: (ascii: string, micron: string, script: string, isDynamic: boolean, warnings: string[]) =>
set({ compiledAscii: ascii, compiledMicron: micron, compiledScript: script, isDynamic, compileWarnings: warnings, isCompiling: false, compileError: null }),
setCompiling: (v: boolean) => set({ isCompiling: v }),
setCompileError: (e: string | null) => set({ compileError: e, isCompiling: false }),
setPreviewMode: (mode: "micron" | "raw" | "script") => set({ previewMode: mode }),
reset: () => set({ ...initialState }),
};
}
export function createEditorStore() {
return create<EditorStore>((set) => ({
...initialState,
...makeActions(set),
}));
}
export const useEditorStore = create<EditorStore>((set) => ({ export const useEditorStore = create<EditorStore>((set) => ({
ufSource: "", ufSource: "",
isDirty: false, isDirty: false,

View File

@@ -9,6 +9,7 @@ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),
"gl-bench": path.resolve(__dirname, "node_modules/gl-bench/dist/gl-bench.module.js"),
}, },
}, },
server: { server: {

21
reticulum-client.conf Normal file
View File

@@ -0,0 +1,21 @@
[reticulum]
enable_transport = False
share_instance = No
[logging]
loglevel = 4
[interfaces]
# Connect to NomadNet's TCP server for local traffic
[[NomadNet Link]]
type = TCPClientInterface
enabled = Yes
target_host = nomadnet
target_port = 4242
# Connect to Quad4 directly for external node announces
[[Quad4]]
type = TCPClientInterface
enabled = Yes
target_host = 62.151.179.77
target_port = 45657

View File

@@ -16,7 +16,7 @@
[[Quad4]] [[Quad4]]
type = TCPClientInterface type = TCPClientInterface
interface_enabled = false interface_enabled = true
target_host = 62.151.179.77 target_host = 62.151.179.77
target_port = 45657 target_port = 45657
mode = full mode = full