Compare commits

..

15 Commits

Author SHA1 Message Date
46185705d7 feat: new day new graph 2026-04-06 22:27:02 +02:00
d8abe311cd feat: configs 2026-04-06 20:28:03 +02:00
2994735f3b feat: performance improvements 2026-04-06 20:16:46 +02:00
d7e9788f99 feat: nomad 2026-04-06 20:16:37 +02:00
6e8a4af40d feat: add settings 2026-04-06 01:09:02 +02:00
20e41f7680 feat: labels 2026-04-06 00:23:10 +02:00
c9eead1965 feat: inertia 2026-04-05 23:49:10 +02:00
a1ad332e01 feat: improvements 2026-04-05 22:39:31 +02:00
3c5856aecb feat: link resolver and spinner 2026-04-05 11:01:48 +02:00
ad89f409bf feat: refactoring 2026-04-05 10:24:01 +02:00
e1db06104e feat: composer improvements 2026-04-05 09:53:01 +02:00
7838760ca4 feat: add self to graph 2026-04-05 00:39:32 +02:00
914945279f feat: editor in popover 2026-04-05 00:31:47 +02:00
3eec7f316e feat: final graph 2026-04-04 23:24:44 +02:00
3132d40391 feat: graph browser 2026-04-04 16:18:21 +02:00
59 changed files with 4206 additions and 1234 deletions

151
CLAUDE.md
View File

@@ -205,27 +205,154 @@ form "name"
``` ```
### Dynamic Features ### Dynamic Features
```
cache 0 # never cache (re-execute)
source cpu : shell "cat /proc/loadavg" # live data at render time
source config : json "/path/config.json" # JSON file read
source ts : python "datetime.now().isoformat()" # Python expression
let name = "Relay Alpha" # variable assignment
Any page using `source`, `if`, `for`, `on_submit`, or `state` becomes **dynamic**: it compiles to an executable Python script instead of static Micron. NomadNet runs the script on each request and serves its stdout.
#### Variables
```
let name = "Relay Alpha" # string assignment
let threshold = 75 # numeric
let tags = "alpha","beta","gamma" # comma-separated → list
```
Variables are substituted with `$name` in text, labels, and other content. They work in both static and dynamic pages.
#### Data Sources
```
source var_name : type "command" [timeout=N]
```
Sources fetch data **at render time** and bind results to variables:
| Type | Description | Example |
|----------|--------------------------------------|----------------------------------------------------------|
| `shell` | Run shell command, capture stdout | `source cpu : shell "cat /proc/loadavg"` |
| `file` | Read file contents as string | `source motd : file "/etc/motd"` |
| `json` | Read + parse JSON file → dict/list | `source config : json "/etc/config.json"` |
| `python` | Evaluate Python expression | `source ts : python "datetime.now().strftime('%H:%M')"` |
| `http` | HTTP request, auto-parses JSON | `source data : http "https://api.example.com/data"` |
| `sqlite` | SQLite query → list of dicts | `source users : sqlite "/path/db" "SELECT * FROM users"` |
| `env` | Read environment variable | `source key : env "API_KEY"` |
| `param` | Read URL parameter from link | `source hash : param "hash"` |
| `rns` | Query Reticulum via `rnstatus` | `source peers : rns "peers"` |
**Shell** commands have a default 5-second timeout (override with `timeout=N`).
**Python** expressions have access to: `datetime` (the class, so `datetime.now()` works), `timedelta`, `secrets`, `os`, `json`. Expressions are evaluated via `eval()` — single expressions only, not statements.
```
# Python source examples
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
source rand_id : python "secrets.token_hex(4)"
source cpu_sim : python "secrets.randbelow(60) + 20"
source uptime : python "str(timedelta(seconds=12345))"
source hostname : python "os.uname().nodename"
```
**HTTP** requests return parsed JSON (dict/list) or raw string. Default timeout 10s.
```
# GET request — JSON auto-parsed into dict
source todo : http "https://api.example.com/todos/1"
text "Title: $todo.title"
# POST with JSON body
source result : http "https://api.example.com/search" method=POST body='{"q":"relay"}'
# Custom headers (semicolon-separated)
source data : http "https://api.example.com/data" headers='Authorization: Bearer tok123'
# Use $var references in URL, headers, and body — resolved at runtime
source token : env "API_TOKEN"
source data : http "https://api.example.com/data" headers='Authorization: Bearer $token'
```
**Env** reads server-side environment variables. Use this for secrets — tokens never appear in `.uf` source or compiled scripts.
```
source api_key : env "API_KEY"
source db_pass : env "DB_PASSWORD"
```
**SQLite** queries return a list of dicts (or a single dict for one row). Uses Python stdlib `sqlite3`.
```
# Query returns list of dicts with column names as keys
source nodes : sqlite "/data/network.db" "SELECT name, status, hops FROM nodes"
# Iterate results
for node in $nodes
label "$node.name" "$node.status ($node.hops hops)"
# Single row queries return a dict directly
source config : sqlite "/data/app.db" "SELECT value FROM config WHERE key='theme'"
text "Theme: $config.value"
```
#### Conditionals
```
if $cpu > 90 if $cpu > 90
text "ALERT: CPU critical" text "ALERT: CPU critical"
elif $cpu > 75 elif $cpu > 75
text "Warning: elevated" text "Warning: elevated"
else
text "All clear"
```
Conditions are Python expressions. `$var` references resolve to the variable's value. Supports `>`, `<`, `>=`, `<=`, `==`, `!=`, `&&` (and), `||` (or).
#### Loops
```
for peer in $peers for peer in $peers
status "$peer.name" $peer.state status "$peer.name" $peer.state
on_submit "search"
source results : shell "search.py '$query'"
text "$results"
state "counter" "/tmp/counter.json" # persistent JSON store
``` ```
Iterates over lists (from JSON sources), dicts (wrapped as single-item list), or newline-delimited strings (from shell output). Access nested fields with `$item.field`.
#### Cache Control
```
cache 0 # never cache (re-execute every request)
cache 300 # cache for 5 minutes
```
Emits the `#!c=N` header that NomadNet uses to control page caching.
#### Form Submission Handling
```
on_submit "form_name"
# Runs when the named form is submitted
# Form field values are available as $field_name
source results : shell "search.py '$query'"
text "Found: $results"
```
Field values are read from `FIELD_*` environment variables set by NomadNet.
#### Persistent State
```
state "counter" "/tmp/counter.json" # load JSON into $counter
```
Loads a JSON file into a variable. Use `_save_state(path, data)` in the generated script to persist changes.
#### Using Variables in Content
```
text "Hello, $name" # inline substitution
label "CPU" "$cpu_pct%" # in labels
gauge "CPU" $cpu_pct 100 28 warn=75 crit=90 # as gauge values
status "$peer" $state # in status indicators
link "View $name" "/page/detail.mu" # in links
```
#### Generated Script Runtime
The compiled script includes these helpers, available in `on_submit` and source blocks:
| Helper | Description |
|-------------------------------------|-----------------------------------------------|
| `_shell(cmd, timeout=5)` | Execute shell command, return stdout |
| `_read_file(path)` | Read file contents |
| `_read_json(path)` | Read + parse JSON file |
| `_http(url, method, body, headers)` | HTTP request, auto-parse JSON response |
| `_sqlite(db_path, query)` | SQLite query → list of dicts (or single dict) |
| `_get_field(name, default)` | Read submitted form field |
| `_get_param(name, default)` | Read URL parameter |
| `_load_state(path)` | Load state from JSON file |
| `_save_state(path, data)` | Save state to JSON file |
| `_iter(val)` | Make a value iterable (list/dict/string) |
### Components ### Components
``` ```

View File

@@ -10,7 +10,6 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./ COPY backend/ ./
COPY frontend/dist/ ./static/ COPY frontend/dist/ ./static/
EXPOSE 8080 EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"] CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

View File

@@ -7,6 +7,7 @@ announces, and exposes discovered nodes + remote page fetching via API.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import logging import logging
import os import os
import threading import threading
@@ -14,6 +15,7 @@ import time
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, Query from fastapi import APIRouter, Query
from starlette.responses import StreamingResponse
router = APIRouter() router = APIRouter()
log = logging.getLogger("browse") log = logging.getLogger("browse")
@@ -24,50 +26,62 @@ log = logging.getLogger("browse")
_nodes: dict[str, dict] = {} # hash_hex -> node info _nodes: dict[str, dict] = {} # hash_hex -> node info
_own_hash: str | None = None _own_hash: str | None = None
_own_name: str = os.environ.get("NOMADNET_NODE_NAME", "Micronomicon") def _own_name() -> str:
"""Read node name from NomadNet config, falling back to env/default."""
try:
path = _CONFIG_PATHS["nomadnet"]()
if path.exists():
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith("node_name"):
_, _, val = stripped.partition("=")
val = val.strip()
if val:
return val
except Exception:
pass
return os.environ.get("NOMADNET_NODE_NAME", "Micronomicon")
_lock = threading.Lock() _lock = threading.Lock()
_started = False _started = False
_subscribers: list[asyncio.Queue] = []
_sub_lock = threading.Lock()
_loop: asyncio.AbstractEventLoop | None = None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# RNS lifecycle # RNS announce handler (must be an object with aspect_filter + method)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def start_browser() -> None: def _push_node(node_data: dict) -> None:
"""Initialize RNS and begin listening for NomadNet node announces.""" """Push a node update to all SSE subscribers (thread-safe)."""
global _started, _own_hash with _sub_lock:
for q in list(_subscribers):
if _started: if _loop and _loop.is_running():
return _loop.call_soon_threadsafe(q.put_nowait, node_data)
else:
try: try:
import RNS q.put_nowait(node_data)
except asyncio.QueueFull:
configdir = os.environ.get("RNS_CONFIG_DIR", None) pass
if configdir:
Path(configdir).mkdir(parents=True, exist_ok=True)
reticulum = RNS.Reticulum(configdir=configdir)
# Register handler for NomadNet page-serving node announces
RNS.Transport.register_announce_handler(
_on_announce,
aspect_filter="nomadnetwork.node",
)
_started = True
log.info("RNS browser started (v%s)", RNS.__version__)
except Exception as exc:
log.warning("Failed to start RNS browser: %s", exc)
def _on_announce( 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, destination_hash: bytes,
announced_identity, announced_identity,
app_data: bytes | None, app_data: bytes | None,
) -> None: **kwargs,
"""Handle an incoming NomadNet node announce.""" ) -> None:
import RNS import RNS
hash_hex = RNS.hexrep(destination_hash, delimit=False) hash_hex = RNS.hexrep(destination_hash, delimit=False)
@@ -79,7 +93,16 @@ def _on_announce(
except Exception: except Exception:
pass pass
is_self = name == _own_name 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: with _lock:
_nodes[hash_hex] = { _nodes[hash_hex] = {
@@ -87,36 +110,184 @@ def _on_announce(
"name": name, "name": name,
"last_seen": time.time(), "last_seen": time.time(),
"is_self": is_self, "is_self": is_self,
"type": "node",
"interface": iface_name,
} }
if is_self: if is_self:
global _own_hash global _own_hash
_own_hash = hash_hex _own_hash = hash_hex
log.info("Node announce: %s (%s)%s", name, hash_hex[:8], " [self]" if is_self else "") 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, _loop, _reticulum
if _started:
return
try:
_loop = asyncio.get_event_loop()
except RuntimeError:
_loop = None
try:
import RNS
configdir = os.environ.get("RNS_CONFIG_DIR", None)
if configdir:
Path(configdir).mkdir(parents=True, exist_ok=True)
_reticulum = RNS.Reticulum(configdir=configdir)
RNS.Transport.register_announce_handler(_AnnounceHandler())
_started = True
log.info("RNS browser started (v%s)", RNS.__version__)
except Exception as exc:
log.warning("Failed to start RNS browser: %s", exc)
# ---------------------------------------------------------------------------
# Config helpers
# ---------------------------------------------------------------------------
_CONFIG_PATHS = {
"reticulum": lambda: Path(os.environ.get("RNS_SERVER_CONFIG_DIR", os.environ.get("RNS_CONFIG_DIR", str(Path.home() / ".reticulum")))) / "config",
"reticulum-client": lambda: Path(os.environ.get("RNS_CONFIG_DIR", str(Path.home() / ".reticulum"))) / "config",
"nomadnet": lambda: Path(os.environ.get("NOMADNET_CONFIG_DIR", str(Path.home() / ".nomadnetwork"))) / "config",
}
def _config_path(kind: str) -> Path:
resolver = _CONFIG_PATHS.get(kind)
if not resolver:
raise ValueError(f"Unknown config kind: {kind}")
return resolver()
def _restart_nomadnet() -> bool:
"""Restart the NomadNet container. Returns True on success."""
try:
from docker_utils import NOMADNET_CONTAINER
import docker
client = docker.from_env()
container = client.containers.get(NOMADNET_CONTAINER)
container.restart()
log.info("NomadNet container restarted")
return True
except Exception as exc:
log.info("NomadNet container not available: %s", exc)
return False
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# API endpoints # API endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/browse/nodes") def _build_snapshot() -> list[dict]:
async def list_nodes(): """Build a full snapshot: self node + interfaces + discovered nodes."""
"""Return all discovered NomadNet page-serving nodes."""
with _lock: with _lock:
nodes = list(_nodes.values()) nodes = list(_nodes.values())
# Ensure the user's own node is always present # Ensure type field on all nodes
for n in nodes:
n.setdefault("type", "node")
if not any(n["is_self"] for n in nodes): if not any(n["is_self"] for n in nodes):
nodes.insert(0, { nodes.insert(0, {
"hash": _own_hash or "self", "hash": _own_hash or "self",
"name": _own_name, "name": _own_name(),
"last_seen": time.time(), "last_seen": time.time(),
"is_self": True, "is_self": True,
"type": "node",
}) })
# Add interfaces
nodes.extend(_collect_interfaces())
return nodes return nodes
@router.get("/browse/nodes")
async def list_nodes():
"""Return all discovered NomadNet nodes and interfaces."""
return _build_snapshot()
@router.get("/browse/nodes/stream")
async def stream_nodes():
"""SSE stream — pushes full snapshot then live node announces."""
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
with _sub_lock:
_subscribers.append(queue)
async def event_generator():
try:
# Send full snapshot (self + interfaces + known nodes)
for entry in _build_snapshot():
yield f"data: {json.dumps(entry)}\n\n"
# Then stream new announces as they arrive
while True:
node = await queue.get()
yield f"data: {json.dumps(node)}\n\n"
except asyncio.CancelledError:
pass
finally:
with _sub_lock:
_subscribers.remove(queue)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
@router.get("/browse/page/{hash_hex}") @router.get("/browse/page/{hash_hex}")
async def get_remote_page(hash_hex: str, path: str = Query("index.mu")): async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
"""Fetch a Micron page from a node. """Fetch a Micron page from a node.
@@ -124,13 +295,11 @@ async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
For the user's own node reads from the local pages directory. For the user's own node reads from the local pages directory.
For remote nodes establishes an RNS link and requests the page. For remote nodes establishes an RNS link and requests the page.
""" """
# Own node → read from disk
with _lock: with _lock:
node = _nodes.get(hash_hex) node = _nodes.get(hash_hex)
if (node and node.get("is_self")) or hash_hex == "self": if (node and node.get("is_self")) or hash_hex == "self":
return _read_local_page(path) return _read_local_page(path)
# Remote node → RNS request
content = await _request_remote_page(hash_hex, path) content = await _request_remote_page(hash_hex, path)
if content is None: if content is None:
return {"content": None, "error": "Could not reach node"} return {"content": None, "error": "Could not reach node"}
@@ -142,12 +311,19 @@ async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _read_local_page(path: str) -> dict: def _read_local_page(path: str) -> dict:
from converter import execute_dynamic_script
pages_dir = os.environ.get("PAGES_DIR", str(Path.home() / ".nomadnetwork/storage/pages")) pages_dir = os.environ.get("PAGES_DIR", str(Path.home() / ".nomadnetwork/storage/pages"))
filepath = Path(pages_dir) / path filepath = Path(pages_dir) / path
try: if not filepath.exists():
return {"content": filepath.read_text()}
except FileNotFoundError:
return {"content": None, "error": "Page not found"} return {"content": None, "error": "Page not found"}
try:
if os.access(filepath, os.X_OK):
script = filepath.read_text(encoding="utf-8")
return {"content": execute_dynamic_script(script)}
return {"content": filepath.read_text()}
except Exception as exc:
return {"content": None, "error": str(exc)}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -168,7 +344,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
try: try:
dest_hash = bytes.fromhex(hash_hex) dest_hash = bytes.fromhex(hash_hex)
# Ensure path to destination is known
if not RNS.Transport.has_path(dest_hash): if not RNS.Transport.has_path(dest_hash):
RNS.Transport.request_path(dest_hash) RNS.Transport.request_path(dest_hash)
deadline = time.time() + 10 deadline = time.time() + 10
@@ -195,7 +370,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
link = RNS.Link(dest) link = RNS.Link(dest)
# Wait for link to become active
deadline = time.time() + 15 deadline = time.time() + 15
while time.time() < deadline: while time.time() < deadline:
if link.status == RNS.Link.ACTIVE: if link.status == RNS.Link.ACTIVE:
@@ -206,7 +380,6 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
loop.call_soon_threadsafe(future.set_result, None) loop.call_soon_threadsafe(future.set_result, None)
return return
# Request the page via NomadNet's protocol
def on_response(request_receipt): def on_response(request_receipt):
try: try:
resp = request_receipt.response resp = request_receipt.response
@@ -236,10 +409,55 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
if not future.done(): if not future.done():
loop.call_soon_threadsafe(future.set_result, None) loop.call_soon_threadsafe(future.set_result, None)
# Run blocking RNS operations in a thread
threading.Thread(target=_do_request, daemon=True).start() threading.Thread(target=_do_request, daemon=True).start()
try: try:
return await asyncio.wait_for(future, timeout=30.0) return await asyncio.wait_for(future, timeout=30.0)
except asyncio.TimeoutError: except asyncio.TimeoutError:
return None return None
# ---------------------------------------------------------------------------
# Reticulum config endpoints
# ---------------------------------------------------------------------------
@router.get("/browse/identity")
async def get_identity():
"""Return the node's RNS identity hash and configured name."""
identity_hash = None
try:
import RNS
if _reticulum and RNS.Transport.identity:
identity_hash = RNS.hexrep(RNS.Transport.identity.hash, delimit=False)
except Exception:
pass
return {
"name": _own_name(),
"hash": _own_hash or identity_hash,
}
@router.post("/browse/restart")
async def restart_services():
"""Restart NomadNet to apply config changes."""
restarted = _restart_nomadnet()
return {"ok": True, "nomadnet_restarted": restarted}
@router.get("/browse/config/{kind}")
async def get_config(kind: str):
"""Return config file contents for reticulum or nomadnet."""
path = _config_path(kind)
if not path.exists():
return {"content": ""}
return {"content": path.read_text(encoding="utf-8")}
@router.post("/browse/config/{kind}")
async def save_config(kind: str, body: dict):
"""Write config file for reticulum or nomadnet."""
content = body.get("content", "")
path = _config_path(kind)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return {"ok": True}

View File

@@ -1,6 +1,9 @@
"""µFrame compile, DSL metadata, and image upload endpoints.""" """µFrame compile, DSL metadata, and image upload endpoints."""
import os import os
import subprocess
import sys
import tempfile
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File from fastapi import APIRouter, HTTPException, UploadFile, File
@@ -14,6 +17,31 @@ from uframe.registry import get_dsl_meta
router = APIRouter() router = APIRouter()
UPLOAD_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) / "images" UPLOAD_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) / "images"
BACKEND_DIR = str(Path(__file__).resolve().parent)
def execute_dynamic_script(script: str, timeout: int = 10) -> str:
"""Execute a dynamic page script and return its stdout (micron output).
Used by both the compile preview and the browse page reader.
"""
env = {**os.environ, "PYTHONPATH": BACKEND_DIR}
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
f.write(script)
f.flush()
try:
result = subprocess.run(
[sys.executable, f.name],
capture_output=True, text=True, timeout=timeout,
cwd=BACKEND_DIR, env=env,
)
if result.returncode != 0 and result.stderr:
return result.stderr
return result.stdout
except subprocess.TimeoutExpired:
return "Error: script timed out"
finally:
os.unlink(f.name)
class CompileRequest(BaseModel): class CompileRequest(BaseModel):
@@ -31,12 +59,26 @@ class CompileResponse(BaseModel):
@router.post("/compile", response_model=CompileResponse) @router.post("/compile", response_model=CompileResponse)
async def compile_source(req: CompileRequest): async def compile_source(req: CompileRequest):
"""Compile µFrame .uf source into ASCII and Micron output.""" """Compile µFrame .uf source into ASCII and Micron output.
For dynamic pages, the generated script is executed and the
resolved micron output replaces the static micron in the response.
"""
try: try:
result = uframe.compile(req.source, width=req.width) result = uframe.compile(req.source, width=req.width)
micron = result.micron
if result.is_dynamic and result.script:
executed = execute_dynamic_script(result.script)
# Strip cache header line if present
lines = executed.split("\n")
if lines and lines[0].startswith("#!c="):
lines = lines[1:]
micron = "\n".join(lines)
return CompileResponse( return CompileResponse(
ascii=result.ascii, ascii=result.ascii,
micron=result.micron, micron=micron,
script=result.script, script=result.script,
is_dynamic=result.is_dynamic, is_dynamic=result.is_dynamic,
warnings=[w.message for w in result.warnings], warnings=[w.message for w in result.warnings],

View File

@@ -4,7 +4,7 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pages import router as pages_router, ensure_default_pages from pages import router as pages_router, files_router, ensure_default_pages
from docker_utils import router as docker_router from docker_utils import router as docker_router
from converter import router as converter_router from converter import router as converter_router
from browse import router as browse_router, start_browser from browse import router as browse_router, start_browser
@@ -13,6 +13,7 @@ app = FastAPI(title="µFrame Editor")
app.include_router(converter_router, prefix="/api") app.include_router(converter_router, prefix="/api")
app.include_router(pages_router, prefix="/api") app.include_router(pages_router, prefix="/api")
app.include_router(files_router, prefix="/api")
app.include_router(docker_router, prefix="/api") app.include_router(docker_router, prefix="/api")
app.include_router(browse_router, prefix="/api") app.include_router(browse_router, prefix="/api")
@@ -28,7 +29,16 @@ async def health():
return {"status": "ok"} return {"status": "ok"}
# Serve built frontend as static files (SPA fallback) # Serve built frontend as static files with SPA fallback
static_dir = Path(__file__).parent / "static" static_dir = Path(__file__).parent / "static"
if static_dir.is_dir(): if static_dir.is_dir():
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static") from fastapi.responses import FileResponse
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
@app.get("/{path:path}")
async def spa_fallback(path: str):
file = static_dir / path
if file.is_file():
return FileResponse(file)
return FileResponse(static_dir / "index.html")

View File

@@ -1,13 +1,15 @@
import os import os
import shlex import shlex
import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel, Field
import uframe import uframe
router = APIRouter() router = APIRouter()
files_router = APIRouter()
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages")) PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
@@ -38,7 +40,7 @@ page "Welcome" 60
def ensure_default_pages(): def ensure_default_pages():
"""Create a default index page if none exists.""" """Create default index page and .env file if they don't exist."""
PAGES_DIR.mkdir(parents=True, exist_ok=True) PAGES_DIR.mkdir(parents=True, exist_ok=True)
SOURCES_DIR.mkdir(parents=True, exist_ok=True) SOURCES_DIR.mkdir(parents=True, exist_ok=True)
@@ -54,6 +56,17 @@ def ensure_default_pages():
if not index_src.is_file(): if not index_src.is_file():
index_src.write_text(DEFAULT_INDEX_SOURCE, encoding="utf-8") index_src.write_text(DEFAULT_INDEX_SOURCE, encoding="utf-8")
env_path = SOURCES_DIR / ".env"
if not env_path.is_file():
env_path.write_text(
"# Environment variables for dynamic pages\n"
"# Access with: source name : env \"KEY\"\n"
"#\n"
"# Example:\n"
"# API_KEY=your-key-here\n",
encoding="utf-8",
)
class PageMeta(BaseModel): class PageMeta(BaseModel):
name: str name: str
@@ -225,3 +238,176 @@ async def delete_page(name: str):
mu_path.unlink() mu_path.unlink()
return {"deleted": name} return {"deleted": name}
# ---------------------------------------------------------------------------
# File browser endpoints
# ---------------------------------------------------------------------------
class FileEntry(BaseModel):
name: str
path: str
type: str # "file" | "folder" | "env"
size: int | None = None
last_modified: float | None = None
title: str | None = None
published: bool = False
class MkdirRequest(BaseModel):
path: str
class MoveRequest(BaseModel):
model_config = {"populate_by_name": True}
from_path: str = Field(alias="from")
to: str
class EnvRequest(BaseModel):
content: str
def _validate_relative_path(rel: str) -> Path:
"""Validate that a relative path has no traversal components and resolves
inside the expected base directories. Returns the cleaned relative Path."""
p = Path(rel)
# Reject absolute paths and any ".." components
if p.is_absolute():
raise HTTPException(status_code=400, detail="Absolute paths not allowed")
for part in p.parts:
if part == "..":
raise HTTPException(status_code=400, detail="Directory traversal not allowed")
# Extra safety: resolve against SOURCES_DIR and verify containment
resolved = (SOURCES_DIR / p).resolve()
if not str(resolved).startswith(str(SOURCES_DIR.resolve())):
raise HTTPException(status_code=400, detail="Path escapes base directory")
return p
def _file_entry(base: Path, rel_path: Path) -> FileEntry:
"""Build a FileEntry for a file or directory at base/rel_path."""
full = base / rel_path
name = rel_path.name
if full.is_dir():
return FileEntry(
name=name,
path=str(rel_path),
type="folder",
)
# .env file
if name == ".env":
stat = full.stat()
return FileEntry(
name=name,
path=str(rel_path),
type="env",
size=stat.st_size,
last_modified=stat.st_mtime,
)
# Regular file
stat = full.stat()
title = None
published = False
if full.suffix == ".uf":
try:
title = _extract_title(full.read_text(encoding="utf-8"))
except Exception:
pass
# Check published status: corresponding .mu in PAGES_DIR at same relative path
mu_rel = rel_path.with_suffix(".mu")
published = (PAGES_DIR / mu_rel).is_file()
return FileEntry(
name=name,
path=str(rel_path),
type="file",
size=stat.st_size,
last_modified=stat.st_mtime,
title=title,
published=published,
)
@files_router.get("/files", response_model=list[FileEntry])
async def list_files(path: str = Query(default="")):
"""List files and folders in SOURCES_DIR, optionally scoped to a subfolder."""
if path:
rel = _validate_relative_path(path)
else:
rel = Path(".")
target = (SOURCES_DIR / rel).resolve()
if not str(target).startswith(str(SOURCES_DIR.resolve())):
raise HTTPException(status_code=400, detail="Path escapes base directory")
if not target.is_dir():
raise HTTPException(status_code=404, detail="Directory not found")
entries: list[FileEntry] = []
for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
item_rel = item.relative_to(SOURCES_DIR)
entries.append(_file_entry(SOURCES_DIR, item_rel))
return entries
@files_router.post("/files/mkdir")
async def mkdir(req: MkdirRequest):
"""Create a folder in both SOURCES_DIR and PAGES_DIR."""
rel = _validate_relative_path(req.path)
(SOURCES_DIR / rel).mkdir(parents=True, exist_ok=True)
(PAGES_DIR / rel).mkdir(parents=True, exist_ok=True)
return {"created": str(rel)}
@files_router.post("/files/move")
async def move_file(req: MoveRequest):
"""Move/rename a file or folder in both SOURCES_DIR and PAGES_DIR."""
from_rel = _validate_relative_path(req.from_path)
to_rel = _validate_relative_path(req.to)
# Move in SOURCES_DIR
src_from = SOURCES_DIR / from_rel
src_to = SOURCES_DIR / to_rel
if src_from.exists():
src_to.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src_from), str(src_to))
# Move in PAGES_DIR (for .uf files, look for .mu counterpart)
if src_from.suffix == ".uf" or (not src_from.exists() and from_rel.suffix == ".uf"):
pages_from = PAGES_DIR / from_rel.with_suffix(".mu")
pages_to = PAGES_DIR / to_rel.with_suffix(".mu")
else:
pages_from = PAGES_DIR / from_rel
pages_to = PAGES_DIR / to_rel
if pages_from.exists():
pages_to.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(pages_from), str(pages_to))
return {"moved": {"from": str(from_rel), "to": str(to_rel)}}
@files_router.get("/files/env")
async def read_env():
"""Read the .env file from SOURCES_DIR root."""
env_path = SOURCES_DIR / ".env"
if env_path.is_file():
return {"content": env_path.read_text(encoding="utf-8")}
return {"content": ""}
@files_router.post("/files/env")
async def save_env(req: EnvRequest):
"""Save the .env file to SOURCES_DIR root."""
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
env_path = SOURCES_DIR / ".env"
env_path.write_text(req.content, encoding="utf-8")
return {"saved": True}

View File

@@ -29,14 +29,56 @@ def _indent(code: str, level: int = 1) -> str:
def _resolve_vars(text: str) -> str: def _resolve_vars(text: str) -> str:
"""Convert $var references to Python f-string expressions.""" """Convert $var references to Python f-string expressions.
First escapes literal braces (e.g. @color{ff0}{text} → @color{{ff0}}{{text}})
so they survive f-string evaluation, then replaces $var → {var}.
"""
import re
# Use a unique placeholder for $var refs, escape all braces, then restore
_PH = "\x00VAR"
counter = [0]
placeholders: dict[str, str] = {}
def stash_var(m: re.Match) -> str:
var = m.group(1)
if "." in var:
parts = var.split(".")
base = parts[0]
chain = "".join(f"['{p}']" for p in parts[1:])
expr = "{" + base + chain + "}"
else:
expr = "{" + var + "}"
key = f"{_PH}{counter[0]}\x00"
counter[0] += 1
placeholders[key] = expr
return key
# 1. Stash $var references with placeholders
result = re.sub(r'\$([a-zA-Z_][\w.]*)', stash_var, text)
# 2. Escape all remaining braces for f-string safety
result = result.replace("{", "{{").replace("}", "}}")
# 3. Restore $var placeholders (unescaped)
for key, expr in placeholders.items():
result = result.replace(key, expr)
return result
def _resolve_vars_code(text: str) -> str:
"""Convert $var references to bare Python identifiers.
Simple vars: $name → name
Dotted paths: $item.name → item['name'] (dict access)
"""
import re import re
# Replace $var.attr.attr with {var_attr_attr} and simple $var with {var}
def replace_var(m: re.Match) -> str: def replace_var(m: re.Match) -> str:
var = m.group(1) var = m.group(1)
# Replace dots with underscores for Python variable names if "." in var:
py_var = var.replace(".", "_") parts = var.split(".")
return "{" + py_var + "}" base = parts[0]
chain = "".join(f"['{p}']" for p in parts[1:])
return base + chain
return var
return re.sub(r'\$([a-zA-Z_][\w.]*)', replace_var, text) return re.sub(r'\$([a-zA-Z_][\w.]*)', replace_var, text)
@@ -67,14 +109,28 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
elif node.source_type == SourceType.JSON: elif node.source_type == SourceType.JSON:
lines.append(f"{ind}{var} = _read_json({node.command!r})") lines.append(f"{ind}{var} = _read_json({node.command!r})")
elif node.source_type == SourceType.PYTHON: elif node.source_type == SourceType.PYTHON:
# Restricted eval — only datetime/secrets modules available lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': _dt_cls, 'timedelta': timedelta, 'secrets': secrets, 'os': os, 'json': json}})")
lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': datetime, 'secrets': secrets}})")
elif node.source_type == SourceType.PARAM: elif node.source_type == SourceType.PARAM:
lines.append(f"{ind}{var} = _get_param({node.command!r})") lines.append(f"{ind}{var} = _get_param({node.command!r})")
elif node.source_type == SourceType.RNS: elif node.source_type == SourceType.RNS:
import shlex as _shlex import shlex as _shlex
safe_cmd = _shlex.quote(node.command) safe_cmd = _shlex.quote(node.command)
lines.append(f"{ind}{var} = _shell('rnstatus ' + shlex.quote({safe_cmd!r}), timeout={node.timeout})") lines.append(f"{ind}{var} = _shell('rnstatus ' + shlex.quote({safe_cmd!r}), timeout={node.timeout})")
elif node.source_type == SourceType.HTTP:
method = node.http_method or "GET"
url = _resolve_vars(node.command)
hdrs = _resolve_vars(node.http_headers) if node.http_headers else ""
body = _resolve_vars(node.http_body) if node.http_body else ""
if body:
lines.append(f"""{ind}{var} = _http(f'''{url}''', method={method!r}, body=f'''{body}''', headers=f'''{hdrs}''', timeout={node.timeout})""")
elif hdrs:
lines.append(f"""{ind}{var} = _http(f'''{url}''', method={method!r}, headers=f'''{hdrs}''', timeout={node.timeout})""")
else:
lines.append(f"{ind}{var} = _http({node.command!r}, method={method!r}, timeout={node.timeout})")
elif node.source_type == SourceType.SQLITE:
lines.append(f"{ind}{var} = _sqlite({node.command!r}, {node.query!r})")
elif node.source_type == SourceType.ENV:
lines.append(f"{ind}{var} = os.environ.get({node.command!r}, '')")
elif isinstance(node, CacheControl): elif isinstance(node, CacheControl):
lines.append(f"{ind}_cache_seconds = {node.seconds}") lines.append(f"{ind}_cache_seconds = {node.seconds}")
@@ -83,8 +139,7 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
lines.append(f"{ind}{node.state_name} = _load_state({node.path!r})") lines.append(f"{ind}{node.state_name} = _load_state({node.path!r})")
elif isinstance(node, IfBlock): elif isinstance(node, IfBlock):
cond = _resolve_vars(node.condition) cond = _resolve_vars_code(node.condition)
# Convert simple comparisons
cond = cond.replace("&&", " and ").replace("||", " or ") cond = cond.replace("&&", " and ").replace("||", " or ")
lines.append(f"{ind}if {cond}:") lines.append(f"{ind}if {cond}:")
if node.children: if node.children:
@@ -94,7 +149,7 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
lines.append(f"{ind} pass") lines.append(f"{ind} pass")
for elif_cond, elif_children in node.elif_branches: for elif_cond, elif_children in node.elif_branches:
ec = _resolve_vars(elif_cond).replace("&&", " and ").replace("||", " or ") ec = _resolve_vars_code(elif_cond).replace("&&", " and ").replace("||", " or ")
lines.append(f"{ind}elif {ec}:") lines.append(f"{ind}elif {ec}:")
if elif_children: if elif_children:
for child in elif_children: for child in elif_children:
@@ -108,7 +163,7 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
lines.extend(_emit_node(child, indent_level + 1)) lines.extend(_emit_node(child, indent_level + 1))
elif isinstance(node, ForLoop): elif isinstance(node, ForLoop):
iterable = _resolve_vars(node.iterable) iterable = _resolve_vars_code(node.iterable)
lines.append(f"{ind}for {node.var_name} in _iter({iterable}):") lines.append(f"{ind}for {node.var_name} in _iter({iterable}):")
if node.children: if node.children:
for child in node.children: for child in node.children:
@@ -146,13 +201,16 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
elif isinstance(node, Gauge): elif isinstance(node, Gauge):
label = _resolve_vars(node.label) label = _resolve_vars(node.label)
value = _resolve_vars(str(node.value)) if "$" in str(node.value) else str(node.value) raw_val = getattr(node, "_raw_value", None)
value = _resolve_vars(raw_val) if raw_val and "$" in raw_val else str(node.value)
raw_max = getattr(node, "_raw_max_val", None)
max_val = _resolve_vars(raw_max) if raw_max and "$" in raw_max else str(node.max_val)
extra = "" extra = ""
if node.warn is not None: if node.warn is not None:
extra += f" warn={node.warn}" extra += f" warn={node.warn}"
if node.crit is not None: if node.crit is not None:
extra += f" crit={node.crit}" extra += f" crit={node.crit}"
lines.append(f"{ind}_uf_source_parts.append(f'gauge \"{label}\" {value} {node.max_val} {node.bar_width}{extra}')") lines.append(f"{ind}_uf_source_parts.append(f'gauge \"{label}\" {value} {max_val} {node.bar_width}{extra}')")
elif isinstance(node, Status): elif isinstance(node, Status):
label = _resolve_vars(node.label) label = _resolve_vars(node.label)
@@ -221,6 +279,7 @@ def _build_script(uframe_import: str, page_logic: str, page_title: str, page_wid
"# Do not edit — regenerate with: uframe compile <source>.uf", "# Do not edit — regenerate with: uframe compile <source>.uf",
"", "",
"import os, sys, json, subprocess, datetime, secrets, shlex", "import os, sys, json, subprocess, datetime, secrets, shlex",
"from datetime import datetime as _dt_cls, timedelta",
"", "",
"# ─── Runtime Helpers ─────────────────────────────────────────", "# ─── Runtime Helpers ─────────────────────────────────────────",
"", "",
@@ -279,6 +338,42 @@ def _build_script(uframe_import: str, page_logic: str, page_title: str, page_wid
' return val.strip().splitlines()', ' return val.strip().splitlines()',
' return []', ' return []',
"", "",
'def _http(url, method="GET", body="", headers="", timeout=10):',
' """HTTP request, return response body (JSON parsed if possible)."""',
' import urllib.request, urllib.error',
' try:',
' data = body.encode("utf-8") if body else None',
' req = urllib.request.Request(url, data=data, method=method)',
' req.add_header("User-Agent", "uframe/1.0")',
' if body and not headers:',
' req.add_header("Content-Type", "application/json")',
' if headers:',
' for pair in headers.split(";"):',
' if ":" in pair:',
' k, v = pair.split(":", 1)',
' req.add_header(k.strip(), v.strip())',
' with urllib.request.urlopen(req, timeout=timeout) as resp:',
' raw = resp.read().decode("utf-8")',
' try:',
' return json.loads(raw)',
' except (json.JSONDecodeError, ValueError):',
' return raw.strip()',
' except Exception as e:',
' return {"error": str(e)}',
"",
'def _sqlite(db_path, query):',
' """Run a SQLite query, return list of dicts."""',
' import sqlite3',
' try:',
' conn = sqlite3.connect(db_path)',
' conn.row_factory = sqlite3.Row',
' cur = conn.execute(query)',
' rows = [dict(r) for r in cur.fetchall()]',
' conn.close()',
' return rows if len(rows) != 1 else rows[0]',
' except Exception as e:',
' return {"error": str(e)}',
"",
f"# ─── µFrame Compile ──────────────────────────────────────────", f"# ─── µFrame Compile ──────────────────────────────────────────",
"", "",
uframe_import, uframe_import,

View File

@@ -78,8 +78,9 @@ def emit_micron(grid: CharGrid, page_title: str = "") -> str:
if cur_style != _EMPTY_STYLE: if cur_style != _EMPTY_STYLE:
line_parts.append(_emit_style_close(cur_style)) line_parts.append(_emit_style_close(cur_style))
cur_style = _EMPTY_STYLE cur_style = _EMPTY_STYLE
# Open new link # Open new link — backtick enters formatting mode
line_parts.append("[") # where the parser recognizes `[` as link start
line_parts.append("`[")
in_link = link in_link = link
# Handle style transitions (not inside links — links handle their own style) # Handle style transitions (not inside links — links handle their own style)

View File

@@ -302,6 +302,9 @@ class SourceType(Enum):
PYTHON = auto() PYTHON = auto()
RNS = auto() RNS = auto()
PARAM = auto() PARAM = auto()
HTTP = auto()
SQLITE = auto()
ENV = auto()
@dataclass @dataclass
@@ -317,6 +320,12 @@ class Source(IRNode):
var_name: str = "" var_name: str = ""
source_type: SourceType = SourceType.SHELL source_type: SourceType = SourceType.SHELL
command: str = "" command: str = ""
# HTTP-specific
http_method: str = "GET"
http_body: str = ""
http_headers: str = ""
# SQLite-specific: command = db path, query = SQL
query: str = ""
timeout: int = 5 timeout: int = 5

View File

@@ -138,7 +138,7 @@ def _parse_list_style(s: str) -> ListStyle:
}.get(s.lower(), ListStyle.BULLET) }.get(s.lower(), ListStyle.BULLET)
def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode: def _parse_line(keyword: str, args: list[str], line_num: int, raw_args: str = "") -> IRNode:
"""Parse a single line into an IR node based on the keyword.""" """Parse a single line into an IR node based on the keyword."""
if keyword == "page": if keyword == "page":
@@ -364,7 +364,7 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
# Dynamic features # Dynamic features
elif keyword == "let": elif keyword == "let":
# let name = "value" or let name = 1,2,3 # let name = "value" or let name = 1,2,3
raw = " ".join(args) raw = raw_args
eq = raw.find("=") eq = raw.find("=")
if eq != -1: if eq != -1:
var_name = raw[:eq].strip() var_name = raw[:eq].strip()
@@ -377,11 +377,10 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
elif keyword == "source": elif keyword == "source":
# source cpu : shell "grep 'cpu' /proc/stat" # source cpu : shell "grep 'cpu' /proc/stat"
# source name : type "command" # source name : type "command"
raw = " ".join(args) colon = raw_args.find(":")
colon = raw.find(":")
if colon != -1: if colon != -1:
var_name = raw[:colon].strip() var_name = raw_args[:colon].strip()
rest = raw[colon + 1:].strip() rest = raw_args[colon + 1:].strip()
parts = _split_args(rest) parts = _split_args(rest)
src_type_str = parts[0] if parts else "shell" src_type_str = parts[0] if parts else "shell"
command = parts[1] if len(parts) > 1 else "" command = parts[1] if len(parts) > 1 else ""
@@ -392,17 +391,38 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
"python": SourceType.PYTHON, "python": SourceType.PYTHON,
"rns": SourceType.RNS, "rns": SourceType.RNS,
"param": SourceType.PARAM, "param": SourceType.PARAM,
"http": SourceType.HTTP,
"sqlite": SourceType.SQLITE,
"env": SourceType.ENV,
}.get(src_type_str.lower(), SourceType.SHELL) }.get(src_type_str.lower(), SourceType.SHELL)
# Parse optional timeout # Parse optional params from remaining parts
timeout = 5 timeout = 5
for p in parts[2:]: http_method = "GET"
if p.startswith("timeout"): http_body = ""
http_headers = ""
query = ""
extra = parts[2:]
if src_type == SourceType.SQLITE and len(parts) > 2:
# sqlite "/path/db" "SELECT ..."
query = parts[2]
extra = parts[3:]
for p in extra:
if p.startswith("timeout="):
try: try:
timeout = int(p.split("=")[1]) if "=" in p else int(parts[parts.index(p) + 1]) timeout = int(p.split("=", 1)[1])
except (ValueError, IndexError): except (ValueError, IndexError):
pass pass
elif p.startswith("method="):
http_method = p.split("=", 1)[1].upper()
elif p.startswith("body="):
http_body = p.split("=", 1)[1]
elif p.startswith("headers="):
http_headers = p.split("=", 1)[1]
return Source(var_name=var_name, source_type=src_type, return Source(var_name=var_name, source_type=src_type,
command=command, timeout=timeout, source_line=line_num) command=command, timeout=timeout,
http_method=http_method, http_body=http_body,
http_headers=http_headers, query=query,
source_line=line_num)
else: else:
return Source(var_name=args[0] if args else "", source_line=line_num) return Source(var_name=args[0] if args else "", source_line=line_num)
@@ -817,7 +837,7 @@ def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Pag
args = _split_args(arg_str) args = _split_args(arg_str)
# Parse this line into a node # Parse this line into a node
node = _parse_line(keyword, args, line_num) node = _parse_line(keyword, args, line_num, raw_args=arg_str)
# Pop stack back to find the parent (parent indent < this indent) # Pop stack back to find the parent (parent indent < this indent)
while stack and stack[-1][0] >= indent: while stack and stack[-1][0] >= indent:

View File

@@ -36,7 +36,7 @@ def test_if_block():
text "Critical"''' text "Critical"'''
result = uframe.compile(source) result = uframe.compile(source)
assert result.is_dynamic assert result.is_dynamic
assert "if {val} > 90:" in result.script assert "if val > 90:" in result.script
def test_for_loop(): def test_for_loop():
@@ -46,7 +46,7 @@ def test_for_loop():
text "$item"''' text "$item"'''
result = uframe.compile(source) result = uframe.compile(source)
assert result.is_dynamic assert result.is_dynamic
assert "for item in _iter({items}):" in result.script assert "for item in _iter(items):" in result.script
def test_let_variable(): def test_let_variable():
@@ -120,5 +120,5 @@ def test_codegen_complete_dashboard():
assert "#!/usr/bin/env python3" in script assert "#!/usr/bin/env python3" in script
assert "_cache_seconds = 0" in script assert "_cache_seconds = 0" in script
assert "_shell" in script assert "_shell" in script
assert "if {cpu} > 90:" in script assert "if cpu > 90:" in script
assert "uframe.compile" in script assert "uframe.compile" in script

View File

@@ -9,10 +9,17 @@ services:
- PAGES_DIR=/data/pages - PAGES_DIR=/data/pages
- SOURCES_DIR=/data/sources - SOURCES_DIR=/data/sources
- NOMADNET_CONTAINER=nomadnet - NOMADNET_CONTAINER=nomadnet
- RNS_CONFIG_DIR=/rns
- RNS_SERVER_CONFIG_DIR=/rns-server
- NOMADNET_CONFIG_DIR=/nomadnet
- LOG_LEVEL=DEBUG
volumes: volumes:
- pages:/data/pages - pages:/data/pages
- sources:/data/sources - sources:/data/sources
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- ./reticulum-client.conf:/rns/config
- ./reticulum.conf:/rns-server/config
- ./nomadnet.conf:/nomadnet/config
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- nomadnet - nomadnet
@@ -24,8 +31,8 @@ services:
volumes: volumes:
- pages:/root/.nomadnetwork/storage/pages - pages:/root/.nomadnetwork/storage/pages
- nomadnet-config:/root/.nomadnetwork - nomadnet-config:/root/.nomadnetwork
- ./nomadnet.conf:/root/.nomadnetwork/config:ro - ./nomadnet.conf:/root/.nomadnetwork/config
- ./reticulum.conf:/root/.reticulum/config:ro - ./reticulum.conf:/root/.reticulum/config
restart: unless-stopped restart: unless-stopped
volumes: volumes:

14
deploy.sh Executable file
View File

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

View File

@@ -158,9 +158,11 @@ source peers : shell "rnstatus -j | python3 -c 'import sys,json; d=json.load(s
source motd : file "/etc/motd" source motd : file "/etc/motd"
source config : json "/home/node/.nomadnetwork/config.json" source config : json "/home/node/.nomadnetwork/config.json"
# Python expression — evaluated inline # Python expression — evaluated inline (available: datetime, timedelta, secrets, os, json)
source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')" source timestamp : python "datetime.now().strftime('%Y-%m-%d %H:%M')"
source rand_hex : python "secrets.token_hex(4)" source rand_hex : python "secrets.token_hex(4)"
source uptime : python "str(timedelta(seconds=12345))"
source hostname : python "os.uname().nodename"
# RNS/Reticulum API — direct integration # RNS/Reticulum API — direct integration
source peer_list : rns "peers" source peer_list : rns "peers"
@@ -346,135 +348,95 @@ A `.uf` file with dynamic features compiles into a Python script
that: that:
1. Sets the shebang and cache header 1. Sets the shebang and cache header
2. Imports required modules 2. Imports required modules + the `uframe` package
3. Reads environment variables (form data) 3. Defines runtime helpers (`_shell`, `_read_file`, `_read_json`, etc.)
4. Executes source commands (shell, file, python, rns) 4. Executes source commands and evaluates conditionals/loops
5. Evaluates conditionals and loops 5. Dynamically builds a `.uf` source string with resolved variables
6. Renders the IR tree into a CharGrid 6. Compiles that source with `uframe.compile()` at runtime
7. Emits the CharGrid as Micron with style tags 7. Prints the resulting Micron to stdout
8. Prints to stdout
```python ```python
#!/usr/bin/env python3 #!/usr/bin/env python3
#!c=0 # Auto-generated by uFrame
# Auto-generated by µFrame from dashboard.uf # Do not edit — regenerate with: uframe compile <source>.uf
# Do not edit — regenerate with: uframe compile dashboard.uf
import os, sys, json, subprocess, datetime, secrets import os, sys, json, subprocess, datetime, secrets, shlex
from datetime import datetime as _dt_cls, timedelta
# ─── µFrame Runtime (embedded) ─────────────────────────────── # ─── Runtime Helpers ─────────────────────────────────────────
class CharGrid: def _shell(cmd, timeout=5):
"""2D character grid with style annotations."""
def __init__(self, width, height):
self.w = width
self.h = height
self.chars = [[' ']*width for _ in range(height)]
self.styles = [[None]*width for _ in range(height)]
def put(self, x, y, ch, style=None):
if 0 <= x < self.w and 0 <= y < self.h:
self.chars[y][x] = ch
self.styles[y][x] = style
def box(self, x, y, w, h, weight='light', title=None, title_style=None):
"""Draw a box with automatic border characters."""
# ... border drawing logic ...
def gauge(self, x, y, w, value, max_val, label=None,
warn=None, crit=None):
"""Render a horizontal gauge bar with threshold colors."""
pct = min(value / max_val, 1.0)
filled = int(w * pct)
for i in range(w):
ch = '█' if i < filled else '░'
fg = None
if crit and value >= crit: fg = 'f00'
elif warn and value >= warn: fg = 'ff0'
elif i < filled: fg = '0f0'
else: fg = '555'
self.put(x + i, y, ch, {'fg': fg})
# ... label and percentage ...
def sparkline(self, x, y, w, values):
"""Render braille sparkline from value array."""
# ... braille pattern generation ...
def emit_micron(self):
"""Scan grid and emit Micron with style tags."""
lines = []
for row_idx in range(self.h):
line = []
cur_style = None
for col_idx in range(self.w):
ch = self.chars[row_idx][col_idx]
st = self.styles[row_idx][col_idx]
if st != cur_style:
# Close previous style tags
if cur_style:
if cur_style.get('fg'): line.append('`f')
if cur_style.get('bold'): line.append('`!')
# Open new style tags
if st:
if st.get('bold'): line.append('`!')
if st.get('fg'): line.append(f'`F{st["fg"]}')
cur_style = st
line.append(ch)
# Close final style
if cur_style:
if cur_style.get('fg'): line.append('`f')
if cur_style.get('bold'): line.append('`!')
lines.append(''.join(line).rstrip())
return '\n'.join(lines)
# ─── Form Data ───────────────────────────────────────────────
def get_field(name, default=''):
"""Read submitted form field from environment."""
return os.environ.get(f'FIELD_{name}', default)
def get_param(name, default=''):
"""Read URL parameter."""
return os.environ.get(f'PARAM_{name}',
os.environ.get(f'var_{name}', default))
# ─── Data Sources ────────────────────────────────────────────
def shell(cmd):
"""Execute shell command, return stdout.""" """Execute shell command, return stdout."""
try: try:
return subprocess.check_output( return subprocess.check_output(cmd, shell=True, timeout=timeout).decode().strip()
cmd, shell=True, timeout=5
).decode().strip()
except Exception: except Exception:
return '' return ""
# ─── Resolve Sources ───────────────────────────────────────── def _read_file(path):
"""Read file contents."""
# ...
cpu_pct = int(shell( def _read_json(path):
"grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'" """Read and parse JSON file."""
) or 0) # ...
mem_pct = int(shell(
"free | awk '/Mem/{print int($3/$2*100)}'"
) or 0)
uptime_str = shell("uptime -p")
peer_count = shell("rnstatus -j 2>/dev/null | python3 -c "
"'import sys,json; print(len(json.load(sys.stdin).get(\"peers\",[])))'")
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M')
# ─── Build Grid & Render ──────────────────────────────────── def _get_field(name, default=""):
"""Read submitted form field from environment."""
return os.environ.get(f"FIELD_{name}", default)
grid = CharGrid(66, 40) def _get_param(name, default=""):
"""Read URL parameter."""
return os.environ.get(f"PARAM_{name}",
os.environ.get(f"var_{name}", default))
# ... all the box(), gauge(), sparkline(), text() calls def _load_state(path):
# ... exactly as the layout engine would produce them ... """Load state from JSON file."""
# ...
# ─── Output ────────────────────────────────────────────────── def _save_state(path, data):
"""Save state to JSON file."""
# ...
print('#!c=0') # cache header: never cache def _iter(val):
print(grid.emit_micron()) """Make a value iterable for for-loops."""
# handles lists, dicts, newline-delimited strings
# ─── µFrame Compile ──────────────────────────────────────────
import uframe
# ─── Page Logic ──────────────────────────────────────────────
_cache_seconds = 0
_uf_source_parts = []
cpu_pct = eval('secrets.randbelow(60) + 20', {'datetime': _dt_cls, ...})
timestamp = eval("datetime.now().strftime('%H:%M:%S')", {'datetime': _dt_cls, ...})
_uf_source_parts.append(f'heading 1 "Resources"')
_uf_source_parts.append(f'gauge "CPU" {cpu_pct} 100 28 warn=75.0 crit=90.0')
_uf_source_parts.append(f'text "Updated: {timestamp}"')
if cpu_pct > 90:
_uf_source_parts.append(f'text "ALERT: CPU critical"')
# ─── Render & Output ─────────────────────────────────────────
_uf_source = f'''page "Live Status" 60
''' + "\n".join(_uf_source_parts)
result = uframe.compile(_uf_source, width=60)
if _cache_seconds >= 0:
print(f"#!c={_cache_seconds}")
print(result.micron)
``` ```
The key insight: the generated script **rebuilds `.uf` source** with
live data substituted in, then compiles it with the full µFrame
pipeline. This means every layout feature (boxes, gauges, tables,
sparklines) works identically in both static and dynamic pages.
### 4.2 CLI usage ### 4.2 CLI usage
```bash ```bash
@@ -644,30 +606,35 @@ sparkline renders identically in both ASCII preview and live Micron.
page "Status" 64 page "Status" 64
cache 0 cache 0
source cpu : shell "cat /proc/loadavg | awk '{print int($1*100/$(nproc))}'" source cpu : python "secrets.randbelow(60) + 20"
source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'" source mem : python "secrets.randbelow(40) + 50"
source net_in : shell "net_traffic.sh in" source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
source net_out : shell "net_traffic.sh out" source timestamp : python "datetime.now().strftime('%H:%M:%S')"
source net_history_in : shell "net_spark.sh in 20"
source net_history_out : shell "net_spark.sh out 20"
box heavy "System Status" box heavy "System Status"
row 2 row 2
gauge "CPU" $cpu 100 28 warn=75 crit=90 gauge "CPU" $cpu 100 28 warn=75 crit=90
gauge "MEM" $mem 100 28 warn=80 crit=95 gauge "MEM" $mem 100 28 warn=80 crit=95
spacer spacer
label "IN" "$net_in KB/s" label "Uptime" "$uptime"
sparkline "IN" $net_history_in 28 label "Updated" "$timestamp"
label "OUT" "$net_out KB/s"
sparkline "OUT" $net_history_out 28
text "@center{@italic{Press Ctrl+R to refresh}}" text "@center{@italic{Press Ctrl+R to refresh}}"
``` ```
Client hits the page → script runs → reads `/proc` → renders Client hits the page → script runs → evaluates sources →
gauges and sparklines with real data → client sees it. renders gauges with live data → client sees it.
Ctrl+R re-requests → fresh execution → updated values. Ctrl+R re-requests → fresh execution → updated values.
On a full Linux node, replace the python sources with shell commands
to read real system data:
```
source cpu : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'"
source uptime : shell "uptime -p"
```
### 6.2 Guestbook with Persistent State ### 6.2 Guestbook with Persistent State
``` ```

View File

@@ -16,12 +16,9 @@
"@codemirror/search": "^6.6.0", "@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.40.0", "@codemirror/view": "^6.40.0",
"@cosmos.gl/graph": "^2.6.4",
"@dagrejs/dagre": "^3.0.0",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
@@ -30,12 +27,14 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-force-graph-3d": "^1.29.1",
"react-resizable-panels": "^4.8.0", "react-resizable-panels": "^4.8.0",
"react-router-dom": "^7.13.2", "react-router-dom": "^7.13.2",
"shadcn": "^4.1.1", "shadcn": "^4.1.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"three-spritetext": "^1.10.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },
@@ -664,46 +663,6 @@
"w3c-keyname": "^2.2.4" "w3c-keyname": "^2.2.4"
} }
}, },
"node_modules/@cosmos.gl/graph": {
"version": "2.6.4",
"resolved": "https://registry.npmjs.org/@cosmos.gl/graph/-/graph-2.6.4.tgz",
"integrity": "sha512-i+N9lSpAjGLTUPelo/bKNbQnKPDqt3k2UnRlfIWe2Lrambc4J3QFgOfpR8AalQ/1tgLRoeNtVBZ1GPpsNqae5w==",
"license": "MIT",
"dependencies": {
"d3-array": "^3.2.0",
"d3-color": "^3.1.0",
"d3-drag": "^3.0.0",
"d3-ease": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.1",
"d3-zoom": "^3.0.0",
"dompurify": "^3.2.6",
"gl-bench": "^1.0.42",
"gl-matrix": "^3.4.3",
"random": "^4.1.0",
"regl": "^2.1.0"
},
"engines": {
"node": ">=12.2.0",
"npm": ">=7.0.0"
}
},
"node_modules/@dagrejs/dagre": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
"integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==",
"license": "MIT",
"dependencies": {
"@dagrejs/graphlib": "4.0.1"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz",
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
"license": "MIT"
},
"node_modules/@dotenvx/dotenvx": { "node_modules/@dotenvx/dotenvx": {
"version": "1.59.1", "version": "1.59.1",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.59.1.tgz", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.59.1.tgz",
@@ -2152,6 +2111,12 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/@tweenjs/tween.js": {
"version": "25.0.0",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
"license": "MIT"
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.1", "version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -2162,55 +2127,6 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/dompurify": { "node_modules/@types/dompurify": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
@@ -2605,64 +2521,20 @@
} }
} }
}, },
"node_modules/@xyflow/react": { "node_modules/3d-force-graph": {
"version": "12.10.2", "version": "1.80.0",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz", "resolved": "https://registry.npmjs.org/3d-force-graph/-/3d-force-graph-1.80.0.tgz",
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==", "integrity": "sha512-tzI353gW1nXPpnC7VTa3JjMg+3cp77qOLUFO0vucPTfF+q5R6sQsNsIqVTbRIb7RSypn14nBa4yfkOe9ThxASw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@xyflow/system": "0.0.76", "accessor-fn": "1",
"classcat": "^5.0.3", "kapsule": "^1.16",
"zustand": "^4.4.0" "three": ">=0.179 <1",
}, "three-forcegraph": "1",
"peerDependencies": { "three-render-objects": "^1.41"
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/react/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
}, },
"engines": { "engines": {
"node": ">=12.7.0" "node": ">=12"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.76",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -2678,6 +2550,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/accessor-fn": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
"integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3021,12 +2902,6 @@
"url": "https://polar.sh/cva" "url": "https://polar.sh/cva"
} }
}, },
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/cli-cursor": { "node_modules/cli-cursor": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@@ -3328,6 +3203,12 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-binarytree": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
"license": "MIT"
},
"node_modules/d3-color": { "node_modules/d3-color": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -3346,28 +3227,22 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-drag": { "node_modules/d3-force-3d": {
"version": "3.0.0", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
"license": "ISC", "license": "MIT",
"dependencies": { "dependencies": {
"d3-binarytree": "1",
"d3-dispatch": "1 - 3", "d3-dispatch": "1 - 3",
"d3-selection": "3" "d3-octree": "1",
"d3-quadtree": "1 - 3",
"d3-timer": "1 - 3"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": { "node_modules/d3-format": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
@@ -3389,6 +3264,21 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-octree": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
"license": "MIT"
},
"node_modules/d3-quadtree": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": { "node_modules/d3-scale": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
@@ -3405,6 +3295,19 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-scale-chromatic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-interpolate": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": { "node_modules/d3-selection": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
@@ -3447,36 +3350,13 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/d3-transition": { "node_modules/data-bind-mapper": {
"version": "3.0.1", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", "resolved": "https://registry.npmjs.org/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", "integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==",
"license": "ISC", "license": "MIT",
"dependencies": { "dependencies": {
"d3-color": "1 - 3", "accessor-fn": "1"
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
}, },
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -4308,6 +4188,20 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/float-tooltip": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz",
"integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
"license": "MIT",
"dependencies": {
"d3-selection": "2 - 3",
"kapsule": "^1.16",
"preact": "10"
},
"engines": {
"node": ">=12"
}
},
"node_modules/formdata-polyfill": { "node_modules/formdata-polyfill": {
"version": "4.0.10", "version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -4476,18 +4370,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/gl-bench": {
"version": "1.0.42",
"resolved": "https://registry.npmjs.org/gl-bench/-/gl-bench-1.0.42.tgz",
"integrity": "sha512-zuMsA/NCPmI8dPy6q3zTUH8OUM5cqKg7uVWwqzrtXJPBqoypM0XeFWEc8iFOqbf/1qtXieWOrbmgFEByKTQt4Q==",
"license": "MIT"
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -4928,6 +4810,15 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/jerrypick": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz",
"integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -5033,6 +4924,18 @@
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
}, },
"node_modules/kapsule": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
"integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
"license": "MIT",
"dependencies": {
"lodash-es": "4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -5337,6 +5240,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -5384,6 +5293,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -5661,6 +5582,44 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
} }
}, },
"node_modules/ngraph.events": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz",
"integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==",
"license": "BSD-3-Clause"
},
"node_modules/ngraph.forcelayout": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/ngraph.forcelayout/-/ngraph.forcelayout-3.3.1.tgz",
"integrity": "sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw==",
"license": "BSD-3-Clause",
"dependencies": {
"ngraph.events": "^1.0.0",
"ngraph.merge": "^1.0.0",
"ngraph.random": "^1.0.0"
}
},
"node_modules/ngraph.graph": {
"version": "20.1.2",
"resolved": "https://registry.npmjs.org/ngraph.graph/-/ngraph.graph-20.1.2.tgz",
"integrity": "sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q==",
"license": "BSD-3-Clause",
"dependencies": {
"ngraph.events": "^1.4.0"
}
},
"node_modules/ngraph.merge": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ngraph.merge/-/ngraph.merge-1.0.0.tgz",
"integrity": "sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg==",
"license": "MIT"
},
"node_modules/ngraph.random": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/ngraph.random/-/ngraph.random-1.2.0.tgz",
"integrity": "sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA==",
"license": "BSD-3-Clause"
},
"node_modules/node-domexception": { "node_modules/node-domexception": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
@@ -6019,6 +5978,18 @@
"node": ">=16.20.0" "node": ">=16.20.0"
} }
}, },
"node_modules/polished": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz",
"integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.17.8"
},
"engines": {
"node": ">=10"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.8", "version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
@@ -6072,6 +6043,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/preact": {
"version": "10.29.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
"integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -6119,6 +6100,17 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -6177,18 +6169,6 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/random": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/random/-/random-4.1.0.tgz",
"integrity": "sha512-6Ajb7XmMSE9EFAMGC3kg9mvE7fGlBip25mYYuSMzw/uUSrmGilvZo2qwX3RnTRjwXkwkS+4swse9otZ92VjAtQ==",
"license": "MIT",
"dependencies": {
"seedrandom": "^3.0.5"
},
"engines": {
"node": ">=14"
}
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@@ -6234,6 +6214,44 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-force-graph-3d": {
"version": "1.29.1",
"resolved": "https://registry.npmjs.org/react-force-graph-3d/-/react-force-graph-3d-1.29.1.tgz",
"integrity": "sha512-5Vp+PGpYnO+zLwgK2NvNqdXHvsWLrFzpDfJW1vUA1twjo9SPvXqfUYQrnRmAbD+K2tOxkZw1BkbH31l5b4TWHg==",
"license": "MIT",
"dependencies": {
"3d-force-graph": "^1.79",
"prop-types": "15",
"react-kapsule": "^2.5"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-kapsule": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz",
"integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==",
"license": "MIT",
"dependencies": {
"jerrypick": "^1.1.1"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"react": ">=16.13.1"
}
},
"node_modules/react-resizable-panels": { "node_modules/react-resizable-panels": {
"version": "4.8.0", "version": "4.8.0",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.8.0.tgz", "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.8.0.tgz",
@@ -6298,12 +6316,6 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/regl": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/regl/-/regl-2.1.1.tgz",
"integrity": "sha512-+IOGrxl3FZ8ZM9ixCWQZzFRiRn7Rzn9bu3iFHwg/yz4tlOUQgbO4PHLgG+1ZT60zcIV8tief6Qrmyl8qcoJP0g==",
"license": "MIT"
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -6481,12 +6493,6 @@
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/seedrandom": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -6922,12 +6928,79 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/three": {
"version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
"license": "MIT"
},
"node_modules/three-forcegraph": {
"version": "1.43.2",
"resolved": "https://registry.npmjs.org/three-forcegraph/-/three-forcegraph-1.43.2.tgz",
"integrity": "sha512-KUlqDaWVsrYtKx0NVVi5M3NR46K5JQIiPEzZnTMvBq7EHVF2tJpWtgGiAT1mhaerrlJ7F4UGNS2rIVYHmVrzYw==",
"license": "MIT",
"dependencies": {
"accessor-fn": "1",
"d3-array": "1 - 3",
"d3-force-3d": "2 - 3",
"d3-scale": "1 - 4",
"d3-scale-chromatic": "1 - 3",
"data-bind-mapper": "1",
"kapsule": "^1.16",
"ngraph.forcelayout": "3",
"ngraph.graph": "20",
"tinycolor2": "1"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"three": ">=0.118.3"
}
},
"node_modules/three-render-objects": {
"version": "1.41.1",
"resolved": "https://registry.npmjs.org/three-render-objects/-/three-render-objects-1.41.1.tgz",
"integrity": "sha512-0H7l7yREPVKfO3HL7RjPQ67T0phHgnyMeEc4ww/OCEfK6jbsm7psEcrR0SGFqGDyS/pDQTPi4DyPbS/xlHRJKw==",
"license": "MIT",
"dependencies": {
"@tweenjs/tween.js": "18 - 25",
"accessor-fn": "1",
"float-tooltip": "^1.7",
"kapsule": "^1.16",
"polished": "4"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"three": ">=0.179"
}
},
"node_modules/three-spritetext": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/three-spritetext/-/three-spritetext-1.10.0.tgz",
"integrity": "sha512-t08iP1FCU1lQh8T5MmCpdijKgas8GDHJE0LqMGBuVu3xqMMpFnEZhTlih7FlxLPQizHIGoumUSpfOlY1GO/Tgg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"peerDependencies": {
"three": ">=0.86.0"
}
},
"node_modules/tiny-invariant": { "node_modules/tiny-invariant": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.15", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",

View File

@@ -18,12 +18,9 @@
"@codemirror/search": "^6.6.0", "@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.40.0", "@codemirror/view": "^6.40.0",
"@cosmos.gl/graph": "^2.6.4",
"@dagrejs/dagre": "^3.0.0",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8",
"@tailwindcss/vite": "^4.2.2", "@tailwindcss/vite": "^4.2.2",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.3.3", "dompurify": "^3.3.3",
@@ -32,12 +29,14 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-force-graph-3d": "^1.29.1",
"react-resizable-panels": "^4.8.0", "react-resizable-panels": "^4.8.0",
"react-router-dom": "^7.13.2", "react-router-dom": "^7.13.2",
"shadcn": "^4.1.1", "shadcn": "^4.1.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"three-spritetext": "^1.10.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"zustand": "^5.0.12" "zustand": "^5.0.12"
}, },

View File

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

View File

@@ -6,6 +6,15 @@
* (e.g. when adding multi-node support with /api/nodes/{id}/...). * (e.g. when adding multi-node support with /api/nodes/{id}/...).
*/ */
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function json<T>(res: Response): Promise<T> {
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Pages // Pages
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -26,12 +35,12 @@ export interface PageDetail {
export async function fetchPages(): Promise<PageMeta[]> { export async function fetchPages(): Promise<PageMeta[]> {
const res = await fetch("/api/pages"); const res = await fetch("/api/pages");
return res.json(); return json(res);
} }
export async function fetchPage(name: string): Promise<PageDetail> { export async function fetchPage(name: string): Promise<PageDetail> {
const res = await fetch(`/api/pages/${name}`); const res = await fetch(`/api/pages/${name}`);
return res.json(); return json(res);
} }
export async function savePage( export async function savePage(
@@ -44,12 +53,12 @@ export async function savePage(
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source, publish }), body: JSON.stringify({ source, publish }),
}); });
if (!res.ok) throw new Error(await res.text()); return json(res);
return res.json();
} }
export async function deletePage(name: string): Promise<void> { export async function deletePage(name: string): Promise<void> {
await fetch(`/api/pages/${name}`, { method: "DELETE" }); const res = await fetch(`/api/pages/${name}`, { method: "DELETE" });
if (!res.ok) throw new Error(await res.text());
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -101,7 +110,7 @@ export interface DslMeta {
export async function fetchDslMeta(): Promise<DslMeta> { export async function fetchDslMeta(): Promise<DslMeta> {
const res = await fetch("/api/dsl-meta"); const res = await fetch("/api/dsl-meta");
return res.json(); return json(res);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -122,14 +131,34 @@ export interface NetworkNode {
name: string; name: string;
last_seen: number; last_seen: number;
is_self: boolean; is_self: boolean;
type?: "node" | "interface";
interface?: string | null;
online?: boolean;
target?: string | null;
txb?: number;
rxb?: number;
bitrate?: number;
clients?: number | null;
} }
export async function fetchBrowseNodes(): Promise<NetworkNode[]> { export async function fetchBrowseNodes(): Promise<NetworkNode[]> {
const res = await fetch("/api/browse/nodes"); const res = await fetch("/api/browse/nodes");
const data = await res.json(); const data = await json<NetworkNode[] | unknown>(res);
return Array.isArray(data) ? data : []; return Array.isArray(data) ? data : [];
} }
export function subscribeBrowseNodes(
onNode: (node: NetworkNode) => void,
): () => void {
const es = new EventSource("/api/browse/nodes/stream");
es.onmessage = (e) => {
try {
onNode(JSON.parse(e.data));
} catch { /* ignore parse errors */ }
};
return () => es.close();
}
export async function fetchRemotePage( export async function fetchRemotePage(
hash: string, hash: string,
path: string = "index.mu", path: string = "index.mu",
@@ -137,6 +166,94 @@ export async function fetchRemotePage(
const res = await fetch( const res = await fetch(
`/api/browse/page/${hash}?path=${encodeURIComponent(path)}`, `/api/browse/page/${hash}?path=${encodeURIComponent(path)}`,
); );
return json(res);
}
// ---------------------------------------------------------------------------
// File Browser
// ---------------------------------------------------------------------------
export interface FileEntry {
name: string;
path: string;
type: "file" | "folder" | "env";
size: number | null;
last_modified: number | null;
title: string | null;
published: boolean;
}
export async function fetchFiles(path: string = ""): Promise<FileEntry[]> {
const params = path ? `?path=${encodeURIComponent(path)}` : "";
const res = await fetch(`/api/files${params}`);
return json(res);
}
export async function createFolder(path: string): Promise<void> {
const res = await fetch("/api/files/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function moveFile(from: string, to: string): Promise<void> {
const res = await fetch("/api/files/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from, to }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function fetchEnv(): Promise<string> {
const res = await fetch("/api/files/env");
const data = await json<{ content: string }>(res);
return data.content;
}
export async function saveEnv(content: string): Promise<void> {
const res = await fetch("/api/files/env", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error(await res.text());
}
// ---------------------------------------------------------------------------
// Config (Reticulum + NomadNet)
// ---------------------------------------------------------------------------
export interface NodeIdentity {
name: string;
hash: string | null;
}
export async function fetchIdentity(): Promise<NodeIdentity> {
const res = await fetch("/api/browse/identity");
return json(res);
}
export async function fetchConfig(kind: "reticulum" | "reticulum-client" | "nomadnet"): Promise<string> {
const res = await fetch(`/api/browse/config/${kind}`);
const data = await json<{ content: string }>(res);
return data.content;
}
export async function saveConfig(kind: "reticulum" | "reticulum-client" | "nomadnet", content: string): Promise<void> {
const res = await fetch(`/api/browse/config/${kind}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function restartServices(): Promise<{ nomadnet_restarted: boolean }> {
const res = await fetch("/api/browse/restart", { method: "POST" });
if (!res.ok) throw new Error(await res.text());
return res.json(); return res.json();
} }
@@ -153,6 +270,5 @@ export async function uploadImage(file: File): Promise<UploadResult> {
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
const res = await fetch("/api/upload-image", { method: "POST", body: form }); const res = await fetch("/api/upload-image", { method: "POST", body: form });
if (!res.ok) throw new Error(await res.text()); return json(res);
return res.json();
} }

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 2.1 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 980 KiB

View File

@@ -0,0 +1,77 @@
import FloatingWindow from "@/components/shared/FloatingWindow";
import Loader from "@/components/shared/Loader";
import type { ManagedWindow } from "@/hooks/useWindowManager";
import type { BrowseWinData } from "./types";
interface BrowseNodeWindowProps {
win: ManagedWindow<BrowseWinData>;
focused: boolean;
onUpdate: (id: string, patch: Partial<ManagedWindow<BrowseWinData>>) => void;
onClose: (id: string) => void;
onFocus: (id: string) => void;
onNavBack: (winId: string, data: BrowseWinData) => void;
onNavForward: (winId: string, data: BrowseWinData) => void;
onNavReload: (winId: string, data: BrowseWinData) => void;
onContentClick: (e: React.MouseEvent, winId: string, data: BrowseWinData) => void;
}
export default function BrowseNodeWindow({
win, focused, onUpdate, onClose, onFocus,
onNavBack, onNavForward, onNavReload, onContentClick,
}: BrowseNodeWindowProps) {
const d = win.data;
const canBack = d.historyIndex > 0;
const canFwd = d.historyIndex < d.history.length - 1;
return (
<FloatingWindow
id={win.id}
title={d.node.name}
x={win.x} y={win.y} w={win.w} h={win.h}
zIndex={win.zIndex}
focused={focused}
onUpdate={onUpdate}
onClose={onClose}
onFocus={onFocus}
addressBar={
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border shrink-0 bg-muted/15">
<button onClick={() => onNavBack(win.id, d)} disabled={!canBack || d.pageLoading}
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Back">
&#9664;
</button>
<button onClick={() => onNavForward(win.id, d)} disabled={!canFwd || d.pageLoading}
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Forward">
&#9654;
</button>
<button onClick={() => onNavReload(win.id, d)} disabled={d.pageLoading}
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Reload">
&#8635;
</button>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<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 cursor-text"
onClick={(e) => { if (e.target === e.currentTarget) { const sel = window.getSelection(); if (sel) { const range = document.createRange(); range.selectNodeContents(e.currentTarget); sel.removeAllRanges(); sel.addRange(range); } } }}>
<span className="text-muted-foreground/60 truncate">{d.node.hash.slice(0, 12)}\u2026/</span>{d.currentPath}
</div>
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
{d.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
: d.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
: d.pageHtml ? <><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /><span className="text-muted-foreground">ok</span></> : null}
</span>
</div>
}
footer={
<div className="flex items-center gap-3 px-3 py-1 border-t border-border shrink-0 bg-muted/15 rounded-b-lg">
<span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">{d.node.type ?? "peer"}</span>
{d.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {d.node.interface}</span>}
</div>
}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="p-3 h-full overflow-auto" onClick={(e) => onContentClick(e, win.id, d)}>
{d.pageLoading && <div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground text-xs"><Loader /> Requesting page...</div>}
{d.pageError && <span className="text-destructive text-xs">{d.pageError}</span>}
{d.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: d.pageHtml }} />}
</div>
</FloatingWindow>
);
}

View File

@@ -0,0 +1,161 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { DITHERED_SHADOW } from "@/components/shared/FloatingWindow";
import type { NetworkNode } from "@/api/client";
interface BrowseSearchBarProps {
filter: string;
onFilterChange: (value: string) => void;
onClear: () => void;
allNodes: NetworkNode[];
suggestions: NetworkNode[];
onSelectNode: (node: NetworkNode) => void;
onHighlightNode: (node: NetworkNode | null) => void;
onSearchFocusChange: (focused: boolean) => void;
focusedWinId: string | null;
windowCount: number;
}
export default function BrowseSearchBar({
filter, onFilterChange, onClear,
allNodes, suggestions, onSelectNode, onHighlightNode, onSearchFocusChange,
focusedWinId, windowCount,
}: BrowseSearchBarProps) {
const searchInputRef = useRef<HTMLInputElement>(null);
const [selectedSuggestion, setSelectedSuggestion] = useState(-1);
const [focused, setFocused] = useState(false);
// The visible list: when focused with no filter, show all nodes; otherwise filtered suggestions
const visibleList = focused && !filter.trim() ? allNodes : suggestions;
const maxVisible = 12;
const displayList = visibleList.slice(0, maxVisible);
const hasMore = visibleList.length > maxVisible;
// Reset selection when list changes
useEffect(() => { setSelectedSuggestion(-1); }, [visibleList.length, filter]);
// Notify parent of highlight changes for camera fly-to
useEffect(() => {
const entry = selectedSuggestion >= 0 ? displayList[selectedSuggestion] : null;
onHighlightNode(entry ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSuggestion, onHighlightNode]);
// Auto-focus search when no windows open
useEffect(() => { searchInputRef.current?.focus(); }, []);
useEffect(() => {
if (windowCount === 0) searchInputRef.current?.focus();
}, [windowCount]);
// Capture typing into search when no window is focused
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (focusedWinId) return;
if (document.activeElement === searchInputRef.current) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key.length !== 1) return;
searchInputRef.current?.focus();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [focusedWinId]);
const handleFocus = useCallback(() => {
setFocused(true);
onSearchFocusChange(true);
}, [onSearchFocusChange]);
const handleBlur = useCallback(() => {
// Delay to allow click on suggestion to fire before closing
setTimeout(() => {
setFocused(false);
onSearchFocusChange(false);
setSelectedSuggestion(-1);
}, 150);
}, [onSearchFocusChange]);
// Draggable position
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]);
if (!searchPos) return null;
const showDropdown = focused && displayList.length > 0;
return createPortal(
<div onMouseDown={onSearchDragStart}
className="fixed z-999 flex flex-col 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 }}>
<div className="flex items-center gap-3 px-3 py-1.5">
<input ref={searchInputRef} type="text" value={filter}
onChange={(e) => onFilterChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={(e) => {
if (e.key === "Escape") { onClear(); e.currentTarget.blur(); return; }
if (e.key === "ArrowDown") { e.preventDefault(); setSelectedSuggestion(i => Math.min(i + 1, displayList.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 ? displayList[selectedSuggestion] : displayList[0];
if (entry && entry.type !== "interface") { onSelectNode(entry); onClear(); }
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={onClear} className="text-muted-foreground hover:text-foreground transition-colors text-xs leading-none px-1" title="Clear search (Esc)">
&times;
</button>
)}
{!filter && focused && (
<span className="text-[9px] text-muted-foreground uppercase tracking-wider shrink-0">
{allNodes.length} nodes
</span>
)}
</div>
{showDropdown && (
<div className="border-t border-border max-h-[360px] overflow-y-auto">
{displayList.map((entry, i) => (
<button
key={entry.hash}
onMouseDown={(e) => { e.preventDefault(); if (entry.type !== "interface") { onSelectNode(entry); onClear(); } }}
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>
))}
{hasMore && (
<div className="px-3 py-1 text-[9px] text-muted-foreground text-center border-t border-border/50">
{visibleList.length - maxVisible} more type to narrow
</div>
)}
</div>
)}
</div>, document.body);
}

View File

@@ -0,0 +1,106 @@
import type { NetworkNode } from "@/api/client";
import { statusRGBA, rgbaToHex, type StatusColors } from "./graphColors";
export interface GraphNode {
id: string;
name: string;
entry: NetworkNode;
type: "self" | "peer" | "interface";
color: string;
size: number;
cluster?: string;
x?: number;
y?: number;
z?: number;
}
export interface GraphLink {
source: string;
target: string;
}
export interface GraphData {
nodes: GraphNode[];
links: GraphLink[];
clusterNames: string[];
}
function findParentIface(
entry: NetworkNode,
interfaces: NetworkNode[],
peerIndex: number,
): NetworkNode | undefined {
if (entry.interface) {
const iface = interfaces.find(i => i.name === entry.interface);
if (iface) return iface;
}
if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length];
return undefined;
}
export function buildGraphData(
rawNodes: NetworkNode[],
prevPositions: Map<string, { x: number; y: number; z: number }>,
theme: StatusColors,
): GraphData {
const interfaces = rawNodes
.filter(e => e.type === "interface")
.sort((a, b) => a.name.localeCompare(b.name));
const selfNode = rawNodes.find(e => e.is_self && e.type !== "interface");
const peers = rawNodes.filter(e => !e.is_self && e.type !== "interface");
const nodes: GraphNode[] = [];
const links: GraphLink[] = [];
// Add interface nodes
for (const iface of interfaces) {
const prev = prevPositions.get(iface.hash);
nodes.push({
id: iface.hash,
name: iface.name,
entry: iface,
type: "interface",
color: rgbaToHex(theme.stale),
size: 2,
...(prev ? { x: prev.x, y: prev.y, z: prev.z } : {}),
});
}
// Add self node
if (selfNode) {
const prev = prevPositions.get(selfNode.hash);
nodes.push({
id: selfNode.hash,
name: selfNode.name,
entry: selfNode,
type: "self",
color: "#ffffff",
size: 2,
...(prev ? { x: prev.x, y: prev.y, z: prev.z } : {}),
});
}
// Add peer nodes + links to parent interface
peers.forEach((peer, i) => {
const prev = prevPositions.get(peer.hash);
const parentIface = findParentIface(peer, interfaces, i);
nodes.push({
id: peer.hash,
name: peer.name,
entry: peer,
type: "peer",
color: rgbaToHex(statusRGBA(peer, theme)),
size: 2,
cluster: parentIface?.name,
...(prev ? { x: prev.x, y: prev.y, z: prev.z } : {}),
});
if (parentIface) {
links.push({ source: parentIface.hash, target: peer.hash });
}
});
const clusterNames = interfaces.map(i => i.name);
return { nodes, links, clusterNames };
}

View File

@@ -0,0 +1,64 @@
import type { NetworkNode } from "@/api/client";
export type RGBA = [number, number, number, number];
export function cssVarToRGBA(varName: string): RGBA {
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];
}
export function lerpRGBA(a: RGBA, b: RGBA, t: number): RGBA {
return [
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
a[3] + (b[3] - a[3]) * t,
];
}
export function brighten(c: RGBA, amount: number): RGBA {
return [
Math.min(1, c[0] + amount),
Math.min(1, c[1] + amount),
Math.min(1, c[2] + amount),
c[3],
];
}
export interface StatusColors {
online: RGBA;
stale: RGBA;
offline: RGBA;
}
export function getThemeStatusColors(): StatusColors {
const primary = brighten(cssVarToRGBA("--primary"), 0.15);
const muted = cssVarToRGBA("--muted-foreground");
return {
online: primary,
stale: lerpRGBA(primary, muted, 0.4),
offline: muted,
};
}
export function statusRGBA(entry: NetworkNode, theme: StatusColors): RGBA {
const age = Date.now() / 1000 - (entry.last_seen ?? 0);
if (age < 300) return theme.online;
if (age < 3600) return theme.stale;
return theme.offline;
}
export function rgbaToHex(c: RGBA): string {
const r = Math.round(c[0] * 255);
const g = Math.round(c[1] * 255);
const b = Math.round(c[2] * 255);
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}

View File

@@ -0,0 +1,17 @@
import type { NetworkNode } from "@/api/client";
export interface HistoryEntry {
path: string;
html: string | null;
error: string | null;
}
export interface BrowseWinData {
node: NetworkNode;
pageHtml: string | null;
pageLoading: boolean;
pageError: string | null;
currentPath: string;
history: HistoryEntry[];
historyIndex: number;
}

View File

@@ -3,7 +3,7 @@ import { EditorView, keymap, lineNumbers, highlightActiveLine, Decoration, ViewP
import { EditorState, RangeSetBuilder } from "@codemirror/state"; import { EditorState, RangeSetBuilder } from "@codemirror/state";
import type { Extension } from "@codemirror/state"; import type { Extension } from "@codemirror/state";
import type { DecorationSet } from "@codemirror/view"; import type { DecorationSet } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { searchKeymap } from "@codemirror/search"; import { searchKeymap } from "@codemirror/search";
import { oneDark } from "./oneDarkTheme"; import { oneDark } from "./oneDarkTheme";
@@ -72,7 +72,7 @@ export default function EditorPane({ value, onChange, extensions = [] }: Props)
lineNumbers({ formatNumber: toRoman }), lineNumbers({ formatNumber: toRoman }),
highlightActiveLine(), highlightActiveLine(),
subtleWhitespace, subtleWhitespace,
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]), keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]),
oneDark, oneDark,
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) { if (update.docChanged) {

View File

@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import pointerSvg from "@/assets/pointer.min.svg"; import pointerSvg from "@/assets/pointer.min.svg";
import { useLazyEyes } from "@/hooks/useLazyEyes";
/** /**
* Floating pointer that tracks the CodeMirror cursor with smooth lerp animation. * Floating pointer that tracks the CodeMirror cursor vertically,
* Positions itself right next to the left border of the editor container. * pinned to the left edge of the editor. Positions relative to
* Uses a ResizeObserver to keep horizontal position synced on window resize. * containerRef (for floating windows).
*/ */
export default function EditorPointer() { export default function EditorPointer({ containerRef, focused = true }: { containerRef?: React.RefObject<HTMLElement | null>; focused?: boolean }) {
const [y, setY] = useState<number | null>(null); const [visible, setVisible] = useState(false);
const [editorLeft, setEditorLeft] = useState<number | null>(null); const [editorLeft, setEditorLeft] = useState<number | null>(null);
const targetRef = useRef(0); const targetRef = useRef(0);
const currentRef = useRef(0); const currentRef = useRef(0);
@@ -16,35 +17,39 @@ export default function EditorPointer() {
const [clickKey, setClickKey] = useState(0); const [clickKey, setClickKey] = useState(0);
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined); const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const cmRef = useRef<Element | null>(null); const cmRef = useRef<Element | null>(null);
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 }); const pointerElRef = useRef<HTMLDivElement>(null);
const eyeTargetRef = useRef({ x: 0, y: 0 });
const eyeCurrentRef = useRef({ x: 0, y: 0 }); const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null);
const { registerIris, unregisterIris } = useLazyEyes({ anchorRef: eyeAnchorRef });
const iris1Ref = useRef<HTMLDivElement>(null);
const iris2Ref = useRef<HTMLDivElement>(null);
// Register/unregister iris elements
useEffect(() => {
const i1 = iris1Ref.current;
const i2 = iris2Ref.current;
if (i1) registerIris(i1);
if (i2) registerIris(i2);
return () => {
if (i1) unregisterIris(i1);
if (i2) unregisterIris(i2);
};
}, [visible, registerIris, unregisterIris]);
useEffect(() => { useEffect(() => {
const ease = 0.09; const ease = 0.09;
const eyeEase = 0.06;
const getContainerRect = () =>
containerRef?.current?.getBoundingClientRect() ?? { left: 0, top: 0 };
const updateLeft = () => { const updateLeft = () => {
if (cmRef.current) { if (!cmRef.current || !containerRef?.current) return;
setEditorLeft(cmRef.current.getBoundingClientRect().left); const cmLeft = cmRef.current.getBoundingClientRect().left;
} const containerLeft = containerRef.current.getBoundingClientRect().left;
setEditorLeft(cmLeft - containerLeft);
}; };
const onMouseMove = (e: MouseEvent) => {
const pointerX = (cmRef.current?.getBoundingClientRect().left ?? 0) - 100;
const pointerY = currentRef.current;
const dx = e.clientX - pointerX;
const dy = e.clientY - pointerY;
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const maxShift = 1.5;
eyeTargetRef.current = {
x: (dx / dist) * maxShift,
y: (dy / dist) * maxShift,
};
};
window.addEventListener("mousemove", onMouseMove);
const onEditorClick = () => { const onEditorClick = () => {
clearTimeout(clickTimerRef.current); clearTimeout(clickTimerRef.current);
setClickKey((k) => k + 1); setClickKey((k) => k + 1);
@@ -53,22 +58,27 @@ export default function EditorPointer() {
const onCursorMove = (e: Event) => { const onCursorMove = (e: Event) => {
const { top } = (e as CustomEvent).detail; const { top } = (e as CustomEvent).detail;
targetRef.current = top;
// Find editor container and attach click listener lazily
if (!cmRef.current) { if (!cmRef.current) {
const cm = document.querySelector(".cm-editor"); const scope = containerRef?.current ?? document;
const cm = scope.querySelector(".cm-editor");
if (cm) { if (cm) {
cmRef.current = cm; cmRef.current = cm;
cm.addEventListener("mousedown", onEditorClick); cm.addEventListener("mousedown", onEditorClick);
ro.observe(cm); ro.observe(cm);
} }
} }
const containerTop = getContainerRect().top;
targetRef.current = top - containerTop;
updateLeft(); updateLeft();
if (!activeRef.current) { if (!activeRef.current) {
currentRef.current = top; currentRef.current = targetRef.current;
setY(top); if (pointerElRef.current) {
pointerElRef.current.style.top = `${targetRef.current}px`;
}
setVisible(true);
activeRef.current = true; activeRef.current = true;
} }
}; };
@@ -81,23 +91,25 @@ export default function EditorPointer() {
} else { } else {
currentRef.current += diff * ease; currentRef.current += diff * ease;
} }
setY(currentRef.current);
// Direct DOM update — no setState
if (pointerElRef.current) {
pointerElRef.current.style.top = `${currentRef.current}px`;
}
const cmLeft = cmRef.current?.getBoundingClientRect().left ?? 0;
const containerTop = getContainerRect().top;
eyeAnchorRef.current = { x: cmLeft - 100, y: containerTop + currentRef.current };
} }
// Lerp eyes toward target
const ec = eyeCurrentRef.current;
const et = eyeTargetRef.current;
ec.x += (et.x - ec.x) * eyeEase;
ec.y += (et.y - ec.y) * eyeEase;
setEyeOffset({ x: ec.x, y: ec.y });
rafRef.current = requestAnimationFrame(tick); rafRef.current = requestAnimationFrame(tick);
}; };
window.addEventListener("cm-cursor-move", onCursorMove); window.addEventListener("cm-cursor-move", onCursorMove);
rafRef.current = requestAnimationFrame(tick); rafRef.current = requestAnimationFrame(tick);
// Keep left position updated on resize
const ro = new ResizeObserver(() => updateLeft()); const ro = new ResizeObserver(() => updateLeft());
const existingCm = document.querySelector(".cm-editor"); const scope = containerRef?.current ?? document;
const existingCm = scope.querySelector(".cm-editor");
if (existingCm) { if (existingCm) {
cmRef.current = existingCm; cmRef.current = existingCm;
ro.observe(existingCm); ro.observe(existingCm);
@@ -106,21 +118,21 @@ export default function EditorPointer() {
return () => { return () => {
window.removeEventListener("cm-cursor-move", onCursorMove); window.removeEventListener("cm-cursor-move", onCursorMove);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("resize", updateLeft); window.removeEventListener("resize", updateLeft);
cmRef.current?.removeEventListener("mousedown", onEditorClick); cmRef.current?.removeEventListener("mousedown", onEditorClick);
cancelAnimationFrame(rafRef.current); cancelAnimationFrame(rafRef.current);
ro.disconnect(); ro.disconnect();
}; };
}, []); }, [containerRef]);
if (y === null || editorLeft === null) return null; if (!focused || !visible || editorLeft === null) return null;
return ( return (
<div <div
ref={pointerElRef}
className={`editor-pointer${clickKey ? " editor-pointer-click" : ""}`} className={`editor-pointer${clickKey ? " editor-pointer-click" : ""}`}
key={clickKey} key={clickKey}
style={{ top: y, left: editorLeft }} style={{ left: editorLeft }}
> >
<div <div
className="editor-pointer-img" className="editor-pointer-img"
@@ -131,14 +143,14 @@ export default function EditorPointer() {
/> />
<div className="editor-pointer-eye" style={{ top: 33, left: 134 }}> <div className="editor-pointer-eye" style={{ top: 33, left: 134 }}>
<div <div
ref={iris1Ref}
className="editor-pointer-iris" className="editor-pointer-iris"
style={{ transform: `translate(${eyeOffset.x}px, ${eyeOffset.y}px)` }}
/> />
</div> </div>
<div className="editor-pointer-eye" style={{ top: 29, left: 144 }}> <div className="editor-pointer-eye" style={{ top: 29, left: 144 }}>
<div <div
ref={iris2Ref}
className="editor-pointer-iris" className="editor-pointer-iris"
style={{ transform: `translate(${eyeOffset.x}px, ${eyeOffset.y}px)` }}
/> />
</div> </div>
</div> </div>

View File

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

View File

@@ -0,0 +1,163 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { autocompletion } from "@codemirror/autocomplete";
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 { useKeyboardSave } from "@/hooks/useKeyboardSave";
import { uframeHighlight } from "./uframeHighlight";
import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "./uframeCommands";
import { keywordHoverTooltip } from "./uframeHover";
import { EditorStoreContext } from "./EditorStoreContext";
import EditorPointer from "./EditorPointer";
import PreviewPane from "./PreviewPane";
import SourcePane from "./SourcePane";
import ToolBar from "./ToolBar";
import FloatingWindow from "@/components/shared/FloatingWindow";
import type { ManagedWindow } from "@/hooks/useWindowManager";
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],
);
useKeyboardSave(
useCallback(() => handleSave(false), [handleSave]),
useCallback(() => handleSave(true), [handleSave]),
focused,
);
// 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>
);
}

View File

@@ -1,17 +1,24 @@
import { useEditorStore } from "@/stores/editorStore"; import { useCallback } from "react";
import { useEditorCtx } from "./EditorStoreContext";
import { renderMicron } from "./micronRenderer"; import { renderMicron } from "./micronRenderer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Loader from "@/components/shared/Loader";
type PreviewMode = "micron" | "raw" | "script"; type PreviewMode = "micron" | "raw" | "script";
export default function PreviewPane() { export default function PreviewPane() {
const previewMode = useEditorStore((s) => s.previewMode); const previewMode = useEditorCtx((s) => s.previewMode);
const setPreviewMode = useEditorStore((s) => s.setPreviewMode); const setPreviewMode = useEditorCtx((s) => s.setPreviewMode);
const compiledMicron = useEditorStore((s) => s.compiledMicron); const compiledMicron = useEditorCtx((s) => s.compiledMicron);
const compiledScript = useEditorStore((s) => s.compiledScript); const compiledScript = useEditorCtx((s) => s.compiledScript);
const isDynamic = useEditorStore((s) => s.isDynamic); const isDynamic = useEditorCtx((s) => s.isDynamic);
const isCompiling = useEditorStore((s) => s.isCompiling); const isCompiling = useEditorCtx((s) => s.isCompiling);
const compileError = useEditorStore((s) => s.compileError); const compileError = useEditorCtx((s) => s.compileError);
const handlePreviewClick = useCallback((e: React.MouseEvent) => {
const anchor = (e.target as HTMLElement).closest("a");
if (anchor) e.preventDefault();
}, []);
const tabs: { value: PreviewMode; label: string; show: boolean }[] = [ const tabs: { value: PreviewMode; label: string; show: boolean }[] = [
{ value: "micron", label: "Micron", show: true }, { value: "micron", label: "Micron", show: true },
@@ -28,7 +35,7 @@ export default function PreviewPane() {
<span className="ml-1.5 text-primary/60 text-[10px]" title="Dynamic page"></span> <span className="ml-1.5 text-primary/60 text-[10px]" title="Dynamic page"></span>
)} )}
{isCompiling && ( {isCompiling && (
<span className="ml-1 text-primary/40 text-[10px] animate-pulse"></span> <Loader size={10} className="ml-1.5 inline-block" />
)} )}
{compileError && ( {compileError && (
<span className="ml-1 text-red-400 text-[10px]" title={compileError}></span> <span className="ml-1 text-red-400 text-[10px]" title={compileError}></span>
@@ -57,7 +64,8 @@ export default function PreviewPane() {
))} ))}
</div> </div>
</div> </div>
<div className="flex-1 bg-background overflow-auto min-h-0"> {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className="flex-1 bg-background overflow-auto min-h-0" onClick={handlePreviewClick}>
{previewMode === "micron" ? ( {previewMode === "micron" ? (
compiledMicron ? ( compiledMicron ? (
<div <div

View File

@@ -0,0 +1,91 @@
import { useRef, useState } from "react";
import { toast } from "sonner";
import { BookOpen, Upload } from "lucide-react";
import type { Extension } from "@codemirror/state";
import * as api from "@/api/client";
import EditorPane from "./EditorPane";
import { EXAMPLES } from "./examples";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverHeader,
PopoverTitle,
} from "@/components/ui/popover";
interface SourcePaneProps {
ufSource: string;
setSource: (s: string) => void;
extensions: Extension[];
}
export default function SourcePane({ ufSource, setSource, extensions }: SourcePaneProps) {
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\u2026" : "Image"}
</button>
</div>
<div className="flex-1 overflow-auto min-h-0">
<EditorPane value={ufSource} onChange={setSource} extensions={extensions} />
</div>
</div>
);
}

View File

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

View File

@@ -410,9 +410,9 @@ export const EXAMPLES: Example[] = [
source: `page "Live Status" 60 source: `page "Live Status" 60
cache 0 cache 0
source cpu_pct : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'" source cpu_pct : python "secrets.randbelow(60) + 20"
source mem_pct : shell "free | awk '/Mem/{print int($3/$2*100)}'" source mem_pct : python "secrets.randbelow(40) + 50"
source uptime : shell "uptime -p" source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
source timestamp : python "datetime.now().strftime('%H:%M:%S')" source timestamp : python "datetime.now().strftime('%H:%M:%S')"
box double "Node Monitor" box double "Node Monitor"

View File

@@ -0,0 +1,80 @@
import {
StreamLanguage,
HighlightStyle,
syntaxHighlighting,
} from "@codemirror/language";
import { tags } from "@lezer/highlight";
/**
* CodeMirror 6 syntax highlighting for INI-style config files
* (Reticulum .conf / NomadNet .conf).
*
* Supports: [sections], [[subsections]], key = value, # comments,
* booleans, numbers, and quoted strings.
*/
const BOOLEANS = new Set([
"true", "false", "yes", "no", "on", "off", "none",
]);
const iniLanguage = StreamLanguage.define({
token(stream) {
// Comments
if (stream.match(/\s*#/)) {
stream.skipToEnd();
return "lineComment";
}
// Skip whitespace
if (stream.eatSpace()) return null;
// Subsection headers [[name]]
if (stream.match(/\[\[.*?\]\]/)) return "heading";
// Section headers [name]
if (stream.match(/\[.*?\]/)) return "typeName";
// Quoted strings
if (stream.match(/"/)) {
while (!stream.eol()) {
if (stream.next() === '"') break;
}
return "string";
}
// Assignment operator
if (stream.match(/=/)) return "punctuation";
// Numbers (integers, floats, ports, IPs with dots)
if (stream.match(/\b\d[\d.]*\b/)) return "number";
// Words
if (stream.match(/[\w\-_.]+/)) {
const word = stream.current().toLowerCase();
if (BOOLEANS.has(word)) return "atom";
// Keys appear before '=', values after — both are plain words
return null;
}
stream.next();
return null;
},
startState: () => ({}),
copyState: (s) => ({ ...s }),
blankLine: () => {},
languageData: {},
});
const iniStyle = HighlightStyle.define([
{ tag: tags.typeName, color: "#c792ea", fontWeight: "bold" }, // [section]
{ tag: tags.heading, color: "#82aaff", fontWeight: "bold" }, // [[subsection]]
{ tag: tags.lineComment, color: "#546e7a", fontStyle: "italic" },
{ tag: tags.string, color: "#c3e88d" },
{ tag: tags.number, color: "#f78c6c" },
{ tag: tags.atom, color: "#89ddff" }, // booleans
{ tag: tags.punctuation, color: "#89ddff" }, // =
]);
export function iniHighlight() {
return [iniLanguage, syntaxHighlighting(iniStyle)];
}

View File

@@ -1,6 +1,9 @@
/** /**
* Micron markup → HTML renderer using the micron-parser library. * Micron markup → HTML renderer using the micron-parser library.
* Reference: https://github.com/RFnexus/micron-parser-js * Reference: https://github.com/RFnexus/micron-parser-js
*
* Uses convertMicronToFragment (DOM-based) instead of convertMicronToHtml
* to avoid DOMPurify stripping nomadnetwork:// hrefs from link tags.
*/ */
import MicronParser from "micron-parser"; import MicronParser from "micron-parser";
@@ -9,11 +12,14 @@ let darkParser: MicronParser | null = null;
let lightParser: MicronParser | null = null; let lightParser: MicronParser | null = null;
export function renderMicron(source: string, darkTheme: boolean = true): string { export function renderMicron(source: string, darkTheme: boolean = true): string {
if (darkTheme) { const parser = darkTheme
if (!darkParser) darkParser = new MicronParser(true, true); ? (darkParser ??= new MicronParser(true, true))
return darkParser.convertMicronToHtml(source); : (lightParser ??= new MicronParser(false, true));
} else {
if (!lightParser) lightParser = new MicronParser(false, true); const fragment = parser.convertMicronToFragment(source);
return lightParser.convertMicronToHtml(source);
} // Serialize the fragment to HTML string
const div = document.createElement("div");
div.appendChild(fragment);
return div.innerHTML;
} }

View File

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

View File

@@ -0,0 +1,154 @@
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);
const velocityRef = useRef({ vx: 0, vy: 0, lastX: 0, lastY: 0, lastT: 0 });
const inertiaRef = useRef(0);
const posRef = useRef({ x, y });
posRef.current = { x, y };
useEffect(() => { if (focused) ref.current?.focus(); }, [focused]);
// Cancel any running inertia animation
const stopInertia = useCallback(() => {
if (inertiaRef.current) { cancelAnimationFrame(inertiaRef.current); inertiaRef.current = 0; }
}, []);
const onDragStart = useCallback((e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault(); onFocus(id);
ref.current?.focus();
stopInertia();
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: x, origY: y };
velocityRef.current = { vx: 0, vy: 0, lastX: e.clientX, lastY: e.clientY, lastT: performance.now() };
document.documentElement.classList.add("cursor-grabbing");
const onMove = (ev: MouseEvent) => {
if (!dragRef.current) return;
const now = performance.now();
const dt = now - velocityRef.current.lastT;
if (dt > 0) {
const smooth = 0.3;
const rawVx = (ev.clientX - velocityRef.current.lastX) / dt * 16;
const rawVy = (ev.clientY - velocityRef.current.lastY) / dt * 16;
velocityRef.current.vx = velocityRef.current.vx * (1 - smooth) + rawVx * smooth;
velocityRef.current.vy = velocityRef.current.vy * (1 - smooth) + rawVy * smooth;
velocityRef.current.lastX = ev.clientX;
velocityRef.current.lastY = ev.clientY;
velocityRef.current.lastT = now;
}
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 = () => {
const { vx, vy } = velocityRef.current;
dragRef.current = null;
document.documentElement.classList.remove("cursor-grabbing");
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
// Kick off inertia if there's meaningful velocity
if (Math.abs(vx) > 0.5 || Math.abs(vy) > 0.5) {
let curVx = vx;
let curVy = vy;
const friction = 0.92;
const el = ref.current;
const tick = () => {
curVx *= friction;
curVy *= friction;
if (Math.abs(curVx) < 0.3 && Math.abs(curVy) < 0.3) {
inertiaRef.current = 0;
// Sync final position to React state once
onUpdate(id, posRef.current);
return;
}
posRef.current = { x: posRef.current.x + curVx, y: Math.max(0, posRef.current.y + curVy) };
// Direct DOM update during animation — skip React reconciliation
if (el) {
el.style.left = `${posRef.current.x}px`;
el.style.top = `${posRef.current.y}px`;
}
inertiaRef.current = requestAnimationFrame(tick);
};
inertiaRef.current = requestAnimationFrame(tick);
}
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}, [id, x, y, onUpdate, onFocus, stopInertia]);
const onResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault(); e.stopPropagation(); onFocus(id);
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: w, origH: h };
document.documentElement.classList.add("cursor-nwse-resize");
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.documentElement.classList.remove("cursor-nwse-resize"); document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
}, [id, w, h, minW, minH, onUpdate, onFocus]);
return createPortal(
<div ref={ref} tabIndex={-1} onKeyDown={(e) => { if (e.key === "Escape") onClose(id); }} onMouseDown={() => onFocus(id)}
className="fixed z-999 flex flex-col bg-popover text-popover-foreground border-2 rounded-lg outline-none transition-[border-color,opacity] duration-150"
style={{ left: x, top: y, width: w, height: h, zIndex: 999 + zIndex, borderColor: focused ? "var(--primary)" : "var(--border)", opacity: focused ? 1 : 0.85, boxShadow: DITHERED_SHADOW }}>
{/* Title bar */}
<div onMouseDown={onDragStart} className="flex items-center gap-2 px-3 py-1.5 border-b-2 border-border cursor-grab active:cursor-grabbing select-none shrink-0 bg-muted/30 rounded-t-lg">
<div className="flex items-center gap-1.5">
<button onClick={() => onClose(id)} className="w-2.5 h-2.5 rounded-full bg-destructive hover:brightness-125 transition-all" />
<span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" /><span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" />
</div>
<span className="flex-1 text-[10px] font-semibold uppercase tracking-wider truncate text-center">{title}</span>
</div>
{/* Optional address bar */}
{addressBar}
{/* Content */}
<div className="flex-1 min-h-0">
{children}
</div>
{/* Optional footer */}
{footer}
{/* Resize handle */}
<div onMouseDown={onResizeStart} className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize" style={{ touchAction: "none" }}>
<svg viewBox="0 0 16 16" className="w-full h-full text-muted-foreground/50"><path d="M14 14L8 14L14 8Z" fill="currentColor" /><path d="M14 14L11 14L14 11Z" fill="currentColor" opacity="0.5" /></svg>
</div>
</div>, document.body);
}

View File

@@ -0,0 +1,77 @@
import { useRef, useEffect, useCallback } 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);
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 { registerIris, unregisterIris } = useLazyEyes({ anchorRef, maxShift, ease });
const irisRef = useCallback((el: HTMLElement | null) => {
if (el) registerIris(el);
return () => { if (el) unregisterIris(el); };
}, [registerIris, unregisterIris]);
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;
return (
<div
key={i}
className="editor-pointer-eye"
style={{ top: eye.top, left: eye.left, width: size, height: size }}
>
<div
ref={irisRef}
className="editor-pointer-iris"
style={{
width: irisSize,
height: irisSize,
marginTop: -(irisSize / 2) - 0.5,
marginLeft: -(irisSize / 2) - 0.5,
}}
/>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import loadingSvg from "@/assets/loading.min.svg";
interface LoaderProps {
size?: number;
className?: string;
}
export default function Loader({ size = 120, className = "" }: LoaderProps) {
return (
<div
className={`animate-spin-slow ${className}`}
style={{
width: size,
height: size,
background: "var(--primary)",
maskImage: `url(${loadingSvg})`,
maskSize: "contain",
maskRepeat: "no-repeat",
maskPosition: "center",
WebkitMaskImage: `url(${loadingSvg})`,
WebkitMaskSize: "contain",
WebkitMaskRepeat: "no-repeat",
WebkitMaskPosition: "center",
}}
/>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
import { useEffect } from "react";
/**
* Registers Ctrl/Cmd+S (save) and optionally Ctrl/Cmd+P (publish) keyboard shortcuts.
* Pass `enabled = false` to temporarily disable (e.g. when a window is not focused).
*/
export function useKeyboardSave(
onSave: () => void,
onPublish?: () => void,
enabled = true,
) {
useEffect(() => {
if (!enabled) return;
const handler = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
onSave();
}
if (onPublish && (e.metaKey || e.ctrlKey) && e.key === "p") {
e.preventDefault();
onPublish();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [onSave, onPublish, enabled]);
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useRef, useCallback } from "react";
interface LazyEyesOptions {
/** Reference point the eyes "live" at (viewport coords). Eyes look away from this toward the mouse. */
anchorRef: React.RefObject<{ x: number; y: number } | null>;
/** Max pixel shift for the iris (default 1.5) */
maxShift?: number;
/** Lerp ease factor 01 for slow drift (default 0.04) */
ease?: number;
/** Saccade threshold — when target jumps more than this, snap fast (default 0.8) */
saccadeThreshold?: number;
/** Fast ease for saccade snap (default 0.35) */
saccadeEase?: number;
}
/**
* Returns a register function to attach iris elements for direct DOM updates.
* No React state is set per frame — transforms are applied directly.
*
* 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 targetRef = useRef({ x: 0, y: 0 });
const currentRef = useRef({ x: 0, y: 0 });
const velocityRef = useRef({ x: 0, y: 0 });
const irisesRef = useRef<Set<HTMLElement>>(new Set());
const offsetRef = useRef({ x: 0, y: 0 });
const registerIris = useCallback((el: HTMLElement | null) => {
if (el) {
irisesRef.current.add(el);
}
}, []);
const unregisterIris = useCallback((el: HTMLElement | null) => {
if (el) {
irisesRef.current.delete(el);
}
}, []);
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;
const dx = et.x - ec.x;
const dy = et.y - ec.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const e_ = dist > saccadeThreshold ? saccadeEase : ease;
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;
if (dist < 0.1) {
ec.x += (Math.random() - 0.5) * 0.02;
ec.y += (Math.random() - 0.5) * 0.02;
}
offsetRef.current.x = ec.x;
offsetRef.current.y = ec.y;
// Direct DOM updates — no React re-render
for (const iris of irisesRef.current) {
iris.style.transform = `translate(${ec.x}px, ${ec.y}px)`;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
window.removeEventListener("mousemove", onMouseMove);
cancelAnimationFrame(raf);
};
}, [anchorRef, maxShift, ease, saccadeThreshold, saccadeEase]);
return { offsetRef, registerIris, unregisterIris };
}

View File

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

View File

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

View File

@@ -15,6 +15,17 @@
} }
} }
@keyframes spin-slow {
0% { transform: rotate(0deg); }
60% { transform: rotate(380deg); }
80% { transform: rotate(355deg); }
100% { transform: rotate(360deg); }
}
@utility animate-spin-slow {
animation: spin-slow 2s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@theme inline { @theme inline {
--font-heading: var(--font-mono); --font-heading: var(--font-mono);
--font-sans: 'JetBrains Mono Variable', 'Courier New', monospace; --font-sans: 'JetBrains Mono Variable', 'Courier New', monospace;
@@ -185,6 +196,11 @@
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
::selection {
background: var(--primary);
color: var(--primary-foreground);
}
html { html {
@apply font-mono; @apply font-mono;
} }
@@ -402,11 +418,19 @@
color: var(--foreground); color: var(--foreground);
} }
/* ── Global drag cursors ── */
.cursor-grabbing, .cursor-grabbing * {
cursor: grabbing !important;
}
.cursor-nwse-resize, .cursor-nwse-resize * {
cursor: nwse-resize !important;
}
/* ── Editor Pointer ── */ /* ── Editor Pointer ── */
.editor-pointer { .editor-pointer {
position: fixed; position: absolute;
z-index: 50; z-index: 9999;
pointer-events: none; pointer-events: none;
will-change: top; will-change: top;
transform: translateX(-100%); transform: translateX(-100%);
@@ -441,7 +465,6 @@
-webkit-mask-size: contain; -webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat; -webkit-mask-repeat: no-repeat;
-webkit-mask-position: center; -webkit-mask-position: center;
opacity: 1;
} }
.editor-pointer-eye { .editor-pointer-eye {

View File

@@ -1,412 +1,651 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Graph } from "@cosmos.gl/graph"; import ForceGraph3D, { type ForceGraphMethods, type NodeObject } from "react-force-graph-3d";
import { fetchBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client"; import SpriteText from "three-spritetext";
import { UnrealBloomPass } from "three/examples/jsm/postprocessing/UnrealBloomPass.js";
import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";
import { Vector2 } from "three";
import { subscribeBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client";
import { renderMicron } from "@/components/editor/micronRenderer"; import { renderMicron } from "@/components/editor/micronRenderer";
import axisMundiUrl from "@/assets/axis-mundi.min.svg"; import { useWindowManager } from "@/hooks/useWindowManager";
import { getThemeStatusColors } from "@/components/browse/graphColors";
import { buildGraphData, type GraphNode, type GraphData } from "@/components/browse/buildGraph";
import type { BrowseWinData, HistoryEntry } from "@/components/browse/types";
import BrowseNodeWindow from "@/components/browse/BrowseNodeWindow";
import BrowseSearchBar from "@/components/browse/BrowseSearchBar";
import Loader from "@/components/shared/Loader";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Theme color maps — hex values matching index.css OKLCH definitions // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface ThemeColors { type FGNode = NodeObject<GraphNode>;
primary: string;
muted: string; function cssVar(name: string): string {
border: string; return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
} }
const THEME_COLORS: Record<string, ThemeColors> = { /** Resolve a CSS variable to a normalised hex string (#rrggbb) */
dark: { // .dark (terra) function cssVarToHex(name: string): string {
primary: "#c47a32", const raw = cssVar(name);
muted: "#8a7560", if (!raw) return "#888888";
border: "#6b5a42", const ctx = document.createElement("canvas").getContext("2d")!;
}, ctx.fillStyle = raw;
azure: { // .theme-azure return ctx.fillStyle; // always "#rrggbb"
primary: "#5aa0d4", }
muted: "#6d8a9e",
border: "#4a6e88", const DIM_COLOR = "rgba(60,60,60,0.15)";
const DIM_LINK = "rgba(60,60,60,0.03)";
// ---------------------------------------------------------------------------
// Retro post-processing shader: pixelation + posterize + scanlines + vignette
// ---------------------------------------------------------------------------
const RetroShader = {
uniforms: {
tDiffuse: { value: null },
resolution: { value: new Vector2(800, 600) },
pixelSize: { value: 2.0 },
colorLevels: { value: 48.0 },
scanlineIntensity: { value: 0.03 },
scanlineDensity: { value: 1.0 },
vignetteIntensity: { value: 0.15 },
tintColor: { value: [1.0, 0.95, 0.85] },
}, },
vertexShader: /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */ `
uniform sampler2D tDiffuse;
uniform vec2 resolution;
uniform float pixelSize;
uniform float colorLevels;
uniform float scanlineIntensity;
uniform float scanlineDensity;
uniform float vignetteIntensity;
uniform vec3 tintColor;
varying vec2 vUv;
void main() {
// Pixelation
vec2 dxy = pixelSize / resolution;
vec2 coord = dxy * floor(vUv / dxy) + dxy * 0.5;
vec4 color = texture2D(tDiffuse, coord);
// Posterize (reduce color depth)
color.rgb = floor(color.rgb * colorLevels + 0.5) / colorLevels;
// Subtle tint towards theme color
color.rgb *= tintColor;
// Scanlines
float scanline = sin(vUv.y * resolution.y * scanlineDensity) * 0.5 + 0.5;
color.rgb -= scanlineIntensity * (1.0 - scanline);
// Vignette
vec2 vig = vUv * (1.0 - vUv);
float vigFactor = pow(vig.x * vig.y * 15.0, vignetteIntensity);
color.rgb *= vigFactor;
gl_FragColor = color;
}
`,
}; };
function getThemeId(): string {
const cl = document.documentElement.classList;
if (cl.contains("theme-azure")) return "azure";
return "dark";
}
function hexToRgba255(hex: string): [number, number, number, number] {
return [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
255,
];
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Data builders — convert NetworkNode[] to Float32Arrays for cosmos.gl // Memoized 3D graph — isolated from window/UI state changes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function buildBuffers(nodes: NetworkNode[], colors: ThemeColors) { type FlyToFn = (pos: { x: number; y: number; z: number }, lookAt: any, durationMs: number) => void;
const sorted = [...nodes].sort((a, b) => (a.is_self ? -1 : b.is_self ? 1 : 0));
const n = sorted.length; interface Graph3DProps {
const positions = new Float32Array(n * 2); graphData: GraphData;
const pointColors = new Float32Array(n * 4); searchMatchIds: Set<string> | null;
const sizes = new Float32Array(n); themeRev: number;
const primaryRgba = hexToRgba255(colors.primary); width: number;
const mutedRgba = hexToRgba255(colors.muted); height: number;
onNodeClick: (node: NetworkNode) => void;
for (let i = 0; i < n; i++) { fgRef: React.MutableRefObject<ForceGraphMethods<FGNode> | undefined>;
if (sorted[i].is_self) { flyToRef: React.MutableRefObject<FlyToFn | undefined>;
positions[i * 2] = 0; containerRef: React.RefObject<HTMLDivElement | null>;
positions[i * 2 + 1] = 0;
} else {
const angle = ((i - 1) / Math.max(1, n - 1)) * Math.PI * 2;
positions[i * 2] = Math.cos(angle) * 100;
positions[i * 2 + 1] = Math.sin(angle) * 100;
}
const rgba = sorted[i].is_self ? primaryRgba : mutedRgba;
pointColors[i * 4] = rgba[0];
pointColors[i * 4 + 1] = rgba[1];
pointColors[i * 4 + 2] = rgba[2];
pointColors[i * 4 + 3] = rgba[3];
sizes[i] = sorted[i].is_self ? 14 : 7;
}
const linkCount = Math.max(0, n - 1);
const links = new Float32Array(linkCount * 2);
const borderRgba = hexToRgba255(colors.border);
const linkColors = new Float32Array(linkCount * 4);
for (let i = 0; i < linkCount; i++) {
links[i * 2] = 0;
links[i * 2 + 1] = i + 1;
linkColors[i * 4] = borderRgba[0];
linkColors[i * 4 + 1] = borderRgba[1];
linkColors[i * 4 + 2] = borderRgba[2];
linkColors[i * 4 + 3] = 180;
}
return { sorted, positions, pointColors, sizes, links, linkColors };
} }
/** Re-apply theme colors to an existing graph instance */ const Graph3D = memo(function Graph3D({ graphData, searchMatchIds, themeRev, width, height, onNodeClick, fgRef, flyToRef, containerRef }: Graph3DProps) {
function applyThemeToGraph(graph: Graph, nodes: NetworkNode[], colors: ThemeColors) { // react-kapsule diffs props during every render — graphData triggers a full
graph.setConfig({ // simulation restart (alpha=1). Stabilise the reference.
pointDefaultColor: colors.primary, const stableDataRef = useRef(graphData);
linkDefaultColor: colors.border, const prevNodeIds = useRef("");
hoveredPointRingColor: colors.primary, const nodeIds = graphData.nodes.map(n => n.id).join(",");
if (nodeIds !== prevNodeIds.current) {
stableDataRef.current = graphData;
prevNodeIds.current = nodeIds;
}
// Dim non-matching nodes/links during search
const nodeColor = useCallback((node: FGNode) => {
if (!searchMatchIds) return (node as GraphNode).color;
return searchMatchIds.has(node.id as string) ? (node as GraphNode).color : DIM_COLOR;
}, [searchMatchIds]);
const linkColor = useCallback((link: any) => {
if (!searchMatchIds) return "rgba(100,100,100,0.15)";
const srcId = typeof link.source === "object" ? (link.source.id as string) : link.source;
const tgtId = typeof link.target === "object" ? (link.target.id as string) : link.target;
if (searchMatchIds.has(srcId) || searchMatchIds.has(tgtId)) return "rgba(100,100,100,0.15)";
return DIM_LINK;
}, [searchMatchIds]);
// Fly camera to search matches
useEffect(() => {
const fg = fgRef.current;
if (!fg || !searchMatchIds) return;
if (searchMatchIds.size <= 10) {
fg.zoomToFit(600, 80, (n: FGNode) => searchMatchIds.has(n.id as string));
}
}, [searchMatchIds, fgRef]);
// ── Camera auto-orbit with smooth ease-in / ease-out ──
const orbitAngleRef = useRef(0);
const orbitTargetSpeed = useRef(1.0); // 1 = full speed, 0 = stopped
const orbitCurrentSpeed = useRef(0.0); // smoothed value
const hoveringNodeRef = useRef(false);
const flyingRef = useRef(false); // true while cameraPosition transition is active — hard-blocks orbit
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const flyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const IDLE_RESUME_MS = 3000;
const BASE_SPEED = Math.PI / 600;
const HOVER_FACTOR = 0.1; // 10% speed when hovering
const EASE_RATE = 0.02; // lerp factor per tick — smaller = smoother
/** Fly camera to a position. Completely blocks orbit during the transition. */
const flyTo = useCallback((pos: { x: number; y: number; z: number }, lookAt: any, durationMs: number) => {
const fg = fgRef.current;
if (!fg) return;
flyingRef.current = true;
orbitCurrentSpeed.current = 0;
orbitTargetSpeed.current = 0;
if (flyTimerRef.current) clearTimeout(flyTimerRef.current);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
fg.cameraPosition(pos, lookAt, durationMs);
flyTimerRef.current = setTimeout(() => {
flyingRef.current = false;
const cam = fg.camera();
orbitAngleRef.current = Math.atan2(cam.position.x, cam.position.z);
idleTimerRef.current = setTimeout(() => { orbitTargetSpeed.current = 1; }, IDLE_RESUME_MS);
}, durationMs);
}, [fgRef]);
// Expose flyTo to parent
flyToRef.current = flyTo;
const pauseOrbit = useCallback(() => {
orbitTargetSpeed.current = 0;
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => { orbitTargetSpeed.current = 1; }, IDLE_RESUME_MS);
}, []);
const onNodeHover = useCallback((node: FGNode | null) => {
hoveringNodeRef.current = !!node;
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const events = ["mousedown", "wheel", "touchstart"] as const;
for (const evt of events) el.addEventListener(evt, pauseOrbit, { passive: true });
return () => { for (const evt of events) el.removeEventListener(evt, pauseOrbit); };
}, [pauseOrbit, containerRef]);
useEffect(() => {
const interval = setInterval(() => {
const fg = fgRef.current;
if (!fg || flyingRef.current) return;
// Smooth target: full speed or hover-reduced
const target = orbitTargetSpeed.current * (hoveringNodeRef.current ? HOVER_FACTOR : 1);
// Ease towards target
orbitCurrentSpeed.current += (target - orbitCurrentSpeed.current) * EASE_RATE;
// Skip negligible movement
if (Math.abs(orbitCurrentSpeed.current) < 0.001) return;
const cam = fg.camera();
const distance = Math.sqrt(cam.position.x ** 2 + cam.position.z ** 2) || 400;
orbitAngleRef.current += BASE_SPEED * orbitCurrentSpeed.current;
fg.cameraPosition({
x: distance * Math.sin(orbitAngleRef.current),
z: distance * Math.cos(orbitAngleRef.current),
}); });
}, 20);
return () => { clearInterval(interval); if (idleTimerRef.current) clearTimeout(idleTimerRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fgRef]);
const n = nodes.length; // ── Post-processing: bloom + retro ──
if (n === 0) return; const postProcInitRef = useRef(false);
const retroPassRef = useRef<ShaderPass | null>(null);
const pointColors = new Float32Array(n * 4); useEffect(() => {
const primaryRgba = hexToRgba255(colors.primary); const fg = fgRef.current;
const mutedRgba = hexToRgba255(colors.muted); if (!fg || postProcInitRef.current) return;
for (let i = 0; i < n; i++) { // Wait a tick for the renderer to be ready
const rgba = nodes[i].is_self ? primaryRgba : mutedRgba; const timer = setTimeout(() => {
pointColors[i * 4] = rgba[0]; try {
pointColors[i * 4 + 1] = rgba[1]; const composer = fg.postProcessingComposer();
pointColors[i * 4 + 2] = rgba[2]; // Bloom — subtle glow
pointColors[i * 4 + 3] = rgba[3]; const bloom = new UnrealBloomPass(new Vector2(width, height), 0.3, 0.3, 0.9);
composer.addPass(bloom);
// Retro shader
const retro = new ShaderPass(RetroShader);
retro.uniforms.resolution.value.set(width, height);
// Tint towards theme primary
const hex = cssVarToHex("--primary");
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;
// Blend towards white so the tint is subtle
retro.uniforms.tintColor.value = [0.7 + r * 0.3, 0.7 + g * 0.3, 0.7 + b * 0.3];
composer.addPass(retro);
retroPassRef.current = retro;
postProcInitRef.current = true;
} catch { /* renderer not ready yet, will retry */ }
}, 500);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fgRef, width, height]);
// Update retro tint when theme changes
useEffect(() => {
const retro = retroPassRef.current;
if (!retro) return;
const hex = cssVarToHex("--primary");
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;
retro.uniforms.tintColor.value = [0.7 + r * 0.3, 0.7 + g * 0.3, 0.7 + b * 0.3];
}, [themeRev]);
// Update resolution uniform on resize
useEffect(() => {
const retro = retroPassRef.current;
if (retro) retro.uniforms.resolution.value.set(width, height);
}, [width, height]);
// ── Custom node objects: text labels above every node ──
const nodeThreeObject = useCallback((node: FGNode) => {
const gn = node as GraphNode;
const sprite = new SpriteText(gn.name);
(sprite as any).material.depthWrite = false;
(sprite as any).renderOrder = 999;
sprite.color = gn.type === "interface"
? (cssVar("--foreground") || "#888")
: gn.color;
sprite.textHeight = gn.type === "interface" ? 4 : 3;
sprite.fontFace = "JetBrains Mono, monospace";
sprite.fontWeight = gn.type === "interface" ? "700" : "400";
sprite.backgroundColor = "transparent";
if (gn.type === "interface") {
(sprite as any).center.set(0.5, 0.5);
} else {
(sprite as any).center.set(0.5, 2.5);
} }
graph.setPointColors(pointColors); return sprite;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeRev]);
const linkCount = Math.max(0, n - 1); const nodeVisibility = useCallback((node: FGNode) => {
if (linkCount > 0) { return (node as GraphNode).type !== "interface";
const borderRgba = hexToRgba255(colors.border); }, []);
const linkColors = new Float32Array(linkCount * 4);
for (let i = 0; i < linkCount; i++) { const nodeThreeObjectExtend = useCallback((node: FGNode) => {
linkColors[i * 4] = borderRgba[0]; return (node as GraphNode).type !== "interface";
linkColors[i * 4 + 1] = borderRgba[1]; }, []);
linkColors[i * 4 + 2] = borderRgba[2];
linkColors[i * 4 + 3] = 180; const showPointerCursor = useCallback((obj: any) => {
} if (!obj || !("type" in obj)) return false;
graph.setLinkColors(linkColors); return (obj as GraphNode).type !== "interface";
}, []);
const handleClick = useCallback((node: FGNode) => {
const gn = node as GraphNode;
if (gn.type === "interface") return;
if (node.x !== undefined && node.y !== undefined && node.z !== undefined) {
const distance = 40;
const dist = Math.hypot(node.x, node.y, node.z);
const newPos = dist > 0
? { x: node.x * (1 + distance / dist), y: node.y * (1 + distance / dist), z: node.z * (1 + distance / dist) }
: { x: 0, y: 0, z: distance };
flyTo(newPos, node as any, 1500);
} }
graph.render(); onNodeClick(gn.entry);
} }, [onNodeClick, flyTo]);
return (
<ForceGraph3D
ref={fgRef}
graphData={stableDataRef.current}
width={width}
height={height}
backgroundColor="rgba(0,0,0,0)"
nodeId="id"
nodeVal="size"
nodeColor={nodeColor}
nodeLabel=""
nodeOpacity={0.9}
nodeResolution={12}
nodeVisibility={nodeVisibility}
nodeThreeObject={nodeThreeObject}
nodeThreeObjectExtend={nodeThreeObjectExtend}
onNodeClick={handleClick}
onNodeHover={onNodeHover}
showPointerCursor={showPointerCursor}
linkColor={linkColor}
linkWidth={0.3}
linkOpacity={0.12}
cooldownTicks={150}
warmupTicks={0}
enableNodeDrag={true}
enableNavigationControls={true}
showNavInfo={false}
/>
);
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Component // Main component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function BrowseView() { export default function BrowseView() {
const [nodes, setNodes] = useState<NetworkNode[]>([]); const [nodes, setNodes] = useState<NetworkNode[]>([]);
const [filter, setFilter] = useState(""); const [filter, setFilter] = useState("");
const [selectedNode, setSelectedNode] = useState<NetworkNode | null>(null); const { windows, focusedId: focusedWinId, open: openWindow, update: updateWindow, close: closeWindowById, focus: focusWindow } = useWindowManager<BrowseWinData>();
const [pageHtml, setPageHtml] = useState<string | null>(null);
const [pageLoading, setPageLoading] = useState(false);
const [pageError, setPageError] = useState<string | null>(null);
const [labelPositions, setLabelPositions] = useState<{ x: number; y: number }[]>([]);
const [themeId, setThemeId] = useState(getThemeId);
const containerRef = useRef<HTMLDivElement>(null); const [themeRev, setThemeRev] = useState(0);
const graphRef = useRef<Graph | null>(null);
const nodesRef = useRef<NetworkNode[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const rafRef = useRef(0);
const colors = THEME_COLORS[themeId] ?? THEME_COLORS.dark; // Watch for theme changes (class on <html>)
const filteredNodes = useMemo(() => {
if (!filter) return nodes;
const q = filter.toLowerCase();
return nodes.filter(
(n) => n.name.toLowerCase().includes(q) || n.hash.toLowerCase().includes(q),
);
}, [nodes, filter]);
// ── Watch for theme changes ──
useEffect(() => { useEffect(() => {
const observer = new MutationObserver(() => { const observer = new MutationObserver(() => setThemeRev(r => r + 1));
const id = getThemeId();
setThemeId(id);
const graph = graphRef.current;
if (graph) {
const c = THEME_COLORS[id] ?? THEME_COLORS.dark;
applyThemeToGraph(graph, nodesRef.current, c);
}
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// ── Initialize cosmos.gl graph ── const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => { const fgRef = useRef<ForceGraphMethods<FGNode>>(undefined);
if (!containerRef.current) return; const flyToRef = useRef<FlyToFn>(undefined);
const prevPositionsRef = useRef<Map<string, { x: number; y: number; z: number }>>(new Map());
const graph = new Graph(containerRef.current, { // Container sizing
backgroundColor: [0, 0, 0, 0], const [dims, setDims] = useState<{ w: number; h: number }>({ w: 800, h: 600 });
pointDefaultColor: colors.primary, useEffect(() => {
pointDefaultSize: 12, const el = containerRef.current;
linkDefaultColor: colors.border, if (!el) return;
linkDefaultWidth: 1, const ro = new ResizeObserver(([entry]) => {
linkOpacity: 0.5, if (!entry) return;
enableSimulation: true, setDims({ w: entry.contentRect.width, h: entry.contentRect.height });
enableDrag: true,
enableZoom: true,
fitViewOnInit: false,
spaceSize: 1024,
simulationGravity: 0.15,
simulationRepulsion: 0.6,
simulationLinkSpring: 0.3,
simulationLinkDistance: 60,
simulationFriction: 0.85,
simulationDecay: 8000,
renderHoveredPointRing: true,
hoveredPointRingColor: colors.primary,
hoveredPointCursor: "pointer",
onPointClick: (index: number) => {
const node = nodesRef.current[index];
if (node) handleNodeClick(node);
},
onClick: () => {
setSelectedNode(null);
setPageHtml(null);
setPageError(null);
},
onSimulationTick: () => updateLabels(),
onZoom: () => updateLabels(),
}); });
ro.observe(el);
graphRef.current = graph; setDims({ w: el.clientWidth, h: el.clientHeight });
return () => ro.disconnect();
return () => {
cancelAnimationFrame(rafRef.current);
graph.destroy();
graphRef.current = null;
};
}, []); }, []);
// ── Update labels from graph positions ── // Build graph data, preserving existing positions
const updateLabels = useCallback(() => { const graphData = useMemo(() => {
const graph = graphRef.current; const fg = fgRef.current;
if (!graph || nodesRef.current.length === 0) return; if (fg) {
try {
const positions = graph.getPointPositions(); // @ts-expect-error — graphData() is on the underlying instance
const next: { x: number; y: number }[] = []; const live = fg.graphData?.() as { nodes: FGNode[] } | undefined;
for (let i = 0; i < nodesRef.current.length; i++) { if (live?.nodes) {
const sx = positions[i * 2]; const map = new Map<string, { x: number; y: number; z: number }>();
const sy = positions[i * 2 + 1]; for (const n of live.nodes) {
if (sx === undefined) break; if (n.x !== undefined && n.y !== undefined && n.z !== undefined) {
const [px, py] = graph.spaceToScreenPosition([sx, sy]); map.set(n.id as string, { x: n.x, y: n.y, z: n.z });
next.push({ x: px, y: py });
} }
setLabelPositions(next); }
prevPositionsRef.current = map;
}
} catch { /* ignore */ }
}
return buildGraphData(nodes, prevPositionsRef.current, getThemeStatusColors());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodes, themeRev]);
// Search — matches + autocomplete suggestions
const searchMatchIds = useMemo(() => {
if (!filter.trim()) return null;
const q = filter.trim().toLowerCase();
const ids = new Set<string>();
for (const n of graphData.nodes) {
if (n.name.toLowerCase().includes(q)) ids.add(n.id);
}
return ids.size > 0 ? ids : null;
}, [filter, graphData]);
const allPeerNodes = useMemo(() => {
return graphData.nodes
.filter(e => e.type !== "interface")
.map(e => e.entry);
}, [graphData]);
const suggestions = useMemo(() => {
if (!filter.trim()) return [];
const q = filter.trim().toLowerCase();
return allPeerNodes.filter(e => e.name.toLowerCase().includes(q));
}, [filter, allPeerNodes]);
// Fly camera to a highlighted search suggestion.
// ForceGraph3D mutates graphData.nodes in-place with x/y/z after simulation,
// so we read positions directly from the graph data nodes.
const onHighlightNode = useCallback((node: NetworkNode | null) => {
if (!node) return;
const fly = flyToRef.current;
if (!fly) return;
const match = graphData.nodes.find(n => n.id === node.hash) as FGNode | undefined;
if (!match || match.x === undefined || match.y === undefined || match.z === undefined) return;
const distance = 60;
const dist = Math.hypot(match.x, match.y, match.z);
const newPos = dist > 0
? { x: match.x * (1 + distance / dist), y: match.y * (1 + distance / dist), z: match.z * (1 + distance / dist) }
: { x: 0, y: 0, z: distance };
fly(newPos, match as any, 800);
}, [graphData]);
const onSearchFocusChange = useCallback((_focused: boolean) => {
// Could be used to dim graph when search is active
}, []); }, []);
// ── Feed node data into graph when nodes change ── // ── Fly camera to focused window's node ──
useEffect(() => { useEffect(() => {
const graph = graphRef.current; if (!focusedWinId) return;
if (!graph) return; const fly = flyToRef.current;
if (filteredNodes.length === 0) { if (!fly) return;
nodesRef.current = []; const match = graphData.nodes.find(n => n.id === focusedWinId) as FGNode | undefined;
setLabelPositions([]); if (!match || match.x === undefined || match.y === undefined || match.z === undefined) return;
graph.setPointPositions(new Float32Array(0)); const distance = 60;
graph.setPointColors(new Float32Array(0)); const dist = Math.hypot(match.x, match.y, match.z);
graph.setPointSizes(new Float32Array(0)); const newPos = dist > 0
graph.setLinks(new Float32Array(0)); ? { x: match.x * (1 + distance / dist), y: match.y * (1 + distance / dist), z: match.z * (1 + distance / dist) }
graph.setLinkColors(new Float32Array(0)); : { x: 0, y: 0, z: distance };
graph.render(); fly(newPos, match as any, 1000);
return; // eslint-disable-next-line react-hooks/exhaustive-deps
} }, [focusedWinId]);
const c = THEME_COLORS[getThemeId()] ?? THEME_COLORS.dark; // ── SSE stream ──
const { sorted, positions, pointColors, sizes, links, linkColors } = buildBuffers(filteredNodes, c); const nodeMapRef = useRef<Map<string, NetworkNode>>(new Map());
nodesRef.current = sorted;
graph.setPointPositions(positions);
graph.setPointColors(pointColors);
graph.setPointSizes(sizes);
if (links.length > 0) {
graph.setLinks(links);
graph.setLinkColors(linkColors);
}
graph.setPinnedPoints([0]);
graph.render();
graph.start();
setTimeout(() => {
graph.fitView(400, 0.4);
updateLabels();
}, 200);
}, [filteredNodes, updateLabels]);
// ── Poll for nodes ──
useEffect(() => { useEffect(() => {
const load = () => { let pending: NetworkNode[] = [];
fetchBrowseNodes().then(setNodes).catch(() => { }); let batchTimer: ReturnType<typeof setTimeout> | null = null;
}; const flush = () => {
load(); batchTimer = null;
pollRef.current = setInterval(load, 30_000); if (pending.length === 0) return;
return () => { const batch = pending; pending = [];
if (pollRef.current) clearInterval(pollRef.current); const map = nodeMapRef.current;
let changed = false;
for (const node of batch) {
if (!map.has(node.hash)) changed = true;
map.set(node.hash, node);
}
if (changed) {
setNodes(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 ── // ── Navigation helpers ──
const handleNodeClick = useCallback((node: NetworkNode) => { const navigateTo = useCallback((winId: string, node: NetworkNode, path: string, prevData?: BrowseWinData) => {
setSelectedNode(node); const loading: BrowseWinData = {
setPageHtml(null); node, pageHtml: null, pageLoading: true, pageError: null,
setPageError(null); currentPath: path,
setPageLoading(true); history: prevData?.history ?? [],
historyIndex: prevData?.historyIndex ?? -1,
};
updateWindow(winId, { data: loading });
fetchRemotePage(node.hash) fetchRemotePage(node.hash, path)
.then((res) => { .then((res) => {
if (res.content) { const html = res.content ? renderMicron(res.content, true) : null;
setPageHtml(renderMicron(res.content, true)); const error = res.content ? null : (res.error ?? "No content");
} else { const entry: HistoryEntry = { path, html, error };
setPageError(res.error ?? "No content");
} const prevHistory = loading.history.slice(0, loading.historyIndex + 1);
const newHistory = [...prevHistory, entry];
const newIndex = newHistory.length - 1;
updateWindow(winId, { data: { node, pageHtml: html, pageError: error, pageLoading: false, currentPath: path, history: newHistory, historyIndex: newIndex } });
}) })
.catch((e) => setPageError(String(e))) .catch((e) => {
.finally(() => setPageLoading(false)); const error = String(e);
const entry: HistoryEntry = { path, html: null, error };
const prevHistory = loading.history.slice(0, loading.historyIndex + 1);
const newHistory = [...prevHistory, entry];
const newIndex = newHistory.length - 1;
updateWindow(winId, { data: { node, pageError: error, pageLoading: false, pageHtml: null, currentPath: path, history: newHistory, historyIndex: newIndex } });
});
}, [updateWindow]);
const navBack = useCallback((winId: string, data: BrowseWinData) => {
const newIndex = data.historyIndex - 1;
if (newIndex < 0) return;
const entry = data.history[newIndex]!;
updateWindow(winId, { data: { ...data, pageHtml: entry.html, pageError: entry.error, pageLoading: false, currentPath: entry.path, historyIndex: newIndex } });
}, [updateWindow]);
const navForward = useCallback((winId: string, data: BrowseWinData) => {
const newIndex = data.historyIndex + 1;
if (newIndex >= data.history.length) return;
const entry = data.history[newIndex]!;
updateWindow(winId, { data: { ...data, pageHtml: entry.html, pageError: entry.error, pageLoading: false, currentPath: entry.path, historyIndex: newIndex } });
}, [updateWindow]);
const navReload = useCallback((winId: string, data: BrowseWinData) => {
navigateTo(winId, data.node, data.currentPath, { ...data, historyIndex: data.historyIndex - 1 });
}, [navigateTo]);
// ── Node click ──
const handleNodeClick = useCallback((node: NetworkNode) => {
const id = node.hash;
const initData: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null, currentPath: "index.mu", history: [], historyIndex: -1 };
openWindow(id, initData);
navigateTo(id, node, "index.mu");
}, [openWindow, navigateTo]);
// ── Handle micron link clicks via event delegation ──
const handleContentClick = useCallback((e: React.MouseEvent, winId: string, data: BrowseWinData) => {
const anchor = (e.target as HTMLElement).closest("a");
if (!anchor) return;
e.preventDefault();
const dest = anchor.getAttribute("data-destination") ?? anchor.getAttribute("href") ?? "";
if (!dest) return;
let raw = dest
.replace(/^nomadnetwork:\/\//, "")
.replace(/^:/, "")
.replace(/^\/page\//, "")
.replace(/^\/+/, "");
if (/^[0-9a-f]{32}$/i.test(raw)) return;
let targetNode = data.node;
let path = raw;
const crossNodeMatch = raw.match(/^([0-9a-f]{32})\/(.+)$/i);
if (crossNodeMatch) {
const targetHash = crossNodeMatch[1]!;
path = crossNodeMatch[2]!;
path = path.replace(/^\/page\//, "").replace(/^\/+/, "");
const known = nodes.find(n => n.hash === targetHash);
if (known) targetNode = known;
}
if (!path || path === "/") return;
if (!path.endsWith(".mu")) path += ".mu";
navigateTo(winId, targetNode, path, data);
}, [navigateTo, nodes]);
const clearSearch = useCallback(() => {
setFilter("");
fgRef.current?.zoomToFit(400, 60);
}, []); }, []);
return ( return (
<div className="flex flex-col" style={{ height: "100%" }}> <div ref={containerRef} className="relative overflow-hidden" style={{ height: "100%" }}>
{/* Header */} <Graph3D
<div className="flex items-center gap-3 px-4 py-2 border-b-2 border-border shrink-0"> graphData={graphData}
<h1 className="text-sm font-semibold">Browse</h1> searchMatchIds={searchMatchIds}
<input themeRev={themeRev}
type="text" width={dims.w}
value={filter} height={dims.h}
onChange={(e) => setFilter(e.target.value)} onNodeClick={handleNodeClick}
placeholder="Filter nodes..." fgRef={fgRef}
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" flyToRef={flyToRef}
containerRef={containerRef}
/> />
<span className="text-[10px] text-muted-foreground uppercase tracking-wider whitespace-nowrap">
{filteredNodes.length}/{nodes.length} node{nodes.length !== 1 && "s"}
</span>
</div>
{/* Graph + labels */} <BrowseSearchBar
<div filter={filter}
ref={containerRef} onFilterChange={setFilter}
className="relative shrink-0 bg-background overflow-hidden" onClear={clearSearch}
style={{ height: 500 }} allNodes={allPeerNodes}
> suggestions={suggestions}
{nodesRef.current.map((node, i) => { onSelectNode={handleNodeClick}
const lp = labelPositions[i]; onHighlightNode={onHighlightNode}
if (!lp) return null; onSearchFocusChange={onSearchFocusChange}
return ( focusedWinId={focusedWinId}
<span windowCount={windows.length}
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 && ( {nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm pointer-events-none">
Listening for nodes on the Reticulum network... <Loader />
Connecting...
</div> </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 */} {/* Stop React synthetic events from portaled windows bubbling into the
<div className="flex-1 min-h-0 border-t-2 border-border flex flex-col"> graph — portals bubble through the React tree, not the DOM tree. */}
<div className="flex items-center px-4 py-2 border-b border-border bg-background shrink-0"> {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<span className="text-xs font-semibold flex-1 truncate"> <div onMouseDown={e => e.stopPropagation()} onPointerDown={e => e.stopPropagation()}>
{selectedNode ? ( {windows.map((win) => (
<> <BrowseNodeWindow
{selectedNode.name} key={win.id}
<span className="ml-2 text-[10px] text-muted-foreground font-normal"> win={win}
{selectedNode.hash.slice(0, 12)} focused={focusedWinId === win.id}
</span> onUpdate={updateWindow}
</> onClose={closeWindowById}
) : ( onFocus={focusWindow}
<span className="text-muted-foreground font-normal">Page</span> onNavBack={navBack}
)} onNavForward={navForward}
</span> onNavReload={navReload}
</div> onContentClick={handleContentClick}
<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> ))}
</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>
</div> </div>
</div> </div>
); );

View File

@@ -1,10 +1,11 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { MoreVertical, Plus, RotateCcw } from "lucide-react"; import { MoreVertical, Plus, FolderPlus, ChevronRight, Folder, FileText, KeyRound, ArrowLeft, ArrowUp, ArrowDown } from "lucide-react";
import { usePagesStore } from "@/stores/pagesStore"; import { usePagesStore } from "@/stores/pagesStore";
import { restartNode } from "@/api/client"; import * as api from "@/api/client";
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
import StatusBadge from "@/components/dashboard/StatusBadge"; import StatusBadge from "@/components/dashboard/StatusBadge";
import Loader from "@/components/shared/Loader";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Table, Table,
@@ -29,34 +30,108 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { useWindowManager } from "@/hooks/useWindowManager";
import EditorWindow, { type EditorWinData } from "@/components/editor/EditorWindow";
import EditorPane from "@/components/editor/EditorPane";
import EditorPointer from "@/components/editor/EditorPointer";
import FloatingWindow from "@/components/shared/FloatingWindow";
import type { ManagedWindow } from "@/hooks/useWindowManager";
// ---------------------------------------------------------------------------
// Env editor window data
// ---------------------------------------------------------------------------
type SortKey = "name" | "size" | "modified";
interface EnvWinData {
kind: "env";
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function ComposeView() { export default function ComposeView() {
const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } = const { deletePage, publishPage, unpublishPage } = usePagesStore();
usePagesStore();
const navigate = useNavigate(); // File browser state
const [currentPath, setCurrentPath] = useState("");
const [files, setFiles] = useState<api.FileEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [pageToDelete, setPageToDelete] = useState<string | null>(null); const [pageToDelete, setPageToDelete] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false); const [newFolderName, setNewFolderName] = useState("");
const [showNewFolder, setShowNewFolder] = useState(false);
const [sortKey, setSortKey] = useState<SortKey>("name");
const [sortAsc, setSortAsc] = useState(true);
useEffect(() => { const sortedFiles = useMemo(() => {
fetchPages(); // Folders always first, then sort within each group
}, []); const folders = files.filter(f => f.type === "folder");
const rest = files.filter(f => f.type !== "folder");
const handleRestart = async () => { const cmp = (a: api.FileEntry, b: api.FileEntry): number => {
setRestarting(true); let v = 0;
try { if (sortKey === "name") v = a.name.localeCompare(b.name);
await restartNode(); else if (sortKey === "size") v = (a.size ?? 0) - (b.size ?? 0);
toast.success("NomadNet restarted"); else if (sortKey === "modified") v = (a.last_modified ?? 0) - (b.last_modified ?? 0);
} catch (e) { return sortAsc ? v : -v;
toast.error(`Restart failed: ${e}`);
} finally {
setRestarting(false);
}
}; };
folders.sort(cmp);
rest.sort(cmp);
return [...folders, ...rest];
}, [files, sortKey, sortAsc]);
const toggleSort = (key: SortKey) => {
if (sortKey === key) setSortAsc(!sortAsc);
else { setSortKey(key); setSortAsc(true); }
};
// Editor windows
const { windows: editorWindows, focusedId: editorFocused, open: openEditorWin, update: updateEditorWin, close: closeEditorWin, focus: focusEditorWin } = useWindowManager<EditorWinData>({ w: 720, h: 520 });
// Env editor windows
const { windows: envWindows, focusedId: envFocused, open: openEnvWin, update: updateEnvWin, close: closeEnvWin, focus: focusEnvWin } = useWindowManager<EnvWinData>({ w: 520, h: 400 });
const loadFiles = useCallback(async (path: string = currentPath) => {
setIsLoading(true);
try {
const entries = await api.fetchFiles(path);
setFiles(entries);
} finally {
setIsLoading(false);
}
}, [currentPath]);
useEffect(() => { loadFiles(currentPath); }, [currentPath, loadFiles]);
const navigateTo = (path: string) => setCurrentPath(path);
const navigateUp = () => {
if (!currentPath) return;
const parts = currentPath.split("/").filter(Boolean);
parts.pop();
setCurrentPath(parts.join("/"));
};
// Path breadcrumbs
const pathParts = currentPath ? currentPath.split("/").filter(Boolean) : [];
const openEditor = (name: string, isNew: boolean) => {
// For files in subfolders, use full relative path as page name
const pageName = isNew ? "" : name;
const id = isNew ? `new-${Date.now()}` : pageName;
openEditorWin(id, { pageName, isNew });
};
const openEnvEditor = () => {
openEnvWin("env-editor", { kind: "env" });
};
const handlePublish = async (name: string) => { const handlePublish = async (name: string) => {
try { try {
await publishPage(name); await publishPage(name);
toast.success(`"${name}" published`); toast.success(`"${name}" published`);
loadFiles();
} catch (e) { } catch (e) {
toast.error(`Failed: ${e}`); toast.error(`Failed: ${e}`);
} }
@@ -66,6 +141,7 @@ export default function ComposeView() {
try { try {
await unpublishPage(name); await unpublishPage(name);
toast.success(`"${name}" unpublished`); toast.success(`"${name}" unpublished`);
loadFiles();
} catch (e) { } catch (e) {
toast.error(`Failed: ${e}`); toast.error(`Failed: ${e}`);
} }
@@ -73,94 +149,200 @@ export default function ComposeView() {
const handleDelete = async () => { const handleDelete = async () => {
if (!pageToDelete) return; if (!pageToDelete) return;
await deletePage(pageToDelete); // Extract stem from path for the pages API
const stem = pageToDelete.replace(/\.uf$/, "");
await deletePage(stem);
toast.success(`"${pageToDelete}" deleted`); toast.success(`"${pageToDelete}" deleted`);
setPageToDelete(null); setPageToDelete(null);
loadFiles();
}; };
if (isLoading) const handleCreateFolder = async () => {
return ( const name = newFolderName.trim();
<div className="flex items-center justify-center h-full text-muted-foreground"> if (!name) return;
Loading... const folderPath = currentPath ? `${currentPath}/${name}` : name;
</div> try {
); await api.createFolder(folderPath);
toast.success(`Folder "${name}" created`);
setNewFolderName("");
setShowNewFolder(false);
loadFiles();
} catch (e) {
toast.error(`Failed: ${e}`);
}
};
const handleFileClick = (entry: api.FileEntry) => {
if (entry.type === "folder") {
navigateTo(entry.path);
} else if (entry.type === "env") {
openEnvEditor();
} else {
// Open .uf file in editor — strip .uf extension for page name
const pageName = entry.path.replace(/\.uf$/, "");
openEditor(pageName, false);
}
};
const formatSize = (size: number | null) => {
if (size == null) return "\u2014";
if (size < 1024) return `${size} B`;
return `${(size / 1024).toFixed(1)} KB`;
};
const formatTime = (ts: number | null) => {
if (ts == null) return "\u2014";
const d = new Date(ts * 1000);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
};
return ( return (
<div> <div className="flex flex-col h-full">
<div> <div className="flex flex-col flex-1 min-h-0">
{/* Header row */} {/* Header row */}
<div className="flex items-center px-4 py-2 border-b-2 border-border"> <div className="flex items-center px-2 py-1.5 border-b-2 border-border">
<h1 className="text-sm font-semibold flex-1">Compose</h1> <h1 className="text-xs font-semibold flex-1">Compose</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" onClick={handleRestart} disabled={restarting}> <Button variant="outline" size="sm" onClick={() => setShowNewFolder(true)}>
<RotateCcw className="w-4 h-4 mr-2" /> <FolderPlus className="w-3 h-3 mr-1.5" />
Restart New Folder
</Button> </Button>
<Button onClick={() => navigate("/editor/new")}> <Button size="sm" onClick={() => openEditor("", true)}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-3 h-3 mr-1.5" />
New Page New Page
</Button> </Button>
</div> </div>
</div> </div>
{/* Table */} {/* Path bar */}
<div className="flex items-center px-3 py-1.5 border-b border-border bg-muted/15 text-xs">
{currentPath && (
<button onClick={navigateUp} className="mr-2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<ArrowLeft className="w-3.5 h-3.5" />
</button>
)}
<button onClick={() => navigateTo("")} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer font-mono">
/
</button>
{pathParts.map((part, i) => {
const partPath = pathParts.slice(0, i + 1).join("/");
return (
<span key={partPath} className="flex items-center">
<ChevronRight className="w-3 h-3 mx-0.5 text-muted-foreground/50" />
<button onClick={() => navigateTo(partPath)} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer font-mono">
{part}
</button>
</span>
);
})}
{/* New folder inline input */}
{showNewFolder && (
<span className="flex items-center ml-4 gap-1">
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<input
autoFocus
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateFolder();
if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); }
}}
placeholder="folder name"
className="h-5 px-1.5 text-xs bg-background border border-border rounded font-mono w-32 focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button onClick={handleCreateFolder} className="text-primary text-xs cursor-pointer">create</button>
<button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="text-muted-foreground text-xs cursor-pointer">cancel</button>
</span>
)}
</div>
{/* File table */}
<div className="flex-1 min-h-0 overflow-auto">
{isLoading ? (
<div className="flex flex-col items-center justify-center h-32 gap-3 text-muted-foreground text-xs"><Loader size={48} /> Loading...</div>
) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <SortableHead label="Name" sortKey="name" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
<TableHead>Title</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Size</TableHead> <SortableHead label="Size" sortKey="size" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
<SortableHead label="Modified" sortKey="modified" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
<TableHead className="w-8" /> <TableHead className="w-8" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{pages.map((p) => ( {sortedFiles.map((entry) => (
<TableRow <TableRow
key={p.name} key={entry.path}
className="cursor-pointer" className="cursor-pointer"
onClick={() => navigate(`/editor/${p.name}`)} onClick={() => handleFileClick(entry)}
> >
<TableCell className="font-mono"> <TableCell className="font-mono">
{p.name} <span className="flex items-center gap-2">
{p.name === "index" && ( {entry.type === "folder" ? (
<span className="ml-2 text-xs text-primary">homepage</span> <Folder className="w-3.5 h-3.5 text-primary/70 shrink-0" />
) : entry.type === "env" ? (
<KeyRound className="w-3.5 h-3.5 text-amber-500/70 shrink-0" />
) : (
<FileText className="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
)} )}
</TableCell> <span>{entry.name}</span>
<TableCell className="text-muted-foreground"> {entry.name === "index.uf" && (
{p.title ?? "\u2014"} <span className="text-[10px] text-primary">homepage</span>
)}
</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<StatusBadge published={p.published} hasSource={p.has_source} /> {entry.type === "file" && entry.name.endsWith(".uf") ? (
<StatusBadge published={entry.published} hasSource={true} />
) : null}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{p.size != null ? `${p.size} B` : "\u2014"} {entry.type !== "folder" ? formatSize(entry.size) : "\u2014"}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTime(entry.last_modified)}
</TableCell> </TableCell>
<TableCell className="text-right w-8"> <TableCell className="text-right w-8">
<PageActions {entry.type === "file" && entry.name.endsWith(".uf") && (
name={p.name} <FileActions
published={p.published} entry={entry}
onPublish={() => handlePublish(p.name)} folders={files.filter(f => f.type === "folder")}
onUnpublish={() => handleUnpublish(p.name)} currentPath={currentPath}
onDelete={() => setPageToDelete(p.name)} onEdit={() => handleFileClick(entry)}
onPublish={() => handlePublish(entry.path.replace(/\.uf$/, ""))}
onUnpublish={() => handleUnpublish(entry.path.replace(/\.uf$/, ""))}
onDelete={() => setPageToDelete(entry.path)}
onMove={async (to) => {
try {
await api.moveFile(entry.path, to);
toast.success(`Moved "${entry.name}" to ${to || "/"}`);
loadFiles();
} catch (e) {
toast.error(`Move failed: ${e}`);
}
}}
/> />
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
{pages.length === 0 && ( {files.length === 0 && (
<TableRow> <TableRow>
<TableCell <TableCell colSpan={5} className="text-center text-muted-foreground py-8">
colSpan={5} {currentPath ? "Empty folder." : "No files yet. Create a page to get started."}
className="text-center text-muted-foreground py-8"
>
No pages yet. Create one to get started.
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</TableBody> </TableBody>
</Table> </Table>
)}
</div>
</div> </div>
{/* Delete confirmation */}
<AlertDialog <AlertDialog
open={pageToDelete !== null} open={pageToDelete !== null}
onOpenChange={(open) => !open && setPageToDelete(null)} onOpenChange={(open) => !open && setPageToDelete(null)}
@@ -169,8 +351,7 @@ export default function ComposeView() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle> <AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This permanently deletes the page and its source. This cannot be This permanently deletes the file and its published version. This cannot be undone.
undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -184,26 +365,70 @@ export default function ComposeView() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{/* Editor windows */}
{editorWindows.map((win) => (
<EditorWindow
key={win.id}
win={win}
focused={editorFocused === win.id}
onUpdate={updateEditorWin}
onClose={closeEditorWin}
onFocus={focusEditorWin}
/>
))}
{/* Env editor windows */}
{envWindows.map((win) => (
<EnvEditorWindow
key={win.id}
win={win}
focused={envFocused === win.id}
onUpdate={updateEnvWin}
onClose={closeEnvWin}
onFocus={focusEnvWin}
/>
))}
</div> </div>
); );
} }
/** Per-row action menu for a page. */ // ---------------------------------------------------------------------------
function PageActions({ // File action menu
name, // ---------------------------------------------------------------------------
published,
function FileActions({
entry,
folders,
currentPath,
onEdit,
onPublish, onPublish,
onUnpublish, onUnpublish,
onDelete, onDelete,
onMove,
}: { }: {
name: string; entry: api.FileEntry;
published: boolean; folders: api.FileEntry[];
currentPath: string;
onEdit: () => void;
onPublish: () => void; onPublish: () => void;
onUnpublish: () => void; onUnpublish: () => void;
onDelete: () => void; onDelete: () => void;
onMove: (to: string) => void;
}) { }) {
const navigate = useNavigate(); const [showMove, setShowMove] = useState(false);
// Build move targets: parent dir (if in a subfolder) + sibling folders
const moveTargets: { label: string; path: string }[] = [];
if (currentPath) {
moveTargets.push({ label: "/ (root)", path: entry.name });
}
for (const f of folders) {
moveTargets.push({ label: f.name + "/", path: f.path + "/" + entry.name });
}
const menuItem = "w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer";
return ( return (
<Popover> <Popover>
@@ -217,27 +442,169 @@ function PageActions({
</button> </button>
} }
/> />
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-36 p-1"> <PopoverContent side="bottom" align="end" sideOffset={4} className="w-44 p-1">
<button <button onClick={(e) => { e.stopPropagation(); onEdit(); }} className={menuItem}>
onClick={(e) => { e.stopPropagation(); navigate(`/editor/${name}`); }} Edit
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer" </button>
>Edit</button> {entry.published ? (
{published ? ( <button onClick={(e) => { e.stopPropagation(); onUnpublish(); }} className={menuItem}>
<button Unpublish
onClick={(e) => { e.stopPropagation(); onUnpublish(); }} </button>
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Unpublish</button>
) : ( ) : (
<button <button onClick={(e) => { e.stopPropagation(); onPublish(); }} className={menuItem}>
onClick={(e) => { e.stopPropagation(); onPublish(); }} Publish
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer" </button>
>Publish</button>
)} )}
{moveTargets.length > 0 && (
<>
<button <button
onClick={(e) => { e.stopPropagation(); onDelete(); }} onClick={(e) => { e.stopPropagation(); setShowMove(!showMove); }}
className="w-full text-left px-3 py-1.5 text-xs text-destructive hover:bg-accent transition-colors cursor-pointer" className={`${menuItem} flex items-center justify-between`}
>Delete</button> >
Move to
<ChevronRight className={`w-3 h-3 transition-transform ${showMove ? "rotate-90" : ""}`} />
</button>
{showMove && (
<div className="border-t border-border mt-0.5 pt-0.5">
{moveTargets.map((t) => (
<button
key={t.path}
onClick={(e) => { e.stopPropagation(); onMove(t.path); }}
className="w-full text-left px-4 py-1.5 text-xs font-mono text-muted-foreground hover:text-foreground hover:bg-accent transition-colors cursor-pointer flex items-center gap-1.5"
>
<Folder className="w-3 h-3 shrink-0" />
{t.label}
</button>
))}
</div>
)}
</>
)}
<button onClick={(e) => { e.stopPropagation(); onDelete(); }} className={`${menuItem} text-destructive`}>
Delete
</button>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
} }
// ---------------------------------------------------------------------------
// Sortable table header
// ---------------------------------------------------------------------------
function SortableHead({ label, sortKey, currentKey, asc, onToggle }: {
label: string;
sortKey: SortKey;
currentKey: string;
asc: boolean;
onToggle: (key: SortKey) => void;
}) {
const active = currentKey === sortKey;
return (
<TableHead>
<button
onClick={(e) => { e.stopPropagation(); onToggle(sortKey); }}
className="flex items-center gap-1 text-inherit hover:text-foreground transition-colors cursor-pointer"
>
{label}
{active && (asc
? <ArrowUp className="w-3 h-3" />
: <ArrowDown className="w-3 h-3" />
)}
</button>
</TableHead>
);
}
// ---------------------------------------------------------------------------
// Env editor floating window
// ---------------------------------------------------------------------------
function EnvEditorWindow({
win,
focused,
onUpdate,
onClose,
onFocus,
}: {
win: ManagedWindow<EnvWinData>;
focused: boolean;
onUpdate: (id: string, patch: Partial<ManagedWindow<EnvWinData>>) => void;
onClose: (id: string) => void;
onFocus: (id: string) => void;
}) {
const windowRef = useRef<HTMLDivElement>(null);
const [content, setContent] = useState("");
const [isDirty, setIsDirty] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.fetchEnv().then((c) => setContent(c));
}, []);
const handleChange = useCallback((v: string) => {
setContent(v);
setIsDirty(true);
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
try {
await api.saveEnv(content);
setIsDirty(false);
toast.success(".env saved");
} catch (e) {
toast.error(`Save failed: ${e}`);
} finally {
setSaving(false);
}
}, [content]);
useKeyboardSave(handleSave, undefined, focused);
const handleClose = useCallback((id: string) => {
if (isDirty && !window.confirm("You have unsaved changes. Close anyway?")) return;
onClose(id);
}, [isDirty, onClose]);
return (
<FloatingWindow
id={win.id}
title=".env"
x={win.x} y={win.y} w={win.w} h={win.h}
zIndex={win.zIndex}
focused={focused}
onUpdate={onUpdate}
onClose={handleClose}
onFocus={onFocus}
minW={360} minH={200}
containerRef={windowRef}
>
<EditorPointer containerRef={windowRef} focused={focused} />
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center px-3 py-1.5 border-b-2 border-border shrink-0 gap-2">
<KeyRound className="w-3.5 h-3.5 text-amber-500/70" />
<span className="text-xs font-semibold flex-1">Environment Variables</span>
<button
onClick={handleSave}
disabled={saving || !isDirty}
className="text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors cursor-pointer"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
{/* Help */}
<div className="px-3 py-1.5 border-b border-border bg-muted/10 text-[10px] text-muted-foreground">
One variable per line: <span className="font-mono">KEY=value</span>. Use <span className="font-mono">source name : env "KEY"</span> in pages.
</div>
{/* CodeMirror editor */}
<div className="flex-1 min-h-0">
<EditorPane value={content} onChange={handleChange} />
</div>
</div>
</FloatingWindow>
);
}

View File

@@ -1,29 +1,20 @@
import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { useEffect, useState, useCallback, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { BookOpen, Upload } from "lucide-react";
import * as api from "@/api/client"; import * as api from "@/api/client";
import { autocompletion } from "@codemirror/autocomplete"; import { autocompletion } from "@codemirror/autocomplete";
import type { Extension } from "@codemirror/state";
import { useEditorStore } from "@/stores/editorStore"; import { useEditorStore } from "@/stores/editorStore";
import { usePagesStore } from "@/stores/pagesStore"; import { usePagesStore } from "@/stores/pagesStore";
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard"; import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
import { useCompile } from "@/hooks/useCompile"; import { useCompile } from "@/hooks/useCompile";
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
import { uframeHighlight } from "@/components/editor/uframeHighlight"; import { uframeHighlight } from "@/components/editor/uframeHighlight";
import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "@/components/editor/uframeCommands"; import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "@/components/editor/uframeCommands";
import { keywordHoverTooltip } from "@/components/editor/uframeHover"; import { keywordHoverTooltip } from "@/components/editor/uframeHover";
import EditorPane from "@/components/editor/EditorPane";
import EditorPointer from "@/components/editor/EditorPointer"; import EditorPointer from "@/components/editor/EditorPointer";
import PreviewPane from "@/components/editor/PreviewPane"; import PreviewPane from "@/components/editor/PreviewPane";
import SourcePane from "@/components/editor/SourcePane";
import ToolBar from "@/components/editor/ToolBar"; import ToolBar from "@/components/editor/ToolBar";
import { EXAMPLES } from "@/components/editor/examples";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverHeader,
PopoverTitle,
} from "@/components/ui/popover";
import { import {
ResizablePanelGroup, ResizablePanelGroup,
ResizablePanel, ResizablePanel,
@@ -81,10 +72,8 @@ export default function EditorView() {
setPageName(name); setPageName(name);
api.fetchPage(name).then((data) => { api.fetchPage(name).then((data) => {
if (data.source != null) { if (data.source != null) {
useEditorStore.setState({ setSource(data.source);
ufSource: data.source, setDirty(false);
isDirty: false,
});
} }
}); });
} }
@@ -118,20 +107,10 @@ export default function EditorView() {
[pageName, ufSource, isNew, navigate], [pageName, ufSource, isNew, navigate],
); );
useEffect(() => { useKeyboardSave(
const handler = (e: KeyboardEvent) => { useCallback(() => handleSave(false), [handleSave]),
if ((e.metaKey || e.ctrlKey) && e.key === "s") { useCallback(() => handleSave(true), [handleSave]),
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);
}, [handleSave]);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@@ -164,82 +143,3 @@ export default function EditorView() {
} }
/** Source pane — editor with header bar matching the Preview pane */
function SourcePane({
ufSource,
setSource,
extensions,
}: {
ufSource: string;
setSource: (s: string) => void;
extensions: Extension[];
}) {
const [examplesOpen, setExamplesOpen] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const data = await api.uploadImage(file);
toast.success(`Uploaded ${data.filename}`);
setSource(`image "${data.path}" braille 30\n align center`);
} catch (err) {
toast.error(`Upload failed: ${err}`);
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
return (
<div className="flex flex-col h-full">
<div className="flex items-center px-4 py-2 border-b-2 border-border shrink-0 gap-2">
<span className="font-medium text-foreground flex-1">Source</span>
<Popover open={examplesOpen} onOpenChange={setExamplesOpen}>
<PopoverTrigger
render={
<button className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 cursor-pointer">
<BookOpen className="h-3 w-3" />
Examples
</button>
}
/>
<PopoverContent side="bottom" align="end" sideOffset={8}>
<PopoverHeader>
<PopoverTitle>Insert Example</PopoverTitle>
</PopoverHeader>
<div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto -mx-1">
{EXAMPLES.map((ex) => (
<button
key={ex.name}
onClick={() => { setSource(ex.source); setExamplesOpen(false); }}
className="flex flex-col items-start px-2 py-1.5 text-left hover:bg-accent transition-colors cursor-pointer"
>
<span className="text-sm font-medium">{ex.name}</span>
<span className="text-xs text-muted-foreground leading-tight">{ex.description}</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleUpload} />
<button
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 disabled:opacity-50 cursor-pointer"
>
<Upload className="h-3 w-3" />
{uploading ? "Uploading…" : "Image"}
</button>
</div>
<div className="flex-1 overflow-auto min-h-0">
<EditorPane value={ufSource} onChange={setSource} extensions={extensions} />
</div>
</div>
);
}

View File

@@ -1,7 +1,211 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import * as api from "@/api/client";
import { useWindowManager } from "@/hooks/useWindowManager";
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
import FloatingWindow from "@/components/shared/FloatingWindow";
import EditorPane from "@/components/editor/EditorPane";
import EditorPointer from "@/components/editor/EditorPointer";
import { iniHighlight } from "@/components/editor/iniHighlight";
import { Button } from "@/components/ui/button";
import type { ManagedWindow } from "@/hooks/useWindowManager";
// ---------------------------------------------------------------------------
// Settings view — config editors + restart
// ---------------------------------------------------------------------------
const iniExtensions = iniHighlight();
type ConfigKind = "reticulum" | "reticulum-client" | "nomadnet";
interface ConfigWinData {
kind: ConfigKind;
}
export default function SettingsView() { export default function SettingsView() {
const {
windows, focusedId,
open, update, close, focus,
} = useWindowManager<ConfigWinData>({ w: 560, h: 440 });
const [identity, setIdentity] = useState<{ name: string; hash: string | null } | null>(null);
const [restarting, setRestarting] = useState(false);
useEffect(() => {
api.fetchIdentity().then(setIdentity).catch(() => {});
}, []);
const handleRestart = useCallback(async () => {
setRestarting(true);
try {
const result = await api.restartServices();
toast.success(
result.nomadnet_restarted
? "NomadNet restarted"
: "NomadNet container not found",
);
} catch {
toast.error("Restart failed");
} finally {
setRestarting(false);
}
}, []);
return ( return (
<div className="flex items-center justify-center h-full text-muted-foreground"> <div className="flex flex-col items-center justify-center h-full gap-4">
Settings {identity && (
<div className="flex flex-col gap-1.5 text-center">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Identity</h2>
<span className="text-sm font-medium">{identity.name}</span>
{identity.hash && (
<span className="text-[11px] font-mono text-muted-foreground select-all">{identity.hash}</span>
)}
</div>
)}
<div className="flex flex-col gap-3 text-center">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Configuration</h2>
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => open("reticulum", { kind: "reticulum" })}
>
Reticulum Server
</Button>
<Button
variant="outline"
onClick={() => open("reticulum-client", { kind: "reticulum-client" })}
>
Reticulum Client
</Button>
<Button
variant="outline"
onClick={() => open("nomadnet", { kind: "nomadnet" })}
>
NomadNet
</Button>
</div>
<Button
variant="default"
disabled={restarting}
onClick={handleRestart}
>
{restarting ? "Restarting..." : "Apply & Restart"}
</Button>
</div>
{windows.map((win) => (
<ConfigEditorWindow
key={win.id}
win={win}
focused={focusedId === win.id}
onUpdate={update}
onClose={close}
onFocus={focus}
/>
))}
</div> </div>
); );
} }
// ---------------------------------------------------------------------------
// Floating config editor window
// ---------------------------------------------------------------------------
const TITLES: Record<ConfigKind, string> = {
reticulum: "Reticulum Server",
"reticulum-client": "Reticulum Client",
nomadnet: "NomadNet Config",
};
function ConfigEditorWindow({
win, focused, onUpdate, onClose, onFocus,
}: {
win: ManagedWindow<ConfigWinData>;
focused: boolean;
onUpdate: (id: string, patch: Partial<ManagedWindow<ConfigWinData>>) => void;
onClose: (id: string) => void;
onFocus: (id: string) => void;
}) {
const kind = win.data.kind;
const windowRef = useRef<HTMLDivElement>(null);
const [content, setContent] = useState<string | null>(null);
const [isDirty, setIsDirty] = useState(false);
const [saving, setSaving] = useState(false);
const savedRef = useRef("");
useEffect(() => {
api.fetchConfig(kind).then((c) => {
setContent(c);
savedRef.current = c;
}).catch(() => toast.error(`Failed to load ${kind} config`));
}, [kind]);
const handleChange = useCallback((v: string) => {
setContent(v);
setIsDirty(v !== savedRef.current);
}, []);
const handleSave = useCallback(async () => {
if (content === null) return;
setSaving(true);
try {
await api.saveConfig(kind, content);
savedRef.current = content;
setIsDirty(false);
toast.success(`${TITLES[kind]} saved`);
} catch {
toast.error("Save failed");
} finally {
setSaving(false);
}
}, [content, kind]);
useKeyboardSave(handleSave, undefined, focused);
const handleClose = useCallback((id: string) => {
if (isDirty && !window.confirm("You have unsaved changes. Close anyway?")) return;
onClose(id);
}, [isDirty, onClose]);
return (
<FloatingWindow
id={win.id}
title={TITLES[kind]}
x={win.x} y={win.y} w={win.w} h={win.h}
zIndex={win.zIndex}
focused={focused}
onUpdate={onUpdate}
onClose={handleClose}
onFocus={onFocus}
minW={360} minH={250}
containerRef={windowRef}
>
<EditorPointer containerRef={windowRef} focused={focused} />
<div className="flex flex-col h-full">
<div className="flex items-center px-3 py-1.5 border-b-2 border-border shrink-0 gap-2">
<span className="text-xs font-semibold flex-1">
{TITLES[kind]}
{isDirty && <span className="text-muted-foreground ml-1.5">(unsaved)</span>}
</span>
<button
onClick={handleSave}
disabled={saving || !isDirty}
className="text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors cursor-pointer"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
<div className="flex-1 min-h-0">
{content !== null ? (
<EditorPane value={content} onChange={handleChange} extensions={iniExtensions} />
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
Loading...
</div>
)}
</div>
</div>
</FloatingWindow>
);
}

View File

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

40
frontend/src/types/three-shims.d.ts vendored Normal file
View File

@@ -0,0 +1,40 @@
declare module "three" {
export class Vector2 {
constructor(x?: number, y?: number);
set(x: number, y: number): this;
x: number;
y: number;
}
}
declare module "three/examples/jsm/postprocessing/UnrealBloomPass.js" {
import { Vector2 } from "three";
export class UnrealBloomPass {
constructor(resolution: Vector2, strength: number, radius: number, threshold: number);
strength: number;
radius: number;
threshold: number;
}
}
declare module "three/examples/jsm/postprocessing/ShaderPass.js" {
export class ShaderPass {
constructor(shader: any);
uniforms: Record<string, { value: any }>;
}
}
declare module "three/examples/jsm/renderers/CSS2DRenderer.js" {
export class CSS2DRenderer {
constructor();
setSize(width: number, height: number): void;
domElement: HTMLElement;
render(scene: any, camera: any): void;
}
export class CSS2DObject {
constructor(element: HTMLElement);
position: { set(x: number, y: number, z: number): void };
center: { set(x: number, y: number): void };
layers: { set(n: number): void };
}
}

View File

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

View File

@@ -16,7 +16,7 @@ glyphs = unicode
[node] [node]
# Enable page-serving node # Enable page-serving node
enable_node = yes enable_node = yes
node_name = Micronomicon node_name = Yopalito
# Announce on the network # Announce on the network
announce_interval = 360 announce_interval = 360

31
package-lock.json generated Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "micronomicon",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"three-spritetext": "^1.10.0"
}
},
"node_modules/three": {
"version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
"integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
"license": "MIT",
"peer": true
},
"node_modules/three-spritetext": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/three-spritetext/-/three-spritetext-1.10.0.tgz",
"integrity": "sha512-t08iP1FCU1lQh8T5MmCpdijKgas8GDHJE0LqMGBuVu3xqMMpFnEZhTlih7FlxLPQizHIGoumUSpfOlY1GO/Tgg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"peerDependencies": {
"three": ">=0.86.0"
}
}
}
}

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"three-spritetext": "^1.10.0"
}
}

23
reticulum-client.conf Normal file
View File

@@ -0,0 +1,23 @@
[reticulum]
enable_transport = False
share_instance = No
[logging]
loglevel = 4
[interfaces]
# Connect to NomadNet's TCP server
[[NomadNet Link]]
type = TCPClientInterface
enabled = Yes
target_host = nomadnet
target_port = 4242
[[Quad4]]
type = TCPClientInterface
interface_enabled = true
target_host = 62.151.179.77
target_port = 45657
mode = full
name = Quad4
selected_interface_mode = 1

View File

@@ -14,11 +14,3 @@
listen_ip = 0.0.0.0 listen_ip = 0.0.0.0
listen_port = 4242 listen_port = 4242
[[Quad4]]
type = TCPClientInterface
interface_enabled = false
target_host = 62.151.179.77
target_port = 45657
mode = full
name = Quad4
selected_interface_mode = 1