Compare commits
25 Commits
f082a30b7f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 46185705d7 | |||
| d8abe311cd | |||
| 2994735f3b | |||
| d7e9788f99 | |||
| 6e8a4af40d | |||
| 20e41f7680 | |||
| c9eead1965 | |||
| a1ad332e01 | |||
| 3c5856aecb | |||
| ad89f409bf | |||
| e1db06104e | |||
| 7838760ca4 | |||
| 914945279f | |||
| 3eec7f316e | |||
| 3132d40391 | |||
| 72ff02dbdf | |||
| e5c9c00d2f | |||
| 285825fff7 | |||
| b878068ab3 | |||
| 234081db09 | |||
| 911f3fb57d | |||
| 0e7e435edd | |||
| e1c7ea7635 | |||
| c820f06d1c | |||
| 0d469f70bf |
151
CLAUDE.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
463
backend/browse.py
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
"""Network browser — discovers NomadNet nodes and fetches pages via Reticulum.
|
||||||
|
|
||||||
|
Starts an RNS transport on FastAPI startup, listens for NomadNet node
|
||||||
|
announces, and exposes discovered nodes + remote page fetching via API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Query
|
||||||
|
from starlette.responses import StreamingResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
log = logging.getLogger("browse")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_nodes: dict[str, dict] = {} # hash_hex -> node info
|
||||||
|
_own_hash: str | None = None
|
||||||
|
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()
|
||||||
|
_started = False
|
||||||
|
_subscribers: list[asyncio.Queue] = []
|
||||||
|
_sub_lock = threading.Lock()
|
||||||
|
_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RNS announce handler (must be an object with aspect_filter + method)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _push_node(node_data: dict) -> None:
|
||||||
|
"""Push a node update to all SSE subscribers (thread-safe)."""
|
||||||
|
with _sub_lock:
|
||||||
|
for q in list(_subscribers):
|
||||||
|
if _loop and _loop.is_running():
|
||||||
|
_loop.call_soon_threadsafe(q.put_nowait, node_data)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
q.put_nowait(node_data)
|
||||||
|
except asyncio.QueueFull:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _AnnounceHandler:
|
||||||
|
"""RNS-compatible announce handler.
|
||||||
|
|
||||||
|
RNS.Transport.register_announce_handler() requires an object with:
|
||||||
|
- aspect_filter: str attribute
|
||||||
|
- received_announce(dest_hash, identity, app_data, ...): callable
|
||||||
|
"""
|
||||||
|
|
||||||
|
aspect_filter = "nomadnetwork.node"
|
||||||
|
|
||||||
|
def received_announce(
|
||||||
|
self,
|
||||||
|
destination_hash: bytes,
|
||||||
|
announced_identity,
|
||||||
|
app_data: bytes | None,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
import RNS
|
||||||
|
|
||||||
|
hash_hex = RNS.hexrep(destination_hash, delimit=False)
|
||||||
|
|
||||||
|
name = hash_hex[:12]
|
||||||
|
if app_data:
|
||||||
|
try:
|
||||||
|
name = app_data.decode("utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
is_self = name == _own_name()
|
||||||
|
|
||||||
|
# Determine which interface this announce arrived on
|
||||||
|
iface_name = None
|
||||||
|
try:
|
||||||
|
path_entry = RNS.Transport.path_table.get(destination_hash)
|
||||||
|
if path_entry and path_entry[5]: # IDX_PT_RVCD_IF = 5
|
||||||
|
iface_name = getattr(path_entry[5], "name", None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
_nodes[hash_hex] = {
|
||||||
|
"hash": hash_hex,
|
||||||
|
"name": name,
|
||||||
|
"last_seen": time.time(),
|
||||||
|
"is_self": is_self,
|
||||||
|
"type": "node",
|
||||||
|
"interface": iface_name,
|
||||||
|
}
|
||||||
|
if is_self:
|
||||||
|
global _own_hash
|
||||||
|
_own_hash = hash_hex
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Node announce: %s (%s) via %s%s",
|
||||||
|
name, hash_hex[:8], iface_name or "?",
|
||||||
|
" [self]" if is_self else "",
|
||||||
|
)
|
||||||
|
_push_node(_nodes[hash_hex])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RNS lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_reticulum = None
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_interfaces() -> list[dict]:
|
||||||
|
"""Read active RNS interfaces and return them as node-like dicts."""
|
||||||
|
try:
|
||||||
|
import RNS
|
||||||
|
except ImportError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
ifaces = []
|
||||||
|
for iface in RNS.Transport.interfaces:
|
||||||
|
name = getattr(iface, "name", str(iface))
|
||||||
|
iface_id = f"iface_{name}"
|
||||||
|
target = getattr(iface, "target_ip", None) or getattr(iface, "target_host", None)
|
||||||
|
port = getattr(iface, "target_port", None) or getattr(iface, "bind_port", None)
|
||||||
|
ifaces.append({
|
||||||
|
"hash": iface_id,
|
||||||
|
"name": name,
|
||||||
|
"last_seen": time.time(),
|
||||||
|
"is_self": False,
|
||||||
|
"type": "interface",
|
||||||
|
"online": getattr(iface, "online", False),
|
||||||
|
"target": f"{target}:{port}" if target and port else None,
|
||||||
|
"txb": getattr(iface, "txb", 0),
|
||||||
|
"rxb": getattr(iface, "rxb", 0),
|
||||||
|
"bitrate": getattr(iface, "bitrate", 0),
|
||||||
|
"clients": len(getattr(iface, "clients", None) or []) if hasattr(iface, "clients") else None,
|
||||||
|
})
|
||||||
|
return ifaces
|
||||||
|
|
||||||
|
|
||||||
|
def start_browser() -> None:
|
||||||
|
"""Initialize RNS and begin listening for NomadNet node announces."""
|
||||||
|
global _started, _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
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_snapshot() -> list[dict]:
|
||||||
|
"""Build a full snapshot: self node + interfaces + discovered nodes."""
|
||||||
|
with _lock:
|
||||||
|
nodes = list(_nodes.values())
|
||||||
|
|
||||||
|
# Ensure type field on all nodes
|
||||||
|
for n in nodes:
|
||||||
|
n.setdefault("type", "node")
|
||||||
|
|
||||||
|
if not any(n["is_self"] for n in nodes):
|
||||||
|
nodes.insert(0, {
|
||||||
|
"hash": _own_hash or "self",
|
||||||
|
"name": _own_name(),
|
||||||
|
"last_seen": time.time(),
|
||||||
|
"is_self": True,
|
||||||
|
"type": "node",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add interfaces
|
||||||
|
nodes.extend(_collect_interfaces())
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/browse/nodes")
|
||||||
|
async def list_nodes():
|
||||||
|
"""Return all discovered NomadNet nodes and interfaces."""
|
||||||
|
return _build_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/browse/nodes/stream")
|
||||||
|
async def stream_nodes():
|
||||||
|
"""SSE stream — pushes full snapshot then live node announces."""
|
||||||
|
queue: asyncio.Queue = asyncio.Queue(maxsize=64)
|
||||||
|
|
||||||
|
with _sub_lock:
|
||||||
|
_subscribers.append(queue)
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
try:
|
||||||
|
# Send full snapshot (self + interfaces + known nodes)
|
||||||
|
for entry in _build_snapshot():
|
||||||
|
yield f"data: {json.dumps(entry)}\n\n"
|
||||||
|
|
||||||
|
# Then stream new announces as they arrive
|
||||||
|
while True:
|
||||||
|
node = await queue.get()
|
||||||
|
yield f"data: {json.dumps(node)}\n\n"
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
with _sub_lock:
|
||||||
|
_subscribers.remove(queue)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_generator(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/browse/page/{hash_hex}")
|
||||||
|
async def get_remote_page(hash_hex: str, path: str = Query("index.mu")):
|
||||||
|
"""Fetch a Micron page from a node.
|
||||||
|
|
||||||
|
For the user's own node reads from the local pages directory.
|
||||||
|
For remote nodes establishes an RNS link and requests the page.
|
||||||
|
"""
|
||||||
|
with _lock:
|
||||||
|
node = _nodes.get(hash_hex)
|
||||||
|
if (node and node.get("is_self")) or hash_hex == "self":
|
||||||
|
return _read_local_page(path)
|
||||||
|
|
||||||
|
content = await _request_remote_page(hash_hex, path)
|
||||||
|
if content is None:
|
||||||
|
return {"content": None, "error": "Could not reach node"}
|
||||||
|
return {"content": content}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Local page reader
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _read_local_page(path: str) -> dict:
|
||||||
|
from converter import execute_dynamic_script
|
||||||
|
|
||||||
|
pages_dir = os.environ.get("PAGES_DIR", str(Path.home() / ".nomadnetwork/storage/pages"))
|
||||||
|
filepath = Path(pages_dir) / path
|
||||||
|
if not filepath.exists():
|
||||||
|
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)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Remote page fetcher (RNS link + request/response)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _request_remote_page(hash_hex: str, path: str) -> str | None:
|
||||||
|
"""Establish an RNS link to a remote NomadNet node and request a page."""
|
||||||
|
try:
|
||||||
|
import RNS
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
future: asyncio.Future[str | None] = loop.create_future()
|
||||||
|
|
||||||
|
def _do_request():
|
||||||
|
try:
|
||||||
|
dest_hash = bytes.fromhex(hash_hex)
|
||||||
|
|
||||||
|
if not RNS.Transport.has_path(dest_hash):
|
||||||
|
RNS.Transport.request_path(dest_hash)
|
||||||
|
deadline = time.time() + 10
|
||||||
|
while time.time() < deadline:
|
||||||
|
if RNS.Transport.has_path(dest_hash):
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
loop.call_soon_threadsafe(future.set_result, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
identity = RNS.Identity.recall(dest_hash)
|
||||||
|
if not identity:
|
||||||
|
loop.call_soon_threadsafe(future.set_result, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
dest = RNS.Destination(
|
||||||
|
identity,
|
||||||
|
RNS.Destination.OUT,
|
||||||
|
RNS.Destination.SINGLE,
|
||||||
|
"nomadnetwork",
|
||||||
|
"node",
|
||||||
|
)
|
||||||
|
|
||||||
|
link = RNS.Link(dest)
|
||||||
|
|
||||||
|
deadline = time.time() + 15
|
||||||
|
while time.time() < deadline:
|
||||||
|
if link.status == RNS.Link.ACTIVE:
|
||||||
|
break
|
||||||
|
time.sleep(0.1)
|
||||||
|
else:
|
||||||
|
link.teardown()
|
||||||
|
loop.call_soon_threadsafe(future.set_result, None)
|
||||||
|
return
|
||||||
|
|
||||||
|
def on_response(request_receipt):
|
||||||
|
try:
|
||||||
|
resp = request_receipt.response
|
||||||
|
if resp is not None:
|
||||||
|
content = resp.decode("utf-8") if isinstance(resp, bytes) else str(resp)
|
||||||
|
loop.call_soon_threadsafe(future.set_result, content)
|
||||||
|
else:
|
||||||
|
loop.call_soon_threadsafe(future.set_result, None)
|
||||||
|
except Exception:
|
||||||
|
loop.call_soon_threadsafe(future.set_result, None)
|
||||||
|
finally:
|
||||||
|
link.teardown()
|
||||||
|
|
||||||
|
def on_failed(request_receipt):
|
||||||
|
if not future.done():
|
||||||
|
loop.call_soon_threadsafe(future.set_result, None)
|
||||||
|
link.teardown()
|
||||||
|
|
||||||
|
link.request(
|
||||||
|
"/page/" + path,
|
||||||
|
response_callback=on_response,
|
||||||
|
failed_callback=on_failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("Remote page request failed: %s", exc)
|
||||||
|
if not future.done():
|
||||||
|
loop.call_soon_threadsafe(future.set_result, None)
|
||||||
|
|
||||||
|
threading.Thread(target=_do_request, daemon=True).start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(future, timeout=30.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
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}
|
||||||
@@ -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],
|
||||||
|
|||||||
139
backend/graph.py
@@ -1,139 +0,0 @@
|
|||||||
import os
|
|
||||||
import re
|
|
||||||
import shlex
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
|
|
||||||
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
|
||||||
|
|
||||||
# Match µFrame link nodes: link "display" "/page/slug.mu" or link "display" "slug"
|
|
||||||
_UF_LINK = re.compile(r'^\s*link\s+', re.IGNORECASE)
|
|
||||||
# Fallback: Micron links [label`slug] or [label`slug.mu]
|
|
||||||
_MICRON_LINK = re.compile(r'\[([^`\]]+)`([a-zA-Z0-9_-]+)(?:\.mu)?\]')
|
|
||||||
|
|
||||||
|
|
||||||
class GraphNode(BaseModel):
|
|
||||||
id: str
|
|
||||||
published: bool
|
|
||||||
title: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class GraphEdge(BaseModel):
|
|
||||||
source: str
|
|
||||||
target: str
|
|
||||||
|
|
||||||
|
|
||||||
class GraphData(BaseModel):
|
|
||||||
nodes: list[GraphNode]
|
|
||||||
edges: list[GraphEdge]
|
|
||||||
|
|
||||||
|
|
||||||
def _all_page_names() -> set[str]:
|
|
||||||
names: set[str] = set()
|
|
||||||
if PAGES_DIR.is_dir():
|
|
||||||
for f in PAGES_DIR.iterdir():
|
|
||||||
if f.suffix == ".mu" and f.is_file():
|
|
||||||
names.add(f.stem)
|
|
||||||
if SOURCES_DIR.is_dir():
|
|
||||||
for f in SOURCES_DIR.iterdir():
|
|
||||||
if f.suffix in (".uf", ".mu") and f.is_file():
|
|
||||||
names.add(f.stem)
|
|
||||||
return names
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_title(source: str) -> str | None:
|
|
||||||
"""Extract title from µFrame page or heading, or legacy Micron >Title."""
|
|
||||||
for line in source.splitlines():
|
|
||||||
stripped = line.strip()
|
|
||||||
if not stripped or stripped.startswith("#"):
|
|
||||||
continue
|
|
||||||
if stripped.lower().startswith("page "):
|
|
||||||
try:
|
|
||||||
parts = shlex.split(stripped)
|
|
||||||
if len(parts) >= 2:
|
|
||||||
return parts[1]
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
break
|
|
||||||
if stripped.lower().startswith("heading "):
|
|
||||||
try:
|
|
||||||
parts = shlex.split(stripped)
|
|
||||||
if len(parts) >= 3:
|
|
||||||
return parts[2]
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
break
|
|
||||||
if stripped.startswith(">") and not stripped.startswith(">>"):
|
|
||||||
return stripped[1:].strip()
|
|
||||||
break
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_links(source: str, all_names: set[str]) -> list[str]:
|
|
||||||
"""Extract internal link targets from µFrame or Micron source."""
|
|
||||||
targets: list[str] = []
|
|
||||||
|
|
||||||
for line in source.splitlines():
|
|
||||||
stripped = line.strip()
|
|
||||||
|
|
||||||
# µFrame: link "display" "/page/slug.mu" or link "display" "slug"
|
|
||||||
if _UF_LINK.match(stripped):
|
|
||||||
try:
|
|
||||||
parts = shlex.split(stripped)
|
|
||||||
if len(parts) >= 3:
|
|
||||||
dest = parts[2]
|
|
||||||
# Normalize: /page/slug.mu → slug
|
|
||||||
slug = dest.rsplit("/", 1)[-1].removesuffix(".mu")
|
|
||||||
if slug in all_names:
|
|
||||||
targets.append(slug)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Fallback: Micron link syntax [label`slug]
|
|
||||||
for m in _MICRON_LINK.finditer(stripped):
|
|
||||||
slug = m.group(2)
|
|
||||||
if slug in all_names:
|
|
||||||
targets.append(slug)
|
|
||||||
|
|
||||||
return targets
|
|
||||||
|
|
||||||
|
|
||||||
def _source_path(name: str) -> Path | None:
|
|
||||||
"""Get source file path, preferring .uf over .mu."""
|
|
||||||
uf = SOURCES_DIR / f"{name}.uf"
|
|
||||||
if uf.is_file():
|
|
||||||
return uf
|
|
||||||
mu = SOURCES_DIR / f"{name}.mu"
|
|
||||||
return mu if mu.is_file() else None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/graph", response_model=GraphData)
|
|
||||||
async def get_graph():
|
|
||||||
all_names = _all_page_names()
|
|
||||||
nodes: list[GraphNode] = []
|
|
||||||
edges: list[GraphEdge] = []
|
|
||||||
|
|
||||||
for name in sorted(all_names):
|
|
||||||
src_path = _source_path(name)
|
|
||||||
mu_path = PAGES_DIR / f"{name}.mu"
|
|
||||||
|
|
||||||
title = None
|
|
||||||
if src_path:
|
|
||||||
content = src_path.read_text(encoding="utf-8")
|
|
||||||
title = _extract_title(content)
|
|
||||||
for target in _extract_links(content, all_names):
|
|
||||||
edges.append(GraphEdge(source=name, target=target))
|
|
||||||
|
|
||||||
nodes.append(GraphNode(
|
|
||||||
id=name,
|
|
||||||
published=mu_path.is_file(),
|
|
||||||
title=title,
|
|
||||||
))
|
|
||||||
|
|
||||||
return GraphData(nodes=nodes, edges=edges)
|
|
||||||
@@ -4,22 +4,24 @@ 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 graph import router as graph_router
|
|
||||||
from docker_utils import router as docker_router
|
from docker_utils import router as docker_router
|
||||||
from converter import router as converter_router
|
from converter import router as converter_router
|
||||||
|
from browse import router as browse_router, start_browser
|
||||||
|
|
||||||
app = FastAPI(title="µFrame Editor")
|
app = FastAPI(title="µFrame Editor")
|
||||||
|
|
||||||
app.include_router(converter_router, prefix="/api")
|
app.include_router(converter_router, prefix="/api")
|
||||||
app.include_router(pages_router, prefix="/api")
|
app.include_router(pages_router, prefix="/api")
|
||||||
app.include_router(graph_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.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup():
|
async def startup():
|
||||||
ensure_default_pages()
|
ensure_default_pages()
|
||||||
|
start_browser()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
@@ -27,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")
|
||||||
|
|||||||
6
backend/package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "backend",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {}
|
||||||
|
}
|
||||||
192
backend/pages.py
@@ -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}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ docker>=7.0
|
|||||||
Pillow>=10.0
|
Pillow>=10.0
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
md2txt @ git+https://codeberg.org/randogoth/md2txt
|
md2txt @ git+https://codeberg.org/randogoth/md2txt
|
||||||
|
rns>=0.9.3
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
11
compose.yml
@@ -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
@@ -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"
|
||||||
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
567
frontend/package-lock.json
generated
@@ -16,28 +16,31 @@
|
|||||||
"@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",
|
||||||
"@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",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
|
"micron-parser": "^1.0.3",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -660,21 +663,6 @@
|
|||||||
"w3c-keyname": "^2.2.4"
|
"w3c-keyname": "^2.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -2123,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",
|
||||||
@@ -2133,53 +2127,14 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-color": {
|
"node_modules/@types/dompurify": {
|
||||||
"version": "3.1.3",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||||
"license": "MIT"
|
"dev": true,
|
||||||
},
|
|
||||||
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-selection": "*"
|
"@types/trusted-types": "*"
|
||||||
}
|
|
||||||
},
|
|
||||||
"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/estree": {
|
"node_modules/@types/estree": {
|
||||||
@@ -2232,6 +2187,13 @@
|
|||||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/trusted-types": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/validate-npm-package-name": {
|
"node_modules/@types/validate-npm-package-name": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||||
@@ -2559,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": {
|
||||||
@@ -2632,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",
|
||||||
@@ -2975,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",
|
||||||
@@ -3270,6 +3191,24 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-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",
|
||||||
@@ -3288,24 +3227,27 @@
|
|||||||
"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": {
|
"node_modules/d3-format": {
|
||||||
"version": "3.0.1",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
@@ -3322,6 +3264,50 @@
|
|||||||
"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": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-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",
|
||||||
@@ -3331,6 +3317,30 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-timer": {
|
"node_modules/d3-timer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
@@ -3340,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"
|
||||||
@@ -3498,6 +3485,15 @@
|
|||||||
"node": ">=0.3.1"
|
"node": ">=0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dompurify": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||||
|
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@types/trusted-types": "^2.0.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.3.1",
|
"version": "17.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||||
@@ -4192,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",
|
||||||
@@ -4578,6 +4588,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||||
@@ -4791,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",
|
||||||
@@ -4896,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",
|
||||||
@@ -5200,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",
|
||||||
@@ -5247,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",
|
||||||
@@ -5344,6 +5402,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micron-parser": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/micron-parser/-/micron-parser-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-6BKZa6eoS2AeFkjfQ5pZp7qoFNhI/N/3cZAiD9gp7bVkwjRi2+Wc7SV7np6DL9hkOmy+BGBcysbX86ygYRYhKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dompurify": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.54.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
@@ -5515,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",
|
||||||
@@ -5873,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",
|
||||||
@@ -5926,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",
|
||||||
@@ -5973,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",
|
||||||
@@ -6076,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",
|
||||||
@@ -6752,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",
|
||||||
|
|||||||
@@ -18,28 +18,31 @@
|
|||||||
"@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",
|
||||||
"@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",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
|
"micron-parser": "^1.0.3",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { Routes, Route } from "react-router-dom";
|
import { Routes, Route } from "react-router-dom";
|
||||||
import AppShell from "./components/shared/AppShell";
|
import AppShell from "./components/shared/AppShell";
|
||||||
import DashboardView from "./routes/DashboardView";
|
import ComposeView from "./routes/ComposeView";
|
||||||
import EditorView from "./routes/EditorView";
|
import BrowseView from "./routes/BrowseView";
|
||||||
import GraphView from "./routes/GraphView";
|
import SettingsView from "./routes/SettingsView";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<DashboardView />} />
|
<Route path="/" element={<ComposeView />} />
|
||||||
<Route path="/editor/new" element={<EditorView />} />
|
<Route path="/browse" element={<BrowseView />} />
|
||||||
<Route path="/editor/:name" element={<EditorView />} />
|
<Route path="/settings" element={<SettingsView />} />
|
||||||
<Route path="/graph" element={<GraphView />} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
274
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* Centralized API client for all backend communication.
|
||||||
|
*
|
||||||
|
* Every fetch call in the app should go through here so that
|
||||||
|
* endpoint URLs live in one place and are easy to update
|
||||||
|
* (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
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PageMeta {
|
||||||
|
name: string;
|
||||||
|
title: string | null;
|
||||||
|
published: boolean;
|
||||||
|
has_source: boolean;
|
||||||
|
last_modified: number | null;
|
||||||
|
size: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageDetail {
|
||||||
|
name: string;
|
||||||
|
source: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPages(): Promise<PageMeta[]> {
|
||||||
|
const res = await fetch("/api/pages");
|
||||||
|
return json(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPage(name: string): Promise<PageDetail> {
|
||||||
|
const res = await fetch(`/api/pages/${name}`);
|
||||||
|
return json(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePage(
|
||||||
|
name: string,
|
||||||
|
source: string,
|
||||||
|
publish: boolean,
|
||||||
|
): Promise<PageMeta> {
|
||||||
|
const res = await fetch(`/api/pages/${name}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ source, publish }),
|
||||||
|
});
|
||||||
|
return json(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePage(name: string): Promise<void> {
|
||||||
|
const res = await fetch(`/api/pages/${name}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CompileResult {
|
||||||
|
ascii: string;
|
||||||
|
micron: string;
|
||||||
|
script: string;
|
||||||
|
is_dynamic: boolean;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compile(
|
||||||
|
source: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<CompileResult> {
|
||||||
|
const res = await fetch("/api/compile", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ source }),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ detail: "Compile failed" }));
|
||||||
|
throw new Error(err.detail || "Compile failed");
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DSL Metadata
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface DslCommand {
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
section: string;
|
||||||
|
snippet: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DslMeta {
|
||||||
|
keywords: string[];
|
||||||
|
values: string[];
|
||||||
|
commands: DslCommand[];
|
||||||
|
themes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDslMeta(): Promise<DslMeta> {
|
||||||
|
const res = await fetch("/api/dsl-meta");
|
||||||
|
return json(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Node Management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function restartNode(): Promise<void> {
|
||||||
|
const res = await fetch("/api/restart", { method: "POST" });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Browse — network node discovery + remote pages
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface NetworkNode {
|
||||||
|
hash: string;
|
||||||
|
name: string;
|
||||||
|
last_seen: number;
|
||||||
|
is_self: boolean;
|
||||||
|
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[]> {
|
||||||
|
const res = await fetch("/api/browse/nodes");
|
||||||
|
const data = await json<NetworkNode[] | unknown>(res);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeBrowseNodes(
|
||||||
|
onNode: (node: NetworkNode) => void,
|
||||||
|
): () => void {
|
||||||
|
const es = new EventSource("/api/browse/nodes/stream");
|
||||||
|
es.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
onNode(JSON.parse(e.data));
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
};
|
||||||
|
return () => es.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRemotePage(
|
||||||
|
hash: string,
|
||||||
|
path: string = "index.mu",
|
||||||
|
): Promise<{ content: string | null; error?: string }> {
|
||||||
|
const res = await fetch(
|
||||||
|
`/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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Images
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
filename: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadImage(file: File): Promise<UploadResult> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const res = await fetch("/api/upload-image", { method: "POST", body: form });
|
||||||
|
return json(res);
|
||||||
|
}
|
||||||
1
frontend/src/assets/axis-mundi.min.svg
Normal file
|
After Width: | Height: | Size: 293 KiB |
1
frontend/src/assets/browser.min.svg
Normal file
|
After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 496 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 2.8 MiB |
1
frontend/src/assets/loading.min.svg
Normal file
|
After Width: | Height: | Size: 980 KiB |
1
frontend/src/assets/menu.min.svg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
1
frontend/src/assets/pointer.min.svg
Normal file
|
After Width: | Height: | Size: 552 KiB |
77
frontend/src/components/browse/BrowseNodeWindow.tsx
Normal 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">
|
||||||
|
◀
|
||||||
|
</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">
|
||||||
|
▶
|
||||||
|
</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">
|
||||||
|
↻
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
161
frontend/src/components/browse/BrowseSearchBar.tsx
Normal 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)">
|
||||||
|
×
|
||||||
|
</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);
|
||||||
|
}
|
||||||
106
frontend/src/components/browse/buildGraph.ts
Normal 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 };
|
||||||
|
}
|
||||||
64
frontend/src/components/browse/graphColors.ts
Normal 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")}`;
|
||||||
|
}
|
||||||
17
frontend/src/components/browse/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import type { BacklinkPage } from "@/hooks/useBacklinks";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
backlinks: BacklinkPage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BacklinkIndicator({ backlinks }: Props) {
|
|
||||||
if (backlinks.length === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={<Button variant="ghost" size="sm" className="text-xs text-muted-foreground h-7 px-2" />}
|
|
||||||
>
|
|
||||||
← {backlinks.length} backlink{backlinks.length !== 1 ? "s" : ""}
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-64 p-2" align="end">
|
|
||||||
<p className="text-xs font-semibold text-muted-foreground mb-2 px-1">
|
|
||||||
Pages linking here
|
|
||||||
</p>
|
|
||||||
<ul className="space-y-0.5">
|
|
||||||
{backlinks.map((page) => (
|
|
||||||
<li key={page.name}>
|
|
||||||
<Link
|
|
||||||
to={`/editor/${page.name}`}
|
|
||||||
className="flex items-center gap-1.5 text-sm px-2 py-1 rounded hover:bg-accent"
|
|
||||||
>
|
|
||||||
<span>{page.title ?? page.name}</span>
|
|
||||||
{page.title && (
|
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
|
||||||
({page.name})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,55 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { EditorView, keymap } from "@codemirror/view";
|
import { EditorView, keymap, lineNumbers, highlightActiveLine, Decoration, ViewPlugin } from "@codemirror/view";
|
||||||
import { EditorState } from "@codemirror/state";
|
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
||||||
import type { Extension } from "@codemirror/state";
|
import type { Extension } from "@codemirror/state";
|
||||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
import type { DecorationSet } from "@codemirror/view";
|
||||||
|
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";
|
||||||
|
|
||||||
|
function toRoman(n: number): string {
|
||||||
|
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
|
||||||
|
const syms = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"];
|
||||||
|
let result = "";
|
||||||
|
for (let i = 0; i < vals.length; i++) {
|
||||||
|
while (n >= vals[i]) {
|
||||||
|
result += syms[i];
|
||||||
|
n -= vals[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotDeco = Decoration.mark({ class: "cm-dot-space" });
|
||||||
|
|
||||||
|
function buildSpaceDeco(view: EditorView): DecorationSet {
|
||||||
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
const text = view.state.sliceDoc(from, to);
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
if (text[i] === " ") {
|
||||||
|
builder.add(from + i, from + i + 1, dotDeco);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtleWhitespace = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.decorations = buildSpaceDeco(view);
|
||||||
|
}
|
||||||
|
update(update: { docChanged: boolean; viewportChanged: boolean; view: EditorView }) {
|
||||||
|
if (update.docChanged || update.viewportChanged) {
|
||||||
|
this.decorations = buildSpaceDeco(update.view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ decorations: (v) => v.decorations },
|
||||||
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
@@ -25,17 +69,47 @@ export default function EditorPane({ value, onChange, extensions = [] }: Props)
|
|||||||
doc: value,
|
doc: value,
|
||||||
extensions: [
|
extensions: [
|
||||||
history(),
|
history(),
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
lineNumbers({ formatNumber: toRoman }),
|
||||||
|
highlightActiveLine(),
|
||||||
|
subtleWhitespace,
|
||||||
|
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
||||||
oneDark,
|
oneDark,
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
onChangeRef.current(update.state.doc.toString());
|
onChangeRef.current(update.state.doc.toString());
|
||||||
}
|
}
|
||||||
|
if (update.selectionSet || update.docChanged) {
|
||||||
|
const pos = update.state.selection.main.head;
|
||||||
|
const coords = update.view.coordsAtPos(pos);
|
||||||
|
if (coords) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("cm-cursor-move", { detail: { top: coords.top, left: coords.left } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
EditorView.theme({
|
EditorView.theme({
|
||||||
"&": { height: "100%", fontSize: "14px" },
|
"&": { height: "100%", fontSize: "14px" },
|
||||||
".cm-scroller": { overflow: "auto" },
|
".cm-scroller": { overflow: "auto" },
|
||||||
".cm-content": { fontFamily: "monospace", padding: "16px" },
|
".cm-content": { fontFamily: "monospace", padding: "16px" },
|
||||||
|
".cm-cursor, .cm-cursor-primary": {
|
||||||
|
borderLeftColor: "var(--primary)",
|
||||||
|
borderLeftWidth: "0.5em",
|
||||||
|
},
|
||||||
|
".cm-dot-space": {
|
||||||
|
color: "transparent",
|
||||||
|
position: "relative",
|
||||||
|
},
|
||||||
|
".cm-dot-space:before": {
|
||||||
|
content: '"\\22C5"',
|
||||||
|
position: "absolute",
|
||||||
|
left: "0",
|
||||||
|
right: "0",
|
||||||
|
textAlign: "center",
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
opacity: "0.5",
|
||||||
|
pointerEvents: "none",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
...extensions,
|
...extensions,
|
||||||
],
|
],
|
||||||
|
|||||||
158
frontend/src/components/editor/EditorPointer.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import pointerSvg from "@/assets/pointer.min.svg";
|
||||||
|
import { useLazyEyes } from "@/hooks/useLazyEyes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Floating pointer that tracks the CodeMirror cursor vertically,
|
||||||
|
* pinned to the left edge of the editor. Positions relative to
|
||||||
|
* containerRef (for floating windows).
|
||||||
|
*/
|
||||||
|
export default function EditorPointer({ containerRef, focused = true }: { containerRef?: React.RefObject<HTMLElement | null>; focused?: boolean }) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [editorLeft, setEditorLeft] = useState<number | null>(null);
|
||||||
|
const targetRef = useRef(0);
|
||||||
|
const currentRef = useRef(0);
|
||||||
|
const rafRef = useRef(0);
|
||||||
|
const activeRef = useRef(false);
|
||||||
|
const [clickKey, setClickKey] = useState(0);
|
||||||
|
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
const cmRef = useRef<Element | null>(null);
|
||||||
|
const pointerElRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
const ease = 0.09;
|
||||||
|
|
||||||
|
const getContainerRect = () =>
|
||||||
|
containerRef?.current?.getBoundingClientRect() ?? { left: 0, top: 0 };
|
||||||
|
|
||||||
|
const updateLeft = () => {
|
||||||
|
if (!cmRef.current || !containerRef?.current) return;
|
||||||
|
const cmLeft = cmRef.current.getBoundingClientRect().left;
|
||||||
|
const containerLeft = containerRef.current.getBoundingClientRect().left;
|
||||||
|
setEditorLeft(cmLeft - containerLeft);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onEditorClick = () => {
|
||||||
|
clearTimeout(clickTimerRef.current);
|
||||||
|
setClickKey((k) => k + 1);
|
||||||
|
clickTimerRef.current = setTimeout(() => setClickKey(0), 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCursorMove = (e: Event) => {
|
||||||
|
const { top } = (e as CustomEvent).detail;
|
||||||
|
|
||||||
|
if (!cmRef.current) {
|
||||||
|
const scope = containerRef?.current ?? document;
|
||||||
|
const cm = scope.querySelector(".cm-editor");
|
||||||
|
if (cm) {
|
||||||
|
cmRef.current = cm;
|
||||||
|
cm.addEventListener("mousedown", onEditorClick);
|
||||||
|
ro.observe(cm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerTop = getContainerRect().top;
|
||||||
|
targetRef.current = top - containerTop;
|
||||||
|
updateLeft();
|
||||||
|
|
||||||
|
if (!activeRef.current) {
|
||||||
|
currentRef.current = targetRef.current;
|
||||||
|
if (pointerElRef.current) {
|
||||||
|
pointerElRef.current.style.top = `${targetRef.current}px`;
|
||||||
|
}
|
||||||
|
setVisible(true);
|
||||||
|
activeRef.current = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (activeRef.current) {
|
||||||
|
const diff = targetRef.current - currentRef.current;
|
||||||
|
if (Math.abs(diff) < 0.5) {
|
||||||
|
currentRef.current = targetRef.current;
|
||||||
|
} else {
|
||||||
|
currentRef.current += diff * ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("cm-cursor-move", onCursorMove);
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => updateLeft());
|
||||||
|
const scope = containerRef?.current ?? document;
|
||||||
|
const existingCm = scope.querySelector(".cm-editor");
|
||||||
|
if (existingCm) {
|
||||||
|
cmRef.current = existingCm;
|
||||||
|
ro.observe(existingCm);
|
||||||
|
}
|
||||||
|
window.addEventListener("resize", updateLeft);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("cm-cursor-move", onCursorMove);
|
||||||
|
window.removeEventListener("resize", updateLeft);
|
||||||
|
cmRef.current?.removeEventListener("mousedown", onEditorClick);
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
ro.disconnect();
|
||||||
|
};
|
||||||
|
}, [containerRef]);
|
||||||
|
|
||||||
|
if (!focused || !visible || editorLeft === null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={pointerElRef}
|
||||||
|
className={`editor-pointer${clickKey ? " editor-pointer-click" : ""}`}
|
||||||
|
key={clickKey}
|
||||||
|
style={{ left: editorLeft }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="editor-pointer-img"
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: `url(${pointerSvg})`,
|
||||||
|
maskImage: `url(${pointerSvg})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="editor-pointer-eye" style={{ top: 33, left: 134 }}>
|
||||||
|
<div
|
||||||
|
ref={iris1Ref}
|
||||||
|
className="editor-pointer-iris"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="editor-pointer-eye" style={{ top: 29, left: 144 }}>
|
||||||
|
<div
|
||||||
|
ref={iris2Ref}
|
||||||
|
className="editor-pointer-iris"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/src/components/editor/EditorStoreContext.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import { useStore, type StoreApi } from "zustand";
|
||||||
|
import type { EditorStore } from "@/stores/editorStore";
|
||||||
|
|
||||||
|
export const EditorStoreContext = createContext<StoreApi<EditorStore> | null>(null);
|
||||||
|
|
||||||
|
export function useEditorCtx<T>(selector: (s: EditorStore) => T): T {
|
||||||
|
const store = useContext(EditorStoreContext);
|
||||||
|
if (!store) throw new Error("useEditorCtx must be used within EditorStoreContext.Provider");
|
||||||
|
return useStore(store, selector);
|
||||||
|
}
|
||||||
163
frontend/src/components/editor/EditorWindow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,21 +1,26 @@
|
|||||||
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 = "ascii" | "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 compiledAscii = useEditorStore((s) => s.compiledAscii);
|
const compiledMicron = useEditorCtx((s) => s.compiledMicron);
|
||||||
const compiledMicron = useEditorStore((s) => s.compiledMicron);
|
const compiledScript = useEditorCtx((s) => s.compiledScript);
|
||||||
const compiledScript = useEditorStore((s) => s.compiledScript);
|
const isDynamic = useEditorCtx((s) => s.isDynamic);
|
||||||
const isDynamic = useEditorStore((s) => s.isDynamic);
|
const isCompiling = useEditorCtx((s) => s.isCompiling);
|
||||||
const isCompiling = useEditorStore((s) => s.isCompiling);
|
const compileError = useEditorCtx((s) => s.compileError);
|
||||||
const compileError = useEditorStore((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: "ascii", label: "ASCII", show: true },
|
|
||||||
{ value: "micron", label: "Micron", show: true },
|
{ value: "micron", label: "Micron", show: true },
|
||||||
{ value: "raw", label: "Raw", show: true },
|
{ value: "raw", label: "Raw", show: true },
|
||||||
{ value: "script", label: "Script", show: isDynamic },
|
{ value: "script", label: "Script", show: isDynamic },
|
||||||
@@ -23,14 +28,14 @@ export default function PreviewPane() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<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">
|
<div className="flex items-center px-4 py-2 border-b-2 border-border shrink-0 gap-2">
|
||||||
<span className="text-xs text-muted-foreground flex-1">
|
<span className="font-medium text-foreground flex-1">
|
||||||
Preview
|
Preview
|
||||||
{isDynamic && (
|
{isDynamic && (
|
||||||
<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>
|
||||||
@@ -59,21 +64,14 @@ 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 */}
|
||||||
{previewMode === "ascii" ? (
|
<div className="flex-1 bg-background overflow-auto min-h-0" onClick={handlePreviewClick}>
|
||||||
<pre className="p-2 font-mono text-[11px] whitespace-pre leading-tight text-green-100/90">
|
{previewMode === "micron" ? (
|
||||||
{compiledAscii || (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
ASCII preview will appear here…
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</pre>
|
|
||||||
) : previewMode === "micron" ? (
|
|
||||||
compiledMicron ? (
|
compiledMicron ? (
|
||||||
<div
|
<div
|
||||||
className="p-2 font-mono text-[11px] whitespace-pre leading-tight"
|
className="p-2 font-mono text-[11px] leading-tight"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: renderMicron(compiledMicron),
|
__html: renderMicron(compiledMicron, true),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
91
frontend/src/components/editor/SourcePane.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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-3 py-1.5 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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
80
frontend/src/components/editor/iniHighlight.ts
Normal 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)];
|
||||||
|
}
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { StreamLanguage, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
|
||||||
import { tags } from "@lezer/highlight";
|
|
||||||
|
|
||||||
const micronLanguage = StreamLanguage.define({
|
|
||||||
token(stream) {
|
|
||||||
if (stream.sol()) {
|
|
||||||
// Depth-4+ indent (before >>> so ">>>> " doesn't match heading3)
|
|
||||||
if (stream.match(/>>>>/)) { stream.skipToEnd(); return "keyword"; }
|
|
||||||
// Headings — longest prefix first
|
|
||||||
if (stream.match(/>>>/)) { stream.skipToEnd(); return "heading3"; }
|
|
||||||
if (stream.match(/>>/)) { stream.skipToEnd(); return "heading2"; }
|
|
||||||
if (stream.match(/>/)) { stream.skipToEnd(); return "heading1"; }
|
|
||||||
// Dividers: line starting with - followed by a non-space, non-dash char
|
|
||||||
if (stream.match(/-[^\s\-]/)) { stream.skipToEnd(); return "contentSeparator"; }
|
|
||||||
// Comment lines
|
|
||||||
if (stream.match(/#/)) { stream.skipToEnd(); return "lineComment"; }
|
|
||||||
// Standalone depth-reset "<"
|
|
||||||
if (stream.string.trim() === "<") { stream.next(); return "meta"; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backtick-based format tags: `! `* `_ `` `F `f `B `b `c `r `l `a `= `<
|
|
||||||
if (stream.match(/`[!*_`FfBbCcRrLlAa=<]/)) return "meta";
|
|
||||||
|
|
||||||
// Hex color values (exactly 3 hex digits) — appear right after `F or `B tags
|
|
||||||
if (stream.match(/[0-9a-fA-F]{3}(?![0-9a-fA-F])/)) return "number";
|
|
||||||
|
|
||||||
// Links [label`url] — consume the whole bracket expression
|
|
||||||
if (stream.match(/\[[^\]]*\]/)) return "link";
|
|
||||||
|
|
||||||
// Form elements <fieldname`default> etc.
|
|
||||||
if (stream.match(/<[^>]+>/)) return "string";
|
|
||||||
|
|
||||||
stream.next();
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
startState: () => ({}),
|
|
||||||
copyState: (s) => ({ ...s }),
|
|
||||||
blankLine: () => {},
|
|
||||||
languageData: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
const micronStyle = HighlightStyle.define([
|
|
||||||
{ tag: tags.heading1, color: "#7ee8a2", fontWeight: "bold" },
|
|
||||||
{ tag: tags.heading2, color: "#70c4e8", fontWeight: "bold" },
|
|
||||||
{ tag: tags.heading3, color: "#a8c4e8", fontWeight: "bold" },
|
|
||||||
{ tag: tags.keyword, color: "#c9d1d9", fontStyle: "italic" }, // depth-4+ indent
|
|
||||||
{ tag: tags.contentSeparator, color: "#484f58", fontStyle: "italic" },
|
|
||||||
{ tag: tags.lineComment, color: "#484f58", fontStyle: "italic" }, // # comments
|
|
||||||
{ tag: tags.meta, color: "#d2a8ff" }, // backtick format codes
|
|
||||||
{ tag: tags.number, color: "#f8d4a8" }, // hex color values
|
|
||||||
{ tag: tags.link, color: "#7dc4e4", textDecoration: "underline" },
|
|
||||||
{ tag: tags.string, color: "#d4a8f8" }, // form elements
|
|
||||||
]);
|
|
||||||
|
|
||||||
export function micronHighlight() {
|
|
||||||
return [micronLanguage, syntaxHighlighting(micronStyle)];
|
|
||||||
}
|
|
||||||
@@ -1,243 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Micron markup → HTML renderer for the editor preview pane.
|
* Micron markup → HTML renderer using the micron-parser library.
|
||||||
* Spec: https://github.com/fr33n0w/micron-composer
|
* Reference: https://github.com/RFnexus/micron-parser-js
|
||||||
|
*
|
||||||
|
* Uses convertMicronToFragment (DOM-based) instead of convertMicronToHtml
|
||||||
|
* to avoid DOMPurify stripping nomadnetwork:// hrefs from link tags.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
import MicronParser from "micron-parser";
|
||||||
return text
|
|
||||||
.replace(/&/g, "&")
|
let darkParser: MicronParser | null = null;
|
||||||
.replace(/</g, "<")
|
let lightParser: MicronParser | null = null;
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """);
|
export function renderMicron(source: string, darkTheme: boolean = true): string {
|
||||||
}
|
const parser = darkTheme
|
||||||
|
? (darkParser ??= new MicronParser(true, true))
|
||||||
/** Render inline Micron formatting codes within a line of text. */
|
: (lightParser ??= new MicronParser(false, true));
|
||||||
function renderInline(raw: string): string {
|
|
||||||
let out = "";
|
const fragment = parser.convertMicronToFragment(source);
|
||||||
let i = 0;
|
|
||||||
|
// Serialize the fragment to HTML string
|
||||||
// Track open/close state for toggle-style tags
|
const div = document.createElement("div");
|
||||||
let boldOpen = false;
|
div.appendChild(fragment);
|
||||||
let italicOpen = false;
|
return div.innerHTML;
|
||||||
let underOpen = false;
|
|
||||||
let fgOpen = false;
|
|
||||||
let bgOpen = false;
|
|
||||||
let alignOpen = false;
|
|
||||||
|
|
||||||
while (i < raw.length) {
|
|
||||||
// Backtick formatting codes
|
|
||||||
if (raw[i] === "`") {
|
|
||||||
const code = raw[i + 1];
|
|
||||||
if (code === "!") {
|
|
||||||
if (boldOpen) { out += "</strong>"; boldOpen = false; }
|
|
||||||
else { out += "<strong>"; boldOpen = true; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "*") {
|
|
||||||
if (italicOpen) { out += "</em>"; italicOpen = false; }
|
|
||||||
else { out += "<em>"; italicOpen = true; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "_") {
|
|
||||||
if (underOpen) { out += "</u>"; underOpen = false; }
|
|
||||||
else { out += "<u>"; underOpen = true; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "`") {
|
|
||||||
// Reset all
|
|
||||||
if (boldOpen) { out += "</strong>"; boldOpen = false; }
|
|
||||||
if (italicOpen) { out += "</em>"; italicOpen = false; }
|
|
||||||
if (underOpen) { out += "</u>"; underOpen = false; }
|
|
||||||
if (fgOpen) { out += "</span>"; fgOpen = false; }
|
|
||||||
if (bgOpen) { out += "</span>"; bgOpen = false; }
|
|
||||||
if (alignOpen) { out += "</span>"; alignOpen = false; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "f") {
|
|
||||||
if (fgOpen) { out += "</span>"; fgOpen = false; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "b") {
|
|
||||||
if (bgOpen) { out += "</span>"; bgOpen = false; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "a") {
|
|
||||||
if (alignOpen) { out += "</span>"; alignOpen = false; }
|
|
||||||
i += 2; continue;
|
|
||||||
} else if (code === "F") {
|
|
||||||
// Foreground color — 3-digit hex only per spec
|
|
||||||
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})/);
|
|
||||||
if (hexMatch) {
|
|
||||||
if (fgOpen) { out += "</span>"; }
|
|
||||||
const [r, g, b] = hexMatch[1].split("");
|
|
||||||
const hex = r + r + g + g + b + b;
|
|
||||||
out += `<span style="color:#${hex}">`;
|
|
||||||
fgOpen = true;
|
|
||||||
i += 2 + hexMatch[1].length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else if (code === "B") {
|
|
||||||
// Background color — 3-digit hex only per spec
|
|
||||||
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})/);
|
|
||||||
if (hexMatch) {
|
|
||||||
if (bgOpen) { out += "</span>"; }
|
|
||||||
const [r, g, b] = hexMatch[1].split("");
|
|
||||||
const hex = r + r + g + g + b + b;
|
|
||||||
out += `<span style="background:#${hex}">`;
|
|
||||||
bgOpen = true;
|
|
||||||
i += 2 + hexMatch[1].length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else if (code === "c") {
|
|
||||||
out += `<span style="display:block;text-align:center">`;
|
|
||||||
alignOpen = true; i += 2; continue;
|
|
||||||
} else if (code === "r") {
|
|
||||||
out += `<span style="display:block;text-align:right">`;
|
|
||||||
alignOpen = true; i += 2; continue;
|
|
||||||
} else if (code === "l") {
|
|
||||||
out += `<span style="display:block;text-align:left">`;
|
|
||||||
alignOpen = true; i += 2; continue;
|
|
||||||
} else if (code === "<") {
|
|
||||||
// Form element: `<...> or `<!...> or `<?...> or `<^...>
|
|
||||||
const closeAngle = raw.indexOf(">", i + 2);
|
|
||||||
if (closeAngle !== -1) {
|
|
||||||
const inner = raw.slice(i + 2, closeAngle);
|
|
||||||
out += renderFormTag(inner);
|
|
||||||
i = closeAngle + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
} else if (code === "[") {
|
|
||||||
// Link with inline formatting: `[`!Label`!`:/dest]
|
|
||||||
const closeBracket = raw.indexOf("]", i + 2);
|
|
||||||
if (closeBracket !== -1) {
|
|
||||||
const inner = raw.slice(i + 2, closeBracket);
|
|
||||||
const colonIdx = inner.indexOf("`:");
|
|
||||||
if (colonIdx !== -1) {
|
|
||||||
const labelRaw = inner.slice(0, colonIdx);
|
|
||||||
const dest = inner.slice(colonIdx + 2);
|
|
||||||
// Strip formatting tags from label for display
|
|
||||||
const label = labelRaw.replace(/`[!*_]/g, "");
|
|
||||||
out += `<a href="${escapeHtml(dest)}" style="color:#7dc4e4;text-decoration:underline">${escapeHtml(label)}</a>`;
|
|
||||||
i = closeBracket + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Links: [label`slug] or [label`slug.mu]
|
|
||||||
if (raw[i] === "[") {
|
|
||||||
const close = raw.indexOf("]", i);
|
|
||||||
if (close !== -1) {
|
|
||||||
const inner = raw.slice(i + 1, close);
|
|
||||||
const backtick = inner.indexOf("`");
|
|
||||||
if (backtick !== -1) {
|
|
||||||
const label = escapeHtml(inner.slice(0, backtick));
|
|
||||||
const slug = escapeHtml(inner.slice(backtick + 1).replace(/\.mu$/, ""));
|
|
||||||
out += `<a href="/view/${slug}" style="color:#7dc4e4;text-decoration:underline">${label}</a>`;
|
|
||||||
i = close + 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out += escapeHtml(raw[i]);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close any remaining open tags
|
|
||||||
if (boldOpen) out += "</strong>";
|
|
||||||
if (italicOpen) out += "</em>";
|
|
||||||
if (underOpen) out += "</u>";
|
|
||||||
if (fgOpen) out += "</span>";
|
|
||||||
if (bgOpen) out += "</span>";
|
|
||||||
if (alignOpen) out += "</span>";
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Render a Micron form tag `<...> as styled HTML. */
|
|
||||||
function renderFormTag(inner: string): string {
|
|
||||||
const esc = escapeHtml;
|
|
||||||
|
|
||||||
// Text field: width|name`placeholder or name`placeholder
|
|
||||||
// Password: !width|name`placeholder
|
|
||||||
// Checkbox: ?|name|value`label or ?|name|value|*`label
|
|
||||||
// Radio: ^|group|value`label or ^|group|value|*`label
|
|
||||||
|
|
||||||
if (inner.startsWith("?")) {
|
|
||||||
// Checkbox
|
|
||||||
const backtick = inner.indexOf("`");
|
|
||||||
const label = backtick !== -1 ? inner.slice(backtick + 1) : "";
|
|
||||||
const checked = inner.includes("|*");
|
|
||||||
const box = checked ? "☑" : "☐";
|
|
||||||
return `<span style="color:#d4a8f8">${box} ${esc(label)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inner.startsWith("^")) {
|
|
||||||
// Radio button
|
|
||||||
const backtick = inner.indexOf("`");
|
|
||||||
const label = backtick !== -1 ? inner.slice(backtick + 1) : "";
|
|
||||||
const selected = inner.includes("|*");
|
|
||||||
const dot = selected ? "◉" : "○";
|
|
||||||
return `<span style="color:#d4a8f8">${dot} ${esc(label)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inner.startsWith("!")) {
|
|
||||||
// Password field
|
|
||||||
const backtick = inner.indexOf("`");
|
|
||||||
const placeholder = backtick !== -1 ? inner.slice(backtick + 1) : "";
|
|
||||||
return `<span style="color:#d4a8f8;border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px">🔒 ${esc(placeholder || "••••••")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular text field: width|name`placeholder or name`placeholder
|
|
||||||
const backtick = inner.indexOf("`");
|
|
||||||
const placeholder = backtick !== -1 ? inner.slice(backtick + 1) : "";
|
|
||||||
return `<span style="color:#d4a8f8;border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px">${esc(placeholder || "...")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function renderMicron(source: string): string {
|
|
||||||
const lines = source.split("\n");
|
|
||||||
const htmlLines: string[] = [];
|
|
||||||
let literalMode = false;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
// Toggle literal mode on standalone `= line
|
|
||||||
if (line.trimEnd() === "`=") {
|
|
||||||
literalMode = !literalMode;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In literal mode — render verbatim
|
|
||||||
if (literalMode) {
|
|
||||||
htmlLines.push(`<div style="font-family:monospace;opacity:0.75;white-space:pre">${escapeHtml(line)}</div>`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Comment lines — hidden in output
|
|
||||||
if (line.startsWith("#")) continue;
|
|
||||||
|
|
||||||
// All output uses inline spans — the parent container has white-space:pre
|
|
||||||
// so newlines come from the \n join at the end.
|
|
||||||
|
|
||||||
// Depth-4+ indent (before >>> check)
|
|
||||||
if (line.startsWith(">>>>")) {
|
|
||||||
htmlLines.push(`<span style="color:#c9d1d9;font-style:italic">${renderInline(line.slice(4))}</span>`);
|
|
||||||
// Headings
|
|
||||||
} else if (line.startsWith(">>>")) {
|
|
||||||
htmlLines.push(`<span style="color:#a8c4e8;font-weight:bold">${renderInline(line.slice(3))}</span>`);
|
|
||||||
} else if (line.startsWith(">>")) {
|
|
||||||
htmlLines.push(`<span style="color:#70c4e8;font-weight:bold">${renderInline(line.slice(2))}</span>`);
|
|
||||||
} else if (line.startsWith(">")) {
|
|
||||||
htmlLines.push(`<span style="color:#7ee8a2;font-weight:bold">${renderInline(line.slice(1))}</span>`);
|
|
||||||
// Dividers: - followed by a non-space, non-dash character
|
|
||||||
} else if (/^-[^\s-]/.test(line)) {
|
|
||||||
const char = line[1];
|
|
||||||
htmlLines.push(`<span style="color:#484f58">${char.repeat(40)}</span>`);
|
|
||||||
// Standalone depth-reset "<"
|
|
||||||
} else if (line.trim() === "<") {
|
|
||||||
htmlLines.push(`<span style="color:#d2a8ff;opacity:0.4">↩ depth reset</span>`);
|
|
||||||
// Empty line
|
|
||||||
} else if (line.trim() === "") {
|
|
||||||
htmlLines.push("");
|
|
||||||
} else {
|
|
||||||
htmlLines.push(renderInline(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return htmlLines.join("\n");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,25 +3,27 @@ import { EditorView } from "@codemirror/view";
|
|||||||
export const oneDark = EditorView.theme(
|
export const oneDark = EditorView.theme(
|
||||||
{
|
{
|
||||||
"&": {
|
"&": {
|
||||||
backgroundColor: "#0d1117",
|
backgroundColor: "var(--background)",
|
||||||
color: "#c9d1d9",
|
color: "var(--foreground)",
|
||||||
},
|
},
|
||||||
".cm-cursor": {
|
".cm-cursor, .cm-cursor-primary": {
|
||||||
borderLeftColor: "#c9d1d9",
|
borderLeftColor: "var(--primary)",
|
||||||
|
borderLeftWidth: "0.5em",
|
||||||
},
|
},
|
||||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||||
backgroundColor: "#264f78",
|
backgroundColor: "color-mix(in oklch, var(--primary) 25%, transparent)",
|
||||||
},
|
},
|
||||||
".cm-activeLine": {
|
".cm-activeLine": {
|
||||||
backgroundColor: "#161b2266",
|
backgroundColor: "color-mix(in oklch, var(--primary) 8%, transparent)",
|
||||||
},
|
},
|
||||||
".cm-gutters": {
|
".cm-gutters": {
|
||||||
backgroundColor: "#0d1117",
|
backgroundColor: "var(--background)",
|
||||||
color: "#484f58",
|
color: "var(--muted-foreground)",
|
||||||
borderRight: "1px solid #21262d",
|
borderRight: "1px solid var(--border)",
|
||||||
},
|
},
|
||||||
".cm-activeLineGutter": {
|
".cm-activeLineGutter": {
|
||||||
backgroundColor: "#161b2266",
|
backgroundColor: "color-mix(in oklch, var(--primary) 8%, transparent)",
|
||||||
|
color: "var(--primary)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ dark: true }
|
{ dark: true }
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import { snippet } from "@codemirror/autocomplete";
|
|
||||||
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
|
|
||||||
import type { EditorView } from "@codemirror/view";
|
|
||||||
|
|
||||||
interface SlashEntry {
|
|
||||||
label: string;
|
|
||||||
detail: string;
|
|
||||||
section: string;
|
|
||||||
apply: Completion["apply"];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert text, replacing from the "/" character (from-1) through the cursor
|
|
||||||
function insert(text: string): Completion["apply"] {
|
|
||||||
return (view: EditorView, _completion: Completion, from: number, to: number) => {
|
|
||||||
view.dispatch({ changes: { from: from - 1, to, insert: text } });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap snippet() to also replace the preceding "/" character
|
|
||||||
function slashSnippet(template: string): Completion["apply"] {
|
|
||||||
const snip = snippet(template);
|
|
||||||
return (view: EditorView, completion: Completion, from: number, to: number) => {
|
|
||||||
snip(view, completion, from - 1, to - 1);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const COMMANDS: SlashEntry[] = [
|
|
||||||
// Headings
|
|
||||||
{ label: "H1", detail: ">...", section: "Heading", apply: slashSnippet(">\${text}") },
|
|
||||||
{ label: "H2", detail: ">>...", section: "Heading", apply: slashSnippet(">>\${text}") },
|
|
||||||
{ label: "H3", detail: ">>>...", section: "Heading", apply: slashSnippet(">>>\${text}") },
|
|
||||||
|
|
||||||
// Text formatting
|
|
||||||
{ label: "Bold", detail: "`!..`!", section: "Format", apply: slashSnippet("`!\${text}`!") },
|
|
||||||
{ label: "Italic", detail: "`*..`*", section: "Format", apply: slashSnippet("`*\${text}`*") },
|
|
||||||
{ label: "Underline", detail: "`_..`_", section: "Format", apply: slashSnippet("`_\${text}`_") },
|
|
||||||
{ label: "Reset", detail: "``", section: "Format", apply: insert("``") },
|
|
||||||
{ label: "Literal", detail: "`=...`=", section: "Format", apply: slashSnippet("`=\n\${content}\n`=") },
|
|
||||||
|
|
||||||
// Alignment
|
|
||||||
{ label: "Center", detail: "`c..`a", section: "Align", apply: slashSnippet("`c\${text}`a") },
|
|
||||||
{ label: "Right", detail: "`r..`a", section: "Align", apply: slashSnippet("`r\${text}`a") },
|
|
||||||
{ label: "Left", detail: "`l..`a", section: "Align", apply: slashSnippet("`l\${text}`a") },
|
|
||||||
|
|
||||||
// Color (3-digit hex)
|
|
||||||
{ label: "Color", detail: "`Fhex..`f", section: "Color", apply: slashSnippet("`F\${hex}\${text}`f") },
|
|
||||||
{ label: "BgColor", detail: "`Bhex..`b", section: "Color", apply: slashSnippet("`B\${hex}\${text}`b") },
|
|
||||||
|
|
||||||
// Links
|
|
||||||
{ label: "Link", detail: "[label`page]", section: "Link", apply: slashSnippet("[\${label}`\${page}]") },
|
|
||||||
|
|
||||||
// Dividers
|
|
||||||
{ label: "Divider ─", detail: "-─", section: "Divider", apply: insert("-─") },
|
|
||||||
{ label: "Divider ━", detail: "-━", section: "Divider", apply: insert("-━") },
|
|
||||||
{ label: "Divider ═", detail: "-═", section: "Divider", apply: insert("-═") },
|
|
||||||
{ label: "Divider ★", detail: "-★", section: "Divider", apply: insert("-★") },
|
|
||||||
|
|
||||||
// Forms — pipe separators per micron-composer spec
|
|
||||||
{ label: "Field", detail: "<name`default>", section: "Form", apply: slashSnippet("<\${name}`\${default}>") },
|
|
||||||
{ label: "Password", detail: "<!w|name`placeholder>", section: "Form", apply: slashSnippet("<!\${width}|\${name}`\${placeholder}>") },
|
|
||||||
{ label: "Checkbox", detail: "<?|name|val`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}`\${label}>") },
|
|
||||||
{ label: "Checked", detail: "<?|name|val|*`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}|*`\${label}>") },
|
|
||||||
{ label: "Radio", detail: "<^|grp|val`label>", section: "Form", apply: slashSnippet("<^\${group}|\${value}`\${label}>") },
|
|
||||||
|
|
||||||
// Depth
|
|
||||||
{ label: "Reset depth", detail: "<", section: "Depth", apply: insert("<\n") },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function slashCommandSource(ctx: CompletionContext): CompletionResult | null {
|
|
||||||
const match = ctx.matchBefore(/\/\w*/);
|
|
||||||
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
|
||||||
return {
|
|
||||||
// Start after "/" so the filter text doesn't include "/" (which would block all matches)
|
|
||||||
from: match.from + 1,
|
|
||||||
filter: true,
|
|
||||||
options: COMMANDS.map((cmd) => ({
|
|
||||||
label: cmd.label,
|
|
||||||
detail: cmd.detail,
|
|
||||||
section: cmd.section,
|
|
||||||
apply: cmd.apply,
|
|
||||||
boost: 99,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -5,17 +5,13 @@ import type {
|
|||||||
CompletionResult,
|
CompletionResult,
|
||||||
} from "@codemirror/autocomplete";
|
} from "@codemirror/autocomplete";
|
||||||
import type { EditorView } from "@codemirror/view";
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
import { fetchDslMeta } from "@/api/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* µFrame slash command palette — auto-populated from /api/dsl-meta.
|
* µFrame slash command palette — auto-populated from the backend DSL registry.
|
||||||
*
|
*
|
||||||
* On first load, uses a minimal fallback set. Once the API responds,
|
* On first load, uses a minimal fallback set. Once the API responds,
|
||||||
* the full command list (including bigtitle, image, themes, etc.)
|
* the full command list replaces it via loadCommandsFromApi().
|
||||||
* replaces it via loadCommandsFromApi().
|
|
||||||
*
|
|
||||||
* Uses window.__uframeCommands as the shared mutable store so that
|
|
||||||
* both the original module instance and any HMR-reloaded copies
|
|
||||||
* read from the same array.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
interface CmdEntry {
|
interface CmdEntry {
|
||||||
@@ -25,12 +21,9 @@ interface CmdEntry {
|
|||||||
apply: Completion["apply"];
|
apply: Completion["apply"];
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
// Module-level cache — survives re-renders but not full page reload.
|
||||||
interface Window {
|
let commands: CmdEntry[] | null = null;
|
||||||
__uframeCommands?: CmdEntry[];
|
let loaded = false;
|
||||||
__uframeCommandsLoaded?: boolean;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function insert(text: string): Completion["apply"] {
|
function insert(text: string): Completion["apply"] {
|
||||||
return (view: EditorView, _c: Completion, from: number, to: number) => {
|
return (view: EditorView, _c: Completion, from: number, to: number) => {
|
||||||
@@ -45,7 +38,6 @@ function slashSnippet(template: string): Completion["apply"] {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal fallback commands (used before API loads)
|
|
||||||
const FALLBACK_COMMANDS: CmdEntry[] = [
|
const FALLBACK_COMMANDS: CmdEntry[] = [
|
||||||
{ label: "page", detail: 'page "Title" 64', section: "Layout", apply: slashSnippet('page "${title}" ${width:64}') },
|
{ label: "page", detail: 'page "Title" 64', section: "Layout", apply: slashSnippet('page "${title}" ${width:64}') },
|
||||||
{ label: "box", detail: 'box light "Title"', section: "Layout", apply: slashSnippet('box ${weight:light} "${title}"') },
|
{ label: "box", detail: 'box light "Title"', section: "Layout", apply: slashSnippet('box ${weight:light} "${title}"') },
|
||||||
@@ -56,20 +48,18 @@ const FALLBACK_COMMANDS: CmdEntry[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function getCommands(): CmdEntry[] {
|
function getCommands(): CmdEntry[] {
|
||||||
return window.__uframeCommands ?? FALLBACK_COMMANDS;
|
return commands ?? FALLBACK_COMMANDS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the full command list from /api/dsl-meta.
|
* Load the full command list from the backend DSL registry.
|
||||||
* Called once on editor mount. Replaces the fallback set with the
|
* Called once on editor mount.
|
||||||
* complete registry-driven list.
|
|
||||||
*/
|
*/
|
||||||
export async function loadCommandsFromApi(): Promise<void> {
|
export async function loadCommandsFromApi(): Promise<void> {
|
||||||
|
if (loaded) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/dsl-meta");
|
const data = await fetchDslMeta();
|
||||||
if (!res.ok) return;
|
|
||||||
const data = await res.json();
|
|
||||||
const apiCommands: CmdEntry[] = [];
|
const apiCommands: CmdEntry[] = [];
|
||||||
|
|
||||||
for (const cmd of data.commands ?? []) {
|
for (const cmd of data.commands ?? []) {
|
||||||
@@ -84,7 +74,7 @@ export async function loadCommandsFromApi(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the dashboard template (not in registry)
|
// Dashboard template (not in registry)
|
||||||
apiCommands.push({
|
apiCommands.push({
|
||||||
label: "dashboard",
|
label: "dashboard",
|
||||||
detail: "Full dashboard template",
|
detail: "Full dashboard template",
|
||||||
@@ -94,8 +84,8 @@ export async function loadCommandsFromApi(): Promise<void> {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
window.__uframeCommands = apiCommands;
|
commands = apiCommands;
|
||||||
window.__uframeCommandsLoaded = true;
|
loaded = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Keep using fallback
|
// Keep using fallback
|
||||||
}
|
}
|
||||||
@@ -122,14 +112,8 @@ export function uframeCommandSource(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Attribute value hints — suggests valid values based on the keyword
|
* Attribute value hints — suggests valid values based on the keyword
|
||||||
* on the current line. Triggers when typing a word after a keyword.
|
* on the current line.
|
||||||
*
|
|
||||||
* e.g. typing `box ` suggests: light, heavy, double, rounded
|
|
||||||
* typing `status "Server" ` suggests: online, offline, degraded
|
|
||||||
* typing `hnav ` suggests: bar, tabs, pills, breadcrumb, underline
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// keyword → list of valid attribute values
|
|
||||||
const KEYWORD_VALUES: Record<string, { values: string[]; hint: string }> = {
|
const KEYWORD_VALUES: Record<string, { values: string[]; hint: string }> = {
|
||||||
box: { values: ["light", "heavy", "double", "rounded"], hint: "border weight" },
|
box: { values: ["light", "heavy", "double", "rounded"], hint: "border weight" },
|
||||||
divider: { values: ["light", "heavy", "double", "dash", "dot"], hint: "divider style" },
|
divider: { values: ["light", "heavy", "double", "dash", "dot"], hint: "divider style" },
|
||||||
@@ -149,14 +133,11 @@ const KEYWORD_VALUES: Record<string, { values: string[]; hint: string }> = {
|
|||||||
export function uframeValueHintSource(
|
export function uframeValueHintSource(
|
||||||
ctx: CompletionContext,
|
ctx: CompletionContext,
|
||||||
): CompletionResult | null {
|
): CompletionResult | null {
|
||||||
// Get the current line text up to cursor
|
|
||||||
const line = ctx.state.doc.lineAt(ctx.pos);
|
const line = ctx.state.doc.lineAt(ctx.pos);
|
||||||
const textBefore = line.text.slice(0, ctx.pos - line.from);
|
const textBefore = line.text.slice(0, ctx.pos - line.from);
|
||||||
|
|
||||||
// Don't trigger if we're typing a slash command
|
|
||||||
if (textBefore.trimStart().startsWith("/")) return null;
|
if (textBefore.trimStart().startsWith("/")) return null;
|
||||||
|
|
||||||
// Extract the keyword (first word on the line, after indentation)
|
|
||||||
const kwMatch = textBefore.match(/^\s*(\w+)\s/);
|
const kwMatch = textBefore.match(/^\s*(\w+)\s/);
|
||||||
if (!kwMatch) return null;
|
if (!kwMatch) return null;
|
||||||
|
|
||||||
@@ -164,20 +145,14 @@ export function uframeValueHintSource(
|
|||||||
const entry = KEYWORD_VALUES[keyword];
|
const entry = KEYWORD_VALUES[keyword];
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
|
|
||||||
// Don't suggest if we're inside quotes
|
|
||||||
const quotesBefore = (textBefore.match(/"/g) || []).length;
|
const quotesBefore = (textBefore.match(/"/g) || []).length;
|
||||||
if (quotesBefore % 2 !== 0) return null;
|
if (quotesBefore % 2 !== 0) return null;
|
||||||
|
|
||||||
// Match: `word:partial` OR `partial` — the colon acts as a trigger
|
|
||||||
// e.g. `bigtitle "text" font:` → match `font:` → suggest block, thin, pixel
|
|
||||||
// e.g. `bigtitle "text" font:b` → match `font:b` → filter to block
|
|
||||||
// e.g. `box l` → match `l` → suggest light
|
|
||||||
// Colon trigger: `font:` or `font:b` → replace entire `font:...` with the value
|
|
||||||
const colonWordMatch = ctx.matchBefore(/\w+:\w*/);
|
const colonWordMatch = ctx.matchBefore(/\w+:\w*/);
|
||||||
if (colonWordMatch) {
|
if (colonWordMatch) {
|
||||||
return {
|
return {
|
||||||
from: colonWordMatch.from, // replace from start of `font:`
|
from: colonWordMatch.from,
|
||||||
filter: false, // show all options, we handle filtering
|
filter: false,
|
||||||
options: entry.values.map((v) => ({
|
options: entry.values.map((v) => ({
|
||||||
label: v,
|
label: v,
|
||||||
detail: entry.hint,
|
detail: entry.hint,
|
||||||
@@ -186,7 +161,6 @@ export function uframeValueHintSource(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plain word match (no colon)
|
|
||||||
const wordMatch = ctx.matchBefore(/\w+/);
|
const wordMatch = ctx.matchBefore(/\w+/);
|
||||||
if (!wordMatch) {
|
if (!wordMatch) {
|
||||||
if (!ctx.explicit) return null;
|
if (!ctx.explicit) return null;
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
|
|
||||||
import type { MutableRefObject } from "react";
|
|
||||||
import type { PageMeta } from "@/stores/editorStore";
|
|
||||||
|
|
||||||
export function wikiLinkSource(pagesRef: MutableRefObject<PageMeta[]>) {
|
|
||||||
return (context: CompletionContext): CompletionResult | null => {
|
|
||||||
const match = context.matchBefore(/\[\[[\w-]*/);
|
|
||||||
if (!match || (match.from === match.to && !context.explicit)) return null;
|
|
||||||
|
|
||||||
const options: Completion[] = pagesRef.current.map((page) => ({
|
|
||||||
label: page.title ?? page.name,
|
|
||||||
detail: page.name,
|
|
||||||
apply: (view, _completion, from, to) => {
|
|
||||||
const title = page.title ?? page.name;
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from, to, insert: `[${title}\`${page.name}]` },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
return { from: match.from, options, filter: true };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,9 @@ import { useEffect, useState, type ReactNode } from "react";
|
|||||||
import { useNavigate } 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 frameTerraSvg from "@/assets/frame.themed.svg";
|
import browserSvg from "@/assets/browser.min.svg";
|
||||||
import frameAzureSvg from "@/assets/frame.azure.svg";
|
import NavMenu from "./NavMenu";
|
||||||
|
import LazyEyes from "./LazyEyes";
|
||||||
|
|
||||||
const TITLE = `
|
const TITLE = `
|
||||||
|
|
||||||
@@ -80,9 +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 [theme, setTheme] = useState<Theme>(getStoredTheme);
|
const [theme, setTheme] = useState<Theme>(getStoredTheme);
|
||||||
|
const layout = frameLayout;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -96,59 +122,75 @@ export default function AppShell({ children }: { children: ReactNode }) {
|
|||||||
localStorage.setItem("micronomicon-theme", theme);
|
localStorage.setItem("micronomicon-theme", theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
const frameSvg = theme === "azure" ? frameAzureSvg : frameTerraSvg;
|
|
||||||
|
|
||||||
const toggleTheme = () => setTheme(t => t === "terra" ? "azure" : "terra");
|
const toggleTheme = () => setTheme(t => t === "terra" ? "azure" : "terra");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<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 */}
|
||||||
<img
|
<div
|
||||||
key={theme}
|
|
||||||
src={frameSvg}
|
|
||||||
alt=""
|
|
||||||
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={{ zIndex: 0, objectFit: "fill" }}
|
style={{
|
||||||
|
zIndex: 0,
|
||||||
|
height: `${layout.frameHeight}px`,
|
||||||
|
background: "var(--primary)",
|
||||||
|
WebkitMaskImage: `url(${layout.svg})`,
|
||||||
|
maskImage: `url(${layout.svg})`,
|
||||||
|
WebkitMaskSize: "cover",
|
||||||
|
maskSize: "cover",
|
||||||
|
WebkitMaskRepeat: "no-repeat",
|
||||||
|
maskRepeat: "no-repeat",
|
||||||
|
objectFit: "fill"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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 */}
|
||||||
|
<div
|
||||||
|
className="absolute z-20"
|
||||||
|
style={layout.nav}
|
||||||
|
>
|
||||||
|
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
||||||
|
</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>
|
||||||
<span className="text-muted-foreground/50">│</span>
|
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className="hover:text-foreground transition-colors cursor-pointer"
|
|
||||||
title={`Switch to ${theme === "terra" ? "Azure" : "Terracotta"} theme`}
|
|
||||||
>
|
|
||||||
{theme === "terra" ? "◐ AZURE" : "◑ TERRA"}
|
|
||||||
</button>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
154
frontend/src/components/shared/FloatingWindow.tsx
Normal 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);
|
||||||
|
}
|
||||||
77
frontend/src/components/shared/LazyEyes.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
frontend/src/components/shared/Loader.tsx
Normal 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",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
// NavBar removed — frame.svg is the visual wrapper now
|
|
||||||
export default function NavBar() {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
102
frontend/src/components/shared/NavMenu.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import menuSvg from "@/assets/menu.min.svg";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ label: "Browse", path: "/browse" },
|
||||||
|
{ label: "Compose", path: "/" },
|
||||||
|
{ label: "Settings", path: "/settings" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smooth scroll-following: tracks the scroll container and lerps
|
||||||
|
* the menu's vertical offset so it glides into place with eased acceleration.
|
||||||
|
*/
|
||||||
|
function useSmoothScroll(scrollSelector: string, ease = 0.08) {
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const targetRef = useRef(0);
|
||||||
|
const currentRef = useRef(0);
|
||||||
|
const rafRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = document.querySelector(scrollSelector);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
targetRef.current = container.scrollTop;
|
||||||
|
};
|
||||||
|
container.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const diff = targetRef.current - currentRef.current;
|
||||||
|
if (Math.abs(diff) < 0.5) {
|
||||||
|
currentRef.current = targetRef.current;
|
||||||
|
} else {
|
||||||
|
currentRef.current += diff * ease;
|
||||||
|
}
|
||||||
|
setOffset(currentRef.current);
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
container.removeEventListener("scroll", onScroll);
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [scrollSelector, ease]);
|
||||||
|
|
||||||
|
return offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavMenuProps {
|
||||||
|
theme: "terra" | "azure";
|
||||||
|
onToggleTheme: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NavMenu({ theme, onToggleTheme }: NavMenuProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const scrollY = useSmoothScroll("[data-scroll-root]", 0.07);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="nav-menu-root"
|
||||||
|
style={{ transform: `translateY(${scrollY}px)` }}
|
||||||
|
>
|
||||||
|
{/* Frame — menu.min.svg as mask, colored by theme */}
|
||||||
|
<div
|
||||||
|
className="nav-menu-frame"
|
||||||
|
style={{
|
||||||
|
WebkitMaskImage: `url(${menuSvg})`,
|
||||||
|
maskImage: `url(${menuSvg})`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Navigation links */}
|
||||||
|
<nav className="nav-menu-links">
|
||||||
|
{NAV_ITEMS.map(({ label, path }) => {
|
||||||
|
const active = location.pathname === path;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={path}
|
||||||
|
onClick={() => navigate(path)}
|
||||||
|
className="nav-menu-item"
|
||||||
|
data-active={active || undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Theme toggle — bottom container */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
className="nav-menu-theme-toggle"
|
||||||
|
title={`Switch to ${theme === "terra" ? "Azure" : "Terracotta"} theme`}
|
||||||
|
>
|
||||||
|
{theme === "terra" ? "◐ AZURE" : "◑ TERRA"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function ScrollArea({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: ScrollAreaPrimitive.Root.Props) {
|
|
||||||
return (
|
|
||||||
<ScrollAreaPrimitive.Root
|
|
||||||
data-slot="scroll-area"
|
|
||||||
className={cn("relative", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Viewport
|
|
||||||
data-slot="scroll-area-viewport"
|
|
||||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ScrollAreaPrimitive.Viewport>
|
|
||||||
<ScrollBar />
|
|
||||||
<ScrollAreaPrimitive.Corner />
|
|
||||||
</ScrollAreaPrimitive.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScrollBar({
|
|
||||||
className,
|
|
||||||
orientation = "vertical",
|
|
||||||
...props
|
|
||||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
|
||||||
return (
|
|
||||||
<ScrollAreaPrimitive.Scrollbar
|
|
||||||
data-slot="scroll-area-scrollbar"
|
|
||||||
data-orientation={orientation}
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Thumb
|
|
||||||
data-slot="scroll-area-thumb"
|
|
||||||
className="relative flex-1 rounded-full bg-border"
|
|
||||||
/>
|
|
||||||
</ScrollAreaPrimitive.Scrollbar>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar }
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Separator({
|
|
||||||
className,
|
|
||||||
orientation = "horizontal",
|
|
||||||
...props
|
|
||||||
}: SeparatorPrimitive.Props) {
|
|
||||||
return (
|
|
||||||
<SeparatorPrimitive
|
|
||||||
data-slot="separator"
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Separator }
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
|
||||||
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
|
||||||
import { type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { toggleVariants } from "@/components/ui/toggle"
|
|
||||||
|
|
||||||
const ToggleGroupContext = React.createContext<
|
|
||||||
VariantProps<typeof toggleVariants> & {
|
|
||||||
spacing?: number
|
|
||||||
orientation?: "horizontal" | "vertical"
|
|
||||||
}
|
|
||||||
>({
|
|
||||||
size: "default",
|
|
||||||
variant: "default",
|
|
||||||
spacing: 0,
|
|
||||||
orientation: "horizontal",
|
|
||||||
})
|
|
||||||
|
|
||||||
function ToggleGroup({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
spacing = 0,
|
|
||||||
orientation = "horizontal",
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: ToggleGroupPrimitive.Props &
|
|
||||||
VariantProps<typeof toggleVariants> & {
|
|
||||||
spacing?: number
|
|
||||||
orientation?: "horizontal" | "vertical"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ToggleGroupPrimitive
|
|
||||||
data-slot="toggle-group"
|
|
||||||
data-variant={variant}
|
|
||||||
data-size={size}
|
|
||||||
data-spacing={spacing}
|
|
||||||
data-orientation={orientation}
|
|
||||||
style={{ "--gap": spacing } as React.CSSProperties}
|
|
||||||
className={cn(
|
|
||||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ToggleGroupContext.Provider
|
|
||||||
value={{ variant, size, spacing, orientation }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ToggleGroupContext.Provider>
|
|
||||||
</ToggleGroupPrimitive>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToggleGroupItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
variant = "default",
|
|
||||||
size = "default",
|
|
||||||
...props
|
|
||||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
|
||||||
const context = React.useContext(ToggleGroupContext)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TogglePrimitive
|
|
||||||
data-slot="toggle-group-item"
|
|
||||||
data-variant={context.variant || variant}
|
|
||||||
data-size={context.size || size}
|
|
||||||
data-spacing={context.spacing}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
|
||||||
toggleVariants({
|
|
||||||
variant: context.variant || variant,
|
|
||||||
size: context.size || size,
|
|
||||||
}),
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</TogglePrimitive>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ToggleGroup, ToggleGroupItem }
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
const toggleVariants = cva(
|
|
||||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-transparent",
|
|
||||||
outline: "border border-input bg-transparent hover:bg-muted",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default:
|
|
||||||
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
||||||
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
||||||
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Toggle({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
size = "default",
|
|
||||||
...props
|
|
||||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
|
||||||
return (
|
|
||||||
<TogglePrimitive
|
|
||||||
data-slot="toggle"
|
|
||||||
className={cn(toggleVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Toggle, toggleVariants }
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { useGraph } from "@/hooks/useGraph";
|
|
||||||
|
|
||||||
export interface BacklinkPage {
|
|
||||||
name: string;
|
|
||||||
title: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBacklinks(currentSlug: string | undefined): BacklinkPage[] {
|
|
||||||
const { data } = useGraph();
|
|
||||||
return useMemo(() => {
|
|
||||||
if (!data || !currentSlug) return [];
|
|
||||||
const nodeMap = new Map(data.nodes.map((n) => [n.id, n]));
|
|
||||||
return data.edges
|
|
||||||
.filter((e) => e.target === currentSlug)
|
|
||||||
.map((e) => ({
|
|
||||||
name: e.source,
|
|
||||||
title: nodeMap.get(e.source)?.title ?? null,
|
|
||||||
}));
|
|
||||||
}, [data, currentSlug]);
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,33 @@
|
|||||||
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";
|
||||||
|
|
||||||
const DEBOUNCE_MS = 400;
|
const DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Debounced hook that compiles µFrame source via POST /api/compile.
|
* 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);
|
||||||
|
|
||||||
const compile = useCallback(
|
const doCompile = useCallback(
|
||||||
async (source: string) => {
|
async (source: string) => {
|
||||||
if (!source.trim()) {
|
if (!source.trim()) {
|
||||||
setCompileResult("", "", "", false, []);
|
setCompileResult("", "", "", false, []);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Abort any in-flight request
|
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
abortRef.current = controller;
|
abortRef.current = controller;
|
||||||
@@ -31,21 +35,14 @@ export function useCompile() {
|
|||||||
setCompiling(true);
|
setCompiling(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/compile", {
|
const data = await compile(source, controller.signal);
|
||||||
method: "POST",
|
setCompileResult(
|
||||||
headers: { "Content-Type": "application/json" },
|
data.ascii,
|
||||||
body: JSON.stringify({ source }),
|
data.micron,
|
||||||
signal: controller.signal,
|
data.script || "",
|
||||||
});
|
data.is_dynamic || false,
|
||||||
|
data.warnings || [],
|
||||||
if (!res.ok) {
|
);
|
||||||
const err = await res.json().catch(() => ({ detail: "Compile failed" }));
|
|
||||||
setCompileError(err.detail || "Compile failed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
setCompileResult(data.ascii, data.micron, data.script || "", data.is_dynamic || false, data.warnings || []);
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof DOMException && e.name === "AbortError") return;
|
if (e instanceof DOMException && e.name === "AbortError") return;
|
||||||
setCompileError(e instanceof Error ? e.message : "Compile failed");
|
setCompileError(e instanceof Error ? e.message : "Compile failed");
|
||||||
@@ -56,13 +53,12 @@ export function useCompile() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
timerRef.current = setTimeout(() => compile(ufSource), DEBOUNCE_MS);
|
timerRef.current = setTimeout(() => doCompile(ufSource), DEBOUNCE_MS);
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
};
|
};
|
||||||
}, [ufSource, compile]);
|
}, [ufSource, doCompile]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { fetchDslMeta, type DslMeta } from "@/api/client";
|
||||||
|
|
||||||
export interface DslMeta {
|
export type { DslMeta } from "@/api/client";
|
||||||
keywords: string[];
|
|
||||||
values: string[];
|
|
||||||
commands: { label: string; detail: string; section: string; snippet: string }[];
|
|
||||||
themes: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_META: DslMeta = {
|
const DEFAULT_META: DslMeta = {
|
||||||
keywords: [],
|
keywords: [],
|
||||||
@@ -22,9 +18,8 @@ export function useDslMeta(): DslMeta {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (cachedMeta) return;
|
if (cachedMeta) return;
|
||||||
|
|
||||||
fetch("/api/dsl-meta")
|
fetchDslMeta()
|
||||||
.then((r) => r.json())
|
.then((data) => {
|
||||||
.then((data: DslMeta) => {
|
|
||||||
cachedMeta = data;
|
cachedMeta = data;
|
||||||
setMeta(data);
|
setMeta(data);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export interface GraphNode {
|
|
||||||
id: string;
|
|
||||||
published: boolean;
|
|
||||||
title: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GraphEdge {
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GraphData {
|
|
||||||
nodes: GraphNode[];
|
|
||||||
edges: GraphEdge[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useGraph() {
|
|
||||||
const [data, setData] = useState<GraphData | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/graph");
|
|
||||||
setData(await res.json());
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { data, loading };
|
|
||||||
}
|
|
||||||
27
frontend/src/hooks/useKeyboardSave.ts
Normal 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]);
|
||||||
|
}
|
||||||
106
frontend/src/hooks/useLazyEyes.ts
Normal 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 0–1 for slow drift (default 0.04) */
|
||||||
|
ease?: number;
|
||||||
|
/** Saccade threshold — when target jumps more than this, snap fast (default 0.8) */
|
||||||
|
saccadeThreshold?: number;
|
||||||
|
/** Fast ease for saccade snap (default 0.35) */
|
||||||
|
saccadeEase?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a 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 };
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
|||||||
63
frontend/src/hooks/useWindowManager.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
|
||||||
|
let nextWinZ = 1;
|
||||||
|
|
||||||
|
export interface ManagedWindow<T> {
|
||||||
|
id: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
zIndex: number;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWindowManager<T>(defaults?: { w: number; h: number }) {
|
||||||
|
const defaultW = defaults?.w ?? 480;
|
||||||
|
const defaultH = defaults?.h ?? 400;
|
||||||
|
const [windows, setWindows] = useState<ManagedWindow<T>[]>([]);
|
||||||
|
const [focusedId, setFocusedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const open = useCallback((id: string, data: T, size?: { w: number; h: number }) => {
|
||||||
|
setWindows((prev) => {
|
||||||
|
const existing = prev.find((w) => w.id === id);
|
||||||
|
if (existing) {
|
||||||
|
const z = ++nextWinZ;
|
||||||
|
setFocusedId(existing.id);
|
||||||
|
return prev.map((w) => w.id === existing.id ? { ...w, zIndex: z } : w);
|
||||||
|
}
|
||||||
|
const winW = size?.w ?? defaultW;
|
||||||
|
const winH = size?.h ?? defaultH;
|
||||||
|
const margin = 20;
|
||||||
|
const z = ++nextWinZ;
|
||||||
|
const win: ManagedWindow<T> = {
|
||||||
|
id, data,
|
||||||
|
x: Math.round(margin + Math.random() * (Math.max(margin, window.innerWidth - winW - margin) - margin)),
|
||||||
|
y: Math.round(margin + Math.random() * (Math.max(margin, window.innerHeight - winH - margin) - margin)),
|
||||||
|
w: winW, h: winH, zIndex: z,
|
||||||
|
};
|
||||||
|
setFocusedId(win.id);
|
||||||
|
return [...prev, win];
|
||||||
|
});
|
||||||
|
}, [defaultW, defaultH]);
|
||||||
|
|
||||||
|
const update = useCallback((id: string, patch: Partial<ManagedWindow<T>>) => {
|
||||||
|
setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, ...patch } : w)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback((id: string) => {
|
||||||
|
setWindows((prev) => prev.filter((w) => w.id !== id));
|
||||||
|
setFocusedId((cur) => cur === id ? null : cur);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const focus = useCallback((id: string) => {
|
||||||
|
setFocusedId((cur) => {
|
||||||
|
if (cur === id) return cur;
|
||||||
|
const z = ++nextWinZ;
|
||||||
|
setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, zIndex: z } : w)));
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { windows, focusedId, open, update, close, focus };
|
||||||
|
}
|
||||||
@@ -6,8 +6,24 @@
|
|||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@keyframes romanSlideIn {
|
@keyframes romanSlideIn {
|
||||||
from { transform: translateY(100%); }
|
from {
|
||||||
to { transform: translateY(0); }
|
transform: translateY(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
||||||
@@ -162,6 +178,13 @@
|
|||||||
--sidebar-ring: oklch(0.72 0.12 230);
|
--sidebar-ring: oklch(0.72 0.12 230);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Terminal-style block cursor — outside @layer so it overrides CodeMirror's theme */
|
||||||
|
.cm-cursor,
|
||||||
|
.cm-cursor-primary {
|
||||||
|
border-left-color: var(--primary) !important;
|
||||||
|
border-left-width: 0.5em !important;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
@@ -173,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;
|
||||||
}
|
}
|
||||||
@@ -205,10 +233,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Terracotta cursor */
|
|
||||||
.cm-cursor {
|
|
||||||
border-left-color: oklch(0.7 0.13 55) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bold console aesthetic */
|
/* Bold console aesthetic */
|
||||||
button,
|
button,
|
||||||
@@ -315,4 +340,152 @@
|
|||||||
input[data-slot="input"] {
|
input[data-slot="input"] {
|
||||||
border: 2px solid var(--border);
|
border: 2px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Floating Nav Menu ── */
|
||||||
|
|
||||||
|
.nav-menu-root {
|
||||||
|
position: relative;
|
||||||
|
width: 200px;
|
||||||
|
height: 400px;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-frame {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--primary);
|
||||||
|
pointer-events: none;
|
||||||
|
mask-size: 100% 100%;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-size: 100% 100%;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-links {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-item:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-item[data-active] {
|
||||||
|
color: var(--primary);
|
||||||
|
background: var(--primary) / 0.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-theme-toggle {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 97px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-menu-theme-toggle:hover {
|
||||||
|
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 {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: top;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
margin-top: -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pointer-click {
|
||||||
|
animation: pointer-click 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pointer-click {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: translateX(-96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pointer-img {
|
||||||
|
width: 200px;
|
||||||
|
height: 130px;
|
||||||
|
background: var(--primary);
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pointer-eye {
|
||||||
|
position: absolute;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: black;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pointer-iris {
|
||||||
|
position: absolute;
|
||||||
|
width: 2px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
margin-top: -1.5px;
|
||||||
|
margin-left: -1.5px;
|
||||||
|
transition: transform 0.15s ease-out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
652
frontend/src/routes/BrowseView.tsx
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import ForceGraph3D, { type ForceGraphMethods, type NodeObject } from "react-force-graph-3d";
|
||||||
|
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 { 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";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type FGNode = NodeObject<GraphNode>;
|
||||||
|
|
||||||
|
function cssVar(name: string): string {
|
||||||
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a CSS variable to a normalised hex string (#rrggbb) */
|
||||||
|
function cssVarToHex(name: string): string {
|
||||||
|
const raw = cssVar(name);
|
||||||
|
if (!raw) return "#888888";
|
||||||
|
const ctx = document.createElement("canvas").getContext("2d")!;
|
||||||
|
ctx.fillStyle = raw;
|
||||||
|
return ctx.fillStyle; // always "#rrggbb"
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Memoized 3D graph — isolated from window/UI state changes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type FlyToFn = (pos: { x: number; y: number; z: number }, lookAt: any, durationMs: number) => void;
|
||||||
|
|
||||||
|
interface Graph3DProps {
|
||||||
|
graphData: GraphData;
|
||||||
|
searchMatchIds: Set<string> | null;
|
||||||
|
themeRev: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
onNodeClick: (node: NetworkNode) => void;
|
||||||
|
fgRef: React.MutableRefObject<ForceGraphMethods<FGNode> | undefined>;
|
||||||
|
flyToRef: React.MutableRefObject<FlyToFn | undefined>;
|
||||||
|
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Graph3D = memo(function Graph3D({ graphData, searchMatchIds, themeRev, width, height, onNodeClick, fgRef, flyToRef, containerRef }: Graph3DProps) {
|
||||||
|
// react-kapsule diffs props during every render — graphData triggers a full
|
||||||
|
// simulation restart (alpha=1). Stabilise the reference.
|
||||||
|
const stableDataRef = useRef(graphData);
|
||||||
|
const prevNodeIds = useRef("");
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// ── Post-processing: bloom + retro ──
|
||||||
|
const postProcInitRef = useRef(false);
|
||||||
|
const retroPassRef = useRef<ShaderPass | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fg = fgRef.current;
|
||||||
|
if (!fg || postProcInitRef.current) return;
|
||||||
|
// Wait a tick for the renderer to be ready
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const composer = fg.postProcessingComposer();
|
||||||
|
// Bloom — subtle glow
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return sprite;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [themeRev]);
|
||||||
|
|
||||||
|
const nodeVisibility = useCallback((node: FGNode) => {
|
||||||
|
return (node as GraphNode).type !== "interface";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const nodeThreeObjectExtend = useCallback((node: FGNode) => {
|
||||||
|
return (node as GraphNode).type !== "interface";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showPointerCursor = useCallback((obj: any) => {
|
||||||
|
if (!obj || !("type" in obj)) return false;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function BrowseView() {
|
||||||
|
const [nodes, setNodes] = useState<NetworkNode[]>([]);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const { windows, focusedId: focusedWinId, open: openWindow, update: updateWindow, close: closeWindowById, focus: focusWindow } = useWindowManager<BrowseWinData>();
|
||||||
|
|
||||||
|
const [themeRev, setThemeRev] = useState(0);
|
||||||
|
|
||||||
|
// Watch for theme changes (class on <html>)
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver(() => setThemeRev(r => r + 1));
|
||||||
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const fgRef = useRef<ForceGraphMethods<FGNode>>(undefined);
|
||||||
|
const flyToRef = useRef<FlyToFn>(undefined);
|
||||||
|
const prevPositionsRef = useRef<Map<string, { x: number; y: number; z: number }>>(new Map());
|
||||||
|
|
||||||
|
// Container sizing
|
||||||
|
const [dims, setDims] = useState<{ w: number; h: number }>({ w: 800, h: 600 });
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
|
if (!entry) return;
|
||||||
|
setDims({ w: entry.contentRect.width, h: entry.contentRect.height });
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
setDims({ w: el.clientWidth, h: el.clientHeight });
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Build graph data, preserving existing positions
|
||||||
|
const graphData = useMemo(() => {
|
||||||
|
const fg = fgRef.current;
|
||||||
|
if (fg) {
|
||||||
|
try {
|
||||||
|
// @ts-expect-error — graphData() is on the underlying instance
|
||||||
|
const live = fg.graphData?.() as { nodes: FGNode[] } | undefined;
|
||||||
|
if (live?.nodes) {
|
||||||
|
const map = new Map<string, { x: number; y: number; z: number }>();
|
||||||
|
for (const n of live.nodes) {
|
||||||
|
if (n.x !== undefined && n.y !== undefined && n.z !== undefined) {
|
||||||
|
map.set(n.id as string, { x: n.x, y: n.y, z: n.z });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Fly camera to focused window's node ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focusedWinId) return;
|
||||||
|
const fly = flyToRef.current;
|
||||||
|
if (!fly) return;
|
||||||
|
const match = graphData.nodes.find(n => n.id === focusedWinId) 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, 1000);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [focusedWinId]);
|
||||||
|
|
||||||
|
// ── SSE stream ──
|
||||||
|
const nodeMapRef = useRef<Map<string, NetworkNode>>(new Map());
|
||||||
|
useEffect(() => {
|
||||||
|
let pending: NetworkNode[] = [];
|
||||||
|
let batchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const flush = () => {
|
||||||
|
batchTimer = null;
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
const batch = pending; pending = [];
|
||||||
|
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(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Navigation helpers ──
|
||||||
|
const navigateTo = useCallback((winId: string, node: NetworkNode, path: string, prevData?: BrowseWinData) => {
|
||||||
|
const loading: BrowseWinData = {
|
||||||
|
node, pageHtml: null, pageLoading: true, pageError: null,
|
||||||
|
currentPath: path,
|
||||||
|
history: prevData?.history ?? [],
|
||||||
|
historyIndex: prevData?.historyIndex ?? -1,
|
||||||
|
};
|
||||||
|
updateWindow(winId, { data: loading });
|
||||||
|
|
||||||
|
fetchRemotePage(node.hash, path)
|
||||||
|
.then((res) => {
|
||||||
|
const html = res.content ? renderMicron(res.content, true) : null;
|
||||||
|
const error = res.content ? null : (res.error ?? "No content");
|
||||||
|
const entry: HistoryEntry = { path, html, error };
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
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 (
|
||||||
|
<div ref={containerRef} className="relative overflow-hidden" style={{ height: "100%" }}>
|
||||||
|
<Graph3D
|
||||||
|
graphData={graphData}
|
||||||
|
searchMatchIds={searchMatchIds}
|
||||||
|
themeRev={themeRev}
|
||||||
|
width={dims.w}
|
||||||
|
height={dims.h}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
fgRef={fgRef}
|
||||||
|
flyToRef={flyToRef}
|
||||||
|
containerRef={containerRef}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BrowseSearchBar
|
||||||
|
filter={filter}
|
||||||
|
onFilterChange={setFilter}
|
||||||
|
onClear={clearSearch}
|
||||||
|
allNodes={allPeerNodes}
|
||||||
|
suggestions={suggestions}
|
||||||
|
onSelectNode={handleNodeClick}
|
||||||
|
onHighlightNode={onHighlightNode}
|
||||||
|
onSearchFocusChange={onSearchFocusChange}
|
||||||
|
focusedWinId={focusedWinId}
|
||||||
|
windowCount={windows.length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{nodes.length === 0 && (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm pointer-events-none">
|
||||||
|
<Loader />
|
||||||
|
Connecting...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stop React synthetic events from portaled windows bubbling into the
|
||||||
|
graph — portals bubble through the React tree, not the DOM tree. */}
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
|
||||||
|
<div onMouseDown={e => e.stopPropagation()} onPointerDown={e => e.stopPropagation()}>
|
||||||
|
{windows.map((win) => (
|
||||||
|
<BrowseNodeWindow
|
||||||
|
key={win.id}
|
||||||
|
win={win}
|
||||||
|
focused={focusedWinId === win.id}
|
||||||
|
onUpdate={updateWindow}
|
||||||
|
onClose={closeWindowById}
|
||||||
|
onFocus={focusWindow}
|
||||||
|
onNavBack={navBack}
|
||||||
|
onNavForward={navForward}
|
||||||
|
onNavReload={navReload}
|
||||||
|
onContentClick={handleContentClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
610
frontend/src/routes/ComposeView.tsx
Normal file
@@ -0,0 +1,610 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MoreVertical, Plus, FolderPlus, ChevronRight, Folder, FileText, KeyRound, ArrowLeft, ArrowUp, ArrowDown } from "lucide-react";
|
||||||
|
import { usePagesStore } from "@/stores/pagesStore";
|
||||||
|
import * as api from "@/api/client";
|
||||||
|
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
|
||||||
|
import StatusBadge from "@/components/dashboard/StatusBadge";
|
||||||
|
import Loader from "@/components/shared/Loader";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} 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() {
|
||||||
|
const { deletePage, publishPage, unpublishPage } = usePagesStore();
|
||||||
|
|
||||||
|
// 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 [newFolderName, setNewFolderName] = useState("");
|
||||||
|
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||||
|
const [sortAsc, setSortAsc] = useState(true);
|
||||||
|
|
||||||
|
const sortedFiles = useMemo(() => {
|
||||||
|
// 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 cmp = (a: api.FileEntry, b: api.FileEntry): number => {
|
||||||
|
let v = 0;
|
||||||
|
if (sortKey === "name") v = a.name.localeCompare(b.name);
|
||||||
|
else if (sortKey === "size") v = (a.size ?? 0) - (b.size ?? 0);
|
||||||
|
else if (sortKey === "modified") v = (a.last_modified ?? 0) - (b.last_modified ?? 0);
|
||||||
|
return sortAsc ? v : -v;
|
||||||
|
};
|
||||||
|
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) => {
|
||||||
|
try {
|
||||||
|
await publishPage(name);
|
||||||
|
toast.success(`"${name}" published`);
|
||||||
|
loadFiles();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`Failed: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnpublish = async (name: string) => {
|
||||||
|
try {
|
||||||
|
await unpublishPage(name);
|
||||||
|
toast.success(`"${name}" unpublished`);
|
||||||
|
loadFiles();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(`Failed: ${e}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!pageToDelete) return;
|
||||||
|
// Extract stem from path for the pages API
|
||||||
|
const stem = pageToDelete.replace(/\.uf$/, "");
|
||||||
|
await deletePage(stem);
|
||||||
|
toast.success(`"${pageToDelete}" deleted`);
|
||||||
|
setPageToDelete(null);
|
||||||
|
loadFiles();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateFolder = async () => {
|
||||||
|
const name = newFolderName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
const folderPath = currentPath ? `${currentPath}/${name}` : name;
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center px-2 py-1.5 border-b-2 border-border">
|
||||||
|
<h1 className="text-xs font-semibold flex-1">Compose</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setShowNewFolder(true)}>
|
||||||
|
<FolderPlus className="w-3 h-3 mr-1.5" />
|
||||||
|
New Folder
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => openEditor("", true)}>
|
||||||
|
<Plus className="w-3 h-3 mr-1.5" />
|
||||||
|
New Page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<SortableHead label="Name" sortKey="name" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
|
||||||
|
<TableHead>Status</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" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedFiles.map((entry) => (
|
||||||
|
<TableRow
|
||||||
|
key={entry.path}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => handleFileClick(entry)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-mono">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{entry.type === "folder" ? (
|
||||||
|
<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" />
|
||||||
|
)}
|
||||||
|
<span>{entry.name}</span>
|
||||||
|
{entry.name === "index.uf" && (
|
||||||
|
<span className="text-[10px] text-primary">homepage</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{entry.type === "file" && entry.name.endsWith(".uf") ? (
|
||||||
|
<StatusBadge published={entry.published} hasSource={true} />
|
||||||
|
) : null}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{entry.type !== "folder" ? formatSize(entry.size) : "\u2014"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">
|
||||||
|
{formatTime(entry.last_modified)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right w-8">
|
||||||
|
{entry.type === "file" && entry.name.endsWith(".uf") && (
|
||||||
|
<FileActions
|
||||||
|
entry={entry}
|
||||||
|
folders={files.filter(f => f.type === "folder")}
|
||||||
|
currentPath={currentPath}
|
||||||
|
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>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{files.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||||
|
{currentPath ? "Empty folder." : "No files yet. Create a page to get started."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
<AlertDialog
|
||||||
|
open={pageToDelete !== null}
|
||||||
|
onOpenChange={(open) => !open && setPageToDelete(null)}
|
||||||
|
>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This permanently deletes the file and its published version. This cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-destructive text-white hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// File action menu
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function FileActions({
|
||||||
|
entry,
|
||||||
|
folders,
|
||||||
|
currentPath,
|
||||||
|
onEdit,
|
||||||
|
onPublish,
|
||||||
|
onUnpublish,
|
||||||
|
onDelete,
|
||||||
|
onMove,
|
||||||
|
}: {
|
||||||
|
entry: api.FileEntry;
|
||||||
|
folders: api.FileEntry[];
|
||||||
|
currentPath: string;
|
||||||
|
onEdit: () => void;
|
||||||
|
onPublish: () => void;
|
||||||
|
onUnpublish: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onMove: (to: string) => void;
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-44 p-1">
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); onEdit(); }} className={menuItem}>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
{entry.published ? (
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); onUnpublish(); }} className={menuItem}>
|
||||||
|
Unpublish
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); onPublish(); }} className={menuItem}>
|
||||||
|
Publish
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{moveTargets.length > 0 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setShowMove(!showMove); }}
|
||||||
|
className={`${menuItem} flex items-center justify-between`}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import { MoreVertical, Plus, RotateCcw } from "lucide-react";
|
|
||||||
import { usePagesStore } from "@/stores/pagesStore";
|
|
||||||
import StatusBadge from "@/components/dashboard/StatusBadge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverTrigger,
|
|
||||||
PopoverContent,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
|
|
||||||
export default function DashboardView() {
|
|
||||||
const { pages, isLoading, fetchPages, deletePage } = usePagesStore();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [pageToDelete, setPageToDelete] = useState<string | null>(null);
|
|
||||||
const [restarting, setRestarting] = useState(false);
|
|
||||||
|
|
||||||
const handleRestart = async () => {
|
|
||||||
setRestarting(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/restart", { method: "POST" });
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
toast.success("NomadNet restarted");
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(`Restart failed: ${e}`);
|
|
||||||
} finally {
|
|
||||||
setRestarting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPages();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleUnpublish = async (name: string) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/pages/${name}`);
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.source) {
|
|
||||||
await fetch(`/api/pages/${name}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ source: data.source, publish: false }),
|
|
||||||
});
|
|
||||||
toast.success(`"${name}" unpublished`);
|
|
||||||
fetchPages();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(`Failed: ${e}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePublish = async (name: string) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/pages/${name}`);
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.source) {
|
|
||||||
await fetch(`/api/pages/${name}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ source: data.source, publish: true }),
|
|
||||||
});
|
|
||||||
toast.success(`"${name}" published`);
|
|
||||||
fetchPages();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(`Failed: ${e}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!pageToDelete) return;
|
|
||||||
await deletePage(pageToDelete);
|
|
||||||
toast.success(`"${pageToDelete}" deleted`);
|
|
||||||
setPageToDelete(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading)
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
{/* Header row */}
|
|
||||||
<div className="flex items-center px-4 py-2 border-b-2 border-border">
|
|
||||||
<h1 className="text-sm font-semibold flex-1">Pages</h1>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={handleRestart} disabled={restarting}>
|
|
||||||
<RotateCcw className="w-4 h-4 mr-2" />
|
|
||||||
Restart
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => navigate("/editor/new")}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
New Page
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table inside bordered container */}
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Name</TableHead>
|
|
||||||
<TableHead>Title</TableHead>
|
|
||||||
<TableHead>Status</TableHead>
|
|
||||||
<TableHead>Size</TableHead>
|
|
||||||
<TableHead className="w-8" />
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{pages.map((p) => (
|
|
||||||
<TableRow
|
|
||||||
key={p.name}
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => navigate(`/editor/${p.name}`)}
|
|
||||||
>
|
|
||||||
<TableCell className="font-mono">
|
|
||||||
{p.name}
|
|
||||||
{p.name === "index" && (
|
|
||||||
<span className="ml-2 text-xs text-primary">homepage</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{p.title ?? "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<StatusBadge published={p.published} hasSource={p.has_source} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-muted-foreground">
|
|
||||||
{p.size != null ? `${p.size} B` : "—"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right w-8">
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<button
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent side="bottom" align="end" sideOffset={4}
|
|
||||||
className="w-36 p-1"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); navigate(`/editor/${p.name}`); }}
|
|
||||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
|
||||||
>Edit</button>
|
|
||||||
{p.published ? (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleUnpublish(p.name); }}
|
|
||||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
|
||||||
>Unpublish</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handlePublish(p.name); }}
|
|
||||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
|
||||||
>Publish</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); setPageToDelete(p.name); }}
|
|
||||||
className="w-full text-left px-3 py-1.5 text-xs text-destructive hover:bg-accent transition-colors cursor-pointer"
|
|
||||||
>Delete</button>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{pages.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={5}
|
|
||||||
className="text-center text-muted-foreground py-8"
|
|
||||||
>
|
|
||||||
No pages yet. Create one to get started.
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>{/* end bordered container */}
|
|
||||||
|
|
||||||
<AlertDialog
|
|
||||||
open={pageToDelete !== null}
|
|
||||||
onOpenChange={(open) => !open && setPageToDelete(null)}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
This permanently deletes the page and its source. This cannot be
|
|
||||||
undone.
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={handleDelete}
|
|
||||||
className="bg-destructive text-white hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,27 +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 { 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 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,
|
||||||
@@ -77,17 +70,12 @@ export default function EditorView() {
|
|||||||
reset();
|
reset();
|
||||||
if (name) {
|
if (name) {
|
||||||
setPageName(name);
|
setPageName(name);
|
||||||
fetch(`/api/pages/${name}`)
|
api.fetchPage(name).then((data) => {
|
||||||
.then((r) => r.json())
|
if (data.source != null) {
|
||||||
.then((data) => {
|
setSource(data.source);
|
||||||
if (data.source != null) {
|
setDirty(false);
|
||||||
useEditorStore.setState({
|
}
|
||||||
ufSource: data.source,
|
});
|
||||||
isDirty: false,
|
|
||||||
currentPage: data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return () => reset();
|
return () => reset();
|
||||||
}, [name]);
|
}, [name]);
|
||||||
@@ -104,13 +92,7 @@ export default function EditorView() {
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/pages/${slug}`, {
|
const meta = await api.savePage(slug, ufSource, publish);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ source: ufSource, publish }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const meta = await res.json();
|
|
||||||
setCurrentPage(meta);
|
setCurrentPage(meta);
|
||||||
setDirty(false);
|
setDirty(false);
|
||||||
fetchPages();
|
fetchPages();
|
||||||
@@ -125,23 +107,14 @@ 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">
|
||||||
|
<EditorPointer />
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
<ToolBar
|
<ToolBar
|
||||||
pageName={pageName}
|
pageName={pageName}
|
||||||
@@ -170,86 +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 form = new FormData();
|
|
||||||
form.append("file", file);
|
|
||||||
const res = await fetch("/api/upload-image", { method: "POST", body: form });
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
const data = await res.json();
|
|
||||||
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-3 py-1.5 border-b-2 border-border shrink-0 gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground flex-1">Source</span>
|
|
||||||
|
|
||||||
<Popover open={examplesOpen} onOpenChange={setExamplesOpen}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<button className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 cursor-pointer">
|
|
||||||
<BookOpen className="h-3 w-3" />
|
|
||||||
Examples
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent side="bottom" align="end" sideOffset={8}>
|
|
||||||
<PopoverHeader>
|
|
||||||
<PopoverTitle>Insert Example</PopoverTitle>
|
|
||||||
</PopoverHeader>
|
|
||||||
<div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto -mx-1">
|
|
||||||
{EXAMPLES.map((ex) => (
|
|
||||||
<button
|
|
||||||
key={ex.name}
|
|
||||||
onClick={() => { setSource(ex.source); setExamplesOpen(false); }}
|
|
||||||
className="flex flex-col items-start px-2 py-1.5 text-left hover:bg-accent transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium">{ex.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground leading-tight">{ex.description}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleUpload} />
|
|
||||||
<button
|
|
||||||
onClick={() => fileRef.current?.click()}
|
|
||||||
disabled={uploading}
|
|
||||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 disabled:opacity-50 cursor-pointer"
|
|
||||||
>
|
|
||||||
<Upload className="h-3 w-3" />
|
|
||||||
{uploading ? "Uploading…" : "Image"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-auto min-h-0">
|
|
||||||
<EditorPane value={ufSource} onChange={setSource} extensions={extensions} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
ReactFlow,
|
|
||||||
Background,
|
|
||||||
Controls,
|
|
||||||
type Node,
|
|
||||||
type Edge,
|
|
||||||
} from "@xyflow/react";
|
|
||||||
import dagre from "@dagrejs/dagre";
|
|
||||||
import { useGraph } from "@/hooks/useGraph";
|
|
||||||
import "@xyflow/react/dist/style.css";
|
|
||||||
|
|
||||||
const NODE_WIDTH = 160;
|
|
||||||
const NODE_HEIGHT = 50;
|
|
||||||
|
|
||||||
function layoutGraph(
|
|
||||||
nodes: Node[],
|
|
||||||
edges: Edge[]
|
|
||||||
): { nodes: Node[]; edges: Edge[] } {
|
|
||||||
const g = new dagre.graphlib.Graph();
|
|
||||||
g.setDefaultEdgeLabel(() => ({}));
|
|
||||||
g.setGraph({ rankdir: "TB", nodesep: 50, ranksep: 80 });
|
|
||||||
|
|
||||||
nodes.forEach((n) =>
|
|
||||||
g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
|
||||||
);
|
|
||||||
edges.forEach((e) => g.setEdge(e.source, e.target));
|
|
||||||
dagre.layout(g);
|
|
||||||
|
|
||||||
const laid = nodes.map((n) => {
|
|
||||||
const pos = g.node(n.id);
|
|
||||||
return {
|
|
||||||
...n,
|
|
||||||
position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return { nodes: laid, edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function GraphView() {
|
|
||||||
const { data, loading } = useGraph();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const { nodes, edges } = useMemo(() => {
|
|
||||||
if (!data) return { nodes: [], edges: [] };
|
|
||||||
|
|
||||||
const rfNodes: Node[] = data.nodes.map((n) => ({
|
|
||||||
id: n.id,
|
|
||||||
data: { label: n.title ?? n.id },
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
style: {
|
|
||||||
background: n.published
|
|
||||||
? "oklch(0.488 0.14 145)"
|
|
||||||
: "oklch(0.7 0.15 80)",
|
|
||||||
color: "#fff",
|
|
||||||
border:
|
|
||||||
n.id === "index"
|
|
||||||
? "2px solid oklch(0.6 0.2 250)"
|
|
||||||
: "1px solid oklch(1 0 0 / 10%)",
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: "8px 16px",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: n.id === "index" ? 700 : 400,
|
|
||||||
width: NODE_WIDTH,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const rfEdges: Edge[] = data.edges.map((e, i) => ({
|
|
||||||
id: `e-${i}`,
|
|
||||||
source: e.source,
|
|
||||||
target: e.target,
|
|
||||||
style: { stroke: "oklch(0.556 0 0)" },
|
|
||||||
}));
|
|
||||||
|
|
||||||
return layoutGraph(rfNodes, rfEdges);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
if (loading)
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
||||||
Loading graph...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full bg-background">
|
|
||||||
<ReactFlow
|
|
||||||
nodes={nodes}
|
|
||||||
edges={edges}
|
|
||||||
onNodeClick={(_, node) => navigate(`/editor/${node.id}`)}
|
|
||||||
fitView
|
|
||||||
proOptions={{ hideAttribution: true }}
|
|
||||||
>
|
|
||||||
<Background color="oklch(0.269 0 0)" gap={20} />
|
|
||||||
<Controls />
|
|
||||||
</ReactFlow>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
211
frontend/src/routes/SettingsView.tsx
Normal file
@@ -0,0 +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() {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,7 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import type { PageMeta } from "@/api/client";
|
||||||
|
|
||||||
export interface PageMeta {
|
export interface EditorStore {
|
||||||
name: string;
|
|
||||||
title: string | null;
|
|
||||||
published: boolean;
|
|
||||||
has_source: boolean;
|
|
||||||
last_modified: number | null;
|
|
||||||
size: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditorStore {
|
|
||||||
// Source
|
// Source
|
||||||
ufSource: string;
|
ufSource: string;
|
||||||
isDirty: boolean;
|
isDirty: boolean;
|
||||||
@@ -25,7 +17,7 @@ interface EditorStore {
|
|||||||
compileError: string | null;
|
compileError: string | null;
|
||||||
|
|
||||||
// Preview
|
// Preview
|
||||||
previewMode: "ascii" | "micron" | "raw" | "script";
|
previewMode: "micron" | "raw" | "script";
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setSource: (s: string) => void;
|
setSource: (s: string) => void;
|
||||||
@@ -34,53 +26,43 @@ interface EditorStore {
|
|||||||
setCompileResult: (ascii: string, micron: string, script: string, isDynamic: boolean, warnings: string[]) => void;
|
setCompileResult: (ascii: string, micron: string, script: string, isDynamic: boolean, warnings: string[]) => void;
|
||||||
setCompiling: (v: boolean) => void;
|
setCompiling: (v: boolean) => void;
|
||||||
setCompileError: (e: string | null) => void;
|
setCompileError: (e: string | null) => void;
|
||||||
setPreviewMode: (mode: "ascii" | "micron" | "raw" | "script") => void;
|
setPreviewMode: (mode: "micron" | "raw" | "script") => void;
|
||||||
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: "ascii",
|
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: "ascii",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { PageMeta } from "@/stores/editorStore";
|
import * as api from "@/api/client";
|
||||||
|
|
||||||
interface PagesStore {
|
interface PagesStore {
|
||||||
pages: PageMeta[];
|
pages: api.PageMeta[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
fetchPages: () => Promise<void>;
|
fetchPages: () => Promise<void>;
|
||||||
deletePage: (name: string) => Promise<void>;
|
deletePage: (name: string) => Promise<void>;
|
||||||
|
publishPage: (name: string) => Promise<void>;
|
||||||
unpublishPage: (name: string) => Promise<void>;
|
unpublishPage: (name: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,8 +17,7 @@ export const usePagesStore = create<PagesStore>((set, get) => ({
|
|||||||
fetchPages: async () => {
|
fetchPages: async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/pages");
|
const pages = await api.fetchPages();
|
||||||
const pages = await res.json();
|
|
||||||
set({ pages });
|
set({ pages });
|
||||||
} finally {
|
} finally {
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
@@ -25,20 +25,22 @@ export const usePagesStore = create<PagesStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
deletePage: async (name: string) => {
|
deletePage: async (name: string) => {
|
||||||
await fetch(`/api/pages/${name}`, { method: "DELETE" });
|
await api.deletePage(name);
|
||||||
|
await get().fetchPages();
|
||||||
|
},
|
||||||
|
|
||||||
|
publishPage: async (name: string) => {
|
||||||
|
const page = await api.fetchPage(name);
|
||||||
|
if (page.source) {
|
||||||
|
await api.savePage(name, page.source, true);
|
||||||
|
}
|
||||||
await get().fetchPages();
|
await get().fetchPages();
|
||||||
},
|
},
|
||||||
|
|
||||||
unpublishPage: async (name: string) => {
|
unpublishPage: async (name: string) => {
|
||||||
// Fetch current source, re-save as draft only
|
const page = await api.fetchPage(name);
|
||||||
const res = await fetch(`/api/pages/${name}`);
|
if (page.source) {
|
||||||
const data = await res.json();
|
await api.savePage(name, page.source, false);
|
||||||
if (data.source) {
|
|
||||||
await fetch(`/api/pages/${name}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ source: data.source, publish: false }),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
await get().fetchPages();
|
await get().fetchPages();
|
||||||
},
|
},
|
||||||
|
|||||||
7
frontend/src/types/micron-parser.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
declare module "micron-parser" {
|
||||||
|
export default class MicronParser {
|
||||||
|
constructor(darkTheme?: boolean, enableForceMonospace?: boolean);
|
||||||
|
convertMicronToHtml(markup: string): string;
|
||||||
|
convertMicronToFragment(markup: string): DocumentFragment;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
frontend/src/types/three-shims.d.ts
vendored
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"three-spritetext": "^1.10.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
reticulum-client.conf
Normal 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
|
||||||
@@ -6,12 +6,6 @@
|
|||||||
loglevel = 4
|
loglevel = 4
|
||||||
|
|
||||||
[interfaces]
|
[interfaces]
|
||||||
|
|
||||||
# Local AutoInterface for LAN discovery
|
|
||||||
[[Default Interface]]
|
|
||||||
type = AutoInterface
|
|
||||||
enabled = Yes
|
|
||||||
|
|
||||||
# TCP Server — allows other Reticulum peers to connect
|
# TCP Server — allows other Reticulum peers to connect
|
||||||
# Expose this port to reach the node from outside Docker
|
# Expose this port to reach the node from outside Docker
|
||||||
[[TCP Server Interface]]
|
[[TCP Server Interface]]
|
||||||
@@ -19,3 +13,4 @@
|
|||||||
enabled = Yes
|
enabled = Yes
|
||||||
listen_ip = 0.0.0.0
|
listen_ip = 0.0.0.0
|
||||||
listen_port = 4242
|
listen_port = 4242
|
||||||
|
|
||||||
|
|||||||