Compare commits
39 Commits
8776459ffb
...
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 | |||
| f082a30b7f | |||
| 576af715bd | |||
| 82cb4a9786 | |||
| f0b0a5dd24 | |||
| fcf3dc74a8 | |||
| 462d6bf289 | |||
| a95594f446 | |||
| a0a5f18128 | |||
| 0642d0f894 | |||
| 528d2d8519 | |||
| 175de893aa | |||
| 8d3245b7b1 | |||
| 01a3e0095c | |||
| 0316e50233 |
@@ -9,8 +9,8 @@
|
||||
},
|
||||
{
|
||||
"name": "Frontend (vite)",
|
||||
"runtimeExecutable": "/Users/dtoro/Projects/micronomicon/frontend/node_modules/.bin/vite",
|
||||
"runtimeArgs": ["/Users/dtoro/Projects/micronomicon/frontend"],
|
||||
"runtimeExecutable": "/opt/homebrew/bin/node",
|
||||
"runtimeArgs": ["/Users/dtoro/Projects/micronomicon/frontend/node_modules/.bin/vite", "/Users/dtoro/Projects/micronomicon/frontend"],
|
||||
"port": 5173
|
||||
}
|
||||
]
|
||||
|
||||
570
CLAUDE.md
@@ -1,136 +1,170 @@
|
||||
# Micronomicon → µFrame
|
||||
# µFrame (Micronomicon)
|
||||
|
||||
A self-hosted web IDE for building rich terminal UIs that publish as `.mu` pages to a NomadNet node.
|
||||
A self-hosted web IDE and CLI for building rich terminal UIs using a declarative DSL. Compiles `.uf` source files into both plain ASCII art and styled Micron `.mu` pages for NomadNet — a decentralized communication platform running on the Reticulum mesh network.
|
||||
|
||||
> **Evolution:** Started as a raw Micron text editor (Phases 1–2, complete).
|
||||
> Now pivoting to **µFrame** — a declarative DSL that compiles `.uf` files into
|
||||
> both plain ASCII art and styled Micron `.mu` output from the same source.
|
||||
|
||||
## Status
|
||||
|
||||
Phases 1–2 complete (raw Micron editor). Phase 3 (µFrame engine) is next.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|------------|--------------------------------------------------|
|
||||
| Backend | Python 3.13 + FastAPI + uvicorn |
|
||||
| µFrame | Pure Python: parser → IR → CharGrid → emitters |
|
||||
| Frontend | React 19 + Vite + TypeScript |
|
||||
| UI | shadcn/ui + Tailwind CSS v4 + tw-animate-css |
|
||||
| Editor | CodeMirror 6 |
|
||||
| Graph | React Flow (@xyflow/react) + dagre |
|
||||
| State | Zustand |
|
||||
| Container | Docker + Compose |
|
||||
|
||||
## Directory Layout
|
||||
## What It Does
|
||||
|
||||
Write this:
|
||||
```
|
||||
micronomicon/
|
||||
Dockerfile
|
||||
compose.yml
|
||||
docs/
|
||||
framework-design-v3.md # µFrame DSL spec + rendering model
|
||||
dynamic-templates.md # Dynamic page addendum (Phases 5–6)
|
||||
backend/
|
||||
main.py # FastAPI app + static file serving
|
||||
converter.py # POST /api/compile (µFrame → ASCII + Micron)
|
||||
pages.py # file management (CRUD /api/pages)
|
||||
graph.py # link parser (GET /api/graph)
|
||||
docker_utils.py # container restart (POST /api/restart)
|
||||
requirements.txt
|
||||
uframe/ # µFrame engine (Phase 3+)
|
||||
__init__.py # compile(source, width) → CompileResult
|
||||
errors.py # ParseError, LayoutError
|
||||
ir.py # IR node dataclasses
|
||||
parser.py # .uf DSL → IR tree
|
||||
grid.py # CharGrid (2D char + style buffer)
|
||||
chars.py # Unicode lookup tables (box-drawing, braille)
|
||||
measure.py # bottom-up size computation
|
||||
layout.py # top-down position assignment
|
||||
paint.py # IR → CharGrid rendering
|
||||
borders.py # junction merging post-pass
|
||||
emit_ascii.py # CharGrid → plain text
|
||||
emit_micron.py # CharGrid → Micron with style tags
|
||||
viz.py # gauge, sparkline, status (Phase 4)
|
||||
table.py # table layout + box-drawn grid (Phase 4)
|
||||
frontend/
|
||||
src/
|
||||
App.tsx
|
||||
routes/ # DashboardView, EditorView, GraphView
|
||||
components/
|
||||
dashboard/ # page list, status badges
|
||||
editor/
|
||||
EditorPane.tsx # CodeMirror host
|
||||
PreviewPane.tsx # ASCII + Micron + Raw preview tabs
|
||||
ToolBar.tsx # save/publish + backlinks
|
||||
uframeHighlight.ts # CM6 .uf syntax highlighting (Phase 3+)
|
||||
uframeCommands.ts # "/" command palette for .uf (Phase 3+)
|
||||
micronRenderer.ts # Micron → HTML (renders compiled output)
|
||||
BacklinkIndicator.tsx
|
||||
shared/
|
||||
ui/ # shadcn components
|
||||
stores/ # editorStore, pagesStore (Zustand)
|
||||
hooks/ # useCompile, useGraph, useUnsavedGuard
|
||||
lib/ # utils (cn)
|
||||
page "Node Status" 60
|
||||
box double "Relay Alpha-7"
|
||||
align center
|
||||
text "Reticulum Network Node"
|
||||
gauge "CPU" 62 100 28 warn=75 crit=90
|
||||
status "East Relay" online
|
||||
table "Routes"
|
||||
columns "Dest" 20 | "Hops" 6 | "Status" 10
|
||||
row "relay-east" | "2" | "@color{0f0}{alive}"
|
||||
```
|
||||
|
||||
## Local Development
|
||||
Get this (ASCII):
|
||||
```
|
||||
╔═ Relay Alpha-7 ════════════════════════════════════════════╗
|
||||
║ Reticulum Network Node ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
CPU ████████████████████░░░░░░░░ 62%
|
||||
● East Relay
|
||||
┌────────────────────┬──────┬──────────┐
|
||||
│Dest │Hops │Status │
|
||||
├────────────────────┼──────┼──────────┤
|
||||
│relay-east │2 │● alive │
|
||||
└────────────────────┴──────┴──────────┘
|
||||
```
|
||||
|
||||
And the same content as Micron `.mu` with color tags, bold, links, and interactive form fields — ready to serve on NomadNet.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Web IDE
|
||||
|
||||
**Backend:**
|
||||
```bash
|
||||
# Backend
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
PAGES_DIR=~/.nomadnetwork/storage/pages \
|
||||
SOURCES_DIR=~/.micron-editor/sources \
|
||||
uvicorn main:app --reload --port 8080
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
```bash
|
||||
# Frontend (separate terminal)
|
||||
cd frontend
|
||||
npm run dev # proxies /api -> localhost:8080
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend at http://localhost:5173, backend at http://localhost:8080.
|
||||
Open http://localhost:5173 → click **New Page** → click **Examples** → pick a template.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
|
||||
# Render to terminal
|
||||
python -m uframe render page.uf
|
||||
|
||||
# Render Micron only
|
||||
python -m uframe render page.uf --micron
|
||||
|
||||
# Compile to .mu file
|
||||
python -m uframe compile page.uf --out page.mu
|
||||
|
||||
# Validate without output
|
||||
python -m uframe check page.uf
|
||||
|
||||
# Compile and deploy to NomadNet
|
||||
python -m uframe deploy page.uf
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|------------|-------------------------------------------------|
|
||||
| Backend | Python 3.13 + FastAPI + uvicorn |
|
||||
| µFrame | Pure Python (zero deps): parser → IR → CharGrid → emitters |
|
||||
| Frontend | React 19 + Vite + TypeScript |
|
||||
| UI | shadcn/ui + Tailwind CSS v4 |
|
||||
| Editor | CodeMirror 6 (custom µFrame syntax mode) |
|
||||
| Graph | React Flow (@xyflow/react) + dagre |
|
||||
| State | Zustand |
|
||||
| Container | Docker + Compose |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
micronomicon/
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI app + static file serving
|
||||
│ ├── converter.py # POST /api/compile endpoint
|
||||
│ ├── pages.py # CRUD /api/pages (.uf sources + .mu publish)
|
||||
│ ├── graph.py # GET /api/graph (link parser)
|
||||
│ ├── docker_utils.py # POST /api/restart (NomadNet container)
|
||||
│ └── uframe/ # µFrame engine (16 modules, ~3800 LOC)
|
||||
│ ├── __init__.py # compile(source, width) → CompileResult
|
||||
│ ├── parser.py # .uf DSL → IR tree (indentation-based)
|
||||
│ ├── ir.py # 30+ IR node dataclasses
|
||||
│ ├── grid.py # CharGrid — 2D char + style buffer
|
||||
│ ├── chars.py # Unicode tables (box-drawing, braille)
|
||||
│ ├── measure.py # Bottom-up size computation
|
||||
│ ├── layout.py # Top-down position assignment
|
||||
│ ├── paint.py # IR nodes → CharGrid rendering
|
||||
│ ├── borders.py # Junction merging post-pass
|
||||
│ ├── emit_ascii.py # CharGrid → plain text
|
||||
│ ├── emit_micron.py # CharGrid → Micron with style tags
|
||||
│ ├── codegen.py # Dynamic page → executable Python script
|
||||
│ ├── cli.py # CLI: render / compile / check / deploy
|
||||
│ ├── errors.py # ParseError, LayoutError, CompileWarning
|
||||
│ └── tests/ # 42 tests (compile, dynamic, components)
|
||||
├── frontend/src/
|
||||
│ ├── routes/ # DashboardView, EditorView, GraphView
|
||||
│ ├── components/editor/
|
||||
│ │ ├── EditorPane.tsx # CodeMirror 6 host
|
||||
│ │ ├── PreviewPane.tsx # ASCII / Micron / Raw / Script tabs
|
||||
│ │ ├── ToolBar.tsx # Save, Publish, Examples, Backlinks
|
||||
│ │ ├── uframeHighlight.ts # µFrame syntax highlighting
|
||||
│ │ ├── uframeCommands.ts # "/" slash command palette
|
||||
│ │ ├── micronRenderer.ts # Micron → HTML preview renderer
|
||||
│ │ └── examples.ts # 9 built-in example templates
|
||||
│ ├── hooks/
|
||||
│ │ ├── useCompile.ts # Debounced POST /api/compile
|
||||
│ │ └── useUnsavedGuard.ts # Prevent accidental navigation
|
||||
│ └── stores/
|
||||
│ ├── editorStore.ts # Zustand: source, compiled output, preview mode
|
||||
│ └── pagesStore.ts # Zustand: page list, delete, unpublish
|
||||
└── docs/
|
||||
├── framework-design-v3.md # Full DSL spec + rendering model
|
||||
└── dynamic-templates.md # Dynamic page addendum
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|---------------------|------------------------------------------------------|
|
||||
| GET | /api/health | Health check |
|
||||
| POST | /api/compile | Compile `.uf` → `{ascii, micron, warnings}` |
|
||||
| POST | /api/compile | Compile `.uf` → `{ascii, micron, script, is_dynamic}` |
|
||||
| GET | /api/pages | List all pages with metadata |
|
||||
| GET | /api/pages/{name} | Read page source (`.uf` or legacy `.mu`) |
|
||||
| POST | /api/pages/{name} | Save page — body `{ source, publish: bool }` |
|
||||
| GET | /api/pages/{name} | Read page source |
|
||||
| POST | /api/pages/{name} | Save page — `{ source, publish }` |
|
||||
| DELETE | /api/pages/{name} | Delete source and/or .mu file |
|
||||
| GET | /api/graph | Graph nodes + edges (parsed from links in source) |
|
||||
| GET | /api/graph | Page link graph (nodes + edges) |
|
||||
| POST | /api/restart | Restart NomadNet Docker container |
|
||||
|
||||
## Storage
|
||||
|
||||
```
|
||||
~/.micron-editor/sources/ ← .uf source files (draft + published)
|
||||
~/.micron-editor/sources/ ← .uf source files (drafts + published)
|
||||
~/.nomadnetwork/storage/pages/ ← Compiled .mu files served by NomadNet
|
||||
```
|
||||
|
||||
On publish: `.uf` is compiled to `.mu` and copied to the NomadNet pages directory.
|
||||
- **Save Draft**: writes `.uf` to sources dir only
|
||||
- **Publish (static)**: compiles `.uf` → `.mu`, writes to pages dir (chmod 644)
|
||||
- **Publish (dynamic)**: compiles `.uf` → executable Python script, writes to pages dir (chmod 755)
|
||||
|
||||
## Conventions
|
||||
|
||||
- Frontend UI components live in `frontend/src/components/ui/` (shadcn)
|
||||
- Feature components grouped by domain: `dashboard/`, `editor/`, `shared/`
|
||||
- State management via Zustand stores in `frontend/src/stores/`
|
||||
- Backend is pure FastAPI; no ORM, flat file storage
|
||||
- µFrame engine is pure Python stdlib — no external dependencies
|
||||
NomadNet auto-detects the execute bit: static pages are served as-is, dynamic pages are executed and their stdout is served.
|
||||
|
||||
## µFrame DSL Reference
|
||||
|
||||
Full spec: `docs/framework-design-v3.md`
|
||||
|
||||
### Layout primitives
|
||||
### Layout
|
||||
```
|
||||
page "Title" [width] # root container (default 64)
|
||||
page "Title" [width] # root (default width 64)
|
||||
box [light|heavy|double|rounded] "Title" # bordered panel
|
||||
row [gap] # horizontal layout
|
||||
col [width] # column in a row
|
||||
@@ -138,7 +172,7 @@ page "Title" [width] # root container (default 64)
|
||||
pad [t] [r] [b] [l] # inner margin
|
||||
```
|
||||
|
||||
### Content primitives
|
||||
### Content
|
||||
```
|
||||
heading [1|2|3] "Text" # styled heading
|
||||
text "Content with @bold{inline} @color{hex}{modifiers}"
|
||||
@@ -150,141 +184,265 @@ divider [light|heavy|double|dash|dot] # horizontal rule
|
||||
# comment # ignored in output
|
||||
```
|
||||
|
||||
### Data visualization (Phase 4)
|
||||
### Data Visualization
|
||||
```
|
||||
gauge "Label" $val $max $width warn=N crit=N
|
||||
sparkline "Label" $values $width # braille patterns
|
||||
status "Label" [online|offline|degraded] # ●○◐ indicators
|
||||
gauge "Label" value max width [warn=N crit=N] # ████░░░░ bar with thresholds
|
||||
sparkline "Label" "1,3,5,8,7,5" width # ⣀⣤⣶⣿⣷⣤ braille chart
|
||||
status "Label" [online|offline|degraded] # ●○◐ colored indicators
|
||||
table "Title"
|
||||
columns "Name" 24 | "Hops" 6 | "Status" 10
|
||||
row "value" | "value" | "value"
|
||||
columns "Name" 20 | "Hops" 6 | "Status" 10
|
||||
row "relay" | "2" | "@color{0f0}{● alive}"
|
||||
```
|
||||
|
||||
### Forms (Phase 5)
|
||||
### Forms
|
||||
```
|
||||
form "name"
|
||||
field "name" [width] "placeholder"
|
||||
radio "group" "Opt A" | "Opt B"
|
||||
checkbox "name" "Label"
|
||||
button "Label" "/action/path"
|
||||
field "name" [width] "placeholder" # text input
|
||||
password "name" [width] "placeholder" # masked input
|
||||
radio "group" "Opt A" | "Opt B" | "Opt C" # radio buttons
|
||||
checkbox "name" "Label" # checkbox
|
||||
button "Label" "/action/path" # submit link
|
||||
```
|
||||
|
||||
### Dynamic features (Phase 6)
|
||||
### Dynamic Features
|
||||
|
||||
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
|
||||
text "ALERT: CPU critical"
|
||||
elif $cpu > 75
|
||||
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
|
||||
status "$peer.name" $peer.state
|
||||
```
|
||||
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
|
||||
```
|
||||
source cpu : shell "cat /proc/loadavg"
|
||||
on_submit "form_name"
|
||||
# handle form data
|
||||
if $val > threshold
|
||||
# conditional rendering
|
||||
for item in $collection
|
||||
# iteration
|
||||
state "store" "/path.json"
|
||||
cache 0 # never cache (re-execute per request)
|
||||
# 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
|
||||
```
|
||||
|
||||
### Rendering pipeline
|
||||
#### 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
|
||||
```
|
||||
.uf source → Parse → IR Tree → Measure → Layout → Paint → CharGrid
|
||||
├→ ASCII emitter (plain text)
|
||||
└→ Micron emitter (styled .mu)
|
||||
# Define a reusable component
|
||||
component stat(label, value, max)
|
||||
gauge "$label" $value $max 20
|
||||
|
||||
# Use it
|
||||
stat "CPU" 62 100
|
||||
stat "MEM" 84 100
|
||||
|
||||
# Import standard library
|
||||
use std/dashboard
|
||||
banner "My Node" "Mesh Network"
|
||||
resources 62 84
|
||||
```
|
||||
|
||||
### Micron syntax (legacy raw editor, still used for compiled output)
|
||||
### Standard Libraries
|
||||
| Library | Components |
|
||||
|---------|-----------|
|
||||
| `std/dashboard` | `banner(title, subtitle)`, `resources(cpu, mem)`, `peer_status(name, state)` |
|
||||
| `std/status-bar` | `status_bar(label, value, max)`, `status_item(name, state)` |
|
||||
| `std/nav` | `nav_link(label, dest)`, `nav_divider()` |
|
||||
|
||||
## Rendering Pipeline
|
||||
|
||||
```
|
||||
>H1 >>H2 >>>H3 headings
|
||||
`!bold`! `*italic`* `_underline`_ formatting
|
||||
`Fhex text`f `Bhex text`b colors (3-digit hex)
|
||||
`c text`a `r text`a `l text`a alignment
|
||||
[label`slug] links
|
||||
-─ -━ -═ -★ dividers
|
||||
`= ... `= literal mode
|
||||
# comment hidden in output
|
||||
.uf source
|
||||
│
|
||||
▼
|
||||
Parse ──→ IR Tree (30+ node types)
|
||||
│
|
||||
▼
|
||||
Measure (bottom-up: compute sizes)
|
||||
│
|
||||
▼
|
||||
Layout (top-down: assign positions)
|
||||
│
|
||||
▼
|
||||
Paint (depth-first: write chars into CharGrid)
|
||||
│
|
||||
▼
|
||||
Merge Borders (fix junction characters)
|
||||
│
|
||||
├──→ ASCII emitter → plain text
|
||||
├──→ Micron emitter → styled .mu (with colors, links, form tags)
|
||||
└──→ Codegen (if dynamic) → executable Python script
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
## Web IDE Features
|
||||
|
||||
### Phase 1 — Core Editor + Dashboard + Graph ✅
|
||||
- **Split-pane editor**: µFrame DSL source (left) / live preview (right)
|
||||
- **Syntax highlighting**: keywords, strings, variables, comments in distinct colors
|
||||
- **`/` command palette**: type `/` to insert layout, content, data viz, form, and style primitives
|
||||
- **4 preview tabs**: ASCII | Micron (rendered) | Raw (Micron source) | Script (dynamic pages only)
|
||||
- **`⚡ dynamic` badge**: auto-detected when source contains `source`, `if`, `for`, etc.
|
||||
- **Examples dropdown**: 9 built-in templates (Hello World → Full Node Page → Dynamic Dashboard)
|
||||
- **Pages dashboard**: table view with Published/Draft/Orphan status badges
|
||||
- **Page graph**: React Flow visualization of inter-page links
|
||||
- **Keyboard shortcuts**: `Ctrl+S` save draft, `Ctrl+P` publish
|
||||
- **Unsaved changes guard**: warns before navigating away
|
||||
- **Backlink indicator**: shows which pages link to the current page
|
||||
|
||||
- Split-pane Micron editor with live preview
|
||||
- Save draft / publish to NomadNet pages dir
|
||||
- Pages dashboard with status badges
|
||||
- Page graph (React Flow + dagre)
|
||||
- NomadNet restart, dark/light theme, keyboard shortcuts
|
||||
## Running Tests
|
||||
|
||||
### Phase 2 — Linking + Editor Enhancements ✅
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
python -m pytest uframe/tests/ -v
|
||||
```
|
||||
|
||||
- `[[` page link autocomplete
|
||||
- Backlink indicator with popover
|
||||
- Pivot to direct Micron editor (removed Markdown pipeline)
|
||||
- `/` slash command palette for Micron syntax
|
||||
- Micron syntax highlighting + HTML preview renderer
|
||||
- Syntax completeness per micron-composer spec
|
||||
42 tests covering:
|
||||
- Static compilation (boxes, headings, text, gauges, tables, links, lists, spacers, dividers)
|
||||
- Form elements (field, radio, checkbox, button)
|
||||
- Dynamic pages (source, if/for, let, state, on_submit, codegen)
|
||||
- Components (inline definitions, standard library, parameter substitution)
|
||||
|
||||
### Phase 3 — µFrame Core Engine (next)
|
||||
## Conventions
|
||||
|
||||
Build `backend/uframe/` — the rendering pipeline:
|
||||
|
||||
1. `errors.py` + `ir.py` — data structures
|
||||
2. `chars.py` — Unicode lookup tables (box-drawing, blocks, braille)
|
||||
3. `parser.py` — `.uf` DSL → IR tree (indentation-based, line-oriented)
|
||||
4. `grid.py` — CharGrid class (2D char + style buffer)
|
||||
5. `measure.py` + `layout.py` — size computation + position assignment
|
||||
6. `paint.py` — IR nodes → CharGrid
|
||||
7. `borders.py` — junction merging post-pass
|
||||
8. `emit_ascii.py` + `emit_micron.py` — CharGrid → output strings
|
||||
9. `__init__.py` — public `compile()` API
|
||||
10. Update `converter.py` → `POST /api/compile` endpoint
|
||||
11. Update `pages.py` — `.uf` sources, compile-on-publish
|
||||
12. Update `graph.py` — parse `.uf` for links
|
||||
|
||||
### Phase 4 — Data Visualization
|
||||
|
||||
- gauge, meter, bar_h, bar_v (block elements)
|
||||
- sparkline (braille sub-cell rendering)
|
||||
- heatmap (shade blocks with per-cell color)
|
||||
- status indicators (●○◐ with color)
|
||||
- table (box-drawn with header separator)
|
||||
- Border merging across nested tables
|
||||
|
||||
### Phase 5 — Web IDE Integration
|
||||
|
||||
- Replace Micron editor with µFrame DSL editor
|
||||
- CodeMirror `.uf` syntax highlighting + autocomplete
|
||||
- `useCompile` hook (debounced API calls)
|
||||
- Triple preview: ASCII | Micron rendered | Raw Micron
|
||||
- Page storage: `.uf` sources, `.mu` compiled output
|
||||
- Migration script for legacy `.mu` sources
|
||||
|
||||
### Phase 6 — Forms & Interactivity
|
||||
|
||||
- Form primitives: field, password, radio, checkbox, button
|
||||
- ASCII: visual placeholders; Micron: live fields
|
||||
- @modifier inline syntax
|
||||
- Variables and `let` bindings
|
||||
|
||||
### Phase 7 — Dynamic Pages
|
||||
|
||||
- `source` blocks for live data (shell, file, json, python, rns)
|
||||
- Compile to executable Python scripts with embedded runtime
|
||||
- `on_submit` form handling via `FIELD_*` env vars
|
||||
- Conditionals (`if`/`elif`/`else`) and loops (`for`)
|
||||
- State persistence (`state` + JSON store)
|
||||
- Cache control headers
|
||||
- CLI: `uframe compile` / `uframe deploy`
|
||||
|
||||
### Phase 8 — Components & Standard Library
|
||||
|
||||
- `component` definitions with argument bindings
|
||||
- Standard library: `std/dashboard`, `std/filebrowser`, `std/board`
|
||||
- Themes (`.uf-theme` color palette files)
|
||||
- `uframe check` linter
|
||||
- Production deployment: systemd + Tailscale
|
||||
- µFrame engine is **pure Python stdlib** — zero external dependencies
|
||||
- Backend is FastAPI; no ORM, flat file storage
|
||||
- Frontend uses shadcn/ui components in `frontend/src/components/ui/`
|
||||
- Feature components grouped by domain: `dashboard/`, `editor/`, `shared/`
|
||||
- State management via Zustand stores
|
||||
- All `.uf` sources stored in `SOURCES_DIR`, compiled `.mu` in `PAGES_DIR`
|
||||
|
||||
## References
|
||||
|
||||
- µFrame design: `docs/framework-design-v3.md`
|
||||
- µFrame dynamic: `docs/dynamic-templates.md`
|
||||
- micron-composer: https://github.com/fr33n0w/micron-composer
|
||||
- micron-parser-js: https://rfnexus.github.io/micron-parser-js/
|
||||
- µFrame design spec: `docs/framework-design-v3.md`
|
||||
- Dynamic templates spec: `docs/dynamic-templates.md`
|
||||
- NomadNet: https://github.com/markqvist/NomadNet
|
||||
- md2txt (legacy): https://codeberg.org/randogoth/md2txt
|
||||
- Micron syntax: https://github.com/fr33n0w/micron-composer
|
||||
- Reticulum: https://github.com/markqvist/Reticulum
|
||||
|
||||
@@ -10,7 +10,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/ ./
|
||||
COPY frontend/dist/ ./static/
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
|
||||
289
README.md
@@ -1,21 +1,64 @@
|
||||
# Micronomicon
|
||||
# µFrame (Micronomicon)
|
||||
|
||||
A self-hosted web editor for writing Markdown and publishing `.mu` pages to a NomadNet node.
|
||||
A declarative DSL and web IDE for building rich terminal UIs that publish as `.mu` pages to [NomadNet](https://github.com/markqvist/NomadNet) — a decentralized communication platform on the [Reticulum](https://github.com/markqvist/Reticulum) mesh network.
|
||||
|
||||
Write in Markdown → preview as Micron → publish directly to `~/.nomadnetwork/storage/pages/`.
|
||||
Write structured layouts with box-drawing, gauges, tables, and forms in a simple DSL. Get both plain ASCII art (viewable in any terminal) and styled Micron markup (with colors, links, and interactive form fields) from the same source.
|
||||
|
||||
```
|
||||
page "Dashboard" 60 ╔═ Relay Alpha ══════════════════╗
|
||||
box double "Relay Alpha" ║ Reticulum Network Node ║
|
||||
align center ╚════════════════════════════════╝
|
||||
text "Reticulum Network Node"
|
||||
CPU ████████████████░░░░ 62%
|
||||
gauge "CPU" 62 100 28 warn=75 crit=90 MEM ██████████████████░░ 84% ⚠
|
||||
gauge "MEM" 84 100 28 warn=80 crit=95
|
||||
┌──────────┬──────┬──────────┐
|
||||
table "Routes" │Dest │Hops │Status │
|
||||
columns "Dest" 10 | "Hops" 6 | ... ├──────────┼──────┼──────────┤
|
||||
row "east" | "2" | "alive" │east │2 │● alive │
|
||||
└──────────┴──────┴──────────┘
|
||||
status "East Relay" online ● East Relay
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
## Quick Start
|
||||
|
||||
- Docker + Docker Compose
|
||||
- Python 3.13+ (for local backend development only)
|
||||
- Node 20+ (for local frontend development only)
|
||||
- A running NomadNet container named `nomadnet` (for the restart button)
|
||||
### Web IDE
|
||||
|
||||
---
|
||||
```bash
|
||||
# 1. Create directories
|
||||
mkdir -p ~/.nomadnetwork/storage/pages ~/.micron-editor/sources
|
||||
|
||||
## Quick Start (Docker)
|
||||
# 2. Backend
|
||||
cd backend
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
PAGES_DIR=~/.nomadnetwork/storage/pages \
|
||||
SOURCES_DIR=~/.micron-editor/sources \
|
||||
uvicorn main:app --reload --port 8080
|
||||
|
||||
# 3. Frontend (separate terminal)
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5173 → **New Page** → **Examples** → pick a template.
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
cd backend && source .venv/bin/activate
|
||||
|
||||
python -m uframe render page.uf # ASCII to stdout
|
||||
python -m uframe render page.uf --micron # Micron to stdout
|
||||
python -m uframe compile page.uf # → page.mu
|
||||
python -m uframe check page.uf # validate
|
||||
python -m uframe deploy page.uf # compile + copy to NomadNet pages
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# 1. Build the frontend
|
||||
@@ -24,48 +67,135 @@ npm install
|
||||
npm run build
|
||||
cd ..
|
||||
|
||||
# 2. Create source directories
|
||||
mkdir -p ~/.nomadnetwork/storage/pages ~/.micron-editor/sources
|
||||
|
||||
# 3. Start the stack
|
||||
docker compose up --build
|
||||
# 2. Build and start all services
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
App is available at `http://localhost:8080`.
|
||||
This starts two containers:
|
||||
|
||||
### Tailscale HTTPS
|
||||
- **micronomicon** — the web IDE + API on <http://localhost:8080>
|
||||
- **nomadnet** — NomadNet node serving your published `.mu` pages
|
||||
|
||||
Pages published through the IDE are written to a shared volume that NomadNet reads from. The Docker socket is mounted read-only so the IDE can restart NomadNet when needed.
|
||||
|
||||
To follow logs: `docker compose logs -f`
|
||||
|
||||
To stop: `docker compose down`
|
||||
|
||||
To expose over Tailscale: `tailscale serve --bg https+insecure://localhost:8080`
|
||||
|
||||
#### Deploy a page via CLI (into the running stack)
|
||||
|
||||
```bash
|
||||
tailscale serve --bg https+insecure://localhost:8080
|
||||
cd backend && source .venv/bin/activate
|
||||
python -m uframe deploy page.uf --dest "$(docker volume inspect micronomicon_pages -f '{{.Mountpoint}}')"
|
||||
```
|
||||
|
||||
Or use the web IDE at <http://localhost:8080> and click **Publish**.
|
||||
|
||||
---
|
||||
|
||||
## DSL Overview
|
||||
|
||||
### Layout
|
||||
```
|
||||
page "Title" [width] # root container
|
||||
box [light|heavy|double|rounded] "Title" # bordered panel
|
||||
row [gap] # horizontal split
|
||||
col [width] # column
|
||||
spacer [lines] # vertical space
|
||||
```
|
||||
|
||||
### Content
|
||||
```
|
||||
heading [1|2|3] "Text" # heading
|
||||
text "Hello @bold{world} @color{0f0}{green}" # text with inline modifiers
|
||||
label "Key" "Value" # key-value pair
|
||||
link "Click me" "/page/dest.mu" # clickable link
|
||||
divider [light|heavy|double] # horizontal rule
|
||||
```
|
||||
|
||||
### Data Visualization
|
||||
```
|
||||
gauge "CPU" 62 100 28 warn=75 crit=90 # ████████░░░░ 62%
|
||||
sparkline "Net" "1,3,5,8,7,5" 20 # ⣀⣤⣶⣿⣷⣤ braille chart
|
||||
status "Server" [online|offline|degraded] # ●○◐ indicator
|
||||
table "Routes"
|
||||
columns "Dest" 20 | "Hops" 6
|
||||
row "east" | "2"
|
||||
```
|
||||
|
||||
### Forms
|
||||
```
|
||||
form "search"
|
||||
field "query" 30 "Search..." # text input
|
||||
radio "scope" "Local" | "Network" # radio buttons
|
||||
checkbox "cache" "Include cached" # checkbox
|
||||
button "Go" "/page/search.mu" # submit
|
||||
```
|
||||
|
||||
### Dynamic Pages
|
||||
```
|
||||
cache 0 # re-execute on every request
|
||||
source cpu : shell "cat /proc/loadavg" # live data
|
||||
if $cpu > 90
|
||||
text "ALERT"
|
||||
for peer in $peers
|
||||
status "$peer.name" $peer.state
|
||||
state "visits" "/tmp/visits.json" # persistent store
|
||||
```
|
||||
|
||||
### Components
|
||||
```
|
||||
component stat(label, value, max)
|
||||
gauge "$label" $value $max 20
|
||||
|
||||
stat "CPU" 62 100 # reuse
|
||||
use std/dashboard # import standard library
|
||||
banner "My Node" "Mesh Network" # use library component
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
## Architecture
|
||||
|
||||
Run backend and frontend separately with hot reload.
|
||||
|
||||
**Backend**
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
PAGES_DIR=~/.nomadnetwork/storage/pages \
|
||||
SOURCES_DIR=~/.micron-editor/sources \
|
||||
uvicorn main:app --reload --port 8080
|
||||
```
|
||||
.uf source → Parse → IR Tree → Measure → Layout → Paint → CharGrid
|
||||
├→ ASCII (plain text)
|
||||
├→ Micron (.mu with styles)
|
||||
└→ Script (dynamic: executable Python)
|
||||
```
|
||||
|
||||
**Frontend**
|
||||
**Static pages**: `.uf` compiles to `.mu` (Micron markup). NomadNet serves the file directly.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev # proxies /api → localhost:8080
|
||||
```
|
||||
**Dynamic pages**: `.uf` with `source`/`if`/`for` compiles to an executable Python script. NomadNet detects the `+x` bit, runs the script on each request, and serves the stdout as Micron. Live system data, form handling, and state persistence all work through this model.
|
||||
|
||||
Open `http://localhost:5173`.
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|---------------------|-------------------------------------------------------|
|
||||
| POST | /api/compile | Compile `.uf` → `{ascii, micron, script, is_dynamic}` |
|
||||
| GET | /api/pages | List all pages with metadata |
|
||||
| GET | /api/pages/{name} | Read page source |
|
||||
| POST | /api/pages/{name} | Save `{source, publish}` — draft or publish |
|
||||
| DELETE | /api/pages/{name} | Delete page |
|
||||
| GET | /api/graph | Page link graph |
|
||||
| POST | /api/restart | Restart NomadNet container |
|
||||
|
||||
---
|
||||
|
||||
## Web IDE Features
|
||||
|
||||
- **Split-pane editor** with µFrame syntax highlighting and live preview
|
||||
- **`/` command palette** — type `/` to insert any DSL primitive
|
||||
- **4 preview tabs** — ASCII | Micron (rendered) | Raw | Script (dynamic only)
|
||||
- **Examples dropdown** — 9 templates from Hello World to Dynamic Dashboard
|
||||
- **Pages dashboard** with Published / Draft / Orphan status badges
|
||||
- **Page graph** — React Flow visualization of inter-page links
|
||||
- **Keyboard shortcuts** — `Ctrl+S` save, `Ctrl+P` publish
|
||||
- **Backlink indicator** — shows which pages link to the current page
|
||||
|
||||
---
|
||||
|
||||
@@ -74,85 +204,24 @@ Open `http://localhost:5173`.
|
||||
| Variable | Default | Description |
|
||||
|----------------------|------------------|--------------------------------------|
|
||||
| `PAGES_DIR` | `/data/pages` | NomadNet pages directory |
|
||||
| `SOURCES_DIR` | `/data/sources` | Markdown source files directory |
|
||||
| `SOURCES_DIR` | `/data/sources` | µFrame source files directory |
|
||||
| `NOMADNET_CONTAINER` | `nomadnet` | Docker container name to restart |
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
## Tests
|
||||
|
||||
| Method | Path | Description |
|
||||
|----------|---------------------|-----------------------------------------------|
|
||||
| `GET` | `/api/health` | Health check |
|
||||
| `POST` | `/api/convert` | Convert `{ markdown }` → `{ micron }` |
|
||||
| `GET` | `/api/pages` | List all pages with metadata |
|
||||
| `GET` | `/api/pages/{name}` | Read page (markdown source + micron output) |
|
||||
| `POST` | `/api/pages/{name}` | Save `{ markdown, publish }` — draft or live |
|
||||
| `DELETE` | `/api/pages/{name}` | Delete source and/or `.mu` file |
|
||||
| `GET` | `/api/graph` | Graph nodes + edges from parsed link sources |
|
||||
| `POST` | `/api/restart` | Restart NomadNet Docker container |
|
||||
|
||||
---
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```
|
||||
micronomicon/
|
||||
Dockerfile
|
||||
compose.yml
|
||||
backend/
|
||||
main.py ← FastAPI app + static file serving
|
||||
converter.py ← md2txt wrapper (POST /api/convert)
|
||||
pages.py ← file management (CRUD /api/pages)
|
||||
graph.py ← link parser (GET /api/graph)
|
||||
docker_utils.py ← container restart (POST /api/restart)
|
||||
requirements.txt
|
||||
frontend/
|
||||
src/
|
||||
App.tsx
|
||||
routes/ ← DashboardView, EditorView, GraphView
|
||||
components/ ← dashboard/, editor/, shared/, ui/ (shadcn)
|
||||
stores/ ← editorStore, pagesStore (Zustand)
|
||||
hooks/ ← useConversion, useGraph, useUnsavedGuard
|
||||
lib/ ← utils (cn)
|
||||
|
||||
~/.nomadnetwork/storage/pages/ ← published .mu files (NomadNet serves these)
|
||||
~/.micron-editor/sources/ ← markdown sources (managed by this app)
|
||||
```bash
|
||||
cd backend && source .venv/bin/activate
|
||||
python -m pytest uframe/tests/ -v # 91 tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Page Lifecycle
|
||||
## References
|
||||
|
||||
```
|
||||
New Page → /editor/new → Save Draft → .md saved to sources/
|
||||
→ Publish → .md saved + .mu written to pages/
|
||||
```
|
||||
|
||||
- **Draft** — `.md` exists, no `.mu`. Not visible on NomadNet.
|
||||
- **Published** — both `.md` and `.mu` exist.
|
||||
- **Orphan** — `.mu` exists but no `.md` source (e.g. pages created outside this tool).
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-----------|-----------------------------------------|
|
||||
| Backend | Python 3.13 + FastAPI + uvicorn |
|
||||
| Converter | md2txt (micron renderer) |
|
||||
| Frontend | React 19 + Vite + TypeScript |
|
||||
| UI | shadcn/ui + Tailwind CSS v4 |
|
||||
| Editor | CodeMirror 6 |
|
||||
| Graph | React Flow + dagre |
|
||||
| State | Zustand |
|
||||
| Container | Docker + Compose |
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations (Phase 1)
|
||||
|
||||
- Micron preview is plain text — full terminal rendering comes in a later phase (micron-parser-js iframe)
|
||||
- `[[` link autocomplete not yet implemented (Phase 2)
|
||||
- Graph view is read-only; click a node to open it in the editor
|
||||
- No metrics (Phase 4)
|
||||
- [NomadNet](https://github.com/markqvist/NomadNet) — decentralized communication
|
||||
- [Reticulum](https://github.com/markqvist/Reticulum) — mesh networking stack
|
||||
- [Micron syntax](https://github.com/fr33n0w/micron-composer) — markup reference
|
||||
- [Design spec](docs/framework-design-v3.md) — full DSL design document
|
||||
- [Dynamic templates](docs/dynamic-templates.md) — dynamic page system spec
|
||||
|
||||
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,13 +1,48 @@
|
||||
"""µFrame compile endpoint — POST /api/compile."""
|
||||
"""µFrame compile, DSL metadata, and image upload endpoints."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
|
||||
import uframe
|
||||
import uframe.keywords # noqa: F401 — triggers keyword registration
|
||||
from uframe.errors import UFrameError
|
||||
from uframe.registry import get_dsl_meta
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
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):
|
||||
source: str
|
||||
@@ -24,15 +59,68 @@ class CompileResponse(BaseModel):
|
||||
|
||||
@router.post("/compile", response_model=CompileResponse)
|
||||
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:
|
||||
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(
|
||||
ascii=result.ascii,
|
||||
micron=result.micron,
|
||||
micron=micron,
|
||||
script=result.script,
|
||||
is_dynamic=result.is_dynamic,
|
||||
warnings=[w.message for w in result.warnings],
|
||||
)
|
||||
except UFrameError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/dsl-meta")
|
||||
async def dsl_meta():
|
||||
"""Return DSL metadata for frontend syntax highlighting and autocomplete."""
|
||||
return get_dsl_meta()
|
||||
|
||||
|
||||
@router.post("/upload-image")
|
||||
async def upload_image(file: UploadFile = File(...)):
|
||||
"""Upload an image for use in .uf pages."""
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Sanitize filename
|
||||
name = file.filename or "upload.png"
|
||||
safe_name = "".join(c for c in name if c.isalnum() or c in "._-").rstrip(".")
|
||||
if not safe_name:
|
||||
safe_name = "upload.png"
|
||||
dest = UPLOAD_DIR / safe_name
|
||||
content = await file.read()
|
||||
dest.write_bytes(content)
|
||||
# Return the path relative to backend working directory
|
||||
rel_path = str(dest)
|
||||
return {"path": rel_path, "filename": safe_name, "size": len(content)}
|
||||
|
||||
|
||||
@router.get("/images")
|
||||
async def list_images():
|
||||
"""List uploaded images available for embedding."""
|
||||
if not UPLOAD_DIR.is_dir():
|
||||
return []
|
||||
images = []
|
||||
for f in sorted(UPLOAD_DIR.iterdir()):
|
||||
if f.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".gif"):
|
||||
images.append({
|
||||
"filename": f.name,
|
||||
"path": str(f),
|
||||
"size": f.stat().st_size,
|
||||
})
|
||||
return images
|
||||
|
||||
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,17 +4,24 @@ from pathlib import Path
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from pages import router as pages_router
|
||||
from graph import router as graph_router
|
||||
from pages import router as pages_router, files_router, ensure_default_pages
|
||||
from docker_utils import router as docker_router
|
||||
from converter import router as converter_router
|
||||
from browse import router as browse_router, start_browser
|
||||
|
||||
app = FastAPI(title="µFrame Editor")
|
||||
|
||||
app.include_router(converter_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(browse_router, prefix="/api")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
ensure_default_pages()
|
||||
start_browser()
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
@@ -22,7 +29,16 @@ async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# Serve built frontend as static files (SPA fallback)
|
||||
# Serve built frontend as static files with SPA fallback
|
||||
static_dir = Path(__file__).parent / "static"
|
||||
if static_dir.is_dir():
|
||||
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets")
|
||||
|
||||
@app.get("/{path:path}")
|
||||
async def spa_fallback(path: str):
|
||||
file = static_dir / path
|
||||
if file.is_file():
|
||||
return FileResponse(file)
|
||||
return FileResponse(static_dir / "index.html")
|
||||
|
||||
6
backend/package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
232
backend/pages.py
@@ -1,17 +1,72 @@
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
import uframe
|
||||
|
||||
router = APIRouter()
|
||||
files_router = APIRouter()
|
||||
|
||||
PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages"))
|
||||
SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources"))
|
||||
|
||||
DEFAULT_INDEX_SOURCE = '''\
|
||||
page "Welcome" 60
|
||||
bigtitle "uFrame" thin
|
||||
|
||||
box rounded "Micronomicon"
|
||||
align center
|
||||
text "Decentralized Page Server"
|
||||
text "Powered by @bold{Reticulum} and @bold{NomadNet}"
|
||||
|
||||
spacer
|
||||
|
||||
heading 2 "Pages"
|
||||
text "This node is serving pages built with the uFrame DSL."
|
||||
text "Use the web IDE to create and publish new pages."
|
||||
|
||||
spacer
|
||||
|
||||
divider light
|
||||
|
||||
label "Node" "Micronomicon"
|
||||
label "Engine" "uFrame v1"
|
||||
status "Node" online
|
||||
'''
|
||||
|
||||
|
||||
def ensure_default_pages():
|
||||
"""Create default index page and .env file if they don't exist."""
|
||||
PAGES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SOURCES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
index_mu = PAGES_DIR / "index.mu"
|
||||
index_src = SOURCES_DIR / "index.uf"
|
||||
|
||||
if not index_mu.is_file():
|
||||
# Compile and publish the default index
|
||||
result = uframe.compile(DEFAULT_INDEX_SOURCE)
|
||||
index_mu.write_text(result.micron, encoding="utf-8")
|
||||
index_mu.chmod(0o644)
|
||||
|
||||
if not index_src.is_file():
|
||||
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):
|
||||
name: str
|
||||
@@ -183,3 +238,176 @@ async def delete_page(name: str):
|
||||
mu_path.unlink()
|
||||
|
||||
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}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
docker>=7.0
|
||||
Pillow>=10.0
|
||||
python-multipart>=0.0.6
|
||||
md2txt @ git+https://codeberg.org/randogoth/md2txt
|
||||
rns>=0.9.3
|
||||
|
||||
BIN
backend/test_logo.png
Normal file
|
After Width: | Height: | Size: 898 B |
@@ -21,6 +21,7 @@ from uframe.ir import (
|
||||
IRNode, Field, Password, Radio, Checkbox, FormButton,
|
||||
Source, IfBlock, ForLoop, OnSubmit, StateDecl, CacheControl,
|
||||
)
|
||||
from uframe.themes import get_theme, ThemeDef
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -79,7 +80,7 @@ def _micron_form_line(node: IRNode) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def compile(source: str, width: int = 64) -> CompileResult:
|
||||
def compile(source: str, width: int = 64, theme: str = "") -> CompileResult:
|
||||
"""Compile a µFrame .uf source string into ASCII and Micron output.
|
||||
|
||||
Args:
|
||||
@@ -102,6 +103,10 @@ def compile(source: str, width: int = 64) -> CompileResult:
|
||||
|
||||
w = page.width
|
||||
|
||||
# 1b. Resolve theme (CLI flag overrides source directive)
|
||||
theme_name = theme or page.theme_name or "default"
|
||||
theme_def = get_theme(theme_name)
|
||||
|
||||
# 2. Measure
|
||||
measure(page, w)
|
||||
|
||||
@@ -110,7 +115,7 @@ def compile(source: str, width: int = 64) -> CompileResult:
|
||||
|
||||
# 4. Create grid and paint
|
||||
grid = CharGrid(w, max(total_h, 1))
|
||||
paint(page, grid)
|
||||
paint(page, grid, theme_def)
|
||||
|
||||
# 5. Merge borders
|
||||
merge_borders(grid)
|
||||
|
||||
@@ -1,148 +1,19 @@
|
||||
"""Border merging post-pass — fix junction characters where borders meet.
|
||||
"""Border merging post-pass (currently no-op).
|
||||
|
||||
Scans the CharGrid for adjacent border cells and replaces with the
|
||||
correct junction character (T-junctions, crosses, corners) from the
|
||||
Unicode box-drawing set.
|
||||
The draw_border and _paint_table functions produce correct border characters
|
||||
directly. The original merge pass caused garbled junctions when borders from
|
||||
different boxes were adjacent, so it was disabled.
|
||||
|
||||
If future features need cross-box junction merging (e.g. tables sharing
|
||||
edges with parent boxes), add targeted logic here using border_id from
|
||||
the grid cells to only merge within the same border group.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uframe.grid import CharGrid
|
||||
from uframe.ir import BorderWeight
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# For each border cell, check which directions have adjacent borders.
|
||||
# Direction flags:
|
||||
UP = 1
|
||||
DOWN = 2
|
||||
LEFT = 4
|
||||
RIGHT = 8
|
||||
|
||||
# Junction lookup: connections bitmask → character
|
||||
# Only light weight for now (most common case)
|
||||
_LIGHT_JUNCTIONS: dict[int, str] = {
|
||||
UP | DOWN: "│",
|
||||
LEFT | RIGHT: "─",
|
||||
DOWN | RIGHT: "┌",
|
||||
DOWN | LEFT: "┐",
|
||||
UP | RIGHT: "└",
|
||||
UP | LEFT: "┘",
|
||||
UP | DOWN | RIGHT: "├",
|
||||
UP | DOWN | LEFT: "┤",
|
||||
LEFT | RIGHT | DOWN: "┬",
|
||||
LEFT | RIGHT | UP: "┴",
|
||||
UP | DOWN | LEFT | RIGHT: "┼",
|
||||
RIGHT: "─",
|
||||
LEFT: "─",
|
||||
UP: "│",
|
||||
DOWN: "│",
|
||||
}
|
||||
|
||||
_HEAVY_JUNCTIONS: dict[int, str] = {
|
||||
UP | DOWN: "┃",
|
||||
LEFT | RIGHT: "━",
|
||||
DOWN | RIGHT: "┏",
|
||||
DOWN | LEFT: "┓",
|
||||
UP | RIGHT: "┗",
|
||||
UP | LEFT: "┛",
|
||||
UP | DOWN | RIGHT: "┣",
|
||||
UP | DOWN | LEFT: "┫",
|
||||
LEFT | RIGHT | DOWN: "┳",
|
||||
LEFT | RIGHT | UP: "┻",
|
||||
UP | DOWN | LEFT | RIGHT: "╋",
|
||||
RIGHT: "━",
|
||||
LEFT: "━",
|
||||
UP: "┃",
|
||||
DOWN: "┃",
|
||||
}
|
||||
|
||||
_DOUBLE_JUNCTIONS: dict[int, str] = {
|
||||
UP | DOWN: "║",
|
||||
LEFT | RIGHT: "═",
|
||||
DOWN | RIGHT: "╔",
|
||||
DOWN | LEFT: "╗",
|
||||
UP | RIGHT: "╚",
|
||||
UP | LEFT: "╝",
|
||||
UP | DOWN | RIGHT: "╠",
|
||||
UP | DOWN | LEFT: "╣",
|
||||
LEFT | RIGHT | DOWN: "╦",
|
||||
LEFT | RIGHT | UP: "╩",
|
||||
UP | DOWN | LEFT | RIGHT: "╬",
|
||||
RIGHT: "═",
|
||||
LEFT: "═",
|
||||
UP: "║",
|
||||
DOWN: "║",
|
||||
}
|
||||
|
||||
_JUNCTION_TABLES = {
|
||||
BorderWeight.LIGHT: _LIGHT_JUNCTIONS,
|
||||
BorderWeight.HEAVY: _HEAVY_JUNCTIONS,
|
||||
BorderWeight.DOUBLE: _DOUBLE_JUNCTIONS,
|
||||
BorderWeight.ROUNDED: _LIGHT_JUNCTIONS, # rounded uses light junctions
|
||||
}
|
||||
|
||||
# Weight priority for mixed-weight junctions
|
||||
_WEIGHT_PRIORITY = {
|
||||
BorderWeight.DOUBLE: 3,
|
||||
BorderWeight.HEAVY: 2,
|
||||
BorderWeight.LIGHT: 1,
|
||||
BorderWeight.ROUNDED: 0,
|
||||
}
|
||||
|
||||
|
||||
def merge_borders(grid: CharGrid) -> None:
|
||||
"""Scan the grid for adjacent border cells and fix junction characters.
|
||||
|
||||
This pass resolves cases where two boxes share an edge or corner,
|
||||
replacing the overlapping border characters with proper junctions.
|
||||
"""
|
||||
for row in range(grid.height):
|
||||
for col in range(grid.width):
|
||||
cell = grid.cells[row][col]
|
||||
if not cell.is_border:
|
||||
continue
|
||||
|
||||
# Detect connections in 4 directions
|
||||
connections = 0
|
||||
max_weight = cell.border_weight or BorderWeight.LIGHT
|
||||
|
||||
# Check each neighbor
|
||||
if row > 0 and grid.cells[row - 1][col].is_border:
|
||||
connections |= UP
|
||||
nw = grid.cells[row - 1][col].border_weight or BorderWeight.LIGHT
|
||||
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
||||
max_weight = nw
|
||||
|
||||
if row < grid.height - 1 and grid.cells[row + 1][col].is_border:
|
||||
connections |= DOWN
|
||||
nw = grid.cells[row + 1][col].border_weight or BorderWeight.LIGHT
|
||||
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
||||
max_weight = nw
|
||||
|
||||
if col > 0 and grid.cells[row][col - 1].is_border:
|
||||
connections |= LEFT
|
||||
nw = grid.cells[row][col - 1].border_weight or BorderWeight.LIGHT
|
||||
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
||||
max_weight = nw
|
||||
|
||||
if col < grid.width - 1 and grid.cells[row][col + 1].is_border:
|
||||
connections |= RIGHT
|
||||
nw = grid.cells[row][col + 1].border_weight or BorderWeight.LIGHT
|
||||
if _WEIGHT_PRIORITY.get(nw, 0) > _WEIGHT_PRIORITY.get(max_weight, 0):
|
||||
max_weight = nw
|
||||
|
||||
# Skip rounded corners — they should preserve ╭╮╰╯
|
||||
if cell.border_weight == BorderWeight.ROUNDED and connections in (
|
||||
DOWN | RIGHT, DOWN | LEFT, UP | RIGHT, UP | LEFT
|
||||
):
|
||||
continue
|
||||
|
||||
# Look up the junction character
|
||||
if connections:
|
||||
table = _JUNCTION_TABLES.get(max_weight, _LIGHT_JUNCTIONS)
|
||||
junction = table.get(connections)
|
||||
if junction:
|
||||
cell.char = junction
|
||||
"""No-op — borders are correctly painted by draw_border and _paint_table."""
|
||||
pass
|
||||
|
||||
@@ -11,7 +11,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -24,7 +23,7 @@ def cmd_render(args: argparse.Namespace) -> int:
|
||||
source = Path(args.file).read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
result = uframe.compile(source, width=args.width)
|
||||
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||
except UFrameError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -53,7 +52,7 @@ def cmd_compile(args: argparse.Namespace) -> int:
|
||||
source = source_path.read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
result = uframe.compile(source, width=args.width)
|
||||
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||
except UFrameError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -76,7 +75,7 @@ def cmd_check(args: argparse.Namespace) -> int:
|
||||
source = Path(args.file).read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
result = uframe.compile(source, width=args.width)
|
||||
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||
except UFrameError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -98,7 +97,7 @@ def cmd_deploy(args: argparse.Namespace) -> int:
|
||||
dest_dir = Path(args.dest or os.path.expanduser("~/.nomadnetwork/storage/pages"))
|
||||
|
||||
try:
|
||||
result = uframe.compile(source, width=args.width)
|
||||
result = uframe.compile(source, width=args.width, theme=getattr(args, 'theme', ''))
|
||||
except UFrameError as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
@@ -118,6 +117,23 @@ def cmd_deploy(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_image(args: argparse.Namespace) -> int:
|
||||
"""Convert an image file to character art."""
|
||||
from uframe.imaging import convert_image
|
||||
try:
|
||||
lines = convert_image(args.file, mode=args.mode, width=args.width,
|
||||
dither=args.dither, invert=args.invert)
|
||||
for line in lines:
|
||||
print(line)
|
||||
except ImportError:
|
||||
print("Error: Pillow is required: pip install Pillow", file=sys.stderr)
|
||||
return 1
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Image not found: {args.file}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="uframe",
|
||||
@@ -131,12 +147,14 @@ def main() -> int:
|
||||
p_render.add_argument("--ascii", action="store_true", help="Output ASCII only")
|
||||
p_render.add_argument("--micron", action="store_true", help="Output Micron only")
|
||||
p_render.add_argument("--width", type=int, default=64, help="Page width (default: 64)")
|
||||
p_render.add_argument("--theme", default="", help="Theme name (default, nouveau, gothic, bamboo, circuit, brutalist)")
|
||||
|
||||
# compile
|
||||
p_compile = sub.add_parser("compile", help="Compile to .mu file")
|
||||
p_compile.add_argument("file", help="Path to .uf source file")
|
||||
p_compile.add_argument("--out", help="Output file path (default: <name>.mu)")
|
||||
p_compile.add_argument("--width", type=int, default=64, help="Page width")
|
||||
p_compile.add_argument("--theme", default="", help="Theme name")
|
||||
|
||||
# check
|
||||
p_check = sub.add_parser("check", help="Validate a .uf file")
|
||||
@@ -148,6 +166,15 @@ def main() -> int:
|
||||
p_deploy.add_argument("file", help="Path to .uf source file")
|
||||
p_deploy.add_argument("--dest", help="Destination directory (default: ~/.nomadnetwork/storage/pages)")
|
||||
p_deploy.add_argument("--width", type=int, default=64, help="Page width")
|
||||
p_deploy.add_argument("--theme", default="", help="Theme name")
|
||||
|
||||
# image (standalone conversion)
|
||||
p_image = sub.add_parser("image", help="Convert image to character art")
|
||||
p_image.add_argument("file", help="Path to image file")
|
||||
p_image.add_argument("--mode", default="braille", help="braille, block, ascii, halfblock")
|
||||
p_image.add_argument("--width", type=int, default=40, help="Output width")
|
||||
p_image.add_argument("--dither", default="floyd", help="floyd, threshold, none")
|
||||
p_image.add_argument("--invert", action="store_true", help="Invert light/dark")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -156,6 +183,7 @@ def main() -> int:
|
||||
"compile": cmd_compile,
|
||||
"check": cmd_check,
|
||||
"deploy": cmd_deploy,
|
||||
"image": cmd_image,
|
||||
}
|
||||
|
||||
return commands[args.command](args)
|
||||
|
||||
@@ -12,16 +12,13 @@ state) and generates a self-contained Python script that:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
from uframe.ir import (
|
||||
IRNode, Page, Box, Row, Col, Spacer, Pad,
|
||||
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
IRNode, Page, Box, Spacer,
|
||||
Heading, Text, Label, Divider, Link,
|
||||
Gauge, Status,
|
||||
Form, Field, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
SourceType, BorderWeight, HeadingLevel, DividerStyle, ListStyle,
|
||||
SourceType,
|
||||
)
|
||||
|
||||
|
||||
@@ -32,14 +29,56 @@ def _indent(code: str, level: int = 1) -> 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
|
||||
# Replace $var.attr.attr with {var_attr_attr} and simple $var with {var}
|
||||
def replace_var(m: re.Match) -> str:
|
||||
var = m.group(1)
|
||||
# Replace dots with underscores for Python variable names
|
||||
py_var = var.replace(".", "_")
|
||||
return "{" + py_var + "}"
|
||||
if "." in var:
|
||||
parts = var.split(".")
|
||||
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)
|
||||
|
||||
|
||||
@@ -70,11 +109,28 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
|
||||
elif node.source_type == SourceType.JSON:
|
||||
lines.append(f"{ind}{var} = _read_json({node.command!r})")
|
||||
elif node.source_type == SourceType.PYTHON:
|
||||
lines.append(f"{ind}{var} = eval({node.command!r})")
|
||||
lines.append(f"{ind}{var} = eval({node.command!r}, {{'datetime': _dt_cls, 'timedelta': timedelta, 'secrets': secrets, 'os': os, 'json': json}})")
|
||||
elif node.source_type == SourceType.PARAM:
|
||||
lines.append(f"{ind}{var} = _get_param({node.command!r})")
|
||||
elif node.source_type == SourceType.RNS:
|
||||
lines.append(f"{ind}{var} = _shell('rnstatus {node.command}', timeout={node.timeout})")
|
||||
import shlex as _shlex
|
||||
safe_cmd = _shlex.quote(node.command)
|
||||
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):
|
||||
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})")
|
||||
|
||||
elif isinstance(node, IfBlock):
|
||||
cond = _resolve_vars(node.condition)
|
||||
# Convert simple comparisons
|
||||
cond = _resolve_vars_code(node.condition)
|
||||
cond = cond.replace("&&", " and ").replace("||", " or ")
|
||||
lines.append(f"{ind}if {cond}:")
|
||||
if node.children:
|
||||
@@ -94,7 +149,7 @@ def _emit_node(node: IRNode, indent_level: int = 0) -> list[str]:
|
||||
lines.append(f"{ind} pass")
|
||||
|
||||
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}:")
|
||||
if 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))
|
||||
|
||||
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}):")
|
||||
if 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):
|
||||
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 = ""
|
||||
if node.warn is not None:
|
||||
extra += f" warn={node.warn}"
|
||||
if node.crit is not None:
|
||||
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):
|
||||
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",
|
||||
"",
|
||||
"import os, sys, json, subprocess, datetime, secrets, shlex",
|
||||
"from datetime import datetime as _dt_cls, timedelta",
|
||||
"",
|
||||
"# ─── 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 []',
|
||||
"",
|
||||
'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 ──────────────────────────────────────────",
|
||||
"",
|
||||
uframe_import,
|
||||
|
||||
@@ -54,15 +54,7 @@ _EMPTY_STYLE = CellStyle()
|
||||
|
||||
|
||||
def emit_micron(grid: CharGrid, page_title: str = "") -> str:
|
||||
"""Emit the CharGrid as Micron markup.
|
||||
|
||||
Args:
|
||||
grid: the rendered character grid
|
||||
page_title: optional page title for a leading >Title line
|
||||
|
||||
Returns:
|
||||
Micron source string
|
||||
"""
|
||||
"""Emit the CharGrid as Micron markup."""
|
||||
lines: list[str] = []
|
||||
|
||||
for row in range(grid.height):
|
||||
@@ -86,8 +78,9 @@ def emit_micron(grid: CharGrid, page_title: str = "") -> str:
|
||||
if cur_style != _EMPTY_STYLE:
|
||||
line_parts.append(_emit_style_close(cur_style))
|
||||
cur_style = _EMPTY_STYLE
|
||||
# Open new link
|
||||
line_parts.append("[")
|
||||
# Open new link — backtick enters formatting mode
|
||||
# where the parser recognizes `[` as link start
|
||||
line_parts.append("`[")
|
||||
in_link = link
|
||||
|
||||
# Handle style transitions (not inside links — links handle their own style)
|
||||
|
||||
209
backend/uframe/fonts.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""µFrame Big Text Fonts — multi-line ASCII art letter definitions.
|
||||
|
||||
Each font is a dict mapping characters to a list of strings (one per line).
|
||||
All characters in a font have the same height.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Block font — solid █ with box-drawing. Height: 6 lines.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BLOCK: dict[str, list[str]] = {
|
||||
"A": [" █████╗ ", "██╔══██╗", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
|
||||
"B": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██████╔╝", "╚═════╝ "],
|
||||
"C": [" ██████╗", "██╔════╝", "██║ ", "██║ ", "╚██████╗", " ╚═════╝"],
|
||||
"D": ["██████╗ ", "██╔══██╗", "██║ ██║", "██║ ██║", "██████╔╝", "╚═════╝ "],
|
||||
"E": ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "███████╗", "╚══════╝"],
|
||||
"F": ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "██║ ", "╚═╝ "],
|
||||
"G": [" ██████╗ ", "██╔════╝ ", "██║ ███╗", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
|
||||
"H": ["██╗ ██╗", "██║ ██║", "███████║", "██╔══██║", "██║ ██║", "╚═╝ ╚═╝"],
|
||||
"I": ["██╗", "██║", "██║", "██║", "██║", "╚═╝"],
|
||||
"J": [" ██╗", " ██║", " ██║", "██ ██║", "╚█████╔╝", " ╚════╝ "],
|
||||
"K": ["██╗ ██╗", "██║ ██╔╝", "█████╔╝ ", "██╔═██╗ ", "██║ ██╗", "╚═╝ ╚═╝"],
|
||||
"L": ["██╗ ", "██║ ", "██║ ", "██║ ", "███████╗", "╚══════╝"],
|
||||
"M": ["███╗ ███╗", "████╗ ████║", "██╔████╔██║", "██║╚██╔╝██║", "██║ ╚═╝ ██║", "╚═╝ ╚═╝"],
|
||||
"N": ["███╗ ██╗", "████╗ ██║", "██╔██╗ ██║", "██║╚██╗██║", "██║ ╚████║", "╚═╝ ╚═══╝"],
|
||||
"O": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
|
||||
"P": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔═══╝ ", "██║ ", "╚═╝ "],
|
||||
"Q": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║▄▄ ██║", "╚██████╔╝", " ╚══▀▀═╝ "],
|
||||
"R": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔══██╗", "██║ ██║", "╚═╝ ╚═╝"],
|
||||
"S": ["███████╗", "██╔════╝", "███████╗", "╚════██║", "███████║", "╚══════╝"],
|
||||
"T": ["████████╗", "╚══██╔══╝", " ██║ ", " ██║ ", " ██║ ", " ╚═╝ "],
|
||||
"U": ["██╗ ██╗", "██║ ██║", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
|
||||
"V": ["██╗ ██╗", "██║ ██║", "██║ ██║", "╚██╗ ██╔╝", " ╚████╔╝ ", " ╚═══╝ "],
|
||||
"W": ["██╗ ██╗", "██║ ██║", "██║ █╗ ██║", "██║███╗██║", "╚███╔███╔╝", " ╚══╝╚══╝ "],
|
||||
"X": ["██╗ ██╗", "╚██╗██╔╝", " ╚███╔╝ ", " ██╔██╗ ", "██╔╝ ██╗", "╚═╝ ╚═╝"],
|
||||
"Y": ["██╗ ██╗", "╚██╗ ██╔╝", " ╚████╔╝ ", " ╚██╔╝ ", " ██║ ", " ╚═╝ "],
|
||||
"Z": ["███████╗", "╚══███╔╝", " ███╔╝ ", " ███╔╝ ", "███████╗", "╚══════╝"],
|
||||
"0": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
|
||||
"1": [" ██╗", "███║", "╚██║", " ██║", " ██║", " ╚═╝"],
|
||||
"2": ["██████╗ ", "╚════██╗", " █████╔╝", "██╔═══╝ ", "███████╗", "╚══════╝"],
|
||||
"3": ["██████╗ ", "╚════██╗", " █████╔╝", " ╚═══██╗", "██████╔╝", "╚═════╝ "],
|
||||
"4": ["██╗ ██╗", "██║ ██║", "███████║", "╚════██║", " ██║", " ╚═╝"],
|
||||
"5": ["███████╗", "██╔════╝", "███████╗", "╚════██║", "███████║", "╚══════╝"],
|
||||
"6": [" ██████╗", "██╔════╝", "██████╗ ", "██╔══██╗", "╚█████╔╝", " ╚════╝ "],
|
||||
"7": ["███████╗", "╚════██║", " ██╔╝", " ██╔╝ ", " ██║ ", " ╚═╝ "],
|
||||
"8": [" █████╗ ", "██╔══██╗", "╚█████╔╝", "██╔══██╗", "╚█████╔╝", " ╚════╝ "],
|
||||
"9": [" █████╗ ", "██╔══██╗", "╚██████║", " ╚═══██║", " █████╔╝", " ╚════╝ "],
|
||||
"-": [" ", " ", "██████╗ ", "╚═════╝ ", " ", " "],
|
||||
" ": [" ", " ", " ", " ", " ", " "],
|
||||
".": [" ", " ", " ", " ", "██╗", "╚═╝"],
|
||||
"!": ["██╗", "██║", "██║", "╚═╝", "██╗", "╚═╝"],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thin font — light single-stroke. Height: 3 lines.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_THIN: dict[str, list[str]] = {
|
||||
"A": ["┌─┐", "├─┤", "┘ └"],
|
||||
"B": ["┬─┐", "├─┤", "┴─┘"],
|
||||
"C": ["┌─ ", "│ ", "└─ "],
|
||||
"D": ["┬─┐", "│ │", "┴─┘"],
|
||||
"E": ["┬──", "├─ ", "┴──"],
|
||||
"F": ["┬──", "├─ ", "┘ "],
|
||||
"G": ["┌─ ", "│ ┐", "└─┘"],
|
||||
"H": ["┐ ┌", "├─┤", "┘ └"],
|
||||
"I": ["┬", "│", "┴"],
|
||||
"J": [" ┬", " │", "└─┘"],
|
||||
"K": ["┐ ┌", "├┬┘", "┘└ "],
|
||||
"L": ["│ ", "│ ", "└──"],
|
||||
"M": ["┌┬┐", "│││", "┘ └"],
|
||||
"N": ["┌┐ ", "│└┐", "┘ └"],
|
||||
"O": ["┌─┐", "│ │", "└─┘"],
|
||||
"P": ["┌─┐", "├─┘", "┘ "],
|
||||
"Q": ["┌─┐", "│ │", "└─┤"],
|
||||
"R": ["┌─┐", "├─┤", "┘ └"],
|
||||
"S": ["┌─ ", "└─┐", " ─┘"],
|
||||
"T": ["┬─┬", " │ ", " ┴ "],
|
||||
"U": ["┐ ┌", "│ │", "└─┘"],
|
||||
"V": ["┐ ┌", "│ │", "└┬┘"],
|
||||
"W": ["┐ ┌", "│││", "└┴┘"],
|
||||
"X": ["╲ ╱", " ╳ ", "╱ ╲"],
|
||||
"Y": ["┐ ┌", "└┬┘", " ┴ "],
|
||||
"Z": ["──┐", " ╱ ", "└──"],
|
||||
"0": ["┌─┐", "│ │", "└─┘"],
|
||||
"1": [" ┐", " │", " ┴"],
|
||||
"2": ["─┐", "┌┘", "└─"],
|
||||
"3": ["─┐", " ┤", "─┘"],
|
||||
"4": ["┐ ┐", "└─┤", " ┘"],
|
||||
"5": ["┌─", "└┐", "─┘"],
|
||||
"6": ["┌─", "├┐", "└┘"],
|
||||
"7": ["──┐", " │", " ┘"],
|
||||
"8": ["┌┐", "├┤", "└┘"],
|
||||
"9": ["┌┐", "└┤", "─┘"],
|
||||
"-": [" ", "── ", " "],
|
||||
" ": [" ", " ", " "],
|
||||
".": [" ", " ", "·"],
|
||||
"!": ["│", " ", "·"],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pixel font — retro bitmap. Height: 3 lines.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_PIXEL: dict[str, list[str]] = {
|
||||
"A": ["▀▀▀▄", "█▀▀█", "▀ ▀"],
|
||||
"B": ["▀▀▀▄", "█▀▀▄", "▀▀▀ "],
|
||||
"C": ["▄▀▀▀", "█ ", "▀▀▀ "],
|
||||
"D": ["▀▀▀▄", "█ █", "▀▀▀ "],
|
||||
"E": ["▀▀▀ ", "█▀ ", "▀▀▀ "],
|
||||
"F": ["▀▀▀ ", "█▀ ", "▀ "],
|
||||
"G": ["▄▀▀ ", "█ ▀█", "▀▀▀ "],
|
||||
"H": ["▀ ▀ ", "█▀▀█", "▀ ▀"],
|
||||
"I": ["▀", "█", "▀"],
|
||||
"J": [" ▀ ", " █ ", "▀▀ "],
|
||||
"K": ["▀ ▄ ", "█▀▄ ", "▀ ▀"],
|
||||
"L": ["▀ ", "█ ", "▀▀▀ "],
|
||||
"M": ["▀▄▄▀", "█▀▀█", "▀ ▀"],
|
||||
"N": ["▀▄ ▀", "█ ▀█", "▀ ▀"],
|
||||
"O": ["▄▀▀▄", "█ █", "▀▀▀ "],
|
||||
"P": ["▀▀▀▄", "█▀▀ ", "▀ "],
|
||||
"Q": ["▄▀▀▄", "█ █", "▀▀▀▄"],
|
||||
"R": ["▀▀▀▄", "█▀▀▄", "▀ ▀"],
|
||||
"S": ["▄▀▀ ", "▀▀▀▄", " ▀▀ "],
|
||||
"T": ["▀▀▀▀", " █ ", " ▀ "],
|
||||
"U": ["▀ ▀", "█ █", "▀▀▀ "],
|
||||
"V": ["▀ ▀", "█ █", " ▀▀ "],
|
||||
"W": ["▀ ▀", "█▄▄█", "▀▀▀▀"],
|
||||
"X": ["▀ ▀", " ▀▀ ", "▀ ▀"],
|
||||
"Y": ["▀ ▀", " ▀▀ ", " ▀ "],
|
||||
"Z": ["▀▀▀▀", " ▄▀ ", "▀▀▀▀"],
|
||||
"0": ["▄▀▀▄", "█ █", "▀▀▀ "],
|
||||
"1": [" ▄", " █", " ▀"],
|
||||
"2": ["▀▀▄", " ▄▀", "▀▀▀"],
|
||||
"3": ["▀▀▄", " ▀▄", "▀▀ "],
|
||||
"4": ["▀ ▀", "▀▀█", " ▀"],
|
||||
"5": ["▀▀▀", "▀▀▄", "▀▀ "],
|
||||
"6": ["▄▀▀", "█▀▄", "▀▀ "],
|
||||
"7": ["▀▀▀", " █", " ▀"],
|
||||
"8": ["▄▀▄", "█▀█", "▀▀ "],
|
||||
"9": ["▄▀▄", "▀▀█", "▀▀ "],
|
||||
"-": [" ", " ▀▀ ", " "],
|
||||
" ": [" ", " ", " "],
|
||||
".": [" ", " ", "▄"],
|
||||
"!": ["█", " ", "▄"],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Font registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FONTS: dict[str, dict[str, list[str]]] = {
|
||||
"block": _BLOCK,
|
||||
"thin": _THIN,
|
||||
"pixel": _PIXEL,
|
||||
}
|
||||
|
||||
FONT_HEIGHTS: dict[str, int] = {
|
||||
"block": 6,
|
||||
"thin": 3,
|
||||
"pixel": 3,
|
||||
}
|
||||
|
||||
|
||||
def render_big_text(text: str, font_name: str = "block", kerning: int = 1) -> list[str]:
|
||||
"""Render text as multi-line ASCII art using the specified font.
|
||||
|
||||
Args:
|
||||
text: the string to render (uppercase recommended for block/pixel)
|
||||
font_name: "block", "thin", or "pixel"
|
||||
kerning: space between characters (0=tight, 1=normal, 2=wide)
|
||||
|
||||
Returns:
|
||||
List of strings, one per line of the rendered text.
|
||||
"""
|
||||
font = FONTS.get(font_name, _BLOCK)
|
||||
height = FONT_HEIGHTS.get(font_name, 6)
|
||||
text_upper = text.upper()
|
||||
|
||||
# Build each line by concatenating character columns
|
||||
lines: list[str] = ["" for _ in range(height)]
|
||||
spacer = " " * kerning
|
||||
|
||||
for i, ch in enumerate(text_upper):
|
||||
glyph = font.get(ch, font.get("?", [" " * 3] * height))
|
||||
for row in range(height):
|
||||
if row < len(glyph):
|
||||
lines[row] += glyph[row]
|
||||
else:
|
||||
lines[row] += " " * (len(glyph[0]) if glyph else 3)
|
||||
if i < len(text_upper) - 1:
|
||||
lines[row] += spacer
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def get_text_width(text: str, font_name: str = "block", kerning: int = 1) -> int:
|
||||
"""Calculate the rendered width of big text without rendering it."""
|
||||
font = FONTS.get(font_name, _BLOCK)
|
||||
text_upper = text.upper()
|
||||
width = 0
|
||||
for i, ch in enumerate(text_upper):
|
||||
glyph = font.get(ch, font.get("?", [" " * 3]))
|
||||
width += len(glyph[0]) if glyph else 3
|
||||
if i < len(text_upper) - 1:
|
||||
width += kerning
|
||||
return width
|
||||
@@ -39,17 +39,19 @@ class Cell:
|
||||
style: CellStyle = field(default_factory=CellStyle)
|
||||
is_border: bool = False # True for box-drawing characters (for merge pass)
|
||||
border_weight: BorderWeight | None = None
|
||||
border_id: int = 0 # Identifies which box this border belongs to
|
||||
link: str | None = None # Micron link destination
|
||||
|
||||
|
||||
class CharGrid:
|
||||
"""2D buffer of cells. Origin (0,0) is top-left."""
|
||||
|
||||
__slots__ = ("width", "height", "cells")
|
||||
__slots__ = ("width", "height", "cells", "_border_counter")
|
||||
|
||||
def __init__(self, width: int, height: int):
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._border_counter = 0
|
||||
self.cells: list[list[Cell]] = [
|
||||
[Cell() for _ in range(width)]
|
||||
for _ in range(height)
|
||||
@@ -62,6 +64,7 @@ class CharGrid:
|
||||
style: CellStyle | None = None,
|
||||
is_border: bool = False,
|
||||
border_weight: BorderWeight | None = None,
|
||||
border_id: int = 0,
|
||||
link: str | None = None) -> None:
|
||||
"""Write a single character to the grid."""
|
||||
if not self.in_bounds(x, y):
|
||||
@@ -72,6 +75,8 @@ class CharGrid:
|
||||
cell.style = style
|
||||
cell.is_border = is_border
|
||||
cell.border_weight = border_weight
|
||||
if border_id:
|
||||
cell.border_id = border_id
|
||||
if link is not None:
|
||||
cell.link = link
|
||||
|
||||
@@ -101,7 +106,9 @@ class CharGrid:
|
||||
def draw_border(self, x: int, y: int, w: int, h: int,
|
||||
weight: BorderWeight = BorderWeight.LIGHT,
|
||||
title: str = "",
|
||||
title_style: CellStyle | None = None) -> None:
|
||||
title_style: CellStyle | None = None,
|
||||
border_chars: dict[str, str] | None = None,
|
||||
title_caps: tuple[str, str] | None = None) -> None:
|
||||
"""Draw a box border. Interior is not cleared.
|
||||
|
||||
Args:
|
||||
@@ -114,33 +121,38 @@ class CharGrid:
|
||||
if w < 2 or h < 2:
|
||||
return
|
||||
|
||||
ch = BOX_CHARS[weight]
|
||||
self._border_counter += 1
|
||||
bid = self._border_counter
|
||||
|
||||
ch = border_chars or BOX_CHARS[weight]
|
||||
border_style = CellStyle()
|
||||
|
||||
# Corners
|
||||
self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(x + w - 1, y, ch["tr"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(x, y + h - 1, ch["bl"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(x + w - 1, y + h - 1, ch["br"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(x, y, ch["tl"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(x + w - 1, y, ch["tr"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(x, y + h - 1, ch["bl"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(x + w - 1, y + h - 1, ch["br"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
|
||||
# Top and bottom edges
|
||||
for col in range(x + 1, x + w - 1):
|
||||
self.put(col, y, ch["h"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(col, y, ch["h"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(col, y + h - 1, ch["h"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
|
||||
# Left and right edges
|
||||
for row in range(y + 1, y + h - 1):
|
||||
self.put(x, row, ch["v"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight)
|
||||
self.put(x, row, ch["v"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
self.put(x + w - 1, row, ch["v"], border_style, is_border=True, border_weight=weight, border_id=bid)
|
||||
|
||||
# Title in top border
|
||||
if title and w > 4:
|
||||
title_text = f" {title} "
|
||||
max_title = w - 4 # leave room for corners + padding
|
||||
lc = title_caps[0] if title_caps else " "
|
||||
rc = title_caps[1] if title_caps else " "
|
||||
title_text = f"{lc}{title}{rc}"
|
||||
max_title = w - 4
|
||||
if len(title_text) > max_title:
|
||||
title_text = title_text[:max_title]
|
||||
|
||||
start_x = x + 2
|
||||
start_x = x + 1
|
||||
ts = title_style or CellStyle(bold=True)
|
||||
self.put_text(start_x, y, title_text, style=ts)
|
||||
|
||||
|
||||
267
backend/uframe/imaging.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""µFrame Image Converter — convert images to character art.
|
||||
|
||||
Supports braille, block, ascii, and halfblock rendering modes
|
||||
with optional dithering and color output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from uframe.chars import BRAILLE_BASE, BRAILLE_LEFT, BRAILLE_RIGHT
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
|
||||
|
||||
# ASCII brightness ramp (light → dark)
|
||||
ASCII_RAMP = " .:-=+*#%@"
|
||||
# Block shade ramp (light → dark)
|
||||
BLOCK_RAMP = " ░▒▓█"
|
||||
|
||||
|
||||
def _load_image(path: str) -> "Image.Image":
|
||||
"""Load an image from file path."""
|
||||
if not HAS_PIL:
|
||||
raise ImportError("Pillow is required for image conversion: pip install Pillow")
|
||||
resolved = Path(path).expanduser()
|
||||
if not resolved.is_file():
|
||||
raise FileNotFoundError(f"Image not found: {path}")
|
||||
return Image.open(str(resolved))
|
||||
|
||||
|
||||
def _floyd_steinberg(pixels: list[list[float]], w: int, h: int, levels: int = 2) -> list[list[int]]:
|
||||
"""Apply Floyd-Steinberg dithering to a grayscale pixel array.
|
||||
|
||||
Returns quantized values in range [0, levels-1].
|
||||
"""
|
||||
result = [[0] * w for _ in range(h)]
|
||||
err = [row[:] for row in pixels] # copy
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
old = err[y][x]
|
||||
new = round(old * (levels - 1)) / (levels - 1) if levels > 1 else (1.0 if old > 0.5 else 0.0)
|
||||
result[y][x] = int(round(new * (levels - 1)))
|
||||
quant_err = old - new
|
||||
|
||||
if x + 1 < w:
|
||||
err[y][x + 1] += quant_err * 7 / 16
|
||||
if y + 1 < h:
|
||||
if x - 1 >= 0:
|
||||
err[y + 1][x - 1] += quant_err * 3 / 16
|
||||
err[y + 1][x] += quant_err * 5 / 16
|
||||
if x + 1 < w:
|
||||
err[y + 1][x + 1] += quant_err * 1 / 16
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def convert_braille(img: "Image.Image", width: int, dither: str = "floyd",
|
||||
invert: bool = False) -> list[str]:
|
||||
"""Convert image to braille character art.
|
||||
|
||||
Each character encodes a 2×4 pixel block. Resolution: 2x horizontal, 4x vertical.
|
||||
"""
|
||||
# Resize: each output char = 2 pixels wide × 4 pixels tall
|
||||
pixel_w = width * 2
|
||||
aspect = img.height / img.width
|
||||
pixel_h = int(pixel_w * aspect / 2) # /2 for terminal cell aspect
|
||||
pixel_h = max(pixel_h, 4)
|
||||
# Round up to multiple of 4
|
||||
pixel_h = ((pixel_h + 3) // 4) * 4
|
||||
|
||||
img_resized = img.resize((pixel_w, pixel_h)).convert("L")
|
||||
|
||||
# Get pixel data as 0.0–1.0 floats
|
||||
pixels = []
|
||||
for y in range(pixel_h):
|
||||
row = []
|
||||
for x in range(pixel_w):
|
||||
v = img_resized.getpixel((x, y)) / 255.0
|
||||
if invert:
|
||||
v = 1.0 - v
|
||||
row.append(v)
|
||||
pixels.append(row)
|
||||
|
||||
# Dither to binary
|
||||
if dither == "floyd":
|
||||
binary = _floyd_steinberg(pixels, pixel_w, pixel_h, levels=2)
|
||||
else:
|
||||
binary = [[1 if p > 0.5 else 0 for p in row] for row in pixels]
|
||||
|
||||
# Map 2×4 blocks to braille characters
|
||||
lines: list[str] = []
|
||||
for by in range(0, pixel_h, 4):
|
||||
line = ""
|
||||
for bx in range(0, pixel_w, 2):
|
||||
code = BRAILLE_BASE
|
||||
for row in range(4):
|
||||
py = by + row
|
||||
if py < pixel_h:
|
||||
# Left column
|
||||
px_l = bx
|
||||
if px_l < pixel_w and binary[py][px_l]:
|
||||
code |= BRAILLE_LEFT[row]
|
||||
# Right column
|
||||
px_r = bx + 1
|
||||
if px_r < pixel_w and binary[py][px_r]:
|
||||
code |= BRAILLE_RIGHT[row]
|
||||
line += chr(code)
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert_block(img: "Image.Image", width: int, dither: str = "none",
|
||||
invert: bool = False) -> list[str]:
|
||||
"""Convert image to block shade characters (░▒▓█)."""
|
||||
aspect = img.height / img.width
|
||||
height = max(1, int(width * aspect / 2)) # /2 for terminal cell aspect
|
||||
|
||||
img_resized = img.resize((width, height)).convert("L")
|
||||
|
||||
pixels = []
|
||||
for y in range(height):
|
||||
row = []
|
||||
for x in range(width):
|
||||
v = img_resized.getpixel((x, y)) / 255.0
|
||||
if invert:
|
||||
v = 1.0 - v
|
||||
row.append(v)
|
||||
pixels.append(row)
|
||||
|
||||
if dither == "floyd":
|
||||
quantized = _floyd_steinberg(pixels, width, height, levels=len(BLOCK_RAMP))
|
||||
else:
|
||||
quantized = [[int(p * (len(BLOCK_RAMP) - 1)) for p in row] for row in pixels]
|
||||
|
||||
lines: list[str] = []
|
||||
for row in quantized:
|
||||
line = "".join(BLOCK_RAMP[min(v, len(BLOCK_RAMP) - 1)] for v in row)
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert_ascii(img: "Image.Image", width: int, dither: str = "none",
|
||||
invert: bool = False) -> list[str]:
|
||||
"""Convert image to classic ASCII art using brightness ramp."""
|
||||
aspect = img.height / img.width
|
||||
height = max(1, int(width * aspect / 2))
|
||||
|
||||
img_resized = img.resize((width, height)).convert("L")
|
||||
|
||||
lines: list[str] = []
|
||||
for y in range(height):
|
||||
line = ""
|
||||
for x in range(width):
|
||||
v = img_resized.getpixel((x, y)) / 255.0
|
||||
if invert:
|
||||
v = 1.0 - v
|
||||
idx = int(v * (len(ASCII_RAMP) - 1))
|
||||
line += ASCII_RAMP[min(idx, len(ASCII_RAMP) - 1)]
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert_halfblock(img: "Image.Image", width: int,
|
||||
invert: bool = False, use_color: bool = False) -> list[tuple[str, str | None, str | None]]:
|
||||
"""Convert image to half-block characters with optional color.
|
||||
|
||||
Uses ▄ with foreground (bottom pixel) and background (top pixel) colors.
|
||||
Returns list of (line_text, fg_colors, bg_colors) tuples.
|
||||
Each fg/bg color string has one 3-digit hex per character, or None for mono.
|
||||
"""
|
||||
aspect = img.height / img.width
|
||||
height = max(2, int(width * aspect / 2))
|
||||
# Round up to even
|
||||
height = height + (height % 2)
|
||||
|
||||
img_resized = img.resize((width, height))
|
||||
|
||||
if use_color:
|
||||
img_rgb = img_resized.convert("RGB")
|
||||
img_gray = img_resized.convert("L")
|
||||
|
||||
lines: list[tuple[str, str | None, str | None]] = []
|
||||
for y in range(0, height, 2):
|
||||
chars = ""
|
||||
fgs = "" if use_color else None
|
||||
bgs = "" if use_color else None
|
||||
|
||||
for x in range(width):
|
||||
top_v = img_gray.getpixel((x, y)) / 255.0
|
||||
bot_v = img_gray.getpixel((x, y + 1)) / 255.0 if y + 1 < height else 0
|
||||
|
||||
if invert:
|
||||
top_v = 1.0 - top_v
|
||||
bot_v = 1.0 - bot_v
|
||||
|
||||
if use_color:
|
||||
top_rgb = img_rgb.getpixel((x, y))
|
||||
bot_rgb = img_rgb.getpixel((x, y + 1)) if y + 1 < height else (0, 0, 0)
|
||||
# Quantize to 3-digit hex
|
||||
fg_hex = f"{round(bot_rgb[0]*15/255):x}{round(bot_rgb[1]*15/255):x}{round(bot_rgb[2]*15/255):x}"
|
||||
bg_hex = f"{round(top_rgb[0]*15/255):x}{round(top_rgb[1]*15/255):x}{round(top_rgb[2]*15/255):x}"
|
||||
fgs += fg_hex
|
||||
bgs += bg_hex
|
||||
|
||||
chars += "▄"
|
||||
|
||||
lines.append((chars, fgs, bgs))
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert_image(path: str, mode: str = "braille", width: int = 30,
|
||||
dither: str = "floyd", invert: bool = False,
|
||||
use_color: bool = False) -> list[str]:
|
||||
"""High-level image conversion — returns list of character art lines.
|
||||
|
||||
Args:
|
||||
path: image file path
|
||||
mode: "braille", "block", "ascii", "halfblock"
|
||||
width: output width in characters
|
||||
dither: "floyd", "threshold", "none"
|
||||
invert: flip light/dark
|
||||
use_color: preserve colors (halfblock only for now)
|
||||
|
||||
Returns:
|
||||
List of strings, one per output line.
|
||||
"""
|
||||
img = _load_image(path)
|
||||
|
||||
if mode == "braille":
|
||||
return convert_braille(img, width, dither, invert)
|
||||
elif mode == "block":
|
||||
return convert_block(img, width, dither, invert)
|
||||
elif mode == "ascii":
|
||||
return convert_ascii(img, width, dither, invert)
|
||||
elif mode == "halfblock":
|
||||
hb_lines = convert_halfblock(img, width, invert, use_color)
|
||||
# For non-color mode, just return the character strings
|
||||
return [line[0] for line in hb_lines]
|
||||
else:
|
||||
return convert_braille(img, width, dither, invert)
|
||||
|
||||
|
||||
def get_image_height(path: str, mode: str = "braille", width: int = 30) -> int:
|
||||
"""Calculate the output height for an image without full conversion."""
|
||||
try:
|
||||
img = _load_image(path)
|
||||
except (ImportError, FileNotFoundError):
|
||||
return 1
|
||||
|
||||
aspect = img.height / img.width
|
||||
|
||||
if mode == "braille":
|
||||
pixel_h = int(width * 2 * aspect / 2)
|
||||
return max(1, ((pixel_h + 3) // 4))
|
||||
else:
|
||||
return max(1, int(width * aspect / 2))
|
||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -119,6 +118,7 @@ class Page(IRNode):
|
||||
"""Root container. One per .uf file."""
|
||||
title: str = ""
|
||||
width: int = 64
|
||||
theme_name: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -302,6 +302,9 @@ class SourceType(Enum):
|
||||
PYTHON = auto()
|
||||
RNS = auto()
|
||||
PARAM = auto()
|
||||
HTTP = auto()
|
||||
SQLITE = auto()
|
||||
ENV = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -317,6 +320,12 @@ class Source(IRNode):
|
||||
var_name: str = ""
|
||||
source_type: SourceType = SourceType.SHELL
|
||||
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
|
||||
|
||||
|
||||
@@ -361,6 +370,55 @@ class StateDecl(IRNode):
|
||||
# Components (Phase 8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Navigation nodes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class NavItem:
|
||||
"""A single item in a navigation bar."""
|
||||
kind: str = "item" # "item", "separator", "heading"
|
||||
label: str = ""
|
||||
dest: str = ""
|
||||
active: bool = False
|
||||
children: list["NavItem"] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HNav(IRNode):
|
||||
"""Horizontal navigation bar."""
|
||||
nav_style: str = "bar" # bar, tabs, pills, breadcrumb, underline
|
||||
items: list[NavItem] = field(default_factory=list)
|
||||
compact: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class VNav(IRNode):
|
||||
"""Vertical navigation panel."""
|
||||
nav_style: str = "list" # list, boxed, tree, sidebar, minimal
|
||||
nav_width: int = 0 # 0 = auto-fit
|
||||
items: list[NavItem] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageNode(IRNode):
|
||||
"""Image converted to character art."""
|
||||
path: str = ""
|
||||
mode: str = "braille" # braille, block, ascii, halfblock
|
||||
img_width: int = 30
|
||||
dither: str = "floyd" # floyd, threshold, none
|
||||
invert: bool = False
|
||||
use_color: bool = False
|
||||
caption: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BigTitle(IRNode):
|
||||
"""Large multi-line ASCII art text."""
|
||||
text: str = ""
|
||||
font: str = "block" # block, thin, pixel
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentDef(IRNode):
|
||||
"""Component definition: component name(arg1, arg2)."""
|
||||
|
||||
289
backend/uframe/keywords.py
Normal file
@@ -0,0 +1,289 @@
|
||||
"""µFrame Keyword Registrations — single source of truth for all DSL keywords.
|
||||
|
||||
Each keyword is registered here with its metadata (section, detail, snippet,
|
||||
highlight values). The actual parse/measure/layout/paint/codegen functions
|
||||
remain in their respective modules for now — this file serves as the
|
||||
registry that the frontend reads via GET /api/dsl-meta.
|
||||
|
||||
To add a new keyword:
|
||||
1. Define its IR node in ir.py
|
||||
2. Add a register_keyword() call here
|
||||
3. Add parse logic in parser.py
|
||||
4. Add measure/layout/paint logic in their respective files
|
||||
5. That's it — syntax highlighting and slash commands auto-update from the registry
|
||||
"""
|
||||
|
||||
from uframe.registry import register_keyword, ALL_THEME_NAMES
|
||||
from uframe.ir import (
|
||||
Page, Box, Row, Col, Spacer, Pad,
|
||||
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle, ImageNode, HNav, VNav,
|
||||
ComponentDef, ComponentUse,
|
||||
)
|
||||
from uframe.themes import BUILTIN_THEMES
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Layout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("page", node_class=Page, section="Layout",
|
||||
detail='page "Title" 64', snippet='page "${1:Title}" ${2:64}',
|
||||
is_container=True)
|
||||
|
||||
register_keyword("box", node_class=Box, section="Layout",
|
||||
detail='box light "Title"', snippet='box ${1:light} "${2:Title}"',
|
||||
highlight_values=["light", "heavy", "double", "rounded"],
|
||||
is_container=True)
|
||||
|
||||
register_keyword("row", node_class=Row, section="Layout",
|
||||
detail="row [gap]", snippet="row ${1:2}",
|
||||
is_container=True)
|
||||
|
||||
register_keyword("col", node_class=Col, section="Layout",
|
||||
detail="col [width]", snippet="col ${1}",
|
||||
is_container=True)
|
||||
|
||||
register_keyword("spacer", node_class=Spacer, section="Layout",
|
||||
detail="spacer [lines]", snippet="spacer",
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("pad", node_class=Pad, section="Layout",
|
||||
detail="pad t r b l", snippet="pad ${1:1} ${2:1} ${3:1} ${4:1}",
|
||||
is_container=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("heading", node_class=Heading, section="Content",
|
||||
detail='heading 1 "Text"', snippet='heading ${1:1} "${2:Text}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("text", node_class=Text, section="Content",
|
||||
detail='text "Content"', snippet='text "${1:Content}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("label", node_class=Label, section="Content",
|
||||
detail='label "Key" "Value"', snippet='label "${1:Key}" "${2:Value}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("divider", node_class=Divider, section="Content",
|
||||
detail="divider heavy", snippet="divider ${1:light}",
|
||||
highlight_values=["light", "heavy", "double", "dash", "dot"],
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("link", node_class=Link, section="Content",
|
||||
detail='link "Text" "/path.mu"', snippet='link "${1:Text}" "${2:/page/dest.mu}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("list", node_class=ListNode, section="Content",
|
||||
detail="list bullet", snippet='list ${1:bullet}\n item "${2:Entry}"',
|
||||
highlight_values=["bullet", "dash", "number", "arrow"],
|
||||
is_container=True)
|
||||
|
||||
register_keyword("item", node_class=ListItem, section="",
|
||||
detail="", snippet="", # not shown in slash commands (child of list)
|
||||
is_leaf=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Visualization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("gauge", node_class=Gauge, section="Data",
|
||||
detail="gauge label val max width",
|
||||
snippet='gauge "${1:Label}" ${2:0} ${3:100} ${4:28} warn=${5:75} crit=${6:90}',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("sparkline", node_class=Sparkline, section="Data",
|
||||
detail="sparkline label values width",
|
||||
snippet='sparkline "${1:Label}" "${2:1,2,3,4}" ${3:20}',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("status", node_class=Status, section="Data",
|
||||
detail="status label state",
|
||||
snippet='status "${1:Label}" ${2:online}',
|
||||
highlight_values=["online", "offline", "degraded", "unknown", "alert"],
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("table", node_class=Table, section="Data",
|
||||
detail="table + columns + rows",
|
||||
snippet='table "Title"\n columns "Name" 20 | "Value" 10\n row "entry" | "data"',
|
||||
is_leaf=True) # table handles its own children (columns/row pseudo-nodes)
|
||||
|
||||
register_keyword("columns", section="",
|
||||
detail="", snippet="") # child of table, not shown in slash commands
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Big Text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("hnav", node_class=HNav, section="Layout",
|
||||
detail='hnav bar', snippet='hnav ${1:bar}\n item "${2:Label}" "${3:/page/dest.mu}" active',
|
||||
highlight_values=["bar", "tabs", "pills", "breadcrumb", "underline"],
|
||||
is_container=True)
|
||||
|
||||
register_keyword("vnav", node_class=VNav, section="Layout",
|
||||
detail='vnav list', snippet='vnav ${1:list}\n item "${2:Label}" "${3:/page/dest.mu}"',
|
||||
highlight_values=["list", "boxed", "tree", "sidebar", "minimal"],
|
||||
is_container=True)
|
||||
|
||||
register_keyword("image", node_class=ImageNode, section="Content",
|
||||
detail='image "path.png" braille 30',
|
||||
snippet='image "${1:path.png}" ${2:braille} ${3:30}',
|
||||
highlight_values=["braille", "block", "ascii", "halfblock"],
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("dither", section="",
|
||||
detail="", snippet="",
|
||||
highlight_values=["floyd", "threshold", "none"],
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("invert", section="",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("caption", section="",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("bigtitle", node_class=BigTitle, section="Content",
|
||||
detail='bigtitle "TEXT" block',
|
||||
snippet='bigtitle "${1:TEXT}" ${2:block}',
|
||||
highlight_values=["block", "thin", "pixel"],
|
||||
is_leaf=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Style
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("align", section="Style",
|
||||
detail="align center", snippet="align ${1:center}",
|
||||
highlight_values=["left", "center", "right"],
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("color", section="Style",
|
||||
detail="color hex", snippet="color ${1:0cf}",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("bg", section="Style",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("bold", section="Style",
|
||||
detail="bold", snippet="bold",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("italic", section="Style",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("underline", section="Style",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forms
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("form", node_class=Form, section="Form",
|
||||
detail='form "name"', snippet='form "${1:name}"',
|
||||
is_container=True)
|
||||
|
||||
register_keyword("field", node_class=Field, section="Form",
|
||||
detail='field "name" 24 "placeholder"',
|
||||
snippet='field "${1:name}" ${2:24} "${3:placeholder}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("password", node_class=Password, section="Form",
|
||||
detail='password "name" 24 "placeholder"',
|
||||
snippet='password "${1:name}" ${2:24} "${3:placeholder}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("radio", node_class=Radio, section="Form",
|
||||
detail='radio "group" "A" | "B" | "C"',
|
||||
snippet='radio "${1:group}" "${2:Option A}" | "${3:Option B}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("checkbox", node_class=Checkbox, section="Form",
|
||||
detail='checkbox "name" "Label"',
|
||||
snippet='checkbox "${1:name}" "${2:Label}"',
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("button", node_class=FormButton, section="Form",
|
||||
detail='button "Label" "/action"',
|
||||
snippet='button "${1:Label}" "${2:/page/action.mu}"',
|
||||
is_leaf=True)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("let", node_class=Let, section="Dynamic",
|
||||
detail='let name = "value"', snippet='let ${1:name} = "${2:value}"',
|
||||
is_metadata=True)
|
||||
|
||||
register_keyword("source", node_class=Source, section="Dynamic",
|
||||
detail='source name : shell "cmd"',
|
||||
snippet='source ${1:name} : shell "${2:command}"',
|
||||
highlight_values=["shell", "file", "json", "python", "rns", "param"],
|
||||
is_metadata=True)
|
||||
|
||||
register_keyword("if", node_class=IfBlock, section="Dynamic",
|
||||
detail="if $var > threshold", snippet="if ${1:condition}",
|
||||
is_container=True)
|
||||
|
||||
register_keyword("elif", section="Dynamic",
|
||||
detail="", snippet="")
|
||||
|
||||
register_keyword("else", section="Dynamic",
|
||||
detail="", snippet="")
|
||||
|
||||
register_keyword("for", node_class=ForLoop, section="Dynamic",
|
||||
detail="for item in $list", snippet='for ${1:item} in ${2:\\$list}',
|
||||
is_container=True)
|
||||
|
||||
register_keyword("cache", node_class=CacheControl, section="Dynamic",
|
||||
detail="cache 0", snippet="cache ${1:0}",
|
||||
is_metadata=True)
|
||||
|
||||
register_keyword("on_submit", node_class=OnSubmit, section="Dynamic",
|
||||
detail='on_submit "form"', snippet='on_submit "${1:form_name}"',
|
||||
is_container=True)
|
||||
|
||||
register_keyword("state", node_class=StateDecl, section="Dynamic",
|
||||
detail='state "name" "/path.json"',
|
||||
snippet='state "${1:name}" "${2:/tmp/state.json}"',
|
||||
is_metadata=True)
|
||||
|
||||
register_keyword("set", section="Dynamic", detail="", snippet="")
|
||||
register_keyword("append", section="Dynamic", detail="", snippet="")
|
||||
register_keyword("prepend", section="Dynamic", detail="", snippet="")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Themes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("theme", section="Theme",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
# Register each built-in theme as a named entry
|
||||
for _theme_name, _theme_def in BUILTIN_THEMES.items():
|
||||
register_keyword(f"theme_{_theme_name}", section="Theme",
|
||||
detail=f"{_theme_def.description}",
|
||||
snippet=f"theme {_theme_name}")
|
||||
ALL_THEME_NAMES.append(_theme_name)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Components
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("component", node_class=ComponentDef, section="",
|
||||
detail="", snippet="",
|
||||
is_container=True)
|
||||
|
||||
register_keyword("use", section="",
|
||||
detail="", snippet="")
|
||||
@@ -8,6 +8,7 @@ from uframe.ir import (
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle, ImageNode, HNav, VNav,
|
||||
)
|
||||
|
||||
|
||||
@@ -135,7 +136,8 @@ def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int:
|
||||
|
||||
elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Field, Password, Radio, Checkbox, FormButton)):
|
||||
Field, Password, Radio, Checkbox, FormButton,
|
||||
BigTitle, ImageNode, HNav, VNav)):
|
||||
node.rect.h = node.pref_height
|
||||
return node.pref_height
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ from uframe.ir import (
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle, ImageNode, HNav, VNav,
|
||||
)
|
||||
from uframe.fonts import FONT_HEIGHTS, get_text_width
|
||||
from uframe.imaging import get_image_height
|
||||
|
||||
|
||||
def _text_height(text: str, width: int) -> int:
|
||||
@@ -243,6 +246,58 @@ def measure(node: IRNode, available_width: int) -> None:
|
||||
node.pref_height = 1
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, HNav):
|
||||
# Height: 3 for bar/tabs/pills (top border + items + bottom border), 2 for underline/breadcrumb
|
||||
if node.nav_style in ("underline",):
|
||||
node.pref_height = 2
|
||||
elif node.nav_style in ("breadcrumb",):
|
||||
node.pref_height = 1
|
||||
else:
|
||||
node.pref_height = 3
|
||||
node.pref_width = available_width
|
||||
node.min_width = 10
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, VNav):
|
||||
count = sum(1 for it in node.items if it.kind in ("item", "heading"))
|
||||
sep_count = sum(1 for it in node.items if it.kind == "separator")
|
||||
h = count + sep_count
|
||||
if node.nav_style == "boxed":
|
||||
h += 2 # top + bottom border
|
||||
node.pref_height = max(h, 1)
|
||||
if node.nav_width > 0:
|
||||
node.pref_width = node.nav_width
|
||||
else:
|
||||
max_label = max((len(it.label) for it in node.items if it.label), default=8)
|
||||
node.pref_width = max_label + 6 # marker + padding
|
||||
node.min_width = 8
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, ImageNode):
|
||||
h = get_image_height(node.path, node.mode, node.img_width)
|
||||
if node.caption:
|
||||
h += 1 # extra line for caption
|
||||
node.pref_width = available_width
|
||||
node.min_width = node.img_width
|
||||
node.pref_height = h
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, BigTitle):
|
||||
# Try the requested font, fall back to smaller if too wide
|
||||
tw = get_text_width(node.text, node.font)
|
||||
if tw <= available_width:
|
||||
h = FONT_HEIGHTS.get(node.font, 6)
|
||||
elif get_text_width(node.text, "pixel") <= available_width:
|
||||
h = FONT_HEIGHTS.get("pixel", 3)
|
||||
elif get_text_width(node.text, "thin") <= available_width:
|
||||
h = FONT_HEIGHTS.get("thin", 3)
|
||||
else:
|
||||
h = 1 # fallback to single styled line
|
||||
node.pref_width = available_width
|
||||
node.min_width = 1
|
||||
node.pref_height = h
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, (Let, Source, CacheControl, StateDecl)):
|
||||
# Zero-height metadata nodes — no visual output
|
||||
node.pref_width = 0
|
||||
|
||||
@@ -7,11 +7,11 @@ own structure (borders, etc.).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import textwrap
|
||||
|
||||
from uframe.chars import (
|
||||
BOX_CHARS, DIVIDER_CHARS, GAUGE_FILLED, GAUGE_EMPTY,
|
||||
STATUS_CHARS, STATUS_COLORS, sparkline_chars,
|
||||
BOX_CHARS, DIVIDER_CHARS, sparkline_chars,
|
||||
)
|
||||
from uframe.grid import CharGrid, CellStyle
|
||||
from uframe.ir import (
|
||||
@@ -19,8 +19,12 @@ from uframe.ir import (
|
||||
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
BigTitle, ImageNode, HNav, VNav,
|
||||
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
|
||||
)
|
||||
from uframe.themes import ThemeDef, THEME_DEFAULT
|
||||
from uframe.fonts import render_big_text, get_text_width, FONT_HEIGHTS
|
||||
from uframe.imaging import convert_image
|
||||
|
||||
|
||||
def _align_text(text: str, width: int, align: Align) -> str:
|
||||
@@ -45,8 +49,9 @@ def _style_from_node(node: IRNode) -> CellStyle:
|
||||
)
|
||||
|
||||
|
||||
def paint(node: IRNode, grid: CharGrid) -> None:
|
||||
def paint(node: IRNode, grid: CharGrid, theme: ThemeDef | None = None) -> None:
|
||||
"""Recursively paint an IR node and its children into the grid."""
|
||||
th = theme or THEME_DEFAULT
|
||||
x, y, w = node.rect.x, node.rect.y, node.rect.w
|
||||
|
||||
# Ensure grid is tall enough
|
||||
@@ -54,42 +59,48 @@ def paint(node: IRNode, grid: CharGrid) -> None:
|
||||
|
||||
if isinstance(node, Page):
|
||||
for child in node.children:
|
||||
paint(child, grid)
|
||||
paint(child, grid, th)
|
||||
|
||||
elif isinstance(node, Box):
|
||||
# Draw the border
|
||||
title_style = CellStyle(bold=True, fg=node.style.fg)
|
||||
# Draw the border with themed characters
|
||||
title_style = CellStyle(bold=True, fg=node.style.fg or th.palette.accent)
|
||||
grid.draw_border(x, y, w, node.rect.h,
|
||||
weight=node.weight,
|
||||
title=node.title,
|
||||
title_style=title_style)
|
||||
# Paint children inside the border
|
||||
title_style=title_style,
|
||||
border_chars=th.border_dict(node.weight.name.lower()),
|
||||
title_caps=(th.title_caps.left, th.title_caps.right))
|
||||
# Propagate box alignment/color to children that don't have their own
|
||||
for child in node.children:
|
||||
paint(child, grid)
|
||||
if node.style.align != Align.LEFT and child.style.align == Align.LEFT:
|
||||
child.style.align = node.style.align
|
||||
if node.style.fg and not child.style.fg:
|
||||
child.style.fg = node.style.fg
|
||||
paint(child, grid, th)
|
||||
|
||||
elif isinstance(node, Row):
|
||||
for child in node.children:
|
||||
paint(child, grid)
|
||||
paint(child, grid, th)
|
||||
|
||||
elif isinstance(node, Col):
|
||||
for child in node.children:
|
||||
paint(child, grid)
|
||||
paint(child, grid, th)
|
||||
|
||||
elif isinstance(node, Spacer):
|
||||
pass # Just empty space
|
||||
|
||||
elif isinstance(node, Pad):
|
||||
for child in node.children:
|
||||
paint(child, grid)
|
||||
paint(child, grid, th)
|
||||
|
||||
elif isinstance(node, Heading):
|
||||
style = CellStyle(bold=True)
|
||||
if node.level == HeadingLevel.H1:
|
||||
style.fg = "0f0" # green
|
||||
style.fg = th.palette.accent
|
||||
elif node.level == HeadingLevel.H2:
|
||||
style.fg = "0cf" # cyan
|
||||
style.fg = th.palette.accent2
|
||||
elif node.level == HeadingLevel.H3:
|
||||
style.fg = "88f" # light blue
|
||||
style.fg = th.palette.accent3
|
||||
|
||||
# Underline-style heading
|
||||
grid.put_text(x, y, node.text[:w], style=style)
|
||||
@@ -138,26 +149,24 @@ def paint(node: IRNode, grid: CharGrid) -> None:
|
||||
|
||||
elif isinstance(node, Divider):
|
||||
ds = node.divider_style
|
||||
char = DIVIDER_CHARS.get(ds.name.lower(), "─")
|
||||
style = CellStyle(fg="555")
|
||||
char = getattr(th.dividers, ds.name.lower(), th.dividers.light)
|
||||
style = CellStyle(fg=th.palette.muted)
|
||||
for col in range(x, x + w):
|
||||
grid.put(col, y, char, style=style)
|
||||
|
||||
elif isinstance(node, Link):
|
||||
style = CellStyle(fg="0cf", underline=True)
|
||||
# In ASCII mode, display as [text]. In Micron, the emitter wraps with link syntax.
|
||||
# Write just the display text — the link metadata goes on cells for Micron emission.
|
||||
style = CellStyle(fg=th.palette.info, underline=True)
|
||||
grid.put_text(x, y, node.display[:w], style=style, link=node.dest)
|
||||
|
||||
elif isinstance(node, ListNode):
|
||||
for child in node.children:
|
||||
paint(child, grid)
|
||||
paint(child, grid, th)
|
||||
|
||||
elif isinstance(node, ListItem):
|
||||
style = _style_from_node(node)
|
||||
# Parent determines bullet style — use a simple bullet for now
|
||||
bullet = "• "
|
||||
grid.put_text(x - 2, y, bullet, style=CellStyle(fg="888"))
|
||||
# Bullet to the left of the content (safe: put_text clips to bounds)
|
||||
bullet_x = max(0, x - 2)
|
||||
grid.put_text(bullet_x, y, f"{th.ornaments.bullet} ", style=CellStyle(fg=th.palette.label))
|
||||
# Wrap content
|
||||
wrapped = textwrap.wrap(node.content, width=w) if node.content else [""]
|
||||
for i, line in enumerate(wrapped):
|
||||
@@ -178,17 +187,17 @@ def paint(node: IRNode, grid: CharGrid) -> None:
|
||||
filled = int(bar_w * pct)
|
||||
|
||||
# Determine color based on thresholds
|
||||
fg = "0f0" # green
|
||||
fg = th.palette.success
|
||||
if node.crit is not None and node.value >= node.crit:
|
||||
fg = "f00" # red
|
||||
fg = th.palette.danger
|
||||
elif node.warn is not None and node.value >= node.warn:
|
||||
fg = "ff0" # yellow
|
||||
fg = th.palette.warning
|
||||
|
||||
for i in range(bar_w):
|
||||
if i < filled:
|
||||
grid.put(bar_x + i, y, GAUGE_FILLED, style=CellStyle(fg=fg))
|
||||
grid.put(bar_x + i, y, th.gauge.filled, style=CellStyle(fg=fg))
|
||||
else:
|
||||
grid.put(bar_x + i, y, GAUGE_EMPTY, style=CellStyle(fg="555"))
|
||||
grid.put(bar_x + i, y, th.gauge.empty, style=CellStyle(fg=th.palette.muted))
|
||||
|
||||
# Percentage
|
||||
pct_text = f" {int(pct * 100)}%"
|
||||
@@ -201,77 +210,293 @@ def paint(node: IRNode, grid: CharGrid) -> None:
|
||||
|
||||
spark_x = x + len(label_text)
|
||||
chars = sparkline_chars(node.values, node.spark_width)
|
||||
spark_style = CellStyle(fg="0cf")
|
||||
spark_style = CellStyle(fg=th.palette.info)
|
||||
for i, ch in enumerate(chars):
|
||||
grid.put(spark_x + i, y, ch, style=spark_style)
|
||||
|
||||
elif isinstance(node, Status):
|
||||
char = STATUS_CHARS.get(node.state, "◌")
|
||||
color = STATUS_COLORS.get(node.state, "888")
|
||||
char = getattr(th.indicators, node.state, th.indicators.unknown)
|
||||
color_map = {"online": th.palette.success, "offline": th.palette.danger,
|
||||
"degraded": th.palette.warning, "unknown": th.palette.label,
|
||||
"alert": th.palette.danger}
|
||||
color = color_map.get(node.state, th.palette.label)
|
||||
grid.put(x, y, char, style=CellStyle(fg=color))
|
||||
grid.put_text(x + 2, y, node.label)
|
||||
|
||||
elif isinstance(node, HNav):
|
||||
_paint_hnav(node, grid, x, y, w, th)
|
||||
|
||||
elif isinstance(node, VNav):
|
||||
_paint_vnav(node, grid, x, y, w, th)
|
||||
|
||||
elif isinstance(node, ImageNode):
|
||||
style = CellStyle(fg=node.style.fg or th.palette.accent)
|
||||
try:
|
||||
lines = convert_image(node.path, node.mode, node.img_width,
|
||||
node.dither, node.invert, node.use_color)
|
||||
# Center if aligned
|
||||
offset = 0
|
||||
actual_w = len(lines[0]) if lines else 0
|
||||
if node.style.align == Align.CENTER:
|
||||
offset = max(0, (w - actual_w) // 2)
|
||||
elif node.style.align == Align.RIGHT:
|
||||
offset = max(0, w - actual_w)
|
||||
|
||||
for row_i, line in enumerate(lines):
|
||||
if y + row_i < grid.height:
|
||||
grid.put_text(x + offset, y + row_i, line, style=style)
|
||||
|
||||
# Caption
|
||||
if node.caption and y + len(lines) < grid.height:
|
||||
cap_style = CellStyle(fg=th.palette.label, italic=True)
|
||||
grid.put_text(x + offset, y + len(lines), node.caption, style=cap_style)
|
||||
except (ImportError, FileNotFoundError) as e:
|
||||
# Render placeholder if image can't be loaded
|
||||
grid.put_text(x, y, f"[image: {node.path}]", style=CellStyle(fg=th.palette.muted))
|
||||
|
||||
elif isinstance(node, BigTitle):
|
||||
style = CellStyle(fg=node.style.fg or th.palette.accent, bold=True)
|
||||
|
||||
# Determine which font fits
|
||||
font = node.font
|
||||
tw = get_text_width(node.text, font)
|
||||
if tw > w:
|
||||
# Try fallback cascade
|
||||
for fallback in ["pixel", "thin"]:
|
||||
if get_text_width(node.text, fallback) <= w:
|
||||
font = fallback
|
||||
tw = get_text_width(node.text, fallback)
|
||||
break
|
||||
else:
|
||||
# Final fallback: styled single line
|
||||
styled = f"═══ {node.text.upper()} ═══"
|
||||
grid.put_text(x, y, styled[:w], style=style)
|
||||
return
|
||||
|
||||
lines = render_big_text(node.text, font)
|
||||
|
||||
# Center if align is set
|
||||
offset = 0
|
||||
if node.style.align == Align.CENTER:
|
||||
offset = max(0, (w - tw) // 2)
|
||||
elif node.style.align == Align.RIGHT:
|
||||
offset = max(0, w - tw)
|
||||
|
||||
for row_i, line in enumerate(lines):
|
||||
if y + row_i < grid.height:
|
||||
grid.put_text(x + offset, y + row_i, line, style=style)
|
||||
|
||||
elif isinstance(node, Table):
|
||||
_paint_table(node, grid, x, y, w)
|
||||
|
||||
elif isinstance(node, Form):
|
||||
for child in node.children:
|
||||
paint(child, grid)
|
||||
paint(child, grid, th)
|
||||
|
||||
elif isinstance(node, Field):
|
||||
label_style = CellStyle(fg="888")
|
||||
field_style = CellStyle(fg="0cf")
|
||||
label_style = CellStyle(fg=th.palette.label)
|
||||
field_style = CellStyle(fg=th.palette.form)
|
||||
label_text = f"{node.field_name}: "
|
||||
grid.put_text(x, y, label_text, style=label_style)
|
||||
# Draw [ placeholder_______ ]
|
||||
fl = th.form.field_l
|
||||
fr = th.form.field_r
|
||||
fx = x + len(label_text)
|
||||
fw = min(node.field_width, w - len(label_text) - 2)
|
||||
grid.put(fx, y, "[", style=field_style)
|
||||
fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
|
||||
grid.put_text(fx, y, fl, style=field_style)
|
||||
placeholder = node.placeholder or node.field_name
|
||||
inner = f" {placeholder}".ljust(fw - 1)[:fw - 1]
|
||||
grid.put_text(fx + 1, y, inner, style=CellStyle(fg="555"))
|
||||
grid.put(fx + fw, y, "]", style=field_style)
|
||||
inner = placeholder.ljust(fw)[:fw]
|
||||
grid.put_text(fx + len(fl), y, inner, style=CellStyle(fg=th.palette.muted))
|
||||
grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
|
||||
|
||||
elif isinstance(node, Password):
|
||||
label_style = CellStyle(fg="888")
|
||||
field_style = CellStyle(fg="0cf")
|
||||
label_style = CellStyle(fg=th.palette.label)
|
||||
field_style = CellStyle(fg=th.palette.form)
|
||||
label_text = f"{node.field_name}: "
|
||||
grid.put_text(x, y, label_text, style=label_style)
|
||||
fl = th.form.field_l
|
||||
fr = th.form.field_r
|
||||
fx = x + len(label_text)
|
||||
fw = min(node.field_width, w - len(label_text) - 2)
|
||||
grid.put(fx, y, "[", style=field_style)
|
||||
inner = " " + "•" * (fw - 2)
|
||||
grid.put_text(fx + 1, y, inner[:fw - 1], style=CellStyle(fg="555"))
|
||||
grid.put(fx + fw, y, "]", style=field_style)
|
||||
fw = min(node.field_width, w - len(label_text) - len(fl) - len(fr))
|
||||
grid.put_text(fx, y, fl, style=field_style)
|
||||
inner = "•" * fw
|
||||
grid.put_text(fx + len(fl), y, inner[:fw], style=CellStyle(fg=th.palette.muted))
|
||||
grid.put_text(fx + len(fl) + fw, y, fr, style=field_style)
|
||||
|
||||
elif isinstance(node, Radio):
|
||||
label_style = CellStyle(fg="888")
|
||||
label_style = CellStyle(fg=th.palette.label)
|
||||
label_text = f"{node.group}: "
|
||||
grid.put_text(x, y, label_text, style=label_style)
|
||||
rx = x + len(label_text)
|
||||
for i, opt in enumerate(node.options):
|
||||
dot = "(•)" if i == 0 else "( )"
|
||||
opt_style = CellStyle(fg="0cf" if i == 0 else "888")
|
||||
dot = th.form.radio_on if i == 0 else th.form.radio_off
|
||||
opt_style = CellStyle(fg=th.palette.form if i == 0 else th.palette.label)
|
||||
grid.put_text(rx, y, dot, style=opt_style)
|
||||
rx += 4
|
||||
rx += len(dot) + 1
|
||||
grid.put_text(rx, y, opt, style=CellStyle())
|
||||
rx += len(opt) + 2
|
||||
|
||||
elif isinstance(node, Checkbox):
|
||||
check_style = CellStyle(fg="0cf")
|
||||
box_char = "[✓]" if node.checked else "[ ]"
|
||||
check_style = CellStyle(fg=th.palette.form)
|
||||
box_char = th.form.check_on if node.checked else th.form.check_off
|
||||
grid.put_text(x, y, box_char, style=check_style)
|
||||
grid.put_text(x + 4, y, node.checkbox_label)
|
||||
grid.put_text(x + len(box_char) + 1, y, node.checkbox_label)
|
||||
|
||||
elif isinstance(node, FormButton):
|
||||
btn_style = CellStyle(bold=True, fg="0f0")
|
||||
btn_style = CellStyle(bold=True, fg=th.palette.button)
|
||||
btn_text = f"[ {node.button_label} ]"
|
||||
grid.put_text(x, y, btn_text, style=btn_style, link=node.dest)
|
||||
|
||||
else:
|
||||
# Generic: paint children
|
||||
for child in node.children:
|
||||
paint(child, grid)
|
||||
paint(child, grid, th)
|
||||
|
||||
|
||||
def _paint_hnav(node: HNav, grid: CharGrid, x: int, y: int, w: int, th: ThemeDef) -> None:
|
||||
"""Paint a horizontal navigation bar."""
|
||||
active_style = CellStyle(bold=True, fg=th.palette.accent)
|
||||
link_style = CellStyle(fg=th.palette.info)
|
||||
sep_style = CellStyle(fg=th.palette.muted)
|
||||
border_style = CellStyle()
|
||||
|
||||
items = [it for it in node.items if it.kind in ("item", "separator")]
|
||||
marker = "▸ "
|
||||
|
||||
if node.nav_style in ("bar", "tabs", "pills"):
|
||||
# Bordered bar
|
||||
bc = th.border_dict("light")
|
||||
grid.draw_border(x, y, w, 3, border_chars=bc,
|
||||
title_caps=(th.title_caps.left, th.title_caps.right))
|
||||
col = x + 2
|
||||
for it in items:
|
||||
if it.kind == "separator":
|
||||
grid.put(col, y + 1, "│", style=sep_style)
|
||||
col += 2
|
||||
continue
|
||||
if it.active:
|
||||
grid.put_text(col, y + 1, marker, style=active_style)
|
||||
col += len(marker)
|
||||
grid.put_text(col, y + 1, it.label, style=active_style)
|
||||
else:
|
||||
grid.put_text(col, y + 1, it.label, style=link_style, link=it.dest)
|
||||
col += len(it.label) + 2
|
||||
if col < x + w - 2:
|
||||
grid.put(col, y + 1, "│", style=sep_style)
|
||||
col += 2
|
||||
|
||||
elif node.nav_style == "breadcrumb":
|
||||
col = x + 2
|
||||
sep = " ▸ "
|
||||
for i, it in enumerate(items):
|
||||
if it.kind == "separator":
|
||||
continue
|
||||
if i > 0:
|
||||
grid.put_text(col, y, sep, style=sep_style)
|
||||
col += len(sep)
|
||||
if it.active:
|
||||
grid.put_text(col, y, it.label, style=active_style)
|
||||
else:
|
||||
grid.put_text(col, y, it.label, style=link_style, link=it.dest)
|
||||
col += len(it.label)
|
||||
|
||||
elif node.nav_style == "underline":
|
||||
col = x + 2
|
||||
active_start = 0
|
||||
active_len = 0
|
||||
for i, it in enumerate(items):
|
||||
if it.kind == "separator":
|
||||
continue
|
||||
if it.active:
|
||||
active_start = col
|
||||
active_len = len(it.label)
|
||||
grid.put_text(col, y, it.label, style=active_style)
|
||||
else:
|
||||
grid.put_text(col, y, it.label, style=link_style, link=it.dest)
|
||||
col += len(it.label) + 5
|
||||
# Underline beneath active
|
||||
if active_len > 0:
|
||||
for c in range(active_start, active_start + active_len):
|
||||
grid.put(c, y + 1, "━", style=CellStyle(fg=th.palette.accent))
|
||||
|
||||
|
||||
def _paint_vnav(node: VNav, grid: CharGrid, x: int, y: int, w: int, th: ThemeDef) -> None:
|
||||
"""Paint a vertical navigation panel."""
|
||||
active_style = CellStyle(bold=True, fg=th.palette.accent)
|
||||
link_style = CellStyle(fg=th.palette.info)
|
||||
heading_style = CellStyle(bold=True, fg=th.palette.muted)
|
||||
sep_style = CellStyle(fg=th.palette.muted)
|
||||
marker = "▸ "
|
||||
|
||||
items = node.items
|
||||
row_y = y
|
||||
|
||||
if node.nav_style == "boxed":
|
||||
# Draw a box and render items inside
|
||||
bc = th.border_dict("light")
|
||||
h = node.rect.h
|
||||
grid.draw_border(x, y, w, h, border_chars=bc,
|
||||
title_caps=(th.title_caps.left, th.title_caps.right))
|
||||
row_y = y + 1
|
||||
for it in items:
|
||||
if it.kind == "separator":
|
||||
# Draw internal separator
|
||||
for c in range(x + 1, x + w - 1):
|
||||
grid.put(c, row_y, bc.get("h", "─"), style=sep_style,
|
||||
is_border=True)
|
||||
grid.put(x, row_y, "├", style=sep_style, is_border=True)
|
||||
grid.put(x + w - 1, row_y, "┤", style=sep_style, is_border=True)
|
||||
row_y += 1
|
||||
elif it.kind == "heading":
|
||||
grid.put_text(x + 2, row_y, it.label.upper(), style=heading_style)
|
||||
row_y += 1
|
||||
elif it.kind == "item":
|
||||
if it.active:
|
||||
grid.put_text(x + 2, row_y, marker, style=active_style)
|
||||
grid.put_text(x + 2 + len(marker), row_y, it.label, style=active_style)
|
||||
else:
|
||||
grid.put_text(x + 4, row_y, it.label, style=link_style, link=it.dest)
|
||||
row_y += 1
|
||||
|
||||
elif node.nav_style == "tree":
|
||||
for idx, it in enumerate(items):
|
||||
if it.kind == "separator":
|
||||
for c in range(x, x + w):
|
||||
grid.put(c, row_y, "─", style=sep_style)
|
||||
row_y += 1
|
||||
elif it.kind == "heading":
|
||||
grid.put_text(x, row_y, it.label, style=heading_style)
|
||||
row_y += 1
|
||||
elif it.kind == "item":
|
||||
# Determine connector
|
||||
remaining = [i for i in items[idx+1:] if i.kind == "item"]
|
||||
connector = "└── " if not remaining else "├── "
|
||||
grid.put_text(x, row_y, connector, style=sep_style)
|
||||
if it.active:
|
||||
grid.put_text(x + len(connector), row_y, it.label, style=active_style)
|
||||
grid.put_text(x + len(connector) + len(it.label) + 2, row_y, "◀",
|
||||
style=CellStyle(fg=th.palette.accent))
|
||||
else:
|
||||
grid.put_text(x + len(connector), row_y, it.label,
|
||||
style=link_style, link=it.dest)
|
||||
row_y += 1
|
||||
|
||||
else:
|
||||
# list, sidebar, minimal
|
||||
for it in items:
|
||||
if it.kind == "separator":
|
||||
for c in range(x, min(x + w, x + 16)):
|
||||
grid.put(c, row_y, "─", style=sep_style)
|
||||
row_y += 1
|
||||
elif it.kind == "heading":
|
||||
grid.put_text(x, row_y, it.label.upper(), style=heading_style)
|
||||
row_y += 1
|
||||
elif it.kind == "item":
|
||||
if it.active:
|
||||
grid.put_text(x, row_y, marker, style=active_style)
|
||||
grid.put_text(x + len(marker), row_y, it.label, style=active_style)
|
||||
else:
|
||||
grid.put_text(x + 2, row_y, it.label, style=link_style, link=it.dest)
|
||||
row_y += 1
|
||||
|
||||
|
||||
def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
|
||||
@@ -357,9 +582,7 @@ def _paint_table(node: Table, grid: CharGrid, x: int, y: int, w: int) -> None:
|
||||
|
||||
# Check for @color{hex}{text} modifiers in cell content
|
||||
if "@" in cell_text:
|
||||
spans = []
|
||||
import re as _re
|
||||
pattern = _re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}")
|
||||
pattern = re.compile(r"@color\{([0-9a-fA-F]{3})\}\{([^}]*)\}")
|
||||
pos = 0
|
||||
styled_parts: list[tuple[str, CellStyle]] = []
|
||||
for m in pattern.finditer(cell_text):
|
||||
|
||||
@@ -11,7 +11,6 @@ from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from typing import Sequence
|
||||
|
||||
from uframe.errors import ParseError
|
||||
from uframe.ir import (
|
||||
@@ -20,6 +19,7 @@ from uframe.ir import (
|
||||
Gauge, Sparkline, Status, Table, TextSpan,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle, ImageNode, HNav, VNav, NavItem,
|
||||
ComponentDef, ComponentUse,
|
||||
SourceType,
|
||||
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
|
||||
@@ -138,7 +138,7 @@ def _parse_list_style(s: str) -> ListStyle:
|
||||
}.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."""
|
||||
|
||||
if keyword == "page":
|
||||
@@ -222,9 +222,19 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
||||
return ListNode(list_style=style, source_line=line_num)
|
||||
|
||||
elif keyword == "item":
|
||||
# Could be a list item or a nav item — disambiguated by parent in tree builder
|
||||
if len(args) >= 2 and ("/" in args[1] or ":" in args[1]):
|
||||
# Nav item: item "Label" "/dest.mu" [active]
|
||||
label = args[0]
|
||||
dest = args[1]
|
||||
active = "active" in args[2:] if len(args) > 2 else False
|
||||
return _NavItemNode("item", label, dest, active, line_num)
|
||||
content = args[0] if args else ""
|
||||
return ListItem(content=content, source_line=line_num)
|
||||
|
||||
elif keyword == "separator":
|
||||
return _NavItemNode("separator", "", "", False, line_num)
|
||||
|
||||
# Style modifiers (applied to parent)
|
||||
elif keyword == "align":
|
||||
val = args[0].lower() if args else "left"
|
||||
@@ -284,8 +294,13 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
||||
elif keyword == "sparkline":
|
||||
label = args[0] if args else ""
|
||||
vals_str = args[1] if len(args) > 1 else ""
|
||||
values = [float(v) for v in vals_str.split(",") if v.strip()] if vals_str else []
|
||||
values: list[float] = []
|
||||
if vals_str and "$" not in vals_str:
|
||||
values = [float(v) for v in vals_str.split(",") if v.strip()]
|
||||
try:
|
||||
width = int(args[2]) if len(args) > 2 else 20
|
||||
except ValueError:
|
||||
width = 20
|
||||
return Sparkline(label=label, values=values, spark_width=width,
|
||||
source_line=line_num)
|
||||
|
||||
@@ -349,7 +364,7 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
||||
# Dynamic features
|
||||
elif keyword == "let":
|
||||
# let name = "value" or let name = 1,2,3
|
||||
raw = " ".join(args)
|
||||
raw = raw_args
|
||||
eq = raw.find("=")
|
||||
if eq != -1:
|
||||
var_name = raw[:eq].strip()
|
||||
@@ -362,11 +377,10 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
||||
elif keyword == "source":
|
||||
# source cpu : shell "grep 'cpu' /proc/stat"
|
||||
# source name : type "command"
|
||||
raw = " ".join(args)
|
||||
colon = raw.find(":")
|
||||
colon = raw_args.find(":")
|
||||
if colon != -1:
|
||||
var_name = raw[:colon].strip()
|
||||
rest = raw[colon + 1:].strip()
|
||||
var_name = raw_args[:colon].strip()
|
||||
rest = raw_args[colon + 1:].strip()
|
||||
parts = _split_args(rest)
|
||||
src_type_str = parts[0] if parts else "shell"
|
||||
command = parts[1] if len(parts) > 1 else ""
|
||||
@@ -377,17 +391,38 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
||||
"python": SourceType.PYTHON,
|
||||
"rns": SourceType.RNS,
|
||||
"param": SourceType.PARAM,
|
||||
"http": SourceType.HTTP,
|
||||
"sqlite": SourceType.SQLITE,
|
||||
"env": SourceType.ENV,
|
||||
}.get(src_type_str.lower(), SourceType.SHELL)
|
||||
# Parse optional timeout
|
||||
# Parse optional params from remaining parts
|
||||
timeout = 5
|
||||
for p in parts[2:]:
|
||||
if p.startswith("timeout"):
|
||||
http_method = "GET"
|
||||
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:
|
||||
timeout = int(p.split("=")[1]) if "=" in p else int(parts[parts.index(p) + 1])
|
||||
timeout = int(p.split("=", 1)[1])
|
||||
except (ValueError, IndexError):
|
||||
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,
|
||||
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:
|
||||
return Source(var_name=args[0] if args else "", source_line=line_num)
|
||||
|
||||
@@ -427,6 +462,41 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
||||
content = " ".join([keyword] + args)
|
||||
return Text(content=content, source_line=line_num)
|
||||
|
||||
elif keyword == "hnav":
|
||||
nav_style = args[0] if args else "bar"
|
||||
return HNav(nav_style=nav_style, source_line=line_num)
|
||||
|
||||
elif keyword == "vnav":
|
||||
nav_style = args[0] if args else "list"
|
||||
nav_width = int(args[1]) if len(args) > 1 and args[1].isdigit() else 0
|
||||
return VNav(nav_style=nav_style, nav_width=nav_width, source_line=line_num)
|
||||
|
||||
elif keyword == "image":
|
||||
path = args[0] if args else ""
|
||||
mode = args[1] if len(args) > 1 else "braille"
|
||||
img_width = int(args[2]) if len(args) > 2 else 30
|
||||
return ImageNode(path=path, mode=mode, img_width=img_width, source_line=line_num)
|
||||
|
||||
elif keyword == "dither":
|
||||
# Style directive for image node
|
||||
return _StyleDirective("dither_val", args[0] if args else "floyd", line_num)
|
||||
|
||||
elif keyword == "invert":
|
||||
return _StyleDirective("invert_val", True, line_num)
|
||||
|
||||
elif keyword == "caption":
|
||||
return _StyleDirective("caption_val", args[0] if args else "", line_num)
|
||||
|
||||
elif keyword == "bigtitle":
|
||||
text = args[0] if args else ""
|
||||
font = args[1] if len(args) > 1 else "block"
|
||||
return BigTitle(text=text, font=font, source_line=line_num)
|
||||
|
||||
elif keyword == "theme":
|
||||
# theme "name" — sets the page theme (handled as _StyleDirective on Page)
|
||||
theme_name = args[0] if args else "default"
|
||||
return _ThemeDirective(theme_name, line_num)
|
||||
|
||||
elif keyword == "component":
|
||||
# component name(arg1, arg2)
|
||||
raw = " ".join(args)
|
||||
@@ -451,6 +521,23 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
||||
return ComponentUse(comp_name=keyword, args=args, source_line=line_num)
|
||||
|
||||
|
||||
class _NavItemNode(IRNode):
|
||||
"""Temporary node — absorbed by parent HNav/VNav during tree building."""
|
||||
def __init__(self, kind: str, label: str, dest: str, active: bool, line_num: int):
|
||||
super().__init__(source_line=line_num)
|
||||
self.kind = kind
|
||||
self.label = label
|
||||
self.dest = dest
|
||||
self.active = active
|
||||
|
||||
|
||||
class _ThemeDirective(IRNode):
|
||||
"""Temporary node — sets theme_name on the Page during tree building."""
|
||||
def __init__(self, theme_name: str, line_num: int):
|
||||
super().__init__(source_line=line_num)
|
||||
self.theme_name = theme_name
|
||||
|
||||
|
||||
class _UseDirective(IRNode):
|
||||
"""Temporary node — triggers library loading during tree building."""
|
||||
def __init__(self, lib_path: str, line_num: int):
|
||||
@@ -594,6 +681,18 @@ component resources(cpu, mem)
|
||||
|
||||
component peer_status(name, state)
|
||||
status "$name" $state
|
||||
|
||||
component info_box(title, content)
|
||||
box light "$title"
|
||||
text "$content"
|
||||
|
||||
component alert_box(title, content)
|
||||
box heavy "$title"
|
||||
color f00
|
||||
text "$content"
|
||||
|
||||
component metric(label, value, max, width)
|
||||
gauge "$label" $value $max $width
|
||||
''',
|
||||
"std/status-bar": '''\
|
||||
component status_bar(label, value, max)
|
||||
@@ -601,6 +700,13 @@ component status_bar(label, value, max)
|
||||
|
||||
component status_item(name, state)
|
||||
status "$name" $state
|
||||
|
||||
component status_row(name1, state1, name2, state2)
|
||||
row 2
|
||||
col 28
|
||||
status "$name1" $state1
|
||||
col 28
|
||||
status "$name2" $state2
|
||||
''',
|
||||
"std/nav": '''\
|
||||
component nav_link(label, dest)
|
||||
@@ -608,6 +714,36 @@ component nav_link(label, dest)
|
||||
|
||||
component nav_divider()
|
||||
divider light
|
||||
|
||||
component nav_bar(label1, dest1, label2, dest2)
|
||||
row 2
|
||||
col 28
|
||||
link "$label1" "$dest1"
|
||||
col 28
|
||||
link "$label2" "$dest2"
|
||||
''',
|
||||
"std/network": '''\
|
||||
component route_table(title)
|
||||
table "$title"
|
||||
|
||||
component peer_list(title)
|
||||
heading 2 "$title"
|
||||
|
||||
component traffic(label_in, vals_in, label_out, vals_out)
|
||||
sparkline "$label_in" "$vals_in" 20
|
||||
sparkline "$label_out" "$vals_out" 20
|
||||
''',
|
||||
"std/form": '''\
|
||||
component search_form(name, action)
|
||||
form "$name"
|
||||
field "query" 30 "Search..."
|
||||
button "Search" "$action"
|
||||
|
||||
component login_form(action)
|
||||
form "login"
|
||||
field "username" 24 "Username"
|
||||
password "password" 24 "Password"
|
||||
button "Login" "$action"
|
||||
''',
|
||||
}
|
||||
|
||||
@@ -701,19 +837,34 @@ def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Pag
|
||||
args = _split_args(arg_str)
|
||||
|
||||
# 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)
|
||||
while stack and stack[-1][0] >= indent:
|
||||
stack.pop()
|
||||
|
||||
if isinstance(node, _StyleDirective):
|
||||
# Apply style directive to the current top of stack (parent)
|
||||
if stack:
|
||||
parent = stack[-1][1]
|
||||
# Image-specific directives
|
||||
if node.attr == "dither_val" and isinstance(parent, ImageNode):
|
||||
parent.dither = node.value
|
||||
elif node.attr == "invert_val" and isinstance(parent, ImageNode):
|
||||
parent.invert = node.value
|
||||
elif node.attr == "caption_val" and isinstance(parent, ImageNode):
|
||||
parent.caption = node.value
|
||||
elif node.attr in ("dither_val", "invert_val", "caption_val"):
|
||||
pass # ignore if not on ImageNode
|
||||
else:
|
||||
setattr(parent.style, node.attr, node.value)
|
||||
continue
|
||||
|
||||
if isinstance(node, _ThemeDirective):
|
||||
# Set theme on the root Page
|
||||
if root and isinstance(root, Page):
|
||||
root.theme_name = node.theme_name
|
||||
continue
|
||||
|
||||
# Table children: columns and rows are absorbed by the Table node
|
||||
if isinstance(node, _TableColumns):
|
||||
if stack and isinstance(stack[-1][1], Table):
|
||||
@@ -743,6 +894,30 @@ def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Pag
|
||||
break
|
||||
continue
|
||||
|
||||
# Nav items — absorbed by parent HNav/VNav
|
||||
if isinstance(node, _NavItemNode):
|
||||
for si in range(len(stack) - 1, -1, -1):
|
||||
parent = stack[si][1]
|
||||
if isinstance(parent, (HNav, VNav)):
|
||||
parent.items.append(NavItem(
|
||||
kind=node.kind, label=node.label,
|
||||
dest=node.dest, active=node.active))
|
||||
break
|
||||
continue
|
||||
|
||||
# Headings inside vnav become nav headings
|
||||
if isinstance(node, Heading):
|
||||
for si in range(len(stack) - 1, -1, -1):
|
||||
if isinstance(stack[si][1], VNav):
|
||||
stack[si][1].items.append(NavItem(
|
||||
kind="heading", label=node.text))
|
||||
break
|
||||
else:
|
||||
# Not inside a vnav — proceed as normal heading
|
||||
pass
|
||||
if any(isinstance(stack[si][1], VNav) for si in range(len(stack))):
|
||||
continue
|
||||
|
||||
# Use directive — load standard library components
|
||||
if isinstance(node, _UseDirective):
|
||||
lib_comps = _load_library(node.lib_path)
|
||||
|
||||
130
backend/uframe/registry.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""µFrame Keyword Registry — single source of truth for all DSL keywords.
|
||||
|
||||
Each keyword is registered with its parse, measure, layout, paint, and
|
||||
codegen functions plus frontend metadata (section, detail, snippet).
|
||||
Adding a new keyword requires only one registration in keywords.py.
|
||||
|
||||
Usage:
|
||||
from uframe.registry import register, KEYWORD_REGISTRY, NODE_REGISTRY
|
||||
|
||||
@register("gauge", section="Data", detail="gauge label val max width",
|
||||
snippet='gauge "${label}" ${value} ${max:100} ${width:28}')
|
||||
def _def_gauge():
|
||||
return KeywordDef(
|
||||
parse=parse_gauge,
|
||||
measure=measure_gauge,
|
||||
paint=paint_gauge,
|
||||
...
|
||||
)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Any
|
||||
|
||||
from uframe.ir import IRNode
|
||||
|
||||
|
||||
@dataclass
|
||||
class KeywordDef:
|
||||
"""Complete definition of a µFrame DSL keyword."""
|
||||
name: str = ""
|
||||
section: str = "" # "Layout", "Content", "Data", "Style", "Theme", "Form", "Dynamic"
|
||||
detail: str = "" # slash command detail text
|
||||
snippet: str = "" # slash command snippet (CodeMirror format)
|
||||
highlight_values: list[str] = field(default_factory=list) # values to highlight as atoms
|
||||
|
||||
# Pipeline functions — all optional, falling back to generic behavior
|
||||
parse: Callable[..., IRNode] | None = None # (args, line_num) → IRNode
|
||||
measure: Callable[..., None] | None = None # (node, available_width) → None
|
||||
layout: Callable[..., int] | None = None # (node, x, y, w, h) → height
|
||||
paint: Callable[..., None] | None = None # (node, grid, theme) → None
|
||||
codegen: Callable[..., list[str]] | None = None # (node, indent_level) → [str]
|
||||
|
||||
# Flags
|
||||
is_container: bool = False # has children (affects layout: vertical stack)
|
||||
is_leaf: bool = False # simple leaf node (layout: return pref_height)
|
||||
is_metadata: bool = False # zero-height metadata (let, source, cache)
|
||||
is_style_directive: bool = False # modifies parent's style (align, color, bold)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Keyword name → KeywordDef
|
||||
KEYWORD_REGISTRY: dict[str, KeywordDef] = {}
|
||||
|
||||
# IR node class → KeywordDef (for measure/layout/paint/codegen dispatch)
|
||||
NODE_REGISTRY: dict[type, KeywordDef] = {}
|
||||
|
||||
# All highlight values (populated during registration)
|
||||
ALL_HIGHLIGHT_VALUES: set[str] = set()
|
||||
|
||||
# All theme names
|
||||
ALL_THEME_NAMES: list[str] = []
|
||||
|
||||
|
||||
def register(name: str, node_class: type | None = None, **kwargs: Any) -> Callable:
|
||||
"""Decorator to register a keyword definition.
|
||||
|
||||
Usage:
|
||||
@register("gauge", node_class=Gauge, section="Data",
|
||||
detail="gauge label val max width",
|
||||
snippet='gauge "${label}" ${value} ...')
|
||||
def def_gauge():
|
||||
return KeywordDef(parse=..., measure=..., paint=..., ...)
|
||||
|
||||
Or simpler — pass all fields directly:
|
||||
register_keyword("gauge", node_class=Gauge, section="Data", ...)
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
kw_def = func()
|
||||
if isinstance(kw_def, KeywordDef):
|
||||
kw_def.name = name
|
||||
for k, v in kwargs.items():
|
||||
if hasattr(kw_def, k):
|
||||
setattr(kw_def, k, v)
|
||||
else:
|
||||
kw_def = KeywordDef(name=name, **kwargs)
|
||||
|
||||
KEYWORD_REGISTRY[name] = kw_def
|
||||
if node_class is not None:
|
||||
NODE_REGISTRY[node_class] = kw_def
|
||||
|
||||
ALL_HIGHLIGHT_VALUES.update(kw_def.highlight_values)
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
def register_keyword(name: str, node_class: type | None = None, **kwargs: Any) -> KeywordDef:
|
||||
"""Direct registration (non-decorator form)."""
|
||||
kw_def = KeywordDef(name=name, **kwargs)
|
||||
KEYWORD_REGISTRY[name] = kw_def
|
||||
if node_class is not None:
|
||||
NODE_REGISTRY[node_class] = kw_def
|
||||
ALL_HIGHLIGHT_VALUES.update(kw_def.highlight_values)
|
||||
return kw_def
|
||||
|
||||
|
||||
def get_dsl_meta() -> dict:
|
||||
"""Return DSL metadata for the frontend (keywords, values, commands, themes)."""
|
||||
keywords = sorted(KEYWORD_REGISTRY.keys())
|
||||
values = sorted(ALL_HIGHLIGHT_VALUES)
|
||||
commands = []
|
||||
for kw in KEYWORD_REGISTRY.values():
|
||||
if kw.detail and kw.snippet:
|
||||
commands.append({
|
||||
"label": kw.name,
|
||||
"detail": kw.detail,
|
||||
"section": kw.section,
|
||||
"snippet": kw.snippet,
|
||||
})
|
||||
themes = ALL_THEME_NAMES or []
|
||||
return {
|
||||
"keywords": keywords,
|
||||
"values": values,
|
||||
"commands": commands,
|
||||
"themes": themes,
|
||||
}
|
||||
102
backend/uframe/tests/test_bigtitle.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Tests for BigTitle and font rendering."""
|
||||
|
||||
import uframe
|
||||
from uframe.fonts import render_big_text, get_text_width, FONTS, FONT_HEIGHTS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for font module
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_font_registry():
|
||||
assert "block" in FONTS
|
||||
assert "thin" in FONTS
|
||||
assert "pixel" in FONTS
|
||||
|
||||
|
||||
def test_font_heights():
|
||||
assert FONT_HEIGHTS["block"] == 6
|
||||
assert FONT_HEIGHTS["thin"] == 3
|
||||
assert FONT_HEIGHTS["pixel"] == 3
|
||||
|
||||
|
||||
def test_render_big_text_block():
|
||||
lines = render_big_text("HI", "block")
|
||||
assert len(lines) == 6
|
||||
# Should contain block characters
|
||||
assert any("█" in line for line in lines)
|
||||
|
||||
|
||||
def test_render_big_text_thin():
|
||||
lines = render_big_text("AB", "thin")
|
||||
assert len(lines) == 3
|
||||
assert any("┌" in line or "├" in line for line in lines)
|
||||
|
||||
|
||||
def test_render_big_text_pixel():
|
||||
lines = render_big_text("OK", "pixel")
|
||||
assert len(lines) == 3
|
||||
assert any("▀" in line or "█" in line for line in lines)
|
||||
|
||||
|
||||
def test_get_text_width():
|
||||
w = get_text_width("A", "block")
|
||||
assert w > 0
|
||||
w2 = get_text_width("AB", "block")
|
||||
assert w2 > w # two chars wider than one
|
||||
|
||||
|
||||
def test_kerning():
|
||||
w_tight = get_text_width("AB", "block", kerning=0)
|
||||
w_normal = get_text_width("AB", "block", kerning=1)
|
||||
w_wide = get_text_width("AB", "block", kerning=2)
|
||||
assert w_tight < w_normal < w_wide
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests — bigtitle through compile pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bigtitle_block_font():
|
||||
source = '''page "Test" 80
|
||||
bigtitle "HI" block'''
|
||||
result = uframe.compile(source)
|
||||
assert "█" in result.ascii
|
||||
lines = result.ascii.strip().split("\n")
|
||||
assert len(lines) >= 6 # block font is 6 lines tall
|
||||
|
||||
|
||||
def test_bigtitle_thin_font():
|
||||
source = '''page "Test" 80
|
||||
bigtitle "OK" thin'''
|
||||
result = uframe.compile(source)
|
||||
# Thin font uses box-drawing characters
|
||||
assert any(c in result.ascii for c in "┌├┐┘└┬")
|
||||
|
||||
|
||||
def test_bigtitle_pixel_font():
|
||||
source = '''page "Test" 80
|
||||
bigtitle "GO" pixel'''
|
||||
result = uframe.compile(source)
|
||||
assert any(c in result.ascii for c in "▀▄█")
|
||||
|
||||
|
||||
def test_bigtitle_fallback_to_smaller_font():
|
||||
"""When text is too wide for block font, should fall back to smaller fonts."""
|
||||
source = '''page "Test" 40
|
||||
bigtitle "ABCDEFGHIJ" block'''
|
||||
result = uframe.compile(source)
|
||||
# Should still render with a fallback font (thin uses box-drawing)
|
||||
assert result.ascii.strip() != ""
|
||||
# Thin font characters should be present (fell back from block)
|
||||
assert any(c in result.ascii for c in "┌├┐┘└┬─")
|
||||
|
||||
|
||||
def test_bigtitle_with_alignment():
|
||||
source = '''page "Test" 80
|
||||
bigtitle "HI" block
|
||||
align center'''
|
||||
result = uframe.compile(source)
|
||||
assert "█" in result.ascii
|
||||
@@ -62,6 +62,59 @@ def test_use_std_nav():
|
||||
assert "About" in result.ascii
|
||||
|
||||
|
||||
def test_use_std_status_bar():
|
||||
source = '''page "Test" 60
|
||||
use std/status-bar
|
||||
status_bar "Uptime" 95 100'''
|
||||
result = uframe.compile(source)
|
||||
assert "Uptime" in result.ascii
|
||||
assert "█" in result.ascii
|
||||
|
||||
|
||||
def test_use_std_status_bar_item():
|
||||
source = '''page "Test" 60
|
||||
use std/status-bar
|
||||
status_item "Gateway" online'''
|
||||
result = uframe.compile(source)
|
||||
assert "Gateway" in result.ascii
|
||||
assert "●" in result.ascii
|
||||
|
||||
|
||||
def test_use_std_status_bar_row():
|
||||
source = '''page "Test" 60
|
||||
use std/status-bar
|
||||
status_row "East" online "West" offline'''
|
||||
result = uframe.compile(source)
|
||||
assert "East" in result.ascii
|
||||
assert "West" in result.ascii
|
||||
|
||||
|
||||
def test_use_std_network_peer_list():
|
||||
source = '''page "Test" 60
|
||||
use std/network
|
||||
peer_list "Active Peers"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Active Peers" in result.ascii
|
||||
|
||||
|
||||
def test_use_std_form_search():
|
||||
source = '''page "Test" 60
|
||||
use std/form
|
||||
search_form "search" "/page/results.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Search" in result.ascii
|
||||
assert "results.mu" in result.micron
|
||||
|
||||
|
||||
def test_use_std_form_login():
|
||||
source = '''page "Test" 60
|
||||
use std/form
|
||||
login_form "/page/auth.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Login" in result.ascii
|
||||
assert "auth.mu" in result.micron
|
||||
|
||||
|
||||
def test_unknown_component_ignored():
|
||||
source = '''page "Test" 50
|
||||
heading 1 "Hello"
|
||||
|
||||
@@ -36,7 +36,7 @@ def test_if_block():
|
||||
text "Critical"'''
|
||||
result = uframe.compile(source)
|
||||
assert result.is_dynamic
|
||||
assert "if {val} > 90:" in result.script
|
||||
assert "if val > 90:" in result.script
|
||||
|
||||
|
||||
def test_for_loop():
|
||||
@@ -46,7 +46,7 @@ def test_for_loop():
|
||||
text "$item"'''
|
||||
result = uframe.compile(source)
|
||||
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():
|
||||
@@ -120,5 +120,5 @@ def test_codegen_complete_dashboard():
|
||||
assert "#!/usr/bin/env python3" in script
|
||||
assert "_cache_seconds = 0" in script
|
||||
assert "_shell" in script
|
||||
assert "if {cpu} > 90:" in script
|
||||
assert "if cpu > 90:" in script
|
||||
assert "uframe.compile" in script
|
||||
|
||||
191
backend/uframe/tests/test_navigation.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Tests for HNav and VNav navigation components."""
|
||||
|
||||
import uframe
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HNav — Horizontal Navigation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_hnav_bar():
|
||||
source = '''page "Test" 60
|
||||
hnav bar
|
||||
item "Home" "/page/index.mu" active
|
||||
item "About" "/page/about.mu"
|
||||
item "Settings" "/page/settings.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Home" in result.ascii
|
||||
assert "About" in result.ascii
|
||||
assert "Settings" in result.ascii
|
||||
# Bar style has borders
|
||||
assert "┌" in result.ascii or "─" in result.ascii
|
||||
|
||||
|
||||
def test_hnav_tabs():
|
||||
source = '''page "Test" 60
|
||||
hnav tabs
|
||||
item "Dashboard" "/page/dash.mu" active
|
||||
item "Logs" "/page/logs.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Dashboard" in result.ascii
|
||||
assert "Logs" in result.ascii
|
||||
|
||||
|
||||
def test_hnav_pills():
|
||||
source = '''page "Test" 60
|
||||
hnav pills
|
||||
item "All" "/page/all.mu" active
|
||||
item "Active" "/page/active.mu"
|
||||
item "Stale" "/page/stale.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "All" in result.ascii
|
||||
assert "Active" in result.ascii
|
||||
assert "Stale" in result.ascii
|
||||
|
||||
|
||||
def test_hnav_breadcrumb():
|
||||
source = '''page "Test" 60
|
||||
hnav breadcrumb
|
||||
item "Home" "/page/index.mu"
|
||||
item "Network" "/page/network.mu"
|
||||
item "Node Alpha" "/page/alpha.mu" active'''
|
||||
result = uframe.compile(source)
|
||||
assert "Home" in result.ascii
|
||||
assert "Network" in result.ascii
|
||||
assert "Node Alpha" in result.ascii
|
||||
# Breadcrumb uses ▸ separator
|
||||
assert "▸" in result.ascii
|
||||
|
||||
|
||||
def test_hnav_underline():
|
||||
source = '''page "Test" 60
|
||||
hnav underline
|
||||
item "Overview" "/page/overview.mu" active
|
||||
item "Details" "/page/details.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Overview" in result.ascii
|
||||
assert "Details" in result.ascii
|
||||
# Active item has underline
|
||||
assert "━" in result.ascii
|
||||
|
||||
|
||||
def test_hnav_with_separator():
|
||||
source = '''page "Test" 60
|
||||
hnav bar
|
||||
item "Home" "/page/index.mu" active
|
||||
separator
|
||||
item "Help" "/page/help.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Home" in result.ascii
|
||||
assert "Help" in result.ascii
|
||||
|
||||
|
||||
def test_hnav_micron_links():
|
||||
source = '''page "Test" 60
|
||||
hnav bar
|
||||
item "Home" "/page/index.mu"
|
||||
item "About" "/page/about.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "index.mu" in result.micron
|
||||
assert "about.mu" in result.micron
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# VNav — Vertical Navigation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_vnav_list():
|
||||
source = '''page "Test" 60
|
||||
vnav list
|
||||
item "Dashboard" "/page/dash.mu" active
|
||||
item "Peers" "/page/peers.mu"
|
||||
item "Routes" "/page/routes.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Dashboard" in result.ascii
|
||||
assert "Peers" in result.ascii
|
||||
assert "Routes" in result.ascii
|
||||
# Active item has marker
|
||||
assert "▸" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_boxed():
|
||||
source = '''page "Test" 60
|
||||
vnav boxed
|
||||
item "Home" "/page/index.mu" active
|
||||
item "Settings" "/page/settings.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Home" in result.ascii
|
||||
assert "Settings" in result.ascii
|
||||
# Boxed style has borders
|
||||
assert "┌" in result.ascii
|
||||
assert "└" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_tree():
|
||||
source = '''page "Test" 60
|
||||
vnav tree
|
||||
item "Root" "/page/root.mu" active
|
||||
item "Child A" "/page/a.mu"
|
||||
item "Child B" "/page/b.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Root" in result.ascii
|
||||
assert "Child A" in result.ascii
|
||||
assert "Child B" in result.ascii
|
||||
# Tree style has connectors
|
||||
assert "├" in result.ascii or "└" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_sidebar():
|
||||
source = '''page "Test" 60
|
||||
vnav sidebar
|
||||
item "Overview" "/page/overview.mu" active
|
||||
item "Metrics" "/page/metrics.mu"
|
||||
item "Alerts" "/page/alerts.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Overview" in result.ascii
|
||||
assert "Metrics" in result.ascii
|
||||
assert "Alerts" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_minimal():
|
||||
source = '''page "Test" 60
|
||||
vnav minimal
|
||||
item "Page 1" "/page/1.mu"
|
||||
item "Page 2" "/page/2.mu" active
|
||||
item "Page 3" "/page/3.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Page 1" in result.ascii
|
||||
assert "Page 2" in result.ascii
|
||||
assert "Page 3" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_with_separator():
|
||||
source = '''page "Test" 60
|
||||
vnav list
|
||||
item "Home" "/page/index.mu" active
|
||||
separator
|
||||
item "Help" "/page/help.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "Home" in result.ascii
|
||||
assert "Help" in result.ascii
|
||||
assert "─" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_with_width():
|
||||
source = '''page "Test" 60
|
||||
vnav boxed 25
|
||||
item "Nav Item" "/page/nav.mu" active'''
|
||||
result = uframe.compile(source)
|
||||
assert "Nav Item" in result.ascii
|
||||
|
||||
|
||||
def test_vnav_micron_links():
|
||||
source = '''page "Test" 60
|
||||
vnav list
|
||||
item "Home" "/page/index.mu"
|
||||
item "About" "/page/about.mu"'''
|
||||
result = uframe.compile(source)
|
||||
assert "index.mu" in result.micron
|
||||
assert "about.mu" in result.micron
|
||||
154
backend/uframe/tests/test_themes.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Tests for the theme system."""
|
||||
|
||||
import uframe
|
||||
from uframe.themes import (
|
||||
get_theme, ThemeDef, BUILTIN_THEMES,
|
||||
THEME_DEFAULT, THEME_NOUVEAU, THEME_GOTHIC,
|
||||
THEME_BAMBOO, THEME_CIRCUIT, THEME_BRUTALIST,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_builtin_themes_exist():
|
||||
assert len(BUILTIN_THEMES) == 6
|
||||
for name in ("default", "nouveau", "gothic", "bamboo", "circuit", "brutalist"):
|
||||
assert name in BUILTIN_THEMES
|
||||
|
||||
|
||||
def test_get_theme_by_name():
|
||||
theme = get_theme("gothic")
|
||||
assert theme.name == "gothic"
|
||||
|
||||
|
||||
def test_get_theme_case_insensitive():
|
||||
theme = get_theme("GOTHIC")
|
||||
assert theme.name == "gothic"
|
||||
|
||||
|
||||
def test_get_theme_unknown_returns_default():
|
||||
theme = get_theme("nonexistent")
|
||||
assert theme.name == "default"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme structure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_theme_has_all_fields():
|
||||
for name, theme in BUILTIN_THEMES.items():
|
||||
assert theme.borders_light is not None
|
||||
assert theme.borders_heavy is not None
|
||||
assert theme.borders_double is not None
|
||||
assert theme.borders_rounded is not None
|
||||
assert theme.dividers is not None
|
||||
assert theme.indicators is not None
|
||||
assert theme.gauge is not None
|
||||
assert theme.form is not None
|
||||
assert theme.palette is not None
|
||||
|
||||
|
||||
def test_border_chars_method():
|
||||
theme = THEME_DEFAULT
|
||||
bc = theme.border_chars("light")
|
||||
assert bc.tl == "┌"
|
||||
assert bc.tr == "┐"
|
||||
|
||||
|
||||
def test_border_dict_method():
|
||||
theme = THEME_DEFAULT
|
||||
bd = theme.border_dict("double")
|
||||
assert bd["tl"] == "╔"
|
||||
assert bd["h"] == "═"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Themed rendering — each theme produces correct characters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_compile_with_default_theme():
|
||||
source = '''page "Test" 40
|
||||
box light "Panel"
|
||||
text "Content"'''
|
||||
result = uframe.compile(source)
|
||||
assert "┌" in result.ascii # default light border
|
||||
|
||||
|
||||
def test_compile_with_gothic_theme():
|
||||
source = '''page "Test" 40
|
||||
box light "Panel"
|
||||
text "Content"'''
|
||||
result = uframe.compile(source, theme="gothic")
|
||||
# Gothic uses double-style borders for light
|
||||
assert "╔" in result.ascii
|
||||
|
||||
|
||||
def test_compile_with_brutalist_theme():
|
||||
source = '''page "Test" 40
|
||||
box light "Panel"
|
||||
text "Content"'''
|
||||
result = uframe.compile(source, theme="brutalist")
|
||||
# Brutalist uses block characters for light borders
|
||||
assert "▛" in result.ascii
|
||||
|
||||
|
||||
def test_compile_with_bamboo_theme():
|
||||
source = '''page "Test" 40
|
||||
box light "Panel"
|
||||
text "Content"'''
|
||||
result = uframe.compile(source, theme="bamboo")
|
||||
# Bamboo uses dashed light borders
|
||||
assert "╌" in result.ascii or "┌" in result.ascii
|
||||
|
||||
|
||||
def test_compile_with_circuit_theme():
|
||||
source = '''page "Test" 40
|
||||
gauge "CPU" 50 100 20'''
|
||||
result = uframe.compile(source, theme="circuit")
|
||||
# Circuit uses ▰/▱ for gauge
|
||||
assert "▰" in result.ascii
|
||||
assert "▱" in result.ascii
|
||||
|
||||
|
||||
def test_compile_with_nouveau_theme():
|
||||
source = '''page "Test" 40
|
||||
status "Server" online'''
|
||||
result = uframe.compile(source, theme="nouveau")
|
||||
# Nouveau uses ❀ for online indicator
|
||||
assert "❀" in result.ascii
|
||||
|
||||
|
||||
def test_theme_divider_chars():
|
||||
source = '''page "Test" 40
|
||||
divider heavy'''
|
||||
result_default = uframe.compile(source)
|
||||
result_gothic = uframe.compile(source, theme="gothic")
|
||||
# Both should render but potentially with different chars
|
||||
assert "━" in result_default.ascii
|
||||
assert "═" in result_gothic.ascii
|
||||
|
||||
|
||||
def test_theme_gauge_chars():
|
||||
source = '''page "Test" 40
|
||||
gauge "Test" 50 100 20'''
|
||||
result_default = uframe.compile(source)
|
||||
result_brutalist = uframe.compile(source, theme="brutalist")
|
||||
# Default: █/░, Brutalist: █/(space)
|
||||
assert "█" in result_default.ascii
|
||||
assert "█" in result_brutalist.ascii
|
||||
|
||||
|
||||
def test_theme_form_chars():
|
||||
source = '''page "Test" 50
|
||||
form "test"
|
||||
checkbox "agree" "Accept"'''
|
||||
result_default = uframe.compile(source)
|
||||
result_gothic = uframe.compile(source, theme="gothic")
|
||||
# Default: [ ], Gothic: ⚐
|
||||
assert "[ ]" in result_default.ascii or "Accept" in result_default.ascii
|
||||
assert "⚐" in result_gothic.ascii or "Accept" in result_gothic.ascii
|
||||
241
backend/uframe/themes.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""µFrame Theme System — decorative styles for rich terminal UIs.
|
||||
|
||||
A theme maps abstract UI elements to concrete character sets and color
|
||||
palettes. The same .uf source renders with different visual character
|
||||
when a different theme is applied.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class BorderChars:
|
||||
tl: str = "┌"; t: str = "─"; tr: str = "┐"
|
||||
l: str = "│"; r: str = "│"
|
||||
bl: str = "└"; b: str = "─"; br: str = "┘"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Indicators:
|
||||
online: str = "●"
|
||||
offline: str = "○"
|
||||
degraded: str = "◐"
|
||||
unknown: str = "◌"
|
||||
alert: str = "⚠"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GaugeChars:
|
||||
filled: str = "█"
|
||||
empty: str = "░"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FormChars:
|
||||
field_l: str = "[ "
|
||||
field_r: str = " ]"
|
||||
radio_on: str = "(•)"
|
||||
radio_off: str = "( )"
|
||||
check_on: str = "[✓]"
|
||||
check_off: str = "[ ]"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ornaments:
|
||||
bullet: str = "•"
|
||||
header: str = ""
|
||||
separator: str = ""
|
||||
footer: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TitleCaps:
|
||||
left: str = "─ "
|
||||
right: str = " ─"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Palette:
|
||||
accent: str = "0f0" # headings, primary highlights
|
||||
accent2: str = "0cf" # secondary (H2, links)
|
||||
accent3: str = "88f" # tertiary (H3)
|
||||
muted: str = "555" # dividers, empty gauge
|
||||
border: str = "" # border color (empty = no color)
|
||||
success: str = "0f0" # online, gauge ok
|
||||
warning: str = "ff0" # degraded, gauge warn
|
||||
danger: str = "f00" # offline, gauge crit
|
||||
info: str = "0cf" # links, sparklines
|
||||
form: str = "0cf" # form element accents
|
||||
label: str = "888" # labels, field names
|
||||
button: str = "0f0" # form buttons
|
||||
|
||||
|
||||
@dataclass
|
||||
class DividerChars:
|
||||
light: str = "─"
|
||||
heavy: str = "━"
|
||||
double: str = "═"
|
||||
dash: str = "╌"
|
||||
dot: str = "┄"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThemeDef:
|
||||
name: str = "default"
|
||||
description: str = "Clean engineering — standard box-drawing"
|
||||
|
||||
borders_light: BorderChars = field(default_factory=BorderChars)
|
||||
borders_heavy: BorderChars = field(default_factory=lambda: BorderChars(
|
||||
tl="┏", t="━", tr="┓", l="┃", r="┃", bl="┗", b="━", br="┛"))
|
||||
borders_double: BorderChars = field(default_factory=lambda: BorderChars(
|
||||
tl="╔", t="═", tr="╗", l="║", r="║", bl="╚", b="═", br="╝"))
|
||||
borders_rounded: BorderChars = field(default_factory=lambda: BorderChars(
|
||||
tl="╭", t="─", tr="╮", l="│", r="│", bl="╰", b="─", br="╯"))
|
||||
|
||||
dividers: DividerChars = field(default_factory=DividerChars)
|
||||
title_caps: TitleCaps = field(default_factory=TitleCaps)
|
||||
indicators: Indicators = field(default_factory=Indicators)
|
||||
gauge: GaugeChars = field(default_factory=GaugeChars)
|
||||
form: FormChars = field(default_factory=FormChars)
|
||||
ornaments: Ornaments = field(default_factory=Ornaments)
|
||||
palette: Palette = field(default_factory=Palette)
|
||||
|
||||
def border_chars(self, weight_name: str) -> BorderChars:
|
||||
return {
|
||||
"light": self.borders_light,
|
||||
"heavy": self.borders_heavy,
|
||||
"double": self.borders_double,
|
||||
"rounded": self.borders_rounded,
|
||||
}.get(weight_name, self.borders_light)
|
||||
|
||||
def border_dict(self, weight_name: str) -> dict[str, str]:
|
||||
"""Return BOX_CHARS-compatible dict for a border weight."""
|
||||
bc = self.border_chars(weight_name)
|
||||
return {
|
||||
"tl": bc.tl, "tr": bc.tr, "bl": bc.bl, "br": bc.br,
|
||||
"h": bc.t, "v": bc.l,
|
||||
"t_down": bc.t, "t_up": bc.b, "t_right": bc.l, "t_left": bc.r,
|
||||
"cross": bc.t,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in themes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
THEME_DEFAULT = ThemeDef()
|
||||
|
||||
THEME_NOUVEAU = ThemeDef(
|
||||
name="nouveau",
|
||||
description="Art Nouveau — organic flowing ornament",
|
||||
borders_heavy=BorderChars(tl="☙", t="━", tr="❧", l="┃", r="┃", bl="☙", b="━", br="❧"),
|
||||
borders_light=BorderChars(tl="╭", t="┈", tr="╮", l="┊", r="┊", bl="╰", b="┈", br="╯"),
|
||||
borders_double=BorderChars(tl="☙", t="━", tr="❧", l="┃", r="┃", bl="☙", b="━", br="❧"),
|
||||
borders_rounded=BorderChars(tl="╭", t="┈", tr="╮", l="┊", r="┊", bl="╰", b="┈", br="╯"),
|
||||
dividers=DividerChars(light="┈", heavy="━", double="━", dash="┈", dot="┈"),
|
||||
title_caps=TitleCaps(left="✾─── ", right=" ───✾"),
|
||||
indicators=Indicators(online="❀", offline="✿", degraded="⚘", unknown="✿", alert="❋"),
|
||||
gauge=GaugeChars(filled="▐", empty="░"),
|
||||
form=FormChars(field_l="❴ ", field_r=" ❵", radio_on="❀", radio_off="✿",
|
||||
check_on="❀", check_off="✿"),
|
||||
ornaments=Ornaments(bullet="❀", header="─✾──────✾─", separator="☙━━━━━━━━━━━━━❧"),
|
||||
palette=Palette(accent="da5", accent2="8b5", accent3="886", muted="886",
|
||||
border="a85", success="6b4", warning="da5", danger="a33",
|
||||
info="68a", form="da5", label="886", button="6b4"),
|
||||
)
|
||||
|
||||
THEME_GOTHIC = ThemeDef(
|
||||
name="gothic",
|
||||
description="Gothic — heavy blackletter, monumental",
|
||||
borders_heavy=BorderChars(tl="╬", t="═", tr="╬", l="║", r="║", bl="╬", b="═", br="╬"),
|
||||
borders_light=BorderChars(tl="╔", t="═", tr="╗", l="║", r="║", bl="╚", b="═", br="╝"),
|
||||
borders_double=BorderChars(tl="╬", t="═", tr="╬", l="║", r="║", bl="╬", b="═", br="╬"),
|
||||
borders_rounded=BorderChars(tl="╔", t="═", tr="╗", l="║", r="║", bl="╚", b="═", br="╝"),
|
||||
dividers=DividerChars(light="═", heavy="═", double="═", dash="═", dot="═"),
|
||||
title_caps=TitleCaps(left="═══╡ ", right=" ╞═══"),
|
||||
indicators=Indicators(online="⚑", offline="⚐", degraded="⚑", unknown="⚐", alert="⚔"),
|
||||
gauge=GaugeChars(filled="▓", empty="░"),
|
||||
form=FormChars(field_l="║ ", field_r=" ║", radio_on="⚑", radio_off="⚐",
|
||||
check_on="⚑", check_off="⚐"),
|
||||
ornaments=Ornaments(bullet="▪", header="═══╡══════╞═══"),
|
||||
palette=Palette(accent="cc8", accent2="a66", accent3="888", muted="666",
|
||||
border="888", success="8a8", warning="cc8", danger="a44",
|
||||
info="8ac", form="cc8", label="888", button="cc8"),
|
||||
)
|
||||
|
||||
THEME_BAMBOO = ThemeDef(
|
||||
name="bamboo",
|
||||
description="Bamboo — East Asian minimalism, light brush strokes",
|
||||
borders_heavy=BorderChars(tl="〔", t=" ", tr="〕", l=" ", r=" ", bl=" ", b=" ", br=" "),
|
||||
borders_light=BorderChars(tl="┌", t="╌", tr="┐", l="╎", r="╎", bl="└", b="╌", br="┘"),
|
||||
borders_double=BorderChars(tl="〔", t=" ", tr="〕", l=" ", r=" ", bl=" ", b=" ", br=" "),
|
||||
borders_rounded=BorderChars(tl="┌", t="╌", tr="┐", l="╎", r="╎", bl="└", b="╌", br="┘"),
|
||||
dividers=DividerChars(light="┄", heavy="┄", double="┄", dash="┄", dot="┄"),
|
||||
title_caps=TitleCaps(left="┄┄┄ ", right=" ┄┄┄"),
|
||||
indicators=Indicators(online="◉", offline="◦", degraded="◎", unknown="◦", alert="◈"),
|
||||
gauge=GaugeChars(filled="▏", empty=" "),
|
||||
form=FormChars(field_l="〈 ", field_r=" 〉", radio_on="◉", radio_off="◦",
|
||||
check_on="◉", check_off="◦"),
|
||||
ornaments=Ornaments(bullet="‣"),
|
||||
palette=Palette(accent="bca", accent2="ab9", accent3="998", muted="998",
|
||||
border="776", success="8b8", warning="cc9", danger="b77",
|
||||
info="9ab", form="bca", label="998", button="8b8"),
|
||||
)
|
||||
|
||||
THEME_CIRCUIT = ThemeDef(
|
||||
name="circuit",
|
||||
description="Circuit — digital, technical, neon",
|
||||
borders_heavy=BorderChars(tl="╒", t="═", tr="╕", l="│", r="│", bl="╘", b="═", br="╛"),
|
||||
borders_light=BorderChars(tl="┌", t="─", tr="┐", l="│", r="│", bl="└", b="─", br="┘"),
|
||||
borders_double=BorderChars(tl="╒", t="═", tr="╕", l="│", r="│", bl="╘", b="═", br="╛"),
|
||||
borders_rounded=BorderChars(tl="╒", t="═", tr="╕", l="│", r="│", bl="╘", b="═", br="╛"),
|
||||
dividers=DividerChars(light="─", heavy="═", double="═", dash="╌", dot="┄"),
|
||||
title_caps=TitleCaps(left="══[ ", right=" ]═══"),
|
||||
indicators=Indicators(online="◈", offline="◇", degraded="◈", unknown="◇", alert="⚡"),
|
||||
gauge=GaugeChars(filled="▰", empty="▱"),
|
||||
form=FormChars(field_l=">_ [ ", field_r=" ]", radio_on="[▰]", radio_off="[▱]",
|
||||
check_on="[▰]", check_off="[▱]"),
|
||||
ornaments=Ornaments(bullet="▸"),
|
||||
palette=Palette(accent="0ff", accent2="f0f", accent3="0af", muted="555",
|
||||
border="0aa", success="0f0", warning="ff0", danger="f00",
|
||||
info="0ff", form="0ff", label="0aa", button="0f0"),
|
||||
)
|
||||
|
||||
THEME_BRUTALIST = ThemeDef(
|
||||
name="brutalist",
|
||||
description="Brutalist — raw blocks, monochrome, anti-decorative",
|
||||
borders_heavy=BorderChars(tl="█", t="█", tr="█", l="█", r="█", bl="█", b="█", br="█"),
|
||||
borders_light=BorderChars(tl="▛", t="▀", tr="▜", l="▌", r="▐", bl="▙", b="▄", br="▟"),
|
||||
borders_double=BorderChars(tl="█", t="█", tr="█", l="█", r="█", bl="█", b="█", br="█"),
|
||||
borders_rounded=BorderChars(tl="▛", t="▀", tr="▜", l="▌", r="▐", bl="▙", b="▄", br="▟"),
|
||||
dividers=DividerChars(light="▔", heavy="█", double="█", dash="▔", dot="▔"),
|
||||
title_caps=TitleCaps(left="▌ ", right=" ▐"),
|
||||
indicators=Indicators(online="■", offline="□", degraded="■", unknown="□", alert="!"),
|
||||
gauge=GaugeChars(filled="█", empty=" "),
|
||||
form=FormChars(field_l="[", field_r="]", radio_on="■", radio_off="□",
|
||||
check_on="■", check_off="□"),
|
||||
ornaments=Ornaments(bullet="▪"),
|
||||
palette=Palette(accent="fff", accent2="fff", accent3="ccc", muted="888",
|
||||
border="fff", success="fff", warning="fff", danger="fff",
|
||||
info="fff", form="fff", label="aaa", button="fff"),
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BUILTIN_THEMES: dict[str, ThemeDef] = {
|
||||
"default": THEME_DEFAULT,
|
||||
"nouveau": THEME_NOUVEAU,
|
||||
"gothic": THEME_GOTHIC,
|
||||
"bamboo": THEME_BAMBOO,
|
||||
"circuit": THEME_CIRCUIT,
|
||||
"brutalist": THEME_BRUTALIST,
|
||||
}
|
||||
|
||||
|
||||
def get_theme(name: str) -> ThemeDef:
|
||||
"""Get a built-in theme by name. Returns default if not found."""
|
||||
return BUILTIN_THEMES.get(name.lower(), THEME_DEFAULT)
|
||||
41
compose.yml
@@ -1,14 +1,41 @@
|
||||
services:
|
||||
micron-editor:
|
||||
build: .
|
||||
micronomicon:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
volumes:
|
||||
- ~/.nomadnetwork/storage/pages:/data/pages
|
||||
- ~/.micron-editor/sources:/data/sources
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- PAGES_DIR=/data/pages
|
||||
- SOURCES_DIR=/data/sources
|
||||
- NOMADNET_CONTAINER=nomadnet
|
||||
restart: always
|
||||
- RNS_CONFIG_DIR=/rns
|
||||
- RNS_SERVER_CONFIG_DIR=/rns-server
|
||||
- NOMADNET_CONFIG_DIR=/nomadnet
|
||||
- LOG_LEVEL=DEBUG
|
||||
volumes:
|
||||
- pages:/data/pages
|
||||
- sources:/data/sources
|
||||
- /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
|
||||
depends_on:
|
||||
- nomadnet
|
||||
|
||||
nomadnet:
|
||||
image: ghcr.io/markqvist/nomadnet:latest
|
||||
ports:
|
||||
- "0.0.0.0:4242:4242"
|
||||
volumes:
|
||||
- pages:/root/.nomadnetwork/storage/pages
|
||||
- nomadnet-config:/root/.nomadnetwork
|
||||
- ./nomadnet.conf:/root/.nomadnetwork/config
|
||||
- ./reticulum.conf:/root/.reticulum/config
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pages:
|
||||
sources:
|
||||
nomadnet-config:
|
||||
|
||||
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 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 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
|
||||
source peer_list : rns "peers"
|
||||
@@ -346,135 +348,95 @@ A `.uf` file with dynamic features compiles into a Python script
|
||||
that:
|
||||
|
||||
1. Sets the shebang and cache header
|
||||
2. Imports required modules
|
||||
3. Reads environment variables (form data)
|
||||
4. Executes source commands (shell, file, python, rns)
|
||||
5. Evaluates conditionals and loops
|
||||
6. Renders the IR tree into a CharGrid
|
||||
7. Emits the CharGrid as Micron with style tags
|
||||
8. Prints to stdout
|
||||
2. Imports required modules + the `uframe` package
|
||||
3. Defines runtime helpers (`_shell`, `_read_file`, `_read_json`, etc.)
|
||||
4. Executes source commands and evaluates conditionals/loops
|
||||
5. Dynamically builds a `.uf` source string with resolved variables
|
||||
6. Compiles that source with `uframe.compile()` at runtime
|
||||
7. Prints the resulting Micron to stdout
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
#!c=0
|
||||
# Auto-generated by µFrame from dashboard.uf
|
||||
# Do not edit — regenerate with: uframe compile dashboard.uf
|
||||
# Auto-generated by uFrame
|
||||
# Do not edit — regenerate with: uframe compile <source>.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:
|
||||
"""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):
|
||||
def _shell(cmd, timeout=5):
|
||||
"""Execute shell command, return stdout."""
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
cmd, shell=True, timeout=5
|
||||
).decode().strip()
|
||||
return subprocess.check_output(cmd, shell=True, timeout=timeout).decode().strip()
|
||||
except Exception:
|
||||
return ''
|
||||
return ""
|
||||
|
||||
# ─── Resolve Sources ─────────────────────────────────────────
|
||||
def _read_file(path):
|
||||
"""Read file contents."""
|
||||
# ...
|
||||
|
||||
cpu_pct = int(shell(
|
||||
"grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
|
||||
) 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')
|
||||
def _read_json(path):
|
||||
"""Read and parse JSON file."""
|
||||
# ...
|
||||
|
||||
# ─── 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
|
||||
# ... exactly as the layout engine would produce them ...
|
||||
def _load_state(path):
|
||||
"""Load state from JSON file."""
|
||||
# ...
|
||||
|
||||
# ─── Output ──────────────────────────────────────────────────
|
||||
def _save_state(path, data):
|
||||
"""Save state to JSON file."""
|
||||
# ...
|
||||
|
||||
print('#!c=0') # cache header: never cache
|
||||
print(grid.emit_micron())
|
||||
def _iter(val):
|
||||
"""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
|
||||
|
||||
```bash
|
||||
@@ -644,30 +606,35 @@ sparkline renders identically in both ASCII preview and live Micron.
|
||||
page "Status" 64
|
||||
cache 0
|
||||
|
||||
source cpu : shell "cat /proc/loadavg | awk '{print int($1*100/$(nproc))}'"
|
||||
source mem : shell "free | awk '/Mem/{print int($3/$2*100)}'"
|
||||
source net_in : shell "net_traffic.sh in"
|
||||
source net_out : shell "net_traffic.sh out"
|
||||
source net_history_in : shell "net_spark.sh in 20"
|
||||
source net_history_out : shell "net_spark.sh out 20"
|
||||
source cpu : python "secrets.randbelow(60) + 20"
|
||||
source mem : python "secrets.randbelow(40) + 50"
|
||||
source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
|
||||
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
|
||||
|
||||
box heavy "System Status"
|
||||
row 2
|
||||
gauge "CPU" $cpu 100 28 warn=75 crit=90
|
||||
gauge "MEM" $mem 100 28 warn=80 crit=95
|
||||
spacer
|
||||
label "IN" "$net_in KB/s"
|
||||
sparkline "IN" $net_history_in 28
|
||||
label "OUT" "$net_out KB/s"
|
||||
sparkline "OUT" $net_history_out 28
|
||||
label "Uptime" "$uptime"
|
||||
label "Updated" "$timestamp"
|
||||
|
||||
text "@center{@italic{Press Ctrl+R to refresh}}"
|
||||
```
|
||||
|
||||
Client hits the page → script runs → reads `/proc` → renders
|
||||
gauges and sparklines with real data → client sees it.
|
||||
Client hits the page → script runs → evaluates sources →
|
||||
renders gauges with live data → client sees it.
|
||||
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
|
||||
|
||||
```
|
||||
|
||||
145
docs/multi-node-plan.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# Multi-Node Support for Micronomicon
|
||||
|
||||
## Context
|
||||
|
||||
Currently Micronomicon runs a single NomadNet node — one identity, one set of pages, one `index.mu`. The user wants to host multiple independent "sites" on the Reticulum network, each with its own node identity, name, and pages. This requires changes across Docker infrastructure, backend API, and frontend UI.
|
||||
|
||||
## Approach: Dynamic Node Containers via Docker API
|
||||
|
||||
The FastAPI backend already has Docker socket access. Instead of statically defining NomadNet services in `compose.yml`, the backend will create/manage NomadNet containers dynamically — one per node. Each node gets its own directory with pages, sources, config, and identity.
|
||||
|
||||
## Storage Layout
|
||||
|
||||
```
|
||||
/data/nodes/
|
||||
default/
|
||||
node.json # {"id", "display_name", "port", "created_at"}
|
||||
nomadnet.conf # generated — unique node_name
|
||||
pages/ # compiled .mu files (mounted into container)
|
||||
sources/ # .uf source files
|
||||
my-relay/
|
||||
node.json
|
||||
nomadnet.conf
|
||||
pages/
|
||||
sources/
|
||||
```
|
||||
|
||||
## Files to Create
|
||||
|
||||
### `backend/nodes.py` — Node lifecycle management
|
||||
- `NodeConfig` pydantic model: `id, display_name, port, created_at`
|
||||
- `NODES_DIR = Path(os.environ.get("NODES_DIR", "/data/nodes"))`
|
||||
- CRUD: `list_nodes()`, `get_node(id)`, `create_node(id, display_name)`, `delete_node(id)`
|
||||
- `_generate_nomadnet_conf(display_name)` — template from existing `nomadnet.conf` with node-specific `node_name`
|
||||
- `_next_port()` — allocate TCP ports starting from 4242
|
||||
- Docker container management via Docker SDK (already a dependency):
|
||||
- `start_node_container(node)` — create container from `ghcr.io/markqvist/nomadnet:latest`, mount node's `pages/` dir, per-node identity volume, shared `reticulum.conf`, generated `nomadnet.conf`
|
||||
- `stop_node_container(id)`, `restart_node_container(id)`, `get_node_status(id)`
|
||||
- `ensure_default_node()` — migration: if `NODES_DIR/default` doesn't exist, create it and move contents from old `PAGES_DIR`/`SOURCES_DIR`
|
||||
- Startup reconciliation: check existing containers, start any that should be running
|
||||
- API router:
|
||||
- `GET /api/nodes` — list all nodes with status
|
||||
- `POST /api/nodes` — create `{id, display_name}`
|
||||
- `GET /api/nodes/{node_id}` — node details
|
||||
- `DELETE /api/nodes/{node_id}` — stop container, remove dir
|
||||
- `POST /api/nodes/{node_id}/restart`
|
||||
|
||||
### `frontend/src/stores/nodesStore.ts` — Node state
|
||||
- `nodes: NodeMeta[]`, `activeNodeId: string` (persisted to localStorage, defaults to `"default"`)
|
||||
- `fetchNodes()`, `setActiveNode(id)`, `createNode(id, displayName)`, `deleteNode(id)`, `restartNode(id)`
|
||||
|
||||
### `frontend/src/components/shared/NodeSelector.tsx` — Header dropdown
|
||||
- Dropdown listing nodes with status indicator (green/red dot)
|
||||
- Switching active node refetches pages
|
||||
- "Manage Nodes" link to `/nodes`
|
||||
|
||||
### `frontend/src/routes/NodesView.tsx` — Node management page
|
||||
- Table: Name, Status, Port, Created, Actions (restart/delete)
|
||||
- "Create Node" button with dialog (ID + display name)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### `backend/pages.py`
|
||||
- Replace global `PAGES_DIR`/`SOURCES_DIR` with helpers: `_node_pages_dir(node_id)` -> `NODES_DIR/node_id/pages`, `_node_sources_dir(node_id)` -> `NODES_DIR/node_id/sources`
|
||||
- Refactor all functions to accept `node_id` parameter
|
||||
- Add node-scoped endpoints: `GET/POST/DELETE /api/nodes/{node_id}/pages/{name}`
|
||||
- Keep existing `/api/pages/{name}` as aliases -> `node_id="default"`
|
||||
- `ensure_default_pages(node_id)` — create `index.mu` per node
|
||||
|
||||
### `backend/graph.py`
|
||||
- Same refactor: accept `node_id`, add `/api/nodes/{node_id}/graph`
|
||||
- Keep `/api/graph` as alias for default
|
||||
|
||||
### `backend/docker_utils.py`
|
||||
- Deprecate in favor of `nodes.py` restart endpoint
|
||||
- Keep `/api/restart` as alias -> restart default node
|
||||
|
||||
### `backend/main.py`
|
||||
- Import and include `nodes` router
|
||||
- Startup: call `ensure_default_node()`, then start all node containers
|
||||
- Remove direct `ensure_default_pages()` call (handled by node creation)
|
||||
|
||||
### `compose.yml`
|
||||
- Remove static `nomadnet` service (backend manages containers dynamically)
|
||||
- Replace `pages`/`sources`/`nomadnet-config` volumes with single `nodes` volume
|
||||
- Add `NODES_DIR=/data/nodes`, `NOMADNET_IMAGE`, `RETICULUM_CONF` env vars
|
||||
- Add `reticulum` bridge network for container communication
|
||||
- Keep `reticulum.conf` bind-mount for backend to pass to spawned containers
|
||||
|
||||
### `frontend/src/stores/pagesStore.ts`
|
||||
- Read `activeNodeId` from `nodesStore`
|
||||
- All fetch calls go to `/api/nodes/{activeNodeId}/pages/...`
|
||||
|
||||
### `frontend/src/routes/DashboardView.tsx`
|
||||
- Show active node name in header
|
||||
- Restart button restarts active node
|
||||
- Page list scoped to active node via store
|
||||
|
||||
### `frontend/src/routes/EditorView.tsx`
|
||||
- Save/publish calls use `/api/nodes/{activeNodeId}/pages/{slug}`
|
||||
- Load page from `/api/nodes/{activeNodeId}/pages/{name}`
|
||||
- Show active node name in toolbar
|
||||
|
||||
### `frontend/src/routes/GraphView.tsx`
|
||||
- Fetch from `/api/nodes/{activeNodeId}/graph`
|
||||
|
||||
### `frontend/src/App.tsx`
|
||||
- Add `/nodes` route -> `NodesView`
|
||||
|
||||
### `frontend/src/components/shared/AppShell.tsx` (or equivalent layout)
|
||||
- Add `NodeSelector` to header/nav area
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Existing `/api/pages/...`, `/api/restart`, `/api/graph` remain as aliases for `node_id="default"`
|
||||
- `ensure_default_node()` migrates old flat `PAGES_DIR`/`SOURCES_DIR` into `NODES_DIR/default/`
|
||||
- Frontend defaults `activeNodeId` to `"default"` — single-node users see no change
|
||||
- `NodeSelector` only shows when >1 node exists (or shows as subtle indicator for single node)
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. `backend/nodes.py` — core model, storage, Docker container management, API
|
||||
2. `backend/pages.py` — refactor to accept `node_id`, add node-scoped endpoints
|
||||
3. `backend/graph.py` — refactor to accept `node_id`
|
||||
4. `backend/main.py` — wire in nodes router, startup logic
|
||||
5. `compose.yml` — restructure (remove static nomadnet, add nodes volume + network)
|
||||
6. `frontend/src/stores/nodesStore.ts` — new store
|
||||
7. `frontend/src/stores/pagesStore.ts` — parameterize by active node
|
||||
8. `frontend/src/components/shared/NodeSelector.tsx` — node switcher
|
||||
9. `frontend/src/routes/NodesView.tsx` — management UI
|
||||
10. `frontend/src/App.tsx` + layout — add routes, add selector to shell
|
||||
11. `frontend/src/routes/DashboardView.tsx` — node-aware
|
||||
12. `frontend/src/routes/EditorView.tsx` — node-aware save/publish/load
|
||||
13. `frontend/src/routes/GraphView.tsx` — node-aware
|
||||
|
||||
## Verification
|
||||
|
||||
1. `docker compose up --build -d` — starts backend only (no static nomadnet)
|
||||
2. Hit `GET /api/nodes` — should return `[{id: "default", display_name: "Micronomicon", status: "running", ...}]`
|
||||
3. Open web IDE — pages dashboard shows default node's pages, NodeSelector shows "Micronomicon"
|
||||
4. Create a page, publish — appears in default node's container at `/root/.nomadnetwork/storage/pages/`
|
||||
5. `POST /api/nodes` with `{id: "relay", display_name: "Relay Alpha"}` — new container starts, `GET /api/nodes` shows 2 nodes
|
||||
6. Switch to "Relay Alpha" in NodeSelector — empty page list, create and publish `index.mu`
|
||||
7. From remote NomadNet client, both nodes visible on network with separate identities and pages
|
||||
8. Delete "Relay Alpha" — container stops, files removed, back to single node
|
||||
9. Run backend tests: `python -m pytest uframe/tests/ -v` — all 91 pass (DSL unchanged)
|
||||
581
frontend/package-lock.json
generated
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"name": "micronomicon",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"name": "micronomicon",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
@@ -16,27 +16,31 @@
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"lucide-react": "^1.7.0",
|
||||
"micron-parser": "^1.0.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-force-graph-3d": "^1.29.1",
|
||||
"react-resizable-panels": "^4.8.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"shadcn": "^4.1.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"three-spritetext": "^1.10.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -659,21 +663,6 @@
|
||||
"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": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.59.1.tgz",
|
||||
@@ -1077,6 +1066,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource-variable/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.12",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.12.tgz",
|
||||
@@ -2113,6 +2111,12 @@
|
||||
"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": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -2123,53 +2127,14 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
"@types/trusted-types": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
@@ -2222,6 +2187,13 @@
|
||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
|
||||
@@ -2549,64 +2521,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
|
||||
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
|
||||
"node_modules/3d-force-graph": {
|
||||
"version": "1.80.0",
|
||||
"resolved": "https://registry.npmjs.org/3d-force-graph/-/3d-force-graph-1.80.0.tgz",
|
||||
"integrity": "sha512-tzI353gW1nXPpnC7VTa3JjMg+3cp77qOLUFO0vucPTfF+q5R6sQsNsIqVTbRIb7RSypn14nBa4yfkOe9ThxASw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.76",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"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"
|
||||
"accessor-fn": "1",
|
||||
"kapsule": "^1.16",
|
||||
"three": ">=0.179 <1",
|
||||
"three-forcegraph": "1",
|
||||
"three-render-objects": "^1.41"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"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": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
@@ -2622,6 +2550,15 @@
|
||||
"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": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -2965,12 +2902,6 @@
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||
@@ -3260,6 +3191,24 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
@@ -3278,24 +3227,27 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"node_modules/d3-force-3d": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
|
||||
"integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-binarytree": "1",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
"d3-octree": "1",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -3312,6 +3264,50 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
@@ -3321,6 +3317,30 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
@@ -3330,36 +3350,13 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"node_modules/data-bind-mapper": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz",
|
||||
"integrity": "sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"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"
|
||||
"accessor-fn": "1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -3488,6 +3485,15 @@
|
||||
"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": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
@@ -4182,6 +4188,20 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
@@ -4568,6 +4588,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"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": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
@@ -4781,6 +4810,15 @@
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"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": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -4886,6 +4924,18 @@
|
||||
"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": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -5190,6 +5240,12 @@
|
||||
"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": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -5237,6 +5293,18 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -5334,6 +5402,15 @@
|
||||
"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": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
@@ -5505,6 +5582,44 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
@@ -5863,6 +5978,18 @@
|
||||
"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": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
@@ -5916,6 +6043,16 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -5963,6 +6100,17 @@
|
||||
"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": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -6066,6 +6214,44 @@
|
||||
"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": {
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.8.0.tgz",
|
||||
@@ -6742,12 +6928,79 @@
|
||||
"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": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"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": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
|
||||
@@ -18,27 +18,31 @@
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.3.3",
|
||||
"lucide-react": "^1.7.0",
|
||||
"micron-parser": "^1.0.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-force-graph-3d": "^1.29.1",
|
||||
"react-resizable-panels": "^4.8.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"shadcn": "^4.1.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"three-spritetext": "^1.10.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import AppShell from "./components/shared/AppShell";
|
||||
import DashboardView from "./routes/DashboardView";
|
||||
import EditorView from "./routes/EditorView";
|
||||
import GraphView from "./routes/GraphView";
|
||||
import ComposeView from "./routes/ComposeView";
|
||||
import BrowseView from "./routes/BrowseView";
|
||||
import SettingsView from "./routes/SettingsView";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardView />} />
|
||||
<Route path="/editor/new" element={<EditorView />} />
|
||||
<Route path="/editor/:name" element={<EditorView />} />
|
||||
<Route path="/graph" element={<GraphView />} />
|
||||
<Route path="/" element={<ComposeView />} />
|
||||
<Route path="/browse" element={<BrowseView />} />
|
||||
<Route path="/settings" element={<SettingsView />} />
|
||||
</Routes>
|
||||
</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 |
38
frontend/src/assets/frame.themed.svg
Normal file
|
After Width: | Height: | Size: 154 KiB |
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 asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground h-7 px-2">
|
||||
← {backlinks.length} backlink{backlinks.length !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
</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 { EditorView, keymap } from "@codemirror/view";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, Decoration, ViewPlugin } from "@codemirror/view";
|
||||
import { EditorState, RangeSetBuilder } 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 { 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 {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -25,17 +69,47 @@ export default function EditorPane({ value, onChange, extensions = [] }: Props)
|
||||
doc: value,
|
||||
extensions: [
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
||||
lineNumbers({ formatNumber: toRoman }),
|
||||
highlightActiveLine(),
|
||||
subtleWhitespace,
|
||||
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
||||
oneDark,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
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({
|
||||
"&": { height: "100%", fontSize: "14px" },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
".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,
|
||||
],
|
||||
|
||||
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,22 +1,26 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import { useCallback } from "react";
|
||||
import { useEditorCtx } from "./EditorStoreContext";
|
||||
import { renderMicron } from "./micronRenderer";
|
||||
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() {
|
||||
const previewMode = useEditorStore((s) => s.previewMode);
|
||||
const setPreviewMode = useEditorStore((s) => s.setPreviewMode);
|
||||
const compiledAscii = useEditorStore((s) => s.compiledAscii);
|
||||
const compiledMicron = useEditorStore((s) => s.compiledMicron);
|
||||
const compiledScript = useEditorStore((s) => s.compiledScript);
|
||||
const isDynamic = useEditorStore((s) => s.isDynamic);
|
||||
const isCompiling = useEditorStore((s) => s.isCompiling);
|
||||
const compileError = useEditorStore((s) => s.compileError);
|
||||
const previewMode = useEditorCtx((s) => s.previewMode);
|
||||
const setPreviewMode = useEditorCtx((s) => s.setPreviewMode);
|
||||
const compiledMicron = useEditorCtx((s) => s.compiledMicron);
|
||||
const compiledScript = useEditorCtx((s) => s.compiledScript);
|
||||
const isDynamic = useEditorCtx((s) => s.isDynamic);
|
||||
const isCompiling = useEditorCtx((s) => s.isCompiling);
|
||||
const compileError = useEditorCtx((s) => s.compileError);
|
||||
|
||||
const 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 }[] = [
|
||||
{ value: "ascii", label: "ASCII", show: true },
|
||||
{ value: "micron", label: "Micron", show: true },
|
||||
{ value: "raw", label: "Raw", show: true },
|
||||
{ value: "script", label: "Script", show: isDynamic },
|
||||
@@ -24,59 +28,50 @@ export default function PreviewPane() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center px-3 py-1.5 border-b shrink-0 gap-2">
|
||||
<span className="text-xs text-muted-foreground flex-1">
|
||||
<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">
|
||||
Preview
|
||||
{isDynamic && (
|
||||
<span className="ml-1.5 text-amber-400" title="This page has dynamic features (source, if, for)">
|
||||
⚡ dynamic
|
||||
</span>
|
||||
<span className="ml-1.5 text-primary/60 text-[10px]" title="Dynamic page">⚡</span>
|
||||
)}
|
||||
{isCompiling && (
|
||||
<span className="ml-2 text-yellow-500 animate-pulse">
|
||||
compiling…
|
||||
</span>
|
||||
<Loader size={10} className="ml-1.5 inline-block" />
|
||||
)}
|
||||
{compileError && (
|
||||
<span className="ml-2 text-red-400" title={compileError}>
|
||||
✗ error
|
||||
</span>
|
||||
<span className="ml-1 text-red-400 text-[10px]" title={compileError}>✗</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-0.5 bg-muted rounded-md p-0.5">
|
||||
<div className="flex items-center">
|
||||
{tabs
|
||||
.filter((t) => t.show)
|
||||
.map((tab) => (
|
||||
.map((tab, i, arr) => (
|
||||
<span key={tab.value} className="flex items-center">
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setPreviewMode(tab.value)}
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded transition-colors",
|
||||
"text-[10px] uppercase tracking-wider transition-colors cursor-pointer px-1.5",
|
||||
previewMode === tab.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
? "text-foreground font-bold"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{i < arr.length - 1 && (
|
||||
<span className="text-muted-foreground text-[8px]">·</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 bg-background">
|
||||
{previewMode === "ascii" ? (
|
||||
<pre className="p-4 font-mono text-sm whitespace-pre leading-tight text-green-100/90">
|
||||
{compiledAscii || (
|
||||
<span className="text-muted-foreground">
|
||||
ASCII preview will appear here…
|
||||
</span>
|
||||
)}
|
||||
</pre>
|
||||
) : previewMode === "micron" ? (
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div className="flex-1 bg-background overflow-auto min-h-0" onClick={handlePreviewClick}>
|
||||
{previewMode === "micron" ? (
|
||||
compiledMicron ? (
|
||||
<div
|
||||
className="p-4 font-mono text-sm whitespace-pre leading-tight"
|
||||
className="p-2 font-mono text-[11px] leading-tight"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMicron(compiledMicron),
|
||||
__html: renderMicron(compiledMicron, true),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -91,11 +86,11 @@ export default function PreviewPane() {
|
||||
{compiledScript || "No dynamic script generated."}
|
||||
</pre>
|
||||
) : (
|
||||
<pre className="p-4 font-mono text-sm whitespace-pre-wrap break-words text-muted-foreground">
|
||||
<pre className="p-4 font-mono text-xs whitespace-pre-wrap break-words text-muted-foreground">
|
||||
{compiledMicron || "Raw Micron output will appear here…"}
|
||||
</pre>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,25 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
} from "@/components/ui/popover";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import { useBacklinks } from "@/hooks/useBacklinks";
|
||||
import BacklinkIndicator from "@/components/editor/BacklinkIndicator";
|
||||
import { EXAMPLES } from "@/components/editor/examples";
|
||||
|
||||
interface Props {
|
||||
pageName: string;
|
||||
onNameChange?: (name: string) => void;
|
||||
onSaveDraft: () => void;
|
||||
onPublish: () => void;
|
||||
onInsertExample: (source: string) => void;
|
||||
saving: boolean;
|
||||
isDirty: boolean;
|
||||
}
|
||||
@@ -29,77 +17,20 @@ export default function ToolBar({
|
||||
onNameChange,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
onInsertExample,
|
||||
saving,
|
||||
isDirty,
|
||||
}: Props) {
|
||||
const currentPage = useEditorStore((s) => s.currentPage);
|
||||
const backlinks = useBacklinks(currentPage?.name);
|
||||
const [examplesOpen, setExamplesOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b bg-card shrink-0">
|
||||
{onNameChange ? (
|
||||
<Input
|
||||
value={pageName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="page-name"
|
||||
className="font-mono w-48 h-8 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-mono font-semibold text-sm">{pageName}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-border shrink-0">
|
||||
<PageNameField pageName={pageName} onNameChange={onNameChange} />
|
||||
|
||||
<Popover open={examplesOpen} onOpenChange={setExamplesOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-md text-xs font-medium h-8 px-3 border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
title="Insert example template"
|
||||
>
|
||||
<BookOpen className="h-3.5 w-3.5" />
|
||||
<span>Examples</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent side="bottom" align="start" 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={() => {
|
||||
onInsertExample(ex.source);
|
||||
setExamplesOpen(false);
|
||||
}}
|
||||
className="flex flex-col items-start rounded-md px-2 py-1.5 text-left hover:bg-accent transition-colors"
|
||||
>
|
||||
<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>
|
||||
{isDirty && (
|
||||
<span className="text-[10px] text-muted-foreground/50">●</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<BacklinkIndicator backlinks={backlinks} />
|
||||
|
||||
{isDirty && (
|
||||
<span className="text-xs text-muted-foreground">Unsaved</span>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSaveDraft}
|
||||
disabled={saving}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={onSaveDraft} disabled={saving}>
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button size="sm" onClick={onPublish} disabled={saving}>
|
||||
@@ -108,3 +39,40 @@ export default function ToolBar({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function PageNameField({
|
||||
pageName,
|
||||
onNameChange,
|
||||
}: {
|
||||
pageName: string;
|
||||
onNameChange?: (name: string) => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(!pageName || !!onNameChange);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
if (editing || onNameChange) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={pageName}
|
||||
onChange={(e) => onNameChange?.(e.target.value)}
|
||||
onBlur={() => { if (pageName) setEditing(false); }}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && pageName) setEditing(false); }}
|
||||
placeholder="page-name"
|
||||
className="font-mono w-36 h-6 text-xs border-0 bg-transparent px-1"
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="flex items-center gap-1 font-mono text-xs font-semibold hover:text-primary transition-colors cursor-pointer"
|
||||
>
|
||||
{pageName}
|
||||
<Pencil className="w-2.5 h-2.5 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -278,6 +278,130 @@ export const EXAMPLES: Example[] = [
|
||||
peer_status "Node Gamma" degraded
|
||||
|
||||
divider heavy
|
||||
link "Home" "/page/index.mu"`,
|
||||
},
|
||||
{
|
||||
name: "Navigation",
|
||||
description: "Horizontal bar + vertical sidebar navigation",
|
||||
source: `page "Dashboard" 64
|
||||
|
||||
hnav bar
|
||||
item "Status" "/page/status.mu" active
|
||||
item "Peers" "/page/peers.mu"
|
||||
item "Files" "/page/files.mu"
|
||||
item "Config" "/page/config.mu"
|
||||
|
||||
spacer
|
||||
|
||||
row 1
|
||||
col 18
|
||||
vnav boxed
|
||||
heading "Network"
|
||||
item "Overview" "/page/overview.mu" active
|
||||
item "Peers" "/page/peers.mu"
|
||||
item "Routes" "/page/routes.mu"
|
||||
separator
|
||||
heading "Tools"
|
||||
item "Ping" "/page/ping.mu"
|
||||
item "Trace" "/page/trace.mu"
|
||||
col
|
||||
heading 1 "Overview"
|
||||
gauge "CPU" 62 100 28 warn=75 crit=90
|
||||
gauge "MEM" 84 100 28 warn=80 crit=95
|
||||
spacer
|
||||
status "East Relay" online
|
||||
status "South Bridge" online
|
||||
status "Node Gamma" degraded`,
|
||||
},
|
||||
{
|
||||
name: "Image Art",
|
||||
description: "Convert images to character art (upload an image first)",
|
||||
source: `page "Image Embedding" 60
|
||||
|
||||
heading 1 "Image Embedding"
|
||||
text "Upload an image with the Image button above,"
|
||||
text "then use the image keyword to embed it."
|
||||
|
||||
spacer
|
||||
|
||||
box light "Syntax"
|
||||
text 'image "path.png" mode width'
|
||||
spacer
|
||||
label "mode" "braille | block | ascii | halfblock"
|
||||
label "width" "output width in characters"
|
||||
|
||||
spacer
|
||||
|
||||
heading 2 "Options"
|
||||
label "dither" "floyd | threshold | none"
|
||||
label "invert" "flip light and dark"
|
||||
label "align" "left | center | right"
|
||||
label "caption" "text below the image"
|
||||
|
||||
spacer
|
||||
|
||||
box rounded "Example"
|
||||
text 'image "logo.png" braille 30'
|
||||
text ' dither floyd'
|
||||
text ' align center'
|
||||
text ' caption "My logo"'`,
|
||||
},
|
||||
{
|
||||
name: "Big Title",
|
||||
description: "Large ASCII art text in block, thin, and pixel fonts",
|
||||
source: `page "Banner" 64
|
||||
|
||||
bigtitle "RELAY" block
|
||||
align center
|
||||
color 0cf
|
||||
|
||||
spacer
|
||||
|
||||
text "@center{Alpha-7 — Reticulum Network Node}"
|
||||
|
||||
spacer
|
||||
|
||||
bigtitle "STATUS" thin
|
||||
align center
|
||||
|
||||
spacer
|
||||
|
||||
gauge "CPU" 62 100 28 warn=75 crit=90
|
||||
gauge "MEM" 84 100 28 warn=80 crit=95
|
||||
|
||||
divider heavy
|
||||
|
||||
bigtitle "OK" pixel
|
||||
align center
|
||||
color 0f0`,
|
||||
},
|
||||
{
|
||||
name: "Themed Page",
|
||||
description: "Same layout with different visual themes (try: nouveau, gothic, bamboo, circuit, brutalist)",
|
||||
source: `page "Node Status" 50
|
||||
theme nouveau
|
||||
|
||||
box heavy "Relay Alpha-7"
|
||||
align center
|
||||
text "Reticulum Network Node"
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Resources"
|
||||
|
||||
gauge "CPU" 62 100 24 warn=75 crit=90
|
||||
gauge "MEM" 84 100 24 warn=80 crit=95
|
||||
|
||||
spacer
|
||||
|
||||
heading 2 "Peers"
|
||||
|
||||
status "East Relay" online
|
||||
status "South Bridge" online
|
||||
status "Node Gamma" degraded
|
||||
|
||||
divider heavy
|
||||
|
||||
link "Home" "/page/index.mu"`,
|
||||
},
|
||||
{
|
||||
@@ -286,9 +410,9 @@ export const EXAMPLES: Example[] = [
|
||||
source: `page "Live Status" 60
|
||||
cache 0
|
||||
|
||||
source cpu_pct : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
|
||||
source mem_pct : shell "free | awk '/Mem/{print int($3/$2*100)}'"
|
||||
source uptime : shell "uptime -p"
|
||||
source cpu_pct : python "secrets.randbelow(60) + 20"
|
||||
source mem_pct : python "secrets.randbelow(40) + 50"
|
||||
source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
|
||||
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
|
||||
|
||||
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,219 +1,25 @@
|
||||
/**
|
||||
* Micron markup → HTML renderer for the editor preview pane.
|
||||
* Spec: https://github.com/fr33n0w/micron-composer
|
||||
* Micron markup → HTML renderer using the micron-parser library.
|
||||
* 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 {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Render inline Micron formatting codes within a line of text. */
|
||||
function renderInline(raw: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
const openTags: string[] = [];
|
||||
|
||||
const closeAll = () => {
|
||||
while (openTags.length) out += openTags.pop()!;
|
||||
};
|
||||
|
||||
while (i < raw.length) {
|
||||
// Backtick formatting codes
|
||||
if (raw[i] === "`") {
|
||||
const code = raw[i + 1];
|
||||
if (code === "!") {
|
||||
out += "<strong>"; openTags.push("</strong>"); i += 2; continue;
|
||||
} else if (code === "*") {
|
||||
out += "<em>"; openTags.push("</em>"); i += 2; continue;
|
||||
} else if (code === "_") {
|
||||
out += "<u>"; openTags.push("</u>"); i += 2; continue;
|
||||
} else if (code === "`") {
|
||||
closeAll(); i += 2; continue;
|
||||
} else if (code === "f" || code === "b") {
|
||||
out += "</span>"; i += 2; continue;
|
||||
} else if (code === "a") {
|
||||
out += "</span>"; 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})(?![0-9a-fA-F])/);
|
||||
if (hexMatch) {
|
||||
const [r, g, b] = hexMatch[1].split("");
|
||||
const hex = r + r + g + g + b + b;
|
||||
out += `<span style="color:#${hex}">`;
|
||||
openTags.push("</span>");
|
||||
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})(?![0-9a-fA-F])/);
|
||||
if (hexMatch) {
|
||||
const [r, g, b] = hexMatch[1].split("");
|
||||
const hex = r + r + g + g + b + b;
|
||||
out += `<span style="background:#${hex}">`;
|
||||
openTags.push("</span>");
|
||||
i += 2 + hexMatch[1].length;
|
||||
continue;
|
||||
}
|
||||
} else if (code === "c") {
|
||||
out += `<span style="display:block;text-align:center">`;
|
||||
openTags.push("</span>"); i += 2; continue;
|
||||
} else if (code === "r") {
|
||||
out += `<span style="display:block;text-align:right">`;
|
||||
openTags.push("</span>"); i += 2; continue;
|
||||
} else if (code === "l") {
|
||||
out += `<span style="display:block;text-align:left">`;
|
||||
openTags.push("</span>"); 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++;
|
||||
}
|
||||
|
||||
closeAll();
|
||||
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>`;
|
||||
}
|
||||
|
||||
/** Render a form element line as a styled badge. */
|
||||
function renderForm(line: string): string {
|
||||
const inner = escapeHtml(line);
|
||||
return `<span style="color:#d4a8f8;background:rgba(212,168,248,0.08);border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px;font-size:0.9em">${inner}</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");
|
||||
import MicronParser from "micron-parser";
|
||||
|
||||
let darkParser: MicronParser | null = null;
|
||||
let lightParser: MicronParser | null = null;
|
||||
|
||||
export function renderMicron(source: string, darkTheme: boolean = true): string {
|
||||
const parser = darkTheme
|
||||
? (darkParser ??= new MicronParser(true, true))
|
||||
: (lightParser ??= new MicronParser(false, true));
|
||||
|
||||
const fragment = parser.convertMicronToFragment(source);
|
||||
|
||||
// Serialize the fragment to HTML string
|
||||
const div = document.createElement("div");
|
||||
div.appendChild(fragment);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
@@ -3,25 +3,27 @@ import { EditorView } from "@codemirror/view";
|
||||
export const oneDark = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
backgroundColor: "#0d1117",
|
||||
color: "#c9d1d9",
|
||||
backgroundColor: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderLeftColor: "#c9d1d9",
|
||||
".cm-cursor, .cm-cursor-primary": {
|
||||
borderLeftColor: "var(--primary)",
|
||||
borderLeftWidth: "0.5em",
|
||||
},
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "#264f78",
|
||||
backgroundColor: "color-mix(in oklch, var(--primary) 25%, transparent)",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "#161b2266",
|
||||
backgroundColor: "color-mix(in oklch, var(--primary) 8%, transparent)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "#0d1117",
|
||||
color: "#484f58",
|
||||
borderRight: "1px solid #21262d",
|
||||
backgroundColor: "var(--background)",
|
||||
color: "var(--muted-foreground)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "#161b2266",
|
||||
backgroundColor: "color-mix(in oklch, var(--primary) 8%, transparent)",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
},
|
||||
{ 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,6 +5,14 @@ import type {
|
||||
CompletionResult,
|
||||
} from "@codemirror/autocomplete";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { fetchDslMeta } from "@/api/client";
|
||||
|
||||
/**
|
||||
* µFrame slash command palette — auto-populated from the backend DSL registry.
|
||||
*
|
||||
* On first load, uses a minimal fallback set. Once the API responds,
|
||||
* the full command list replaces it via loadCommandsFromApi().
|
||||
*/
|
||||
|
||||
interface CmdEntry {
|
||||
label: string;
|
||||
@@ -13,6 +21,10 @@ interface CmdEntry {
|
||||
apply: Completion["apply"];
|
||||
}
|
||||
|
||||
// Module-level cache — survives re-renders but not full page reload.
|
||||
let commands: CmdEntry[] | null = null;
|
||||
let loaded = false;
|
||||
|
||||
function insert(text: string): Completion["apply"] {
|
||||
return (view: EditorView, _c: Completion, from: number, to: number) => {
|
||||
view.dispatch({ changes: { from: from - 1, to, insert: text } });
|
||||
@@ -22,179 +34,73 @@ function insert(text: string): Completion["apply"] {
|
||||
function slashSnippet(template: string): Completion["apply"] {
|
||||
const snip = snippet(template);
|
||||
return (view: EditorView, c: Completion, from: number, to: number) => {
|
||||
snip(view, c, from - 1, to - 1);
|
||||
snip(view, c, from - 1, to);
|
||||
};
|
||||
}
|
||||
|
||||
const COMMANDS: CmdEntry[] = [
|
||||
// Layout
|
||||
{
|
||||
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: "row",
|
||||
detail: "row [gap]",
|
||||
section: "Layout",
|
||||
apply: slashSnippet("row ${gap:2}"),
|
||||
},
|
||||
{
|
||||
label: "col",
|
||||
detail: "col [width]",
|
||||
section: "Layout",
|
||||
apply: slashSnippet("col ${width}"),
|
||||
},
|
||||
{
|
||||
label: "spacer",
|
||||
detail: "spacer [lines]",
|
||||
section: "Layout",
|
||||
apply: insert("spacer"),
|
||||
},
|
||||
{
|
||||
label: "pad",
|
||||
detail: "pad t r b l",
|
||||
section: "Layout",
|
||||
apply: slashSnippet("pad ${top:1} ${right:1} ${bottom:1} ${left:1}"),
|
||||
},
|
||||
const FALLBACK_COMMANDS: CmdEntry[] = [
|
||||
{ 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: "heading", detail: 'heading 1 "Text"', section: "Content", apply: slashSnippet('heading ${level:1} "${text}"') },
|
||||
{ label: "text", detail: 'text "Content"', section: "Content", apply: slashSnippet('text "${content}"') },
|
||||
{ label: "gauge", detail: "gauge label val max", section: "Data", apply: slashSnippet('gauge "${label}" ${value} ${max:100} ${width:28}') },
|
||||
{ label: "status", detail: "status label state", section: "Data", apply: slashSnippet('status "${label}" ${state:online}') },
|
||||
];
|
||||
|
||||
// Content
|
||||
{
|
||||
label: "heading",
|
||||
detail: 'heading 1 "Text"',
|
||||
section: "Content",
|
||||
apply: slashSnippet('heading ${level:1} "${text}"'),
|
||||
},
|
||||
{
|
||||
label: "text",
|
||||
detail: 'text "Content"',
|
||||
section: "Content",
|
||||
apply: slashSnippet('text "${content}"'),
|
||||
},
|
||||
{
|
||||
label: "label",
|
||||
detail: 'label "Key" "Value"',
|
||||
section: "Content",
|
||||
apply: slashSnippet('label "${key}" "${value}"'),
|
||||
},
|
||||
{
|
||||
label: "divider",
|
||||
detail: "divider heavy",
|
||||
section: "Content",
|
||||
apply: slashSnippet("divider ${style:light}"),
|
||||
},
|
||||
{
|
||||
label: "link",
|
||||
detail: 'link "Text" "/path.mu"',
|
||||
section: "Content",
|
||||
apply: slashSnippet('link "${display}" "${dest}"'),
|
||||
},
|
||||
{
|
||||
label: "list",
|
||||
detail: "list bullet",
|
||||
section: "Content",
|
||||
apply: slashSnippet("list ${style:bullet}\n item \"${entry}\""),
|
||||
},
|
||||
function getCommands(): CmdEntry[] {
|
||||
return commands ?? FALLBACK_COMMANDS;
|
||||
}
|
||||
|
||||
// Data Viz
|
||||
{
|
||||
label: "gauge",
|
||||
detail: "gauge label val max width",
|
||||
section: "Data",
|
||||
apply: slashSnippet(
|
||||
'gauge "${label}" ${value} ${max:100} ${width:28} warn=${warn:75} crit=${crit:90}',
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "sparkline",
|
||||
detail: "sparkline label values width",
|
||||
section: "Data",
|
||||
apply: slashSnippet('sparkline "${label}" "${values}" ${width:20}'),
|
||||
},
|
||||
{
|
||||
label: "status",
|
||||
detail: "status label state",
|
||||
section: "Data",
|
||||
apply: slashSnippet('status "${label}" ${state:online}'),
|
||||
},
|
||||
/**
|
||||
* Load the full command list from the backend DSL registry.
|
||||
* Called once on editor mount.
|
||||
*/
|
||||
export async function loadCommandsFromApi(): Promise<void> {
|
||||
if (loaded) return;
|
||||
|
||||
// Style
|
||||
{
|
||||
label: "align",
|
||||
detail: "align center",
|
||||
section: "Style",
|
||||
apply: slashSnippet("align ${align:center}"),
|
||||
},
|
||||
{
|
||||
label: "color",
|
||||
detail: "color hex",
|
||||
section: "Style",
|
||||
apply: slashSnippet("color ${hex}"),
|
||||
},
|
||||
{
|
||||
label: "bold",
|
||||
detail: "bold",
|
||||
section: "Style",
|
||||
apply: insert("bold"),
|
||||
},
|
||||
try {
|
||||
const data = await fetchDslMeta();
|
||||
const apiCommands: CmdEntry[] = [];
|
||||
|
||||
// Table
|
||||
{
|
||||
label: "table",
|
||||
detail: 'table + columns + rows',
|
||||
section: "Data",
|
||||
apply: insert(
|
||||
`table "Title"\n columns "Name" 20 | "Value" 10\n row "entry" | "data"`,
|
||||
),
|
||||
},
|
||||
for (const cmd of data.commands ?? []) {
|
||||
if (!cmd.detail || !cmd.snippet) continue;
|
||||
apiCommands.push({
|
||||
label: cmd.label,
|
||||
detail: cmd.detail,
|
||||
section: cmd.section,
|
||||
apply: cmd.snippet.includes("${")
|
||||
? slashSnippet(cmd.snippet)
|
||||
: insert(cmd.snippet),
|
||||
});
|
||||
}
|
||||
|
||||
// Templates
|
||||
{
|
||||
// Dashboard template (not in registry)
|
||||
apiCommands.push({
|
||||
label: "dashboard",
|
||||
detail: "Full dashboard template",
|
||||
section: "Template",
|
||||
apply: insert(
|
||||
`page "Dashboard" 64
|
||||
box double "Node Status"
|
||||
align center
|
||||
text "Reticulum Network Node"
|
||||
|
||||
spacer
|
||||
|
||||
heading 1 "Resources"
|
||||
|
||||
gauge "CPU" 0 100 28 warn=75 crit=90
|
||||
gauge "MEM" 0 100 28 warn=80 crit=95
|
||||
|
||||
spacer
|
||||
|
||||
heading 2 "Network"
|
||||
|
||||
status "Relay East" online
|
||||
status "Bridge South" online
|
||||
|
||||
divider heavy
|
||||
link "Home" "/page/index.mu"`,
|
||||
`page "Dashboard" 64\n box double "Node Status"\n align center\n text "Reticulum Network Node"\n\n spacer\n\n heading 1 "Resources"\n\n gauge "CPU" 0 100 28 warn=75 crit=90\n gauge "MEM" 0 100 28 warn=80 crit=95\n\n spacer\n\n heading 2 "Network"\n\n status "Relay East" online\n status "Bridge South" online\n\n divider heavy\n link "Home" "/page/index.mu"`,
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
commands = apiCommands;
|
||||
loaded = true;
|
||||
} catch {
|
||||
// Keep using fallback
|
||||
}
|
||||
}
|
||||
|
||||
export function uframeCommandSource(
|
||||
ctx: CompletionContext,
|
||||
): CompletionResult | null {
|
||||
const match = ctx.matchBefore(/\/\w*/);
|
||||
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
||||
|
||||
return {
|
||||
from: match.from + 1,
|
||||
filter: true,
|
||||
options: COMMANDS.map((cmd) => ({
|
||||
options: getCommands().map((cmd) => ({
|
||||
label: cmd.label,
|
||||
detail: cmd.detail,
|
||||
section: cmd.section,
|
||||
@@ -203,3 +109,82 @@ export function uframeCommandSource(
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribute value hints — suggests valid values based on the keyword
|
||||
* on the current line.
|
||||
*/
|
||||
const KEYWORD_VALUES: Record<string, { values: string[]; hint: string }> = {
|
||||
box: { values: ["light", "heavy", "double", "rounded"], hint: "border weight" },
|
||||
divider: { values: ["light", "heavy", "double", "dash", "dot"], hint: "divider style" },
|
||||
heading: { values: ["1", "2", "3"], hint: "heading level" },
|
||||
list: { values: ["bullet", "dash", "number", "arrow"], hint: "list style" },
|
||||
align: { values: ["left", "center", "right"], hint: "alignment" },
|
||||
status: { values: ["online", "offline", "degraded", "unknown", "alert"], hint: "state" },
|
||||
hnav: { values: ["bar", "tabs", "pills", "breadcrumb", "underline"], hint: "nav style" },
|
||||
vnav: { values: ["list", "boxed", "tree", "sidebar", "minimal"], hint: "nav style" },
|
||||
bigtitle: { values: ["block", "thin", "pixel"], hint: "font" },
|
||||
image: { values: ["braille", "block", "ascii", "halfblock"], hint: "render mode" },
|
||||
dither: { values: ["floyd", "threshold", "none"], hint: "dither algorithm" },
|
||||
source: { values: ["shell", "file", "json", "python", "rns", "param"], hint: "source type" },
|
||||
theme: { values: ["default", "nouveau", "gothic", "bamboo", "circuit", "brutalist"], hint: "theme" },
|
||||
};
|
||||
|
||||
export function uframeValueHintSource(
|
||||
ctx: CompletionContext,
|
||||
): CompletionResult | null {
|
||||
const line = ctx.state.doc.lineAt(ctx.pos);
|
||||
const textBefore = line.text.slice(0, ctx.pos - line.from);
|
||||
|
||||
if (textBefore.trimStart().startsWith("/")) return null;
|
||||
|
||||
const kwMatch = textBefore.match(/^\s*(\w+)\s/);
|
||||
if (!kwMatch) return null;
|
||||
|
||||
const keyword = kwMatch[1].toLowerCase();
|
||||
const entry = KEYWORD_VALUES[keyword];
|
||||
if (!entry) return null;
|
||||
|
||||
const quotesBefore = (textBefore.match(/"/g) || []).length;
|
||||
if (quotesBefore % 2 !== 0) return null;
|
||||
|
||||
const colonWordMatch = ctx.matchBefore(/\w+:\w*/);
|
||||
if (colonWordMatch) {
|
||||
return {
|
||||
from: colonWordMatch.from,
|
||||
filter: false,
|
||||
options: entry.values.map((v) => ({
|
||||
label: v,
|
||||
detail: entry.hint,
|
||||
type: "enum" as const,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const wordMatch = ctx.matchBefore(/\w+/);
|
||||
if (!wordMatch) {
|
||||
if (!ctx.explicit) return null;
|
||||
return {
|
||||
from: ctx.pos,
|
||||
options: entry.values.map((v) => ({
|
||||
label: v,
|
||||
detail: entry.hint,
|
||||
type: "enum" as const,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const typed = ctx.state.sliceDoc(wordMatch.from, wordMatch.to);
|
||||
if (typed.toLowerCase() === keyword) return null;
|
||||
|
||||
return {
|
||||
from: wordMatch.from,
|
||||
filter: true,
|
||||
options: entry.values.map((v) => ({
|
||||
label: v,
|
||||
detail: entry.hint,
|
||||
type: "enum" as const,
|
||||
boost: -1,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,32 +8,66 @@ import { tags } from "@lezer/highlight";
|
||||
/**
|
||||
* CodeMirror 6 syntax highlighting for the µFrame .uf DSL.
|
||||
*
|
||||
* Keywords: page, box, row, col, spacer, pad, heading, text, label,
|
||||
* divider, link, list, item, gauge, sparkline, status,
|
||||
* table, columns, form, field, radio, checkbox, button,
|
||||
* source, let, if, elif, else, for, align, color, bg,
|
||||
* bold, italic, underline, cache, state, on_submit
|
||||
* Keywords and values can be updated dynamically via setDslKeywords()
|
||||
* which is called when the frontend fetches /api/dsl-meta.
|
||||
*/
|
||||
|
||||
const KEYWORDS = new Set([
|
||||
// Mutable sets — updated from /api/dsl-meta
|
||||
let KEYWORDS = new Set([
|
||||
// Layout
|
||||
"page", "box", "row", "col", "spacer", "pad",
|
||||
"heading", "text", "label", "divider", "link",
|
||||
"list", "item", "gauge", "sparkline", "status",
|
||||
"table", "columns", "form", "field", "radio",
|
||||
"checkbox", "button", "source", "let", "if",
|
||||
"elif", "else", "for", "align", "color", "bg",
|
||||
"bold", "italic", "underline", "cache", "state",
|
||||
"on_submit", "meter", "bar_h", "bar_v", "bar",
|
||||
"heatmap", "component", "use",
|
||||
// Content
|
||||
"heading", "text", "label", "divider", "link", "list", "item",
|
||||
"bigtitle", "image", "dither", "invert", "caption",
|
||||
// Data
|
||||
"gauge", "sparkline", "status", "table", "columns",
|
||||
// Navigation
|
||||
"hnav", "vnav", "separator",
|
||||
// Form
|
||||
"form", "field", "radio", "checkbox", "button", "password",
|
||||
// Style
|
||||
"align", "color", "bg", "bold", "italic", "underline", "theme",
|
||||
// Dynamic
|
||||
"source", "let", "if", "elif", "else", "for", "cache", "state", "on_submit",
|
||||
"set", "append", "prepend",
|
||||
// Components
|
||||
"component", "use",
|
||||
]);
|
||||
|
||||
const WEIGHT_VALS = new Set([
|
||||
let WEIGHT_VALS = new Set([
|
||||
// Border weights
|
||||
"light", "heavy", "double", "rounded",
|
||||
// List styles
|
||||
"bullet", "dash", "number", "arrow",
|
||||
// Alignment
|
||||
"left", "center", "right",
|
||||
"online", "offline", "degraded", "unknown",
|
||||
// Status states
|
||||
"online", "offline", "degraded", "unknown", "alert",
|
||||
// Source types
|
||||
"shell", "file", "json", "python", "rns", "param",
|
||||
// Themes
|
||||
"default", "nouveau", "gothic", "bamboo", "circuit", "brutalist",
|
||||
// Nav styles
|
||||
"bar", "tabs", "pills", "breadcrumb", "underline",
|
||||
"boxed", "tree", "sidebar", "minimal",
|
||||
// Fonts
|
||||
"block", "thin", "pixel",
|
||||
// Image modes
|
||||
"braille", "ascii", "halfblock",
|
||||
// Dither
|
||||
"floyd", "threshold", "none",
|
||||
// Divider styles
|
||||
"dot",
|
||||
// Misc
|
||||
"active", "compact",
|
||||
]);
|
||||
|
||||
/** Update keywords and values from /api/dsl-meta response. */
|
||||
export function setDslKeywords(keywords: string[], values: string[]) {
|
||||
if (keywords.length > 0) KEYWORDS = new Set(keywords);
|
||||
if (values.length > 0) WEIGHT_VALS = new Set(values);
|
||||
}
|
||||
|
||||
const uframeLanguage = StreamLanguage.define({
|
||||
token(stream) {
|
||||
// Comments
|
||||
|
||||
281
frontend/src/components/editor/uframeHover.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { hoverTooltip, type EditorView, type Tooltip } from "@codemirror/view";
|
||||
|
||||
/**
|
||||
* µFrame keyword hover tooltips — shows syntax, attributes, and examples
|
||||
* when hovering over a DSL keyword in the editor.
|
||||
*/
|
||||
|
||||
interface KeywordDoc {
|
||||
syntax: string;
|
||||
attrs: string;
|
||||
example: string;
|
||||
}
|
||||
|
||||
const KEYWORD_DOCS: Record<string, KeywordDoc> = {
|
||||
// Layout
|
||||
page: {
|
||||
syntax: 'page "title" [width]',
|
||||
attrs: "title: string — page title\nwidth: number — page width in chars (default: 64)",
|
||||
example: 'page "Node Status" 60',
|
||||
},
|
||||
box: {
|
||||
syntax: 'box [weight] "title"',
|
||||
attrs: "weight: light | heavy | double | rounded\ntitle: string — title in top border",
|
||||
example: 'box double "System"\n text "Content here"',
|
||||
},
|
||||
row: {
|
||||
syntax: "row [gap]",
|
||||
attrs: "gap: number — space between columns (default: 1)",
|
||||
example: "row 2\n col 20\n text \"Left\"\n col\n text \"Right\"",
|
||||
},
|
||||
col: {
|
||||
syntax: "col [width]",
|
||||
attrs: "width: number — column width in chars (auto if omitted)",
|
||||
example: "col 30\n text \"Fixed width column\"",
|
||||
},
|
||||
spacer: {
|
||||
syntax: "spacer [lines]",
|
||||
attrs: "lines: number — vertical space (default: 1)",
|
||||
example: "spacer 2",
|
||||
},
|
||||
pad: {
|
||||
syntax: "pad [top] [right] [bottom] [left]",
|
||||
attrs: "top, right, bottom, left: number — padding in chars",
|
||||
example: "pad 1 2 1 2\n text \"Padded content\"",
|
||||
},
|
||||
|
||||
// Content
|
||||
heading: {
|
||||
syntax: 'heading [level] "text"',
|
||||
attrs: "level: 1 | 2 | 3 — heading size\ntext: string — heading text",
|
||||
example: 'heading 1 "Main Title"',
|
||||
},
|
||||
text: {
|
||||
syntax: 'text "content"',
|
||||
attrs: "content: string — supports @bold{}, @italic{},\n @color{hex}{}, @bg{hex}{}, @under{}",
|
||||
example: 'text "Hello @bold{world} @color{0f0}{green}"',
|
||||
},
|
||||
label: {
|
||||
syntax: 'label "key" "value"',
|
||||
attrs: "key: string — label name (bold)\nvalue: string — label value",
|
||||
example: 'label "Uptime" "14d 3h 22m"',
|
||||
},
|
||||
divider: {
|
||||
syntax: "divider [style]",
|
||||
attrs: "style: light | heavy | double | dash | dot",
|
||||
example: "divider heavy",
|
||||
},
|
||||
link: {
|
||||
syntax: 'link "display" "destination"',
|
||||
attrs: "display: string — visible text\ndestination: string — Micron page path",
|
||||
example: 'link "Home" "/page/index.mu"',
|
||||
},
|
||||
list: {
|
||||
syntax: "list [style]",
|
||||
attrs: "style: bullet | dash | number | arrow",
|
||||
example: 'list bullet\n item "First entry"\n item "Second entry"',
|
||||
},
|
||||
item: {
|
||||
syntax: 'item "text" OR item "label" "dest" [active]',
|
||||
attrs: "text: string — list item content\nlabel, dest: nav item with link\nactive: flag — highlight as current",
|
||||
example: 'item "Entry" — in list\nitem "Home" "/page/index.mu" active — in nav',
|
||||
},
|
||||
|
||||
// Data Visualization
|
||||
gauge: {
|
||||
syntax: 'gauge "label" value max width [warn=N] [crit=N]',
|
||||
attrs: "label: string — gauge name\nvalue: number — current value\nmax: number — maximum\nwidth: number — bar width in chars\nwarn: number — warning threshold\ncrit: number — critical threshold",
|
||||
example: 'gauge "CPU" 62 100 28 warn=75 crit=90',
|
||||
},
|
||||
sparkline: {
|
||||
syntax: 'sparkline "label" "values" width',
|
||||
attrs: "label: string — chart name\nvalues: string — comma-separated numbers\nwidth: number — chart width in chars",
|
||||
example: 'sparkline "Traffic" "1,3,5,8,7,5,3" 20',
|
||||
},
|
||||
status: {
|
||||
syntax: 'status "label" state',
|
||||
attrs: "label: string — indicator name\nstate: online | offline | degraded | unknown | alert",
|
||||
example: 'status "East Relay" online',
|
||||
},
|
||||
table: {
|
||||
syntax: 'table "title"',
|
||||
attrs: "title: string — table caption\nchildren: columns + row entries",
|
||||
example: 'table "Routes"\n columns "Dest" 20 | "Hops" 6\n row "east" | "2"',
|
||||
},
|
||||
columns: {
|
||||
syntax: 'columns "name" width | "name" width | ...',
|
||||
attrs: "name: string — column header\nwidth: number — column width in chars\nseparated by | pipes",
|
||||
example: 'columns "Name" 20 | "Status" 10',
|
||||
},
|
||||
|
||||
// Navigation
|
||||
hnav: {
|
||||
syntax: "hnav [style]",
|
||||
attrs: "style: bar | tabs | pills | breadcrumb | underline\nchildren: item, separator",
|
||||
example: 'hnav bar\n item "Status" "/page/status.mu" active\n item "Peers" "/page/peers.mu"',
|
||||
},
|
||||
vnav: {
|
||||
syntax: "vnav [style] [width]",
|
||||
attrs: "style: list | boxed | tree | sidebar | minimal\nwidth: number — panel width (auto if omitted)\nchildren: item, separator, heading",
|
||||
example: 'vnav boxed\n heading "Section"\n item "Page" "/page/page.mu" active',
|
||||
},
|
||||
|
||||
// Forms
|
||||
form: {
|
||||
syntax: 'form "name"',
|
||||
attrs: "name: string — form identifier\nchildren: field, password, radio, checkbox, button",
|
||||
example: 'form "search"\n field "query" 30 "Search..."\n button "Go" "/page/search.mu"',
|
||||
},
|
||||
field: {
|
||||
syntax: 'field "name" [width] "placeholder"',
|
||||
attrs: "name: string — field name\nwidth: number — input width (default: 24)\nplaceholder: string — hint text",
|
||||
example: 'field "query" 30 "Enter search term..."',
|
||||
},
|
||||
password: {
|
||||
syntax: 'password "name" [width] "placeholder"',
|
||||
attrs: "name: string — field name\nwidth: number — input width (default: 24)\nplaceholder: string — hint text",
|
||||
example: 'password "pass" 24 "Enter password"',
|
||||
},
|
||||
radio: {
|
||||
syntax: 'radio "group" "opt1" | "opt2" | "opt3"',
|
||||
attrs: "group: string — radio group name\noptions: strings separated by | pipes",
|
||||
example: 'radio "mode" "Ping" | "Trace" | "Page"',
|
||||
},
|
||||
checkbox: {
|
||||
syntax: 'checkbox "name" "label"',
|
||||
attrs: "name: string — field name\nlabel: string — display text",
|
||||
example: 'checkbox "verbose" "Verbose output"',
|
||||
},
|
||||
button: {
|
||||
syntax: 'button "label" "destination"',
|
||||
attrs: "label: string — button text\ndestination: string — link target on click",
|
||||
example: 'button "Submit" "/page/submit.mu"',
|
||||
},
|
||||
|
||||
// Big Text & Image
|
||||
bigtitle: {
|
||||
syntax: 'bigtitle "text" [font]',
|
||||
attrs: "text: string — text to render large\nfont: block | thin | pixel\nmodifiers: align, color",
|
||||
example: 'bigtitle "RELAY" block\n align center\n color 0cf',
|
||||
},
|
||||
image: {
|
||||
syntax: 'image "path" [mode] [width]',
|
||||
attrs: "path: string — image file path\nmode: braille | block | ascii | halfblock\nwidth: number — output width in chars\nmodifiers: dither, invert, align, caption",
|
||||
example: 'image "logo.png" braille 30\n dither floyd\n caption "Logo"',
|
||||
},
|
||||
|
||||
// Style
|
||||
align: {
|
||||
syntax: "align [direction]",
|
||||
attrs: "direction: left | center | right",
|
||||
example: "align center",
|
||||
},
|
||||
color: {
|
||||
syntax: "color [hex]",
|
||||
attrs: "hex: 3-digit hex color (e.g. 0f0, f00, 0cf)",
|
||||
example: "color 0cf",
|
||||
},
|
||||
bold: {
|
||||
syntax: "bold",
|
||||
attrs: "no arguments — applies bold to parent",
|
||||
example: "box light \"Title\"\n bold\n text \"Bold content\"",
|
||||
},
|
||||
theme: {
|
||||
syntax: "theme [name]",
|
||||
attrs: "name: default | nouveau | gothic | bamboo | circuit | brutalist",
|
||||
example: "theme nouveau",
|
||||
},
|
||||
|
||||
// Dynamic
|
||||
source: {
|
||||
syntax: 'source name : type "command"',
|
||||
attrs: "name: string — variable name\ntype: shell | file | json | python | rns | param\ncommand: string — command to execute",
|
||||
example: 'source cpu : shell "cat /proc/loadavg"',
|
||||
},
|
||||
let: {
|
||||
syntax: 'let name = "value"',
|
||||
attrs: "name: string — variable name\nvalue: string or number",
|
||||
example: 'let node_name = "Relay Alpha"',
|
||||
},
|
||||
cache: {
|
||||
syntax: "cache [seconds]",
|
||||
attrs: "seconds: number — cache duration (0 = never cache)",
|
||||
example: "cache 0",
|
||||
},
|
||||
if: {
|
||||
syntax: "if condition",
|
||||
attrs: "condition: expression with $variables\nchildren: content to show when true",
|
||||
example: "if $cpu > 90\n text \"ALERT: CPU critical\"",
|
||||
},
|
||||
for: {
|
||||
syntax: "for var in $collection",
|
||||
attrs: "var: string — loop variable name\ncollection: $variable — iterable data",
|
||||
example: "for peer in $peers\n status \"$peer\" online",
|
||||
},
|
||||
on_submit: {
|
||||
syntax: 'on_submit "form_name"',
|
||||
attrs: "form_name: string — form to handle\nchildren: handler logic with $field vars",
|
||||
example: 'on_submit "search"\n source results : shell "search.py \'$query\'"',
|
||||
},
|
||||
state: {
|
||||
syntax: 'state "name" "path"',
|
||||
attrs: "name: string — state variable name\npath: string — JSON file path for persistence",
|
||||
example: 'state "counter" "/tmp/counter.json"',
|
||||
},
|
||||
|
||||
// Components
|
||||
component: {
|
||||
syntax: "component name(arg1, arg2, ...)",
|
||||
attrs: "name: string — component name\nargs: parameter names\nchildren: component body template",
|
||||
example: 'component stat(label, value, max)\n gauge "$label" $value $max 20',
|
||||
},
|
||||
use: {
|
||||
syntax: 'use "library"',
|
||||
attrs: "library: std/dashboard | std/status-bar | std/nav |\n std/network | std/form | or file path",
|
||||
example: "use std/dashboard\nbanner \"My Node\" \"Mesh\"",
|
||||
},
|
||||
};
|
||||
|
||||
function getWordAt(view: EditorView, pos: number): { word: string; from: number; to: number } | null {
|
||||
const line = view.state.doc.lineAt(pos);
|
||||
const text = line.text;
|
||||
const col = pos - line.from;
|
||||
|
||||
let start = col;
|
||||
let end = col;
|
||||
while (start > 0 && /\w/.test(text[start - 1])) start--;
|
||||
while (end < text.length && /\w/.test(text[end])) end++;
|
||||
|
||||
if (start === end) return null;
|
||||
return { word: text.slice(start, end), from: line.from + start, to: line.from + end };
|
||||
}
|
||||
|
||||
export const keywordHoverTooltip = hoverTooltip((view, pos) => {
|
||||
const result = getWordAt(view, pos);
|
||||
if (!result) return null;
|
||||
|
||||
const doc = KEYWORD_DOCS[result.word.toLowerCase()];
|
||||
if (!doc) return null;
|
||||
|
||||
return {
|
||||
pos: result.from,
|
||||
end: result.to,
|
||||
above: true,
|
||||
create() {
|
||||
const dom = document.createElement("div");
|
||||
dom.className = "cm-keyword-tooltip";
|
||||
dom.innerHTML = `
|
||||
<div style="font-family:var(--font-mono);font-size:11px;max-width:360px;line-height:1.4">
|
||||
<div style="color:var(--primary);font-weight:bold;margin-bottom:4px;font-size:12px">${escHtml(doc.syntax)}</div>
|
||||
<div style="color:var(--muted-foreground);white-space:pre-wrap;margin-bottom:6px;border-bottom:1px solid var(--border);padding-bottom:6px">${escHtml(doc.attrs)}</div>
|
||||
<div style="color:var(--foreground);opacity:0.8;white-space:pre;background:var(--muted);padding:4px 6px;margin:-2px -6px -6px">${escHtml(doc.example)}</div>
|
||||
</div>
|
||||
`;
|
||||
return { dom };
|
||||
},
|
||||
} satisfies Tooltip;
|
||||
});
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -1,14 +1,197 @@
|
||||
import type { ReactNode } from "react";
|
||||
import NavBar from "./NavBar";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import browserSvg from "@/assets/browser.min.svg";
|
||||
import NavMenu from "./NavMenu";
|
||||
import LazyEyes from "./LazyEyes";
|
||||
|
||||
const TITLE = `
|
||||
|
||||
▄▄▄▄███▄▄▄▄ ▄█ ▄████████ ▄████████ ▄██████▄ ███▄▄▄▄ ▄██████▄ ▄▄▄▄███▄▄▄▄ ▄█ ▄████████ ▄██████▄ ███▄▄▄▄
|
||||
▄██▀▀▀███▀▀▀██▄ ███ ███ ███ ███ ███ ███ ███ ███▀▀▀██▄ ███ ███ ▄██▀▀▀███▀▀▀██▄ ███ ███ ███ ███ ███ ███▀▀▀██▄
|
||||
███ ███ ███ ███▌ ███ █▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███▌ ███ █▀ ███ ███ ███ ███
|
||||
███ ███ ███ ███▌ ███ ▄███▄▄▄▄██▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███▌ ███ ███ ███ ███ ███
|
||||
███ ███ ███ ███▌ ███ ▀▀███▀▀▀▀▀ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███▌ ███ ███ ███ ███ ███
|
||||
███ ███ ███ ███ ███ █▄ ▀███████████ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▄ ███ ███ ███ ███
|
||||
███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███
|
||||
▀█ ███ █▀ █▀ ████████▀ ███ ███ ▀██████▀ ▀█ █▀ ▀██████▀ ▀█ ███ █▀ █▀ ████████▀ ▀██████▀ ▀█ █▀
|
||||
███ ███
|
||||
|
||||
`;
|
||||
|
||||
type Theme = "terra" | "azure";
|
||||
|
||||
function getStoredTheme(): Theme {
|
||||
try { return (localStorage.getItem("micronomicon-theme") as Theme) || "terra"; }
|
||||
catch { return "terra"; }
|
||||
}
|
||||
|
||||
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 r = "";
|
||||
for (let i = 0; i < vals.length; i++) {
|
||||
while (n >= vals[i]) { r += syms[i]; n -= vals[i]; }
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
function RomanDrum({ value }: { value: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
overflow: "hidden",
|
||||
height: "1em",
|
||||
lineHeight: "1em",
|
||||
verticalAlign: "bottom",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
key={value}
|
||||
style={{
|
||||
display: "inline-block",
|
||||
animation: "romanSlideIn 0.25s cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RomanClock() {
|
||||
const [time, setTime] = useState(new Date());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTime(new Date()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const h = time.getHours();
|
||||
const m = time.getMinutes();
|
||||
const s = time.getSeconds();
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "baseline", gap: 0 }}>
|
||||
<RomanDrum value={toRoman(h || 12)} />
|
||||
<span>:</span>
|
||||
<RomanDrum value={toRoman(m || 1)} />
|
||||
<span>:</span>
|
||||
<RomanDrum value={toRoman(s || 1)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 }) {
|
||||
const navigate = useNavigate();
|
||||
const [theme, setTheme] = useState<Theme>(getStoredTheme);
|
||||
const layout = frameLayout;
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
// Remove all theme classes, then apply
|
||||
root.classList.remove("dark", "theme-azure");
|
||||
if (theme === "terra") {
|
||||
root.classList.add("dark");
|
||||
} else {
|
||||
root.classList.add("theme-azure");
|
||||
}
|
||||
localStorage.setItem("micronomicon-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => setTheme(t => t === "terra" ? "azure" : "terra");
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col h-screen bg-background text-foreground">
|
||||
<NavBar />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
<main className="flex-1 overflow-auto" data-scroll-root>
|
||||
<div className={`relative ${layout.containerMaxW} ${layout.containerMinW} mx-auto min-h-full`}>
|
||||
{/* Frame SVG — background */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 w-full min-w-full pointer-events-none select-none"
|
||||
style={{
|
||||
zIndex: 0,
|
||||
height: `${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 */}
|
||||
<div
|
||||
className="relative z-10 flex flex-col overflow-hidden"
|
||||
style={layout.title}
|
||||
>
|
||||
<pre
|
||||
className="select-none text-primary/70 hover:text-primary transition-colors whitespace-pre origin-center cursor-pointer bg-background"
|
||||
style={{ height: 60, fontSize: 6, lineHeight: 1.05, transform: "scale(0.50)", transformOrigin: "left center", alignContent: "center" }}
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
{TITLE}
|
||||
</pre>
|
||||
<span className="font-mono text-[10px] w-fit text-muted-foreground bg-background"><RomanClock /></span>
|
||||
</div>
|
||||
|
||||
{/* Bottom hole — main content */}
|
||||
<div
|
||||
className="relative z-10 overflow-auto"
|
||||
style={layout.content}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Nav menu — anchored below the frame, left side */}
|
||||
<div
|
||||
className="absolute z-20"
|
||||
style={layout.nav}
|
||||
>
|
||||
<NavMenu theme={theme} onToggleTheme={toggleTheme} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
<footer className="flex items-center justify-center gap-3 text-[10px] text-muted-foreground py-4 shrink-0 tracking-widest uppercase">
|
||||
<span>Created with hubris · MMXXVI</span>
|
||||
</footer>
|
||||
</div>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
|
||||
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,88 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const navLink = ({ isActive }: { isActive: boolean }) =>
|
||||
cn(
|
||||
"px-3 py-1.5 text-sm rounded-md transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
);
|
||||
|
||||
export default function NavBar() {
|
||||
const [restartOpen, setRestartOpen] = useState(false);
|
||||
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);
|
||||
setRestartOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 px-4 h-12 border-b bg-card shrink-0">
|
||||
<span className="font-bold mr-6 text-foreground">Micronomicon</span>
|
||||
|
||||
<NavLink to="/" end className={navLink}>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink to="/editor/new" className={navLink}>
|
||||
New Page
|
||||
</NavLink>
|
||||
<NavLink to="/graph" className={navLink}>
|
||||
Graph
|
||||
</NavLink>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={restarting}
|
||||
onClick={() => setRestartOpen(true)}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Restart NomadNet
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={restartOpen} onOpenChange={setRestartOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Restart NomadNet?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will briefly interrupt mesh network connectivity.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRestart}>
|
||||
Restart
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -49,6 +49,7 @@ function Button({
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -30,7 +30,7 @@ function PopoverContent({
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
className="isolate z-9999"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
|
||||
@@ -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 }
|
||||
@@ -31,7 +31,7 @@ function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
className={cn("", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -68,7 +68,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
"h-7 px-1.5 text-left align-middle font-medium whitespace-nowrap text-foreground text-xs [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -81,7 +81,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
"px-1.5 py-1 align-middle whitespace-nowrap text-xs [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,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 { useStore, type StoreApi } from "zustand";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import type { EditorStore } from "@/stores/editorStore";
|
||||
import { compile } from "@/api/client";
|
||||
|
||||
const DEBOUNCE_MS = 400;
|
||||
|
||||
/**
|
||||
* Debounced hook that compiles µFrame source via POST /api/compile.
|
||||
* Debounced hook that compiles µFrame source via the API.
|
||||
* Automatically triggers on ufSource changes.
|
||||
* Accepts an optional store API for per-window instances; falls back to the global singleton.
|
||||
*/
|
||||
export function useCompile() {
|
||||
const ufSource = useEditorStore((s) => s.ufSource);
|
||||
const setCompileResult = useEditorStore((s) => s.setCompileResult);
|
||||
const setCompiling = useEditorStore((s) => s.setCompiling);
|
||||
const setCompileError = useEditorStore((s) => s.setCompileError);
|
||||
export function useCompile(storeApi?: StoreApi<EditorStore>) {
|
||||
const store = storeApi ?? useEditorStore;
|
||||
const ufSource = useStore(store, (s) => s.ufSource);
|
||||
const setCompileResult = useStore(store, (s) => s.setCompileResult);
|
||||
const setCompiling = useStore(store, (s) => s.setCompiling);
|
||||
const setCompileError = useStore(store, (s) => s.setCompileError);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const compile = useCallback(
|
||||
const doCompile = useCallback(
|
||||
async (source: string) => {
|
||||
if (!source.trim()) {
|
||||
setCompileResult("", "", "", false, []);
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any in-flight request
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
@@ -31,21 +35,14 @@ export function useCompile() {
|
||||
setCompiling(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/compile", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
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 || []);
|
||||
const data = await compile(source, controller.signal);
|
||||
setCompileResult(
|
||||
data.ascii,
|
||||
data.micron,
|
||||
data.script || "",
|
||||
data.is_dynamic || false,
|
||||
data.warnings || [],
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof DOMException && e.name === "AbortError") return;
|
||||
setCompileError(e instanceof Error ? e.message : "Compile failed");
|
||||
@@ -56,13 +53,12 @@ export function useCompile() {
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => compile(ufSource), DEBOUNCE_MS);
|
||||
timerRef.current = setTimeout(() => doCompile(ufSource), DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [ufSource, compile]);
|
||||
}, [ufSource, doCompile]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
|
||||
30
frontend/src/hooks/useDslMeta.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchDslMeta, type DslMeta } from "@/api/client";
|
||||
|
||||
export type { DslMeta } from "@/api/client";
|
||||
|
||||
const DEFAULT_META: DslMeta = {
|
||||
keywords: [],
|
||||
values: [],
|
||||
commands: [],
|
||||
themes: [],
|
||||
};
|
||||
|
||||
let cachedMeta: DslMeta | null = null;
|
||||
|
||||
export function useDslMeta(): DslMeta {
|
||||
const [meta, setMeta] = useState<DslMeta>(cachedMeta || DEFAULT_META);
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedMeta) return;
|
||||
|
||||
fetchDslMeta()
|
||||
.then((data) => {
|
||||
cachedMeta = data;
|
||||
setMeta(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
return meta;
|
||||
}
|
||||
@@ -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 { useStore, type StoreApi } from "zustand";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import type { EditorStore } from "@/stores/editorStore";
|
||||
|
||||
export function useUnsavedGuard() {
|
||||
const isDirty = useEditorStore((s) => s.isDirty);
|
||||
export function useUnsavedGuard(storeApi?: StoreApi<EditorStore>) {
|
||||
const store = storeApi ?? useEditorStore;
|
||||
const isDirty = useStore(store, (s) => s.isDirty);
|
||||
|
||||
useEffect(() => {
|
||||
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 };
|
||||
}
|
||||
@@ -1,13 +1,35 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
@import "@fontsource-variable/jetbrains-mono";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@keyframes romanSlideIn {
|
||||
from {
|
||||
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 {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--font-heading: var(--font-mono);
|
||||
--font-sans: 'JetBrains Mono Variable', 'Courier New', monospace;
|
||||
--font-mono: 'JetBrains Mono Variable', 'Courier New', monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
@@ -39,92 +61,431 @@
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
--radius-sm: 0px;
|
||||
--radius-md: 0px;
|
||||
--radius-lg: 0px;
|
||||
--radius-xl: 0px;
|
||||
--radius-2xl: 0px;
|
||||
--radius-3xl: 0px;
|
||||
--radius-4xl: 0px;
|
||||
}
|
||||
|
||||
/* ── Terracotta Light Theme ── */
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--background: oklch(0.94 0.02 55);
|
||||
--foreground: oklch(0.18 0.03 45);
|
||||
--card: oklch(0.91 0.025 55);
|
||||
--card-foreground: oklch(0.18 0.03 45);
|
||||
--popover: oklch(0.91 0.025 55);
|
||||
--popover-foreground: oklch(0.18 0.03 45);
|
||||
--primary: oklch(0.55 0.14 45);
|
||||
--primary-foreground: oklch(0.95 0.02 55);
|
||||
--secondary: oklch(0.86 0.03 55);
|
||||
--secondary-foreground: oklch(0.18 0.03 45);
|
||||
--muted: oklch(0.86 0.025 55);
|
||||
--muted-foreground: oklch(0.45 0.04 45);
|
||||
--accent: oklch(0.84 0.035 55);
|
||||
--accent-foreground: oklch(0.18 0.03 45);
|
||||
--destructive: oklch(0.5 0.2 25);
|
||||
--border: oklch(0.6 0.08 45);
|
||||
--input: oklch(0.78 0.04 55);
|
||||
--ring: oklch(0.55 0.14 45);
|
||||
--chart-1: oklch(0.55 0.14 45);
|
||||
--chart-2: oklch(0.65 0.1 70);
|
||||
--chart-3: oklch(0.5 0.08 30);
|
||||
--chart-4: oklch(0.6 0.06 90);
|
||||
--chart-5: oklch(0.4 0.04 45);
|
||||
--radius: 0px;
|
||||
--sidebar: oklch(0.90 0.025 55);
|
||||
--sidebar-foreground: oklch(0.18 0.03 45);
|
||||
--sidebar-primary: oklch(0.55 0.14 45);
|
||||
--sidebar-primary-foreground: oklch(0.95 0.02 55);
|
||||
--sidebar-accent: oklch(0.84 0.035 55);
|
||||
--sidebar-accent-foreground: oklch(0.18 0.03 45);
|
||||
--sidebar-border: oklch(0.6 0.08 45);
|
||||
--sidebar-ring: oklch(0.55 0.14 45);
|
||||
}
|
||||
|
||||
/* ── Black-Figure Dark Theme (primary) ──
|
||||
Inspired by Greek pottery: black ground, terracotta figures, gold accents
|
||||
*/
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--background: oklch(0.12 0.015 45);
|
||||
--foreground: oklch(0.72 0.08 55);
|
||||
--card: oklch(0.16 0.02 45);
|
||||
--card-foreground: oklch(0.72 0.08 55);
|
||||
--popover: oklch(0.16 0.02 45);
|
||||
--popover-foreground: oklch(0.72 0.08 55);
|
||||
--primary: oklch(0.7 0.13 55);
|
||||
--primary-foreground: oklch(0.12 0.015 45);
|
||||
--secondary: oklch(0.22 0.025 45);
|
||||
--secondary-foreground: oklch(0.7 0.08 55);
|
||||
--muted: oklch(0.22 0.02 45);
|
||||
--muted-foreground: oklch(0.5 0.05 55);
|
||||
--accent: oklch(0.24 0.03 45);
|
||||
--accent-foreground: oklch(0.72 0.08 55);
|
||||
--destructive: oklch(0.55 0.18 25);
|
||||
--border: oklch(0.38 0.06 45);
|
||||
--input: oklch(0.26 0.03 45);
|
||||
--ring: oklch(0.7 0.13 55);
|
||||
--chart-1: oklch(0.7 0.13 55);
|
||||
--chart-2: oklch(0.65 0.1 70);
|
||||
--chart-3: oklch(0.55 0.08 30);
|
||||
--chart-4: oklch(0.6 0.06 90);
|
||||
--chart-5: oklch(0.45 0.04 45);
|
||||
--sidebar: oklch(0.14 0.018 45);
|
||||
--sidebar-foreground: oklch(0.72 0.08 55);
|
||||
--sidebar-primary: oklch(0.7 0.13 55);
|
||||
--sidebar-primary-foreground: oklch(0.12 0.015 45);
|
||||
--sidebar-accent: oklch(0.24 0.03 45);
|
||||
--sidebar-accent-foreground: oklch(0.72 0.08 55);
|
||||
--sidebar-border: oklch(0.38 0.06 45);
|
||||
--sidebar-ring: oklch(0.7 0.13 55);
|
||||
}
|
||||
|
||||
/* ── Azure Theme — blue and white, clean ── */
|
||||
.theme-azure {
|
||||
--background: oklch(0.14 0.02 240);
|
||||
--foreground: oklch(0.82 0.04 220);
|
||||
--card: oklch(0.17 0.025 240);
|
||||
--card-foreground: oklch(0.82 0.04 220);
|
||||
--popover: oklch(0.17 0.025 240);
|
||||
--popover-foreground: oklch(0.82 0.04 220);
|
||||
--primary: oklch(0.72 0.12 230);
|
||||
--primary-foreground: oklch(0.14 0.02 240);
|
||||
--secondary: oklch(0.22 0.03 240);
|
||||
--secondary-foreground: oklch(0.78 0.04 220);
|
||||
--muted: oklch(0.22 0.025 240);
|
||||
--muted-foreground: oklch(0.55 0.04 230);
|
||||
--accent: oklch(0.24 0.035 240);
|
||||
--accent-foreground: oklch(0.82 0.04 220);
|
||||
--destructive: oklch(0.55 0.18 25);
|
||||
--border: oklch(0.38 0.06 230);
|
||||
--input: oklch(0.26 0.03 240);
|
||||
--ring: oklch(0.72 0.12 230);
|
||||
--chart-1: oklch(0.72 0.12 230);
|
||||
--chart-2: oklch(0.65 0.1 200);
|
||||
--chart-3: oklch(0.55 0.08 260);
|
||||
--chart-4: oklch(0.6 0.06 180);
|
||||
--chart-5: oklch(0.45 0.04 230);
|
||||
--sidebar: oklch(0.16 0.02 240);
|
||||
--sidebar-foreground: oklch(0.82 0.04 220);
|
||||
--sidebar-primary: oklch(0.72 0.12 230);
|
||||
--sidebar-primary-foreground: oklch(0.14 0.02 240);
|
||||
--sidebar-accent: oklch(0.24 0.035 240);
|
||||
--sidebar-accent-foreground: oklch(0.82 0.04 220);
|
||||
--sidebar-border: oklch(0.38 0.06 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 {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-size: 13px;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
|
||||
html {
|
||||
@apply font-sans;
|
||||
@apply font-mono;
|
||||
}
|
||||
|
||||
/* Theme-aware scrollbars */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Bold console aesthetic */
|
||||
button,
|
||||
[role="button"],
|
||||
[data-slot="button"] {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
/* Keyword hover tooltip */
|
||||
.cm-tooltip.cm-tooltip-hover {
|
||||
background: var(--card) !important;
|
||||
border: 2px solid var(--border) !important;
|
||||
padding: 8px 10px;
|
||||
box-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);
|
||||
}
|
||||
|
||||
/* Ensure all interactive elements have pointer cursor */
|
||||
a,
|
||||
[role="link"],
|
||||
[role="tab"],
|
||||
[role="option"],
|
||||
[data-slot="popover-trigger"],
|
||||
[data-slot="toggle-group-item"],
|
||||
[data-slot="alert-dialog-action"],
|
||||
[data-slot="alert-dialog-cancel"],
|
||||
.cm-tooltip-autocomplete [role="option"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
nav {
|
||||
border-bottom-width: 3px;
|
||||
}
|
||||
|
||||
/* ── Dithered offset shadow ──
|
||||
Simulates a stippled/dot-matrix shadow using multiple box-shadow
|
||||
dots at alternating positions. Works even with overflow:auto.
|
||||
*/
|
||||
|
||||
/* Panels — dithered shadow (dialogs, popovers) */
|
||||
[data-slot="alert-dialog-content"],
|
||||
[data-slot="popover-content"] {
|
||||
border: 2px solid var(--border);
|
||||
box-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;
|
||||
}
|
||||
|
||||
/* Table container — no own border when inside a bordered parent */
|
||||
[data-slot="table-container"] {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Primary (default) buttons only — dithered shadow */
|
||||
button[data-slot="button"][data-variant="default"] {
|
||||
border: 2px solid var(--border);
|
||||
box-shadow:
|
||||
2px 2px 0 0 var(--border),
|
||||
4px 2px 0 0 transparent,
|
||||
6px 2px 0 0 var(--border),
|
||||
3px 3px 0 0 transparent,
|
||||
5px 3px 0 0 var(--border),
|
||||
2px 4px 0 0 var(--border),
|
||||
4px 4px 0 0 transparent,
|
||||
6px 4px 0 0 var(--border);
|
||||
transition: box-shadow 0.1s, transform 0.1s;
|
||||
}
|
||||
|
||||
button[data-slot="button"][data-variant="default"]:active {
|
||||
box-shadow: none;
|
||||
transform: translate(2px, 2px);
|
||||
}
|
||||
|
||||
/* All other buttons — no shadow */
|
||||
button[data-slot="button"][data-variant="outline"],
|
||||
button[data-slot="button"][data-variant="secondary"],
|
||||
button[data-slot="button"][data-variant="ghost"],
|
||||
button[data-slot="button"][data-variant="link"],
|
||||
button[data-slot="button"][data-variant="destructive"] {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Input fields */
|
||||
input[data-slot="input"] {
|
||||
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,149 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Pencil, Plus, Trash2 } 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 {
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPages();
|
||||
}, []);
|
||||
|
||||
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 className="p-8 max-w-4xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-xl font-semibold">Pages</h1>
|
||||
<Button onClick={() => navigate("/editor/new")}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pages.map((p) => (
|
||||
<TableRow key={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>
|
||||
<div className="flex gap-1 justify-end">
|
||||
{p.has_source && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/editor/${p.name}`)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setPageToDelete(p.name)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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,15 +1,19 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import * as api from "@/api/client";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import { usePagesStore } from "@/stores/pagesStore";
|
||||
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
|
||||
import { useCompile } from "@/hooks/useCompile";
|
||||
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
|
||||
import { uframeHighlight } from "@/components/editor/uframeHighlight";
|
||||
import { uframeCommandSource } from "@/components/editor/uframeCommands";
|
||||
import EditorPane from "@/components/editor/EditorPane";
|
||||
import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "@/components/editor/uframeCommands";
|
||||
import { keywordHoverTooltip } from "@/components/editor/uframeHover";
|
||||
import EditorPointer from "@/components/editor/EditorPointer";
|
||||
import PreviewPane from "@/components/editor/PreviewPane";
|
||||
import SourcePane from "@/components/editor/SourcePane";
|
||||
import ToolBar from "@/components/editor/ToolBar";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
@@ -38,13 +42,20 @@ export default function EditorView() {
|
||||
() => [
|
||||
...uframeHighlight(),
|
||||
autocompletion({
|
||||
override: [uframeCommandSource],
|
||||
override: [uframeCommandSource, uframeValueHintSource],
|
||||
icons: false,
|
||||
activateOnTyping: true,
|
||||
}),
|
||||
keywordHoverTooltip,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
// Load DSL commands from backend registry on mount
|
||||
useEffect(() => {
|
||||
loadCommandsFromApi();
|
||||
}, []);
|
||||
|
||||
// Auto-compile on source changes
|
||||
useCompile();
|
||||
useUnsavedGuard();
|
||||
@@ -59,15 +70,10 @@ export default function EditorView() {
|
||||
reset();
|
||||
if (name) {
|
||||
setPageName(name);
|
||||
fetch(`/api/pages/${name}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
api.fetchPage(name).then((data) => {
|
||||
if (data.source != null) {
|
||||
useEditorStore.setState({
|
||||
ufSource: data.source,
|
||||
isDirty: false,
|
||||
currentPage: data,
|
||||
});
|
||||
setSource(data.source);
|
||||
setDirty(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -86,13 +92,7 @@ export default function EditorView() {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/pages/${slug}`, {
|
||||
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();
|
||||
const meta = await api.savePage(slug, ufSource, publish);
|
||||
setCurrentPage(meta);
|
||||
setDirty(false);
|
||||
fetchPages();
|
||||
@@ -107,37 +107,28 @@ export default function EditorView() {
|
||||
[pageName, ufSource, isNew, navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
handleSave(false);
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "p") {
|
||||
e.preventDefault();
|
||||
handleSave(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [handleSave]);
|
||||
useKeyboardSave(
|
||||
useCallback(() => handleSave(false), [handleSave]),
|
||||
useCallback(() => handleSave(true), [handleSave]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<EditorPointer />
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<ToolBar
|
||||
pageName={pageName}
|
||||
onNameChange={isNew ? setPageName : undefined}
|
||||
onSaveDraft={() => handleSave(false)}
|
||||
onPublish={() => handleSave(true)}
|
||||
onInsertExample={(source) => setSource(source)}
|
||||
saving={saving}
|
||||
isDirty={isDirty}
|
||||
/>
|
||||
<ResizablePanelGroup orientation="horizontal" className="flex-1">
|
||||
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0">
|
||||
<ResizablePanel defaultSize={50} minSize={20}>
|
||||
<EditorPane
|
||||
value={ufSource}
|
||||
onChange={setSource}
|
||||
<SourcePane
|
||||
ufSource={ufSource}
|
||||
setSource={setSource}
|
||||
extensions={extensions}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
@@ -147,5 +138,8 @@ export default function EditorView() {
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</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 type { PageMeta } from "@/api/client";
|
||||
|
||||
export interface PageMeta {
|
||||
name: string;
|
||||
title: string | null;
|
||||
published: boolean;
|
||||
has_source: boolean;
|
||||
last_modified: number | null;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
interface EditorStore {
|
||||
export interface EditorStore {
|
||||
// Source
|
||||
ufSource: string;
|
||||
isDirty: boolean;
|
||||
@@ -25,7 +17,7 @@ interface EditorStore {
|
||||
compileError: string | null;
|
||||
|
||||
// Preview
|
||||
previewMode: "ascii" | "micron" | "raw" | "script";
|
||||
previewMode: "micron" | "raw" | "script";
|
||||
|
||||
// Actions
|
||||
setSource: (s: string) => void;
|
||||
@@ -34,53 +26,43 @@ interface EditorStore {
|
||||
setCompileResult: (ascii: string, micron: string, script: string, isDynamic: boolean, warnings: string[]) => void;
|
||||
setCompiling: (v: boolean) => void;
|
||||
setCompileError: (e: string | null) => void;
|
||||
setPreviewMode: (mode: "ascii" | "micron" | "raw" | "script") => void;
|
||||
setPreviewMode: (mode: "micron" | "raw" | "script") => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorStore>((set) => ({
|
||||
const initialState = {
|
||||
ufSource: "",
|
||||
isDirty: false,
|
||||
currentPage: null,
|
||||
|
||||
currentPage: null as PageMeta | null,
|
||||
compiledAscii: "",
|
||||
compiledMicron: "",
|
||||
compiledScript: "",
|
||||
isDynamic: false,
|
||||
compileWarnings: [],
|
||||
compileWarnings: [] as string[],
|
||||
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 }),
|
||||
setCurrentPage: (p) => set({ currentPage: p }),
|
||||
setDirty: (v) => set({ isDirty: v }),
|
||||
setCompileResult: (ascii, micron, script, isDynamic, warnings) =>
|
||||
set({
|
||||
compiledAscii: ascii,
|
||||
compiledMicron: micron,
|
||||
compiledScript: script,
|
||||
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",
|
||||
}),
|
||||
}));
|
||||
export function createEditorStore() {
|
||||
return create<EditorStore>((set) => ({
|
||||
...initialState,
|
||||
...makeActions(set),
|
||||
}));
|
||||
}
|
||||
|
||||
export const useEditorStore = createEditorStore();
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { create } from "zustand";
|
||||
import type { PageMeta } from "@/stores/editorStore";
|
||||
import * as api from "@/api/client";
|
||||
|
||||
interface PagesStore {
|
||||
pages: PageMeta[];
|
||||
pages: api.PageMeta[];
|
||||
isLoading: boolean;
|
||||
fetchPages: () => Promise<void>;
|
||||
deletePage: (name: string) => Promise<void>;
|
||||
publishPage: (name: string) => Promise<void>;
|
||||
unpublishPage: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -16,8 +17,7 @@ export const usePagesStore = create<PagesStore>((set, get) => ({
|
||||
fetchPages: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const res = await fetch("/api/pages");
|
||||
const pages = await res.json();
|
||||
const pages = await api.fetchPages();
|
||||
set({ pages });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
@@ -25,20 +25,22 @@ export const usePagesStore = create<PagesStore>((set, get) => ({
|
||||
},
|
||||
|
||||
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();
|
||||
},
|
||||
|
||||
unpublishPage: async (name: string) => {
|
||||
// Fetch current source, re-save as draft only
|
||||
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 }),
|
||||
});
|
||||
const page = await api.fetchPage(name);
|
||||
if (page.source) {
|
||||
await api.savePage(name, page.source, false);
|
||||
}
|
||||
await get().fetchPages();
|
||||
},
|
||||
|
||||