Compare commits
3 Commits
72ff02dbdf
...
914945279f
| Author | SHA1 | Date | |
|---|---|---|---|
| 914945279f | |||
| 3eec7f316e | |||
| 3132d40391 |
@@ -7,6 +7,7 @@ announces, and exposes discovered nodes + remote page fetching via API.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
@@ -14,6 +15,7 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
router = APIRouter()
|
||||
log = logging.getLogger("browse")
|
||||
@@ -27,19 +29,136 @@ _own_hash: str | None = None
|
||||
_own_name: str = os.environ.get("NOMADNET_NODE_NAME", "Micronomicon")
|
||||
_lock = threading.Lock()
|
||||
_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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_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:
|
||||
"""Initialize RNS and begin listening for NomadNet node announces."""
|
||||
global _started, _own_hash
|
||||
global _started, _loop, _reticulum
|
||||
|
||||
if _started:
|
||||
return
|
||||
|
||||
try:
|
||||
_loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
_loop = None
|
||||
|
||||
try:
|
||||
import RNS
|
||||
|
||||
@@ -47,13 +166,9 @@ def start_browser() -> None:
|
||||
if configdir:
|
||||
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(
|
||||
_on_announce,
|
||||
aspect_filter="nomadnetwork.node",
|
||||
)
|
||||
RNS.Transport.register_announce_handler(_AnnounceHandler())
|
||||
|
||||
_started = True
|
||||
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)
|
||||
|
||||
|
||||
def _on_announce(
|
||||
destination_hash: bytes,
|
||||
announced_identity,
|
||||
app_data: bytes | None,
|
||||
) -> None:
|
||||
"""Handle an incoming NomadNet node announce."""
|
||||
import RNS
|
||||
|
||||
hash_hex = RNS.hexrep(destination_hash, delimit=False)
|
||||
|
||||
name = hash_hex[:12]
|
||||
if app_data:
|
||||
try:
|
||||
name = app_data.decode("utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
is_self = name == _own_name
|
||||
|
||||
with _lock:
|
||||
_nodes[hash_hex] = {
|
||||
"hash": hash_hex,
|
||||
"name": name,
|
||||
"last_seen": time.time(),
|
||||
"is_self": is_self,
|
||||
}
|
||||
if is_self:
|
||||
global _own_hash
|
||||
_own_hash = hash_hex
|
||||
|
||||
log.info("Node announce: %s (%s)%s", name, hash_hex[:8], " [self]" if is_self else "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# API endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/browse/nodes")
|
||||
async def list_nodes():
|
||||
"""Return all discovered NomadNet page-serving nodes."""
|
||||
def _build_snapshot() -> list[dict]:
|
||||
"""Build a full snapshot: self node + interfaces + discovered nodes."""
|
||||
with _lock:
|
||||
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):
|
||||
nodes.insert(0, {
|
||||
"hash": _own_hash or "self",
|
||||
"name": _own_name,
|
||||
"last_seen": time.time(),
|
||||
"is_self": True,
|
||||
"type": "node",
|
||||
})
|
||||
|
||||
# Add interfaces
|
||||
nodes.extend(_collect_interfaces())
|
||||
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}")
|
||||
async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
|
||||
"""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 remote nodes establishes an RNS link and requests the page.
|
||||
"""
|
||||
# Own node → read from disk
|
||||
with _lock:
|
||||
node = _nodes.get(hash_hex)
|
||||
if (node and node.get("is_self")) or hash_hex == "self":
|
||||
return _read_local_page(path)
|
||||
|
||||
# Remote node → RNS request
|
||||
content = await _request_remote_page(hash_hex, path)
|
||||
if content is None:
|
||||
return {"content": None, "error": "Could not reach node"}
|
||||
@@ -168,7 +290,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
|
||||
try:
|
||||
dest_hash = bytes.fromhex(hash_hex)
|
||||
|
||||
# Ensure path to destination is known
|
||||
if not RNS.Transport.has_path(dest_hash):
|
||||
RNS.Transport.request_path(dest_hash)
|
||||
deadline = time.time() + 10
|
||||
@@ -195,7 +316,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
|
||||
|
||||
link = RNS.Link(dest)
|
||||
|
||||
# Wait for link to become active
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline:
|
||||
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)
|
||||
return
|
||||
|
||||
# Request the page via NomadNet's protocol
|
||||
def on_response(request_receipt):
|
||||
try:
|
||||
resp = request_receipt.response
|
||||
@@ -236,7 +355,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
|
||||
if not future.done():
|
||||
loop.call_soon_threadsafe(future.set_result, None)
|
||||
|
||||
# Run blocking RNS operations in a thread
|
||||
threading.Thread(target=_do_request, daemon=True).start()
|
||||
|
||||
try:
|
||||
|
||||
@@ -28,7 +28,16 @@ async def health():
|
||||
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"
|
||||
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")
|
||||
|
||||
@@ -9,10 +9,12 @@ services:
|
||||
- PAGES_DIR=/data/pages
|
||||
- SOURCES_DIR=/data/sources
|
||||
- NOMADNET_CONTAINER=nomadnet
|
||||
- LOG_LEVEL=DEBUG
|
||||
volumes:
|
||||
- pages:/data/pages
|
||||
- sources:/data/sources
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- ./reticulum-client.conf:/root/.reticulum/config:ro
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- nomadnet
|
||||
|
||||
14
deploy.sh
Executable file
14
deploy.sh
Executable 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"
|
||||
97
frontend/package-lock.json
generated
97
frontend/package-lock.json
generated
@@ -16,15 +16,19 @@
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@cosmograph/cosmos": "^1.6.1",
|
||||
"@cosmos.gl/graph": "^2.6.4",
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@sigma/node-square": "^3.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-layout-force": "^0.2.4",
|
||||
"lucide-react": "^1.7.0",
|
||||
"micron-parser": "^1.0.3",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -33,6 +37,7 @@
|
||||
"react-resizable-panels": "^4.8.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"shadcn": "^4.1.1",
|
||||
"sigma": "^3.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
@@ -664,6 +669,30 @@
|
||||
"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": {
|
||||
"version": "2.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@cosmos.gl/graph/-/graph-2.6.4.tgz",
|
||||
@@ -1836,6 +1865,15 @@
|
||||
"integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
|
||||
@@ -3979,6 +4017,15 @@
|
||||
"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": {
|
||||
"version": "3.0.7",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "16.13.2",
|
||||
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz",
|
||||
@@ -6700,6 +6787,16 @@
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
|
||||
@@ -18,15 +18,19 @@
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@cosmograph/cosmos": "^1.6.1",
|
||||
"@cosmos.gl/graph": "^2.6.4",
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@sigma/node-square": "^3.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"graphology": "^0.26.0",
|
||||
"graphology-layout-force": "^0.2.4",
|
||||
"lucide-react": "^1.7.0",
|
||||
"micron-parser": "^1.0.3",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -35,6 +39,7 @@
|
||||
"react-resizable-panels": "^4.8.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"shadcn": "^4.1.1",
|
||||
"sigma": "^3.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Routes, Route } from "react-router-dom";
|
||||
import AppShell from "./components/shared/AppShell";
|
||||
import ComposeView from "./routes/ComposeView";
|
||||
import BrowseView from "./routes/BrowseView";
|
||||
import EditorView from "./routes/EditorView";
|
||||
import SettingsView from "./routes/SettingsView";
|
||||
|
||||
export default function App() {
|
||||
@@ -12,8 +11,6 @@ export default function App() {
|
||||
<Route path="/" element={<ComposeView />} />
|
||||
<Route path="/browse" element={<BrowseView />} />
|
||||
<Route path="/settings" element={<SettingsView />} />
|
||||
<Route path="/editor/new" element={<EditorView />} />
|
||||
<Route path="/editor/:name" element={<EditorView />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -122,6 +122,14 @@ export interface NetworkNode {
|
||||
name: string;
|
||||
last_seen: number;
|
||||
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[]> {
|
||||
@@ -130,6 +138,18 @@ export async function fetchBrowseNodes(): Promise<NetworkNode[]> {
|
||||
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(
|
||||
hash: string,
|
||||
path: string = "index.mu",
|
||||
|
||||
1
frontend/src/assets/browser.min.svg
Normal file
1
frontend/src/assets/browser.min.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 2.1 MiB |
@@ -1,12 +1,13 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import pointerSvg from "@/assets/pointer.min.svg";
|
||||
import { useLazyEyes } from "@/hooks/useLazyEyes";
|
||||
|
||||
/**
|
||||
* Floating pointer that tracks the CodeMirror cursor with smooth lerp animation.
|
||||
* Positions itself right next to the left border of the editor container.
|
||||
* Uses a ResizeObserver to keep horizontal position synced on window resize.
|
||||
* Floating pointer that tracks the CodeMirror cursor vertically,
|
||||
* pinned to the left edge of the editor. Positions relative to
|
||||
* 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 [editorLeft, setEditorLeft] = useState<number | null>(null);
|
||||
const targetRef = useRef(0);
|
||||
@@ -16,35 +17,24 @@ export default function EditorPointer() {
|
||||
const [clickKey, setClickKey] = useState(0);
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const cmRef = useRef<Element | null>(null);
|
||||
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 });
|
||||
const eyeTargetRef = useRef({ x: 0, y: 0 });
|
||||
const eyeCurrentRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// Eye anchor must be in viewport coords (useLazyEyes compares against e.clientX/Y)
|
||||
const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const eyeOffset = useLazyEyes({ anchorRef: eyeAnchorRef });
|
||||
|
||||
useEffect(() => {
|
||||
const ease = 0.09;
|
||||
const eyeEase = 0.06;
|
||||
|
||||
const getContainerRect = () =>
|
||||
containerRef?.current?.getBoundingClientRect() ?? { left: 0, top: 0 };
|
||||
|
||||
const updateLeft = () => {
|
||||
if (cmRef.current) {
|
||||
setEditorLeft(cmRef.current.getBoundingClientRect().left);
|
||||
}
|
||||
if (!cmRef.current || !containerRef?.current) return;
|
||||
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 = () => {
|
||||
clearTimeout(clickTimerRef.current);
|
||||
setClickKey((k) => k + 1);
|
||||
@@ -53,22 +43,26 @@ export default function EditorPointer() {
|
||||
|
||||
const onCursorMove = (e: Event) => {
|
||||
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) {
|
||||
const cm = document.querySelector(".cm-editor");
|
||||
const scope = containerRef?.current ?? document;
|
||||
const cm = scope.querySelector(".cm-editor");
|
||||
if (cm) {
|
||||
cmRef.current = cm;
|
||||
cm.addEventListener("mousedown", onEditorClick);
|
||||
ro.observe(cm);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert viewport top to container-relative top
|
||||
const containerTop = getContainerRect().top;
|
||||
targetRef.current = top - containerTop;
|
||||
updateLeft();
|
||||
|
||||
if (!activeRef.current) {
|
||||
currentRef.current = top;
|
||||
setY(top);
|
||||
currentRef.current = targetRef.current;
|
||||
setY(targetRef.current);
|
||||
activeRef.current = true;
|
||||
}
|
||||
};
|
||||
@@ -82,13 +76,12 @@ export default function EditorPointer() {
|
||||
currentRef.current += diff * ease;
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -97,7 +90,8 @@ export default function EditorPointer() {
|
||||
|
||||
// Keep left position updated on resize
|
||||
const ro = new ResizeObserver(() => updateLeft());
|
||||
const existingCm = document.querySelector(".cm-editor");
|
||||
const scope = containerRef?.current ?? document;
|
||||
const existingCm = scope.querySelector(".cm-editor");
|
||||
if (existingCm) {
|
||||
cmRef.current = existingCm;
|
||||
ro.observe(existingCm);
|
||||
@@ -106,15 +100,14 @@ export default function EditorPointer() {
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("cm-cursor-move", onCursorMove);
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("resize", updateLeft);
|
||||
cmRef.current?.removeEventListener("mousedown", onEditorClick);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
}, [containerRef]);
|
||||
|
||||
if (y === null || editorLeft === null) return null;
|
||||
if (!focused || y === null || editorLeft === null) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
11
frontend/src/components/editor/EditorStoreContext.tsx
Normal file
11
frontend/src/components/editor/EditorStoreContext.tsx
Normal 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);
|
||||
}
|
||||
255
frontend/src/components/editor/EditorWindow.tsx
Normal file
255
frontend/src/components/editor/EditorWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import { useEditorCtx } from "./EditorStoreContext";
|
||||
import { renderMicron } from "./micronRenderer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PreviewMode = "micron" | "raw" | "script";
|
||||
|
||||
export default function PreviewPane() {
|
||||
const previewMode = useEditorStore((s) => s.previewMode);
|
||||
const setPreviewMode = useEditorStore((s) => s.setPreviewMode);
|
||||
const compiledMicron = useEditorStore((s) => s.compiledMicron);
|
||||
const compiledScript = useEditorStore((s) => s.compiledScript);
|
||||
const isDynamic = useEditorStore((s) => s.isDynamic);
|
||||
const isCompiling = useEditorStore((s) => s.isCompiling);
|
||||
const compileError = useEditorStore((s) => s.compileError);
|
||||
const previewMode = useEditorCtx((s) => s.previewMode);
|
||||
const setPreviewMode = useEditorCtx((s) => s.setPreviewMode);
|
||||
const compiledMicron = useEditorCtx((s) => s.compiledMicron);
|
||||
const compiledScript = useEditorCtx((s) => s.compiledScript);
|
||||
const isDynamic = useEditorCtx((s) => s.isDynamic);
|
||||
const isCompiling = useEditorCtx((s) => s.isCompiling);
|
||||
const compileError = useEditorCtx((s) => s.compileError);
|
||||
|
||||
const tabs: { value: PreviewMode; label: string; show: boolean }[] = [
|
||||
{ value: "micron", label: "Micron", show: true },
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { ChevronLeft, Pencil } from "lucide-react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
@@ -21,20 +20,8 @@ export default function ToolBar({
|
||||
saving,
|
||||
isDirty,
|
||||
}: Props) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-4 py-2 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>
|
||||
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-border shrink-0">
|
||||
<PageNameField pageName={pageName} onNameChange={onNameChange} />
|
||||
|
||||
{isDirty && (
|
||||
@@ -43,10 +30,10 @@ export default function ToolBar({
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button variant="outline" onClick={onSaveDraft} disabled={saving}>
|
||||
<Button variant="outline" size="sm" onClick={onSaveDraft} disabled={saving}>
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button onClick={onPublish} disabled={saving}>
|
||||
<Button size="sm" onClick={onPublish} disabled={saving}>
|
||||
Publish
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { Toaster } from "@/components/ui/sonner";
|
||||
import frameSvg from "@/assets/frame.themed.svg";
|
||||
import browserSvg from "@/assets/browser.min.svg";
|
||||
import NavMenu from "./NavMenu";
|
||||
import LazyEyes from "./LazyEyes";
|
||||
|
||||
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 }) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [theme, setTheme] = useState<Theme>(getStoredTheme);
|
||||
const isEditor = location.pathname.startsWith("/editor");
|
||||
const layout = frameLayout;
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
@@ -104,17 +128,17 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col h-screen bg-background text-foreground">
|
||||
<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 */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 w-full min-w-full pointer-events-none select-none"
|
||||
style={{
|
||||
zIndex: 0,
|
||||
height: "1315px",
|
||||
height: `${layout.frameHeight}px`,
|
||||
background: "var(--primary)",
|
||||
WebkitMaskImage: `url(${frameSvg})`,
|
||||
maskImage: `url(${frameSvg})`,
|
||||
WebkitMaskImage: `url(${layout.svg})`,
|
||||
maskImage: `url(${layout.svg})`,
|
||||
WebkitMaskSize: "cover",
|
||||
maskSize: "cover",
|
||||
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 */}
|
||||
<div
|
||||
className="relative z-10 flex flex-col cursor-pointer overflow-hidden"
|
||||
style={{ paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 }}
|
||||
onClick={() => navigate("/")}
|
||||
className="relative z-10 flex flex-col overflow-hidden"
|
||||
style={layout.title}
|
||||
>
|
||||
<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" }}
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
{TITLE}
|
||||
</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>
|
||||
|
||||
{/* Bottom hole — main content */}
|
||||
<div
|
||||
className="relative z-10 overflow-auto"
|
||||
style={{ marginLeft: 12, marginRight: 68, width: 843, height: 1030, paddingTop: 8, paddingBottom: 20 }}
|
||||
style={layout.content}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Nav menu — anchored below the frame, left side (hidden on editor) */}
|
||||
{!isEditor && (
|
||||
<div
|
||||
className="absolute z-20"
|
||||
style={{ top: 150, left: -210 }}
|
||||
>
|
||||
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
||||
</div>
|
||||
)}
|
||||
{/* Nav menu — anchored below the frame, left side */}
|
||||
<div
|
||||
className="absolute z-20"
|
||||
style={layout.nav}
|
||||
>
|
||||
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
<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>
|
||||
</div>
|
||||
<Toaster />
|
||||
|
||||
86
frontend/src/components/shared/FloatingWindow.tsx
Normal file
86
frontend/src/components/shared/FloatingWindow.tsx
Normal 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);
|
||||
}
|
||||
79
frontend/src/components/shared/LazyEyes.tsx
Normal file
79
frontend/src/components/shared/LazyEyes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ function PopoverContent({
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
className="isolate z-9999"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
|
||||
@@ -68,7 +68,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
@@ -81,7 +81,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useStore, type StoreApi } from "zustand";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import type { EditorStore } from "@/stores/editorStore";
|
||||
import { compile } from "@/api/client";
|
||||
|
||||
const DEBOUNCE_MS = 400;
|
||||
@@ -7,12 +9,23 @@ const DEBOUNCE_MS = 400;
|
||||
/**
|
||||
* Debounced hook that compiles µFrame source via the API.
|
||||
* Automatically triggers on ufSource changes.
|
||||
* Accepts an optional store API for per-window instances; falls back to the global singleton.
|
||||
*/
|
||||
export function useCompile() {
|
||||
const ufSource = useEditorStore((s) => s.ufSource);
|
||||
const setCompileResult = useEditorStore((s) => s.setCompileResult);
|
||||
const setCompiling = useEditorStore((s) => s.setCompiling);
|
||||
const setCompileError = useEditorStore((s) => s.setCompileError);
|
||||
export function useCompile(storeApi?: StoreApi<EditorStore>) {
|
||||
const globalSource = useEditorStore((s) => s.ufSource);
|
||||
const globalSetResult = useEditorStore((s) => s.setCompileResult);
|
||||
const globalSetCompiling = useEditorStore((s) => s.setCompiling);
|
||||
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 abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
90
frontend/src/hooks/useLazyEyes.ts
Normal file
90
frontend/src/hooks/useLazyEyes.ts
Normal 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 0–1 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;
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { useStore, type StoreApi } from "zustand";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import type { EditorStore } from "@/stores/editorStore";
|
||||
|
||||
export function useUnsavedGuard() {
|
||||
const isDirty = useEditorStore((s) => s.isDirty);
|
||||
export function useUnsavedGuard(storeApi?: StoreApi<EditorStore>) {
|
||||
const globalDirty = useEditorStore((s) => s.isDirty);
|
||||
const localDirty = useStore(storeApi ?? useEditorStore, (s) => s.isDirty);
|
||||
const isDirty = storeApi ? localDirty : globalDirty;
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
|
||||
63
frontend/src/hooks/useWindowManager.ts
Normal file
63
frontend/src/hooks/useWindowManager.ts
Normal 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 };
|
||||
}
|
||||
@@ -405,8 +405,8 @@
|
||||
/* ── Editor Pointer ── */
|
||||
|
||||
.editor-pointer {
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
position: absolute;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
will-change: top;
|
||||
transform: translateX(-100%);
|
||||
@@ -441,7 +441,6 @@
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.editor-pointer-eye {
|
||||
|
||||
@@ -1,413 +1,640 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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 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 {
|
||||
primary: string;
|
||||
muted: string;
|
||||
border: string;
|
||||
const SPACE_SIZE = 4096;
|
||||
const CENTER = SPACE_SIZE / 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme-aware status colors — reads CSS variables, returns 0–1 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> = {
|
||||
dark: { // .dark (terra)
|
||||
primary: "#c47a32",
|
||||
muted: "#8a7560",
|
||||
border: "#6b5a42",
|
||||
},
|
||||
azure: { // .theme-azure
|
||||
primary: "#5aa0d4",
|
||||
muted: "#6d8a9e",
|
||||
border: "#4a6e88",
|
||||
},
|
||||
};
|
||||
|
||||
function getThemeId(): string {
|
||||
const cl = document.documentElement.classList;
|
||||
if (cl.contains("theme-azure")) return "azure";
|
||||
return "dark";
|
||||
}
|
||||
|
||||
function hexToRgba255(hex: string): [number, number, number, number] {
|
||||
function lerpRGBA(
|
||||
a: [number, number, number, number],
|
||||
b: [number, number, number, number],
|
||||
t: number,
|
||||
): [number, number, number, number] {
|
||||
return [
|
||||
parseInt(hex.slice(1, 3), 16),
|
||||
parseInt(hex.slice(3, 5), 16),
|
||||
parseInt(hex.slice(5, 7), 16),
|
||||
255,
|
||||
a[0] + (b[0] - a[0]) * t,
|
||||
a[1] + (b[1] - a[1]) * t,
|
||||
a[2] + (b[2] - a[2]) * t,
|
||||
a[3] + (b[3] - a[3]) * t,
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data builders — convert NetworkNode[] to Float32Arrays for cosmos.gl
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildBuffers(nodes: NetworkNode[], colors: ThemeColors) {
|
||||
const sorted = [...nodes].sort((a, b) => (a.is_self ? -1 : b.is_self ? 1 : 0));
|
||||
|
||||
const n = sorted.length;
|
||||
const positions = new Float32Array(n * 2);
|
||||
const pointColors = new Float32Array(n * 4);
|
||||
const sizes = new Float32Array(n);
|
||||
const primaryRgba = hexToRgba255(colors.primary);
|
||||
const mutedRgba = hexToRgba255(colors.muted);
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (sorted[i].is_self) {
|
||||
positions[i * 2] = 0;
|
||||
positions[i * 2 + 1] = 0;
|
||||
} else {
|
||||
const angle = ((i - 1) / Math.max(1, n - 1)) * Math.PI * 2;
|
||||
positions[i * 2] = Math.cos(angle) * 100;
|
||||
positions[i * 2 + 1] = Math.sin(angle) * 100;
|
||||
}
|
||||
|
||||
const rgba = sorted[i].is_self ? primaryRgba : mutedRgba;
|
||||
pointColors[i * 4] = rgba[0];
|
||||
pointColors[i * 4 + 1] = rgba[1];
|
||||
pointColors[i * 4 + 2] = rgba[2];
|
||||
pointColors[i * 4 + 3] = rgba[3];
|
||||
|
||||
sizes[i] = sorted[i].is_self ? 14 : 7;
|
||||
}
|
||||
|
||||
const linkCount = Math.max(0, n - 1);
|
||||
const links = new Float32Array(linkCount * 2);
|
||||
const borderRgba = hexToRgba255(colors.border);
|
||||
const linkColors = new Float32Array(linkCount * 4);
|
||||
for (let i = 0; i < linkCount; i++) {
|
||||
links[i * 2] = 0;
|
||||
links[i * 2 + 1] = i + 1;
|
||||
linkColors[i * 4] = borderRgba[0];
|
||||
linkColors[i * 4 + 1] = borderRgba[1];
|
||||
linkColors[i * 4 + 2] = borderRgba[2];
|
||||
linkColors[i * 4 + 3] = 180;
|
||||
}
|
||||
|
||||
return { sorted, positions, pointColors, sizes, links, linkColors };
|
||||
function brighten(c: [number, number, number, number], amount: number): [number, number, number, number] {
|
||||
return [
|
||||
Math.min(1, c[0] + amount),
|
||||
Math.min(1, c[1] + amount),
|
||||
Math.min(1, c[2] + amount),
|
||||
c[3],
|
||||
];
|
||||
}
|
||||
|
||||
/** Re-apply theme colors to an existing graph instance */
|
||||
function applyThemeToGraph(graph: Graph, nodes: NetworkNode[], colors: ThemeColors) {
|
||||
graph.setConfig({
|
||||
pointDefaultColor: colors.primary,
|
||||
linkDefaultColor: colors.border,
|
||||
hoveredPointRingColor: colors.primary,
|
||||
function getThemeStatusColors() {
|
||||
const primary = brighten(cssVarToRGBA("--primary"), 0.15);
|
||||
const muted = cssVarToRGBA("--muted-foreground");
|
||||
return {
|
||||
online: primary, // --primary brightened
|
||||
stale: lerpRGBA(primary, muted, 0.4), // blend, closer to primary
|
||||
offline: muted, // --muted-foreground
|
||||
};
|
||||
}
|
||||
|
||||
function statusRGBA(entry: NetworkNode, theme: ReturnType<typeof getThemeStatusColors>): [number, number, number, number] {
|
||||
const age = Date.now() / 1000 - (entry.last_seen ?? 0);
|
||||
if (age < 300) return theme.online;
|
||||
if (age < 3600) return theme.stale;
|
||||
return theme.offline;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build flat arrays for cosmos — positions around spaceSize/2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BuiltGraph {
|
||||
entries: NetworkNode[];
|
||||
positions: Float32Array;
|
||||
colors: Float32Array;
|
||||
sizes: Float32Array;
|
||||
clusterIndices: (number | undefined)[];
|
||||
clusterPositions: (number | undefined)[];
|
||||
clusterStrength: Float32Array;
|
||||
clusterNames: string[];
|
||||
hashToIndex: Map<string, number>;
|
||||
}
|
||||
|
||||
function findParentIface(
|
||||
entry: NetworkNode,
|
||||
interfaces: NetworkNode[],
|
||||
peerIndex: number,
|
||||
): string | undefined {
|
||||
if (entry.interface) {
|
||||
const iface = interfaces.find(i => i.name === entry.interface);
|
||||
if (iface) return iface.hash;
|
||||
}
|
||||
if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length].hash;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildGraphArrays(
|
||||
rawNodes: NetworkNode[],
|
||||
prevHashToIndex: Map<string, number>,
|
||||
prevPositions: number[],
|
||||
theme: ReturnType<typeof getThemeStatusColors>,
|
||||
): BuiltGraph {
|
||||
const interfaces = rawNodes.filter(e => e.type === "interface").sort((a, b) => a.name.localeCompare(b.name));
|
||||
const 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;
|
||||
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();
|
||||
return { entries, positions, colors, sizes, clusterIndices, clusterPositions, clusterStrength, clusterNames, hashToIndex };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// Browse window data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BrowseWinData {
|
||||
node: NetworkNode;
|
||||
pageHtml: string | null;
|
||||
pageLoading: boolean;
|
||||
pageError: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function BrowseView() {
|
||||
const [nodes, setNodes] = useState<NetworkNode[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [selectedNode, setSelectedNode] = useState<NetworkNode | null>(null);
|
||||
const [pageHtml, setPageHtml] = useState<string | null>(null);
|
||||
const [pageLoading, setPageLoading] = useState(false);
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
const [labelPositions, setLabelPositions] = useState<{ x: number; y: number }[]>([]);
|
||||
const [themeId, setThemeId] = useState(getThemeId);
|
||||
const { windows, focusedId: focusedWinId, open: openWindow, update: updateWindow, close: closeWindowById, focus: focusWindow } = useWindowManager<BrowseWinData>();
|
||||
const [hoveredLabel, setHoveredLabel] = useState<{ text: string; x: number; y: number } | null>(null);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<Graph | null>(null);
|
||||
const nodesRef = useRef<NetworkNode[]>([]);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const rafRef = useRef(0);
|
||||
const [themeRev, setThemeRev] = useState(0);
|
||||
|
||||
const colors = THEME_COLORS[themeId] ?? THEME_COLORS.dark;
|
||||
|
||||
const filteredNodes = useMemo(() => {
|
||||
if (!filter) return nodes;
|
||||
const q = filter.toLowerCase();
|
||||
return nodes.filter(
|
||||
(n) => n.name.toLowerCase().includes(q) || n.hash.toLowerCase().includes(q),
|
||||
);
|
||||
}, [nodes, filter]);
|
||||
|
||||
// ── Watch for theme changes ──
|
||||
// Watch for theme changes (class on <html>)
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
const id = getThemeId();
|
||||
setThemeId(id);
|
||||
const graph = graphRef.current;
|
||||
if (graph) {
|
||||
const c = THEME_COLORS[id] ?? THEME_COLORS.dark;
|
||||
applyThemeToGraph(graph, nodesRef.current, c);
|
||||
}
|
||||
});
|
||||
const observer = new MutationObserver(() => setThemeRev(r => r + 1));
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
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(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const graph = new Graph(containerRef.current, {
|
||||
backgroundColor: [0, 0, 0, 0],
|
||||
pointDefaultColor: colors.primary,
|
||||
pointDefaultSize: 12,
|
||||
linkDefaultColor: colors.border,
|
||||
linkDefaultWidth: 1,
|
||||
linkOpacity: 0.5,
|
||||
enableSimulation: true,
|
||||
enableDrag: true,
|
||||
enableZoom: true,
|
||||
fitViewOnInit: false,
|
||||
spaceSize: 1024,
|
||||
simulationGravity: 0.15,
|
||||
simulationRepulsion: 0.6,
|
||||
simulationLinkSpring: 0.3,
|
||||
simulationLinkDistance: 60,
|
||||
simulationFriction: 0.85,
|
||||
simulationDecay: 8000,
|
||||
spaceSize: SPACE_SIZE,
|
||||
backgroundColor: "transparent",
|
||||
pointDefaultColor: "#888888",
|
||||
pointDefaultSize: 10,
|
||||
renderLinks: false,
|
||||
fitViewOnInit: true,
|
||||
fitViewDelay: 1500,
|
||||
fitViewPadding: 0.2,
|
||||
renderHoveredPointRing: true,
|
||||
hoveredPointRingColor: colors.primary,
|
||||
hoveredPointCursor: "pointer",
|
||||
onPointClick: (index: number) => {
|
||||
const node = nodesRef.current[index];
|
||||
if (node) handleNodeClick(node);
|
||||
hoveredPointRingColor: "#ffffff",
|
||||
scalePointsOnZoom: true,
|
||||
pointGreyoutOpacity: 0.1,
|
||||
// Simulation defaults — dynamically adjusted by node count in data update
|
||||
simulationGravity: 0.5,
|
||||
simulationRepulsion: 1,
|
||||
simulationCluster: 0.5,
|
||||
simulationDecay: 5000,
|
||||
simulationFriction: 0.85,
|
||||
simulationLinkSpring: 0,
|
||||
simulationLinkDistance: 1,
|
||||
// Events
|
||||
onClick: (index, _pos, _event) => {
|
||||
if (index === undefined) return;
|
||||
const entry = entriesRef.current[index];
|
||||
if (entry && entry.type !== "interface") handleNodeClick(entry);
|
||||
},
|
||||
onClick: () => {
|
||||
setSelectedNode(null);
|
||||
setPageHtml(null);
|
||||
setPageError(null);
|
||||
onMouseMove: (index, pointPosition) => {
|
||||
if (index === undefined || !pointPosition || !graphRef.current) {
|
||||
setHoveredLabel(null);
|
||||
return;
|
||||
}
|
||||
const entry = entriesRef.current[index];
|
||||
if (!entry) return;
|
||||
const screen = graphRef.current.spaceToScreenPosition(pointPosition);
|
||||
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
|
||||
},
|
||||
onSimulationTick: () => updateLabels(),
|
||||
onZoom: () => updateLabels(),
|
||||
onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
|
||||
onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); },
|
||||
onZoom: () => { updateClusterLabels(); updateSearchLabels(); },
|
||||
});
|
||||
|
||||
graphRef.current = graph;
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
labelDivsRef.current.forEach(d => d.remove());
|
||||
labelDivsRef.current = [];
|
||||
graph.destroy();
|
||||
graphRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Update labels from graph positions ──
|
||||
const updateLabels = useCallback(() => {
|
||||
// ── Update data — following the example's exact call order ──
|
||||
const isFirstLoadRef = useRef(true);
|
||||
useEffect(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || nodesRef.current.length === 0) return;
|
||||
if (!graph || graphData.entries.length === 0) return;
|
||||
|
||||
const positions = graph.getPointPositions();
|
||||
const next: { x: number; y: number }[] = [];
|
||||
for (let i = 0; i < nodesRef.current.length; i++) {
|
||||
const sx = positions[i * 2];
|
||||
const sy = positions[i * 2 + 1];
|
||||
if (sx === undefined) break;
|
||||
const [px, py] = graph.spaceToScreenPosition([sx, sy]);
|
||||
next.push({ x: px, y: py });
|
||||
// Scale simulation params by node count
|
||||
// Reference: 10k nodes → repulsion 10, cluster 0.25, gravity 2
|
||||
// Scale logarithmically so it works from 10 to 10k nodes
|
||||
const n = graphData.entries.length;
|
||||
const scale = Math.log10(Math.max(10, n)) / Math.log10(10000); // 0..1
|
||||
graph.setConfig({
|
||||
simulationRepulsion: 0.5 + scale * 9.5, // 0.5 → 10
|
||||
simulationCluster: 1.0 - scale * 0.75, // 1.0 → 0.25
|
||||
simulationGravity: 0.25 + scale * 1.75, // 0.25 → 2
|
||||
});
|
||||
|
||||
graph.setPointPositions(graphData.positions);
|
||||
graph.setPointColors(graphData.colors);
|
||||
graph.setPointSizes(graphData.sizes);
|
||||
graph.setPointClusters(graphData.clusterIndices);
|
||||
graph.setClusterPositions(graphData.clusterPositions);
|
||||
graph.setPointClusterStrength(graphData.clusterStrength);
|
||||
graph.setLinks(new Float32Array(0));
|
||||
|
||||
if (isFirstLoadRef.current) {
|
||||
graph.render(1);
|
||||
isFirstLoadRef.current = false;
|
||||
} else {
|
||||
graph.render(0.1);
|
||||
}
|
||||
updateClusterLabels();
|
||||
}, [graphData]);
|
||||
|
||||
// ── Search highlighting + labels ──
|
||||
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(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph) return;
|
||||
if (filteredNodes.length === 0) {
|
||||
nodesRef.current = [];
|
||||
setLabelPositions([]);
|
||||
graph.setPointPositions(new Float32Array(0));
|
||||
graph.setPointColors(new Float32Array(0));
|
||||
graph.setPointSizes(new Float32Array(0));
|
||||
graph.setLinks(new Float32Array(0));
|
||||
graph.setLinkColors(new Float32Array(0));
|
||||
graph.render();
|
||||
return;
|
||||
|
||||
clearSearchLabels();
|
||||
|
||||
if (searchMatchIndices) {
|
||||
searchIndicesRef.current = searchMatchIndices;
|
||||
graph.selectPointsByIndices(searchMatchIndices);
|
||||
if (searchMatchIndices.length <= 10) {
|
||||
graph.fitViewByPointIndices(searchMatchIndices, 500);
|
||||
}
|
||||
// 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;
|
||||
const { sorted, positions, pointColors, sizes, links, linkColors } = buildBuffers(filteredNodes, c);
|
||||
nodesRef.current = sorted;
|
||||
return () => clearSearchLabels();
|
||||
}, [searchMatchIndices, clearSearchLabels, updateSearchLabels]);
|
||||
|
||||
graph.setPointPositions(positions);
|
||||
graph.setPointColors(pointColors);
|
||||
graph.setPointSizes(sizes);
|
||||
if (links.length > 0) {
|
||||
graph.setLinks(links);
|
||||
graph.setLinkColors(linkColors);
|
||||
}
|
||||
graph.setPinnedPoints([0]);
|
||||
graph.render();
|
||||
graph.start();
|
||||
|
||||
setTimeout(() => {
|
||||
graph.fitView(400, 0.4);
|
||||
updateLabels();
|
||||
}, 200);
|
||||
}, [filteredNodes, updateLabels]);
|
||||
|
||||
// ── Poll for nodes ──
|
||||
// ── SSE stream ──
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
fetchBrowseNodes().then(setNodes).catch(() => { });
|
||||
};
|
||||
load();
|
||||
pollRef.current = setInterval(load, 30_000);
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
let pending: NetworkNode[] = [];
|
||||
let batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const flush = () => {
|
||||
batchTimer = null;
|
||||
if (pending.length === 0) return;
|
||||
const batch = pending; pending = [];
|
||||
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) => {
|
||||
setSelectedNode(node);
|
||||
setPageHtml(null);
|
||||
setPageError(null);
|
||||
setPageLoading(true);
|
||||
|
||||
const id = node.hash;
|
||||
const data: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null };
|
||||
openWindow(id, data);
|
||||
fetchRemotePage(node.hash)
|
||||
.then((res) => {
|
||||
if (res.content) {
|
||||
setPageHtml(renderMicron(res.content, true));
|
||||
} else {
|
||||
setPageError(res.error ?? "No content");
|
||||
}
|
||||
})
|
||||
.catch((e) => setPageError(String(e)))
|
||||
.finally(() => setPageLoading(false));
|
||||
}, []);
|
||||
.then((res) => updateWindow(id, { data: { node, pageHtml: res.content ? renderMicron(res.content, true) : null, pageError: res.content ? null : (res.error ?? "No content"), pageLoading: false } }))
|
||||
.catch((e) => updateWindow(id, { data: { node, pageError: String(e), pageLoading: false, pageHtml: null } }));
|
||||
}, [openWindow, updateWindow]);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setFilter("");
|
||||
graphRef.current?.unselectPoints();
|
||||
graphRef.current?.fitView(300, 0.2);
|
||||
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 (
|
||||
<div className="flex flex-col" style={{ height: "100%" }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b-2 border-border shrink-0">
|
||||
<h1 className="text-sm font-semibold">Browse</h1>
|
||||
<input
|
||||
type="text"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter nodes..."
|
||||
className="flex-1 h-8 px-2 text-xs bg-muted/50 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider whitespace-nowrap">
|
||||
{filteredNodes.length}/{nodes.length} node{nodes.length !== 1 && "s"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative overflow-hidden" style={{ height: "100%" }}>
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
|
||||
{/* Graph + labels */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative shrink-0 bg-background overflow-hidden"
|
||||
style={{ height: 500 }}
|
||||
>
|
||||
{nodesRef.current.map((node, i) => {
|
||||
const lp = labelPositions[i];
|
||||
if (!lp) return null;
|
||||
return (
|
||||
<span
|
||||
key={node.hash}
|
||||
className="absolute text-[10px] font-mono pointer-events-none select-none whitespace-nowrap"
|
||||
style={{
|
||||
left: lp.x,
|
||||
top: lp.y - (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>
|
||||
{/* Hover label */}
|
||||
{hoveredLabel && (
|
||||
<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"
|
||||
style={{ left: hoveredLabel.x + 12, top: hoveredLabel.y - 10, boxShadow: "0 2px 8px rgba(0,0,0,0.3)" }}>
|
||||
{hoveredLabel.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto p-3">
|
||||
{!selectedNode && !pageLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-muted-foreground">
|
||||
<div
|
||||
className="w-80 h-64"
|
||||
style={{
|
||||
backgroundColor: colors.primary,
|
||||
mask: `url(${axisMundiUrl}) center/contain no-repeat`,
|
||||
WebkitMask: `url(${axisMundiUrl}) center/contain no-repeat`,
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">Click a node to view its page</span>
|
||||
{/* Search bar */}
|
||||
{searchPos && createPortal(
|
||||
<div onMouseDown={onSearchDragStart}
|
||||
className="fixed z-999 flex items-center gap-3 px-3 py-1.5 bg-popover border-2 border-border focus-within:border-primary rounded-lg cursor-grab active:cursor-grabbing transition-[border-color] duration-150"
|
||||
style={{ left: searchPos.x, top: searchPos.y, width: 400, boxShadow: DITHERED_SHADOW }}>
|
||||
<input ref={searchInputRef} type="text" value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") { clearSearch(); e.currentTarget.blur(); return; }
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setSelectedSuggestion(i => Math.min(i + 1, suggestions.length - 1)); return; }
|
||||
if (e.key === "ArrowUp") { e.preventDefault(); setSelectedSuggestion(i => Math.max(i - 1, -1)); return; }
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const entry = selectedSuggestion >= 0 ? suggestions[selectedSuggestion] : suggestions[0];
|
||||
if (entry && entry.type !== "interface") { handleNodeClick(entry); clearSearch(); }
|
||||
return;
|
||||
}
|
||||
}}
|
||||
placeholder="Search nodes..."
|
||||
className="flex-1 h-7 px-2 text-xs bg-background/60 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary cursor-text" />
|
||||
{filter && (
|
||||
<button onClick={clearSearch} className="text-muted-foreground hover:text-foreground transition-colors text-xs leading-none px-1" title="Clear search (Esc)">
|
||||
×
|
||||
</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>
|
||||
)}
|
||||
{pageLoading && (
|
||||
<span className="text-muted-foreground text-xs animate-pulse">
|
||||
Requesting page...
|
||||
</span>
|
||||
)}
|
||||
{pageError && (
|
||||
<span className="text-destructive text-xs">{pageError}</span>
|
||||
)}
|
||||
{pageHtml && (
|
||||
<div
|
||||
className="font-mono text-[11px] leading-tight"
|
||||
dangerouslySetInnerHTML={{ __html: pageHtml }}
|
||||
/>
|
||||
)}
|
||||
</div>, document.body)}
|
||||
|
||||
{nodes.length === 0 && (
|
||||
<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...
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { MoreVertical, Plus, RotateCcw } from "lucide-react";
|
||||
import { usePagesStore } from "@/stores/pagesStore";
|
||||
@@ -29,13 +28,20 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useWindowManager } from "@/hooks/useWindowManager";
|
||||
import EditorWindow, { type EditorWinData } from "@/components/editor/EditorWindow";
|
||||
|
||||
export default function ComposeView() {
|
||||
const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } =
|
||||
usePagesStore();
|
||||
const navigate = useNavigate();
|
||||
const [pageToDelete, setPageToDelete] = useState<string | null>(null);
|
||||
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(() => {
|
||||
fetchPages();
|
||||
@@ -89,15 +95,15 @@ export default function ComposeView() {
|
||||
<div>
|
||||
<div>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center px-4 py-2 border-b-2 border-border">
|
||||
<h1 className="text-sm font-semibold flex-1">Compose</h1>
|
||||
<div className="flex items-center px-2 py-1.5 border-b-2 border-border">
|
||||
<h1 className="text-xs font-semibold flex-1">Compose</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleRestart} disabled={restarting}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
<Button variant="outline" size="sm" onClick={handleRestart} disabled={restarting}>
|
||||
<RotateCcw className="w-3 h-3 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/editor/new")}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<Button size="sm" onClick={() => openEditor("", true)}>
|
||||
<Plus className="w-3 h-3 mr-1.5" />
|
||||
New Page
|
||||
</Button>
|
||||
</div>
|
||||
@@ -119,7 +125,7 @@ export default function ComposeView() {
|
||||
<TableRow
|
||||
key={p.name}
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate(`/editor/${p.name}`)}
|
||||
onClick={() => openEditor(p.name, false)}
|
||||
>
|
||||
<TableCell className="font-mono">
|
||||
{p.name}
|
||||
@@ -138,8 +144,8 @@ export default function ComposeView() {
|
||||
</TableCell>
|
||||
<TableCell className="text-right w-8">
|
||||
<PageActions
|
||||
name={p.name}
|
||||
published={p.published}
|
||||
onEdit={() => openEditor(p.name, false)}
|
||||
onPublish={() => handlePublish(p.name)}
|
||||
onUnpublish={() => handleUnpublish(p.name)}
|
||||
onDelete={() => setPageToDelete(p.name)}
|
||||
@@ -184,6 +190,18 @@ export default function ComposeView() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Floating editor windows */}
|
||||
{windows.map((win) => (
|
||||
<EditorWindow
|
||||
key={win.id}
|
||||
win={win}
|
||||
focused={focusedId === win.id}
|
||||
onUpdate={update}
|
||||
onClose={close}
|
||||
onFocus={focus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -191,20 +209,18 @@ export default function ComposeView() {
|
||||
|
||||
/** Per-row action menu for a page. */
|
||||
function PageActions({
|
||||
name,
|
||||
published,
|
||||
onEdit,
|
||||
onPublish,
|
||||
onUnpublish,
|
||||
onDelete,
|
||||
}: {
|
||||
name: string;
|
||||
published: boolean;
|
||||
onEdit: () => void;
|
||||
onPublish: () => void;
|
||||
onUnpublish: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
@@ -219,7 +235,7 @@ function PageActions({
|
||||
/>
|
||||
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-36 p-1">
|
||||
<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"
|
||||
>Edit</button>
|
||||
{published ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from "zustand";
|
||||
import type { PageMeta } from "@/api/client";
|
||||
|
||||
interface EditorStore {
|
||||
export interface EditorStore {
|
||||
// Source
|
||||
ufSource: string;
|
||||
isDirty: boolean;
|
||||
@@ -30,6 +30,41 @@ interface EditorStore {
|
||||
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) => ({
|
||||
ufSource: "",
|
||||
isDirty: false,
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"gl-bench": path.resolve(__dirname, "node_modules/gl-bench/dist/gl-bench.module.js"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
||||
21
reticulum-client.conf
Normal file
21
reticulum-client.conf
Normal 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
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
[[Quad4]]
|
||||
type = TCPClientInterface
|
||||
interface_enabled = false
|
||||
interface_enabled = true
|
||||
target_host = 62.151.179.77
|
||||
target_port = 45657
|
||||
mode = full
|
||||
|
||||
Reference in New Issue
Block a user