Compare commits
4 Commits
c9eead1965
...
2994735f3b
| Author | SHA1 | Date | |
|---|---|---|---|
| 2994735f3b | |||
| d7e9788f99 | |||
| 6e8a4af40d | |||
| 20e41f7680 |
@@ -10,7 +10,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
|
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
COPY frontend/dist/ ./static/
|
COPY frontend/dist/ ./static/
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
|
|||||||
@@ -177,6 +177,38 @@ def start_browser() -> None:
|
|||||||
log.warning("Failed to start RNS browser: %s", 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",
|
||||||
|
"nomadnet": lambda: Path(os.environ.get("NOMADNET_CONFIG_DIR", str(Path.home() / ".nomadnetwork"))) / "config",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _config_path(kind: str) -> Path:
|
||||||
|
resolver = _CONFIG_PATHS.get(kind)
|
||||||
|
if not resolver:
|
||||||
|
raise ValueError(f"Unknown config kind: {kind}")
|
||||||
|
return resolver()
|
||||||
|
|
||||||
|
|
||||||
|
def _restart_nomadnet() -> bool:
|
||||||
|
"""Restart the NomadNet container. Returns True on success."""
|
||||||
|
try:
|
||||||
|
from docker_utils import NOMADNET_CONTAINER
|
||||||
|
import docker
|
||||||
|
client = docker.from_env()
|
||||||
|
container = client.containers.get(NOMADNET_CONTAINER)
|
||||||
|
container.restart()
|
||||||
|
log.info("NomadNet container restarted")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
log.info("NomadNet container not available: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# API endpoints
|
# API endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -368,3 +400,33 @@ async def _request_remote_page(hash_hex: str, path: str) -> str | None:
|
|||||||
return await asyncio.wait_for(future, timeout=30.0)
|
return await asyncio.wait_for(future, timeout=30.0)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Reticulum config endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.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}
|
||||||
|
|||||||
11
compose.yml
11
compose.yml
@@ -9,12 +9,17 @@ services:
|
|||||||
- PAGES_DIR=/data/pages
|
- PAGES_DIR=/data/pages
|
||||||
- SOURCES_DIR=/data/sources
|
- SOURCES_DIR=/data/sources
|
||||||
- NOMADNET_CONTAINER=nomadnet
|
- NOMADNET_CONTAINER=nomadnet
|
||||||
|
- RNS_CONFIG_DIR=/rns
|
||||||
|
- RNS_SERVER_CONFIG_DIR=/rns-server
|
||||||
|
- NOMADNET_CONFIG_DIR=/nomadnet
|
||||||
- LOG_LEVEL=DEBUG
|
- LOG_LEVEL=DEBUG
|
||||||
volumes:
|
volumes:
|
||||||
- pages:/data/pages
|
- pages:/data/pages
|
||||||
- sources:/data/sources
|
- sources:/data/sources
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- ./reticulum-client.conf:/root/.reticulum/config:ro
|
- ./reticulum-client.conf:/rns/config
|
||||||
|
- ./reticulum.conf:/rns-server/config
|
||||||
|
- ./nomadnet.conf:/nomadnet/config
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- nomadnet
|
- nomadnet
|
||||||
@@ -26,8 +31,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pages:/root/.nomadnetwork/storage/pages
|
- pages:/root/.nomadnetwork/storage/pages
|
||||||
- nomadnet-config:/root/.nomadnetwork
|
- nomadnet-config:/root/.nomadnetwork
|
||||||
- ./nomadnet.conf:/root/.nomadnetwork/config:ro
|
- ./nomadnet.conf:/root/.nomadnetwork/config
|
||||||
- ./reticulum.conf:/root/.reticulum/config:ro
|
- ./reticulum.conf:/root/.reticulum/config
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -222,6 +222,31 @@ export async function saveEnv(content: string): Promise<void> {
|
|||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config (Reticulum + NomadNet)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function fetchConfig(kind: "reticulum" | "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" | "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
|
// Images
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -66,8 +66,10 @@ export function buildGraphArrays(
|
|||||||
positions[i * 2] = prevPositions[prevIdx * 2]!;
|
positions[i * 2] = prevPositions[prevIdx * 2]!;
|
||||||
positions[i * 2 + 1] = prevPositions[prevIdx * 2 + 1]!;
|
positions[i * 2 + 1] = prevPositions[prevIdx * 2 + 1]!;
|
||||||
} else {
|
} else {
|
||||||
positions[i * 2] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5;
|
// Tighter initial spread for small graphs so they don't scatter
|
||||||
positions[i * 2 + 1] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5;
|
const spread = n <= 20 ? 0.1 : n <= 100 ? 0.25 : 0.5;
|
||||||
|
positions[i * 2] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * spread;
|
||||||
|
positions[i * 2 + 1] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * spread;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self node = bright white, others by status
|
// Self node = bright white, others by status
|
||||||
@@ -79,7 +81,8 @@ export function buildGraphArrays(
|
|||||||
colors[i * 4 + 2] = rgba[2];
|
colors[i * 4 + 2] = rgba[2];
|
||||||
colors[i * 4 + 3] = rgba[3];
|
colors[i * 4 + 3] = rgba[3];
|
||||||
|
|
||||||
sizes[i] = 3;
|
// Scale node size inversely with count: bigger dots when fewer nodes
|
||||||
|
sizes[i] = n <= 10 ? 8 : n <= 50 ? 5 : n <= 200 ? 4 : 3;
|
||||||
|
|
||||||
// Cluster assignment
|
// Cluster assignment
|
||||||
if (i === selfIndex) {
|
if (i === selfIndex) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { EditorView, keymap, lineNumbers, highlightActiveLine, Decoration, ViewP
|
|||||||
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
||||||
import type { Extension } from "@codemirror/state";
|
import type { Extension } from "@codemirror/state";
|
||||||
import type { DecorationSet } from "@codemirror/view";
|
import type { DecorationSet } from "@codemirror/view";
|
||||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||||
import { searchKeymap } from "@codemirror/search";
|
import { searchKeymap } from "@codemirror/search";
|
||||||
import { oneDark } from "./oneDarkTheme";
|
import { oneDark } from "./oneDarkTheme";
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ export default function EditorPane({ value, onChange, extensions = [] }: Props)
|
|||||||
lineNumbers({ formatNumber: toRoman }),
|
lineNumbers({ formatNumber: toRoman }),
|
||||||
highlightActiveLine(),
|
highlightActiveLine(),
|
||||||
subtleWhitespace,
|
subtleWhitespace,
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
||||||
oneDark,
|
oneDark,
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged) {
|
if (update.docChanged) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useLazyEyes } from "@/hooks/useLazyEyes";
|
|||||||
* containerRef (for floating windows).
|
* containerRef (for floating windows).
|
||||||
*/
|
*/
|
||||||
export default function EditorPointer({ containerRef, focused = true }: { containerRef?: React.RefObject<HTMLElement | null>; focused?: boolean }) {
|
export default function EditorPointer({ containerRef, focused = true }: { containerRef?: React.RefObject<HTMLElement | null>; focused?: boolean }) {
|
||||||
const [y, setY] = useState<number | null>(null);
|
const [visible, setVisible] = useState(false);
|
||||||
const [editorLeft, setEditorLeft] = useState<number | null>(null);
|
const [editorLeft, setEditorLeft] = useState<number | null>(null);
|
||||||
const targetRef = useRef(0);
|
const targetRef = useRef(0);
|
||||||
const currentRef = useRef(0);
|
const currentRef = useRef(0);
|
||||||
@@ -17,10 +17,25 @@ export default function EditorPointer({ containerRef, focused = true }: { contai
|
|||||||
const [clickKey, setClickKey] = useState(0);
|
const [clickKey, setClickKey] = useState(0);
|
||||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
const clickTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
const cmRef = useRef<Element | null>(null);
|
const cmRef = useRef<Element | null>(null);
|
||||||
|
const pointerElRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Eye anchor must be in viewport coords (useLazyEyes compares against e.clientX/Y)
|
|
||||||
const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null);
|
const eyeAnchorRef = useRef<{ x: number; y: number } | null>(null);
|
||||||
const eyeOffset = useLazyEyes({ anchorRef: eyeAnchorRef });
|
const { registerIris, unregisterIris } = useLazyEyes({ anchorRef: eyeAnchorRef });
|
||||||
|
|
||||||
|
const iris1Ref = useRef<HTMLDivElement>(null);
|
||||||
|
const iris2Ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Register/unregister iris elements
|
||||||
|
useEffect(() => {
|
||||||
|
const i1 = iris1Ref.current;
|
||||||
|
const i2 = iris2Ref.current;
|
||||||
|
if (i1) registerIris(i1);
|
||||||
|
if (i2) registerIris(i2);
|
||||||
|
return () => {
|
||||||
|
if (i1) unregisterIris(i1);
|
||||||
|
if (i2) unregisterIris(i2);
|
||||||
|
};
|
||||||
|
}, [visible, registerIris, unregisterIris]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ease = 0.09;
|
const ease = 0.09;
|
||||||
@@ -44,7 +59,6 @@ export default function EditorPointer({ containerRef, focused = true }: { contai
|
|||||||
const onCursorMove = (e: Event) => {
|
const onCursorMove = (e: Event) => {
|
||||||
const { top } = (e as CustomEvent).detail;
|
const { top } = (e as CustomEvent).detail;
|
||||||
|
|
||||||
// Find editor container lazily, scoped to our window
|
|
||||||
if (!cmRef.current) {
|
if (!cmRef.current) {
|
||||||
const scope = containerRef?.current ?? document;
|
const scope = containerRef?.current ?? document;
|
||||||
const cm = scope.querySelector(".cm-editor");
|
const cm = scope.querySelector(".cm-editor");
|
||||||
@@ -55,14 +69,16 @@ export default function EditorPointer({ containerRef, focused = true }: { contai
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert viewport top to container-relative top
|
|
||||||
const containerTop = getContainerRect().top;
|
const containerTop = getContainerRect().top;
|
||||||
targetRef.current = top - containerTop;
|
targetRef.current = top - containerTop;
|
||||||
updateLeft();
|
updateLeft();
|
||||||
|
|
||||||
if (!activeRef.current) {
|
if (!activeRef.current) {
|
||||||
currentRef.current = targetRef.current;
|
currentRef.current = targetRef.current;
|
||||||
setY(targetRef.current);
|
if (pointerElRef.current) {
|
||||||
|
pointerElRef.current.style.top = `${targetRef.current}px`;
|
||||||
|
}
|
||||||
|
setVisible(true);
|
||||||
activeRef.current = true;
|
activeRef.current = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -75,9 +91,12 @@ export default function EditorPointer({ containerRef, focused = true }: { contai
|
|||||||
} else {
|
} else {
|
||||||
currentRef.current += diff * ease;
|
currentRef.current += diff * ease;
|
||||||
}
|
}
|
||||||
setY(currentRef.current);
|
|
||||||
|
|
||||||
// Eye anchor in viewport coords for useLazyEyes
|
// Direct DOM update — no setState
|
||||||
|
if (pointerElRef.current) {
|
||||||
|
pointerElRef.current.style.top = `${currentRef.current}px`;
|
||||||
|
}
|
||||||
|
|
||||||
const cmLeft = cmRef.current?.getBoundingClientRect().left ?? 0;
|
const cmLeft = cmRef.current?.getBoundingClientRect().left ?? 0;
|
||||||
const containerTop = getContainerRect().top;
|
const containerTop = getContainerRect().top;
|
||||||
eyeAnchorRef.current = { x: cmLeft - 100, y: containerTop + currentRef.current };
|
eyeAnchorRef.current = { x: cmLeft - 100, y: containerTop + currentRef.current };
|
||||||
@@ -88,7 +107,6 @@ export default function EditorPointer({ containerRef, focused = true }: { contai
|
|||||||
window.addEventListener("cm-cursor-move", onCursorMove);
|
window.addEventListener("cm-cursor-move", onCursorMove);
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
|
||||||
// Keep left position updated on resize
|
|
||||||
const ro = new ResizeObserver(() => updateLeft());
|
const ro = new ResizeObserver(() => updateLeft());
|
||||||
const scope = containerRef?.current ?? document;
|
const scope = containerRef?.current ?? document;
|
||||||
const existingCm = scope.querySelector(".cm-editor");
|
const existingCm = scope.querySelector(".cm-editor");
|
||||||
@@ -107,13 +125,14 @@ export default function EditorPointer({ containerRef, focused = true }: { contai
|
|||||||
};
|
};
|
||||||
}, [containerRef]);
|
}, [containerRef]);
|
||||||
|
|
||||||
if (!focused || y === null || editorLeft === null) return null;
|
if (!focused || !visible || editorLeft === null) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={pointerElRef}
|
||||||
className={`editor-pointer${clickKey ? " editor-pointer-click" : ""}`}
|
className={`editor-pointer${clickKey ? " editor-pointer-click" : ""}`}
|
||||||
key={clickKey}
|
key={clickKey}
|
||||||
style={{ top: y, left: editorLeft }}
|
style={{ left: editorLeft }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="editor-pointer-img"
|
className="editor-pointer-img"
|
||||||
@@ -124,14 +143,14 @@ export default function EditorPointer({ containerRef, focused = true }: { contai
|
|||||||
/>
|
/>
|
||||||
<div className="editor-pointer-eye" style={{ top: 33, left: 134 }}>
|
<div className="editor-pointer-eye" style={{ top: 33, left: 134 }}>
|
||||||
<div
|
<div
|
||||||
|
ref={iris1Ref}
|
||||||
className="editor-pointer-iris"
|
className="editor-pointer-iris"
|
||||||
style={{ transform: `translate(${eyeOffset.x}px, ${eyeOffset.y}px)` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="editor-pointer-eye" style={{ top: 29, left: 144 }}>
|
<div className="editor-pointer-eye" style={{ top: 29, left: 144 }}>
|
||||||
<div
|
<div
|
||||||
|
ref={iris2Ref}
|
||||||
className="editor-pointer-iris"
|
className="editor-pointer-iris"
|
||||||
style={{ transform: `translate(${eyeOffset.x}px, ${eyeOffset.y}px)` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
80
frontend/src/components/editor/iniHighlight.ts
Normal file
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)];
|
||||||
|
}
|
||||||
@@ -91,12 +91,22 @@ export default function FloatingWindow({
|
|||||||
let curVx = vx;
|
let curVx = vx;
|
||||||
let curVy = vy;
|
let curVy = vy;
|
||||||
const friction = 0.92;
|
const friction = 0.92;
|
||||||
|
const el = ref.current;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
curVx *= friction;
|
curVx *= friction;
|
||||||
curVy *= friction;
|
curVy *= friction;
|
||||||
if (Math.abs(curVx) < 0.3 && Math.abs(curVy) < 0.3) { inertiaRef.current = 0; return; }
|
if (Math.abs(curVx) < 0.3 && Math.abs(curVy) < 0.3) {
|
||||||
posRef.current = { x: posRef.current.x + curVx, y: Math.max(0, posRef.current.y + curVy) };
|
inertiaRef.current = 0;
|
||||||
|
// Sync final position to React state once
|
||||||
onUpdate(id, posRef.current);
|
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);
|
||||||
};
|
};
|
||||||
inertiaRef.current = requestAnimationFrame(tick);
|
inertiaRef.current = requestAnimationFrame(tick);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect, useCallback } from "react";
|
||||||
import { useLazyEyes } from "@/hooks/useLazyEyes";
|
import { useLazyEyes } from "@/hooks/useLazyEyes";
|
||||||
|
|
||||||
interface EyeSpec {
|
interface EyeSpec {
|
||||||
@@ -21,7 +21,6 @@ export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: La
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const anchorRef = useRef<{ x: number; y: number } | null>(anchor ?? null);
|
const anchorRef = useRef<{ x: number; y: number } | null>(anchor ?? null);
|
||||||
|
|
||||||
// Keep anchorRef in sync with prop or auto-compute from DOM
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (anchor) {
|
if (anchor) {
|
||||||
anchorRef.current = anchor;
|
anchorRef.current = anchor;
|
||||||
@@ -42,19 +41,18 @@ export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: La
|
|||||||
};
|
};
|
||||||
}, [anchor]);
|
}, [anchor]);
|
||||||
|
|
||||||
const offset = useLazyEyes({ anchorRef, maxShift, ease });
|
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 (
|
return (
|
||||||
<div ref={containerRef} className={className} style={{ position: "absolute", pointerEvents: "none" }}>
|
<div ref={containerRef} className={className} style={{ position: "absolute", pointerEvents: "none" }}>
|
||||||
{eyes.map((eye, i) => {
|
{eyes.map((eye, i) => {
|
||||||
const size = eye.size ?? 5;
|
const size = eye.size ?? 5;
|
||||||
const irisSize = eye.irisSize ?? 2;
|
const irisSize = eye.irisSize ?? 2;
|
||||||
// Clamp iris within eye circle
|
|
||||||
const maxR = (size - irisSize) / 2;
|
|
||||||
const dist = Math.sqrt(offset.x * offset.x + offset.y * offset.y) || 0;
|
|
||||||
const scale = dist > maxR && dist > 0 ? maxR / dist : 1;
|
|
||||||
const cx = offset.x * scale;
|
|
||||||
const cy = offset.y * scale;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
@@ -62,13 +60,13 @@ export default function LazyEyes({ eyes, anchor, maxShift, ease, className }: La
|
|||||||
style={{ top: eye.top, left: eye.left, width: size, height: size }}
|
style={{ top: eye.top, left: eye.left, width: size, height: size }}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={irisRef}
|
||||||
className="editor-pointer-iris"
|
className="editor-pointer-iris"
|
||||||
style={{
|
style={{
|
||||||
width: irisSize,
|
width: irisSize,
|
||||||
height: irisSize,
|
height: irisSize,
|
||||||
marginTop: -(irisSize / 2) - 0.5,
|
marginTop: -(irisSize / 2) - 0.5,
|
||||||
marginLeft: -(irisSize / 2) - 0.5,
|
marginLeft: -(irisSize / 2) - 0.5,
|
||||||
transform: `translate(${cx}px, ${cy}px)`,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
interface LazyEyesOptions {
|
interface LazyEyesOptions {
|
||||||
/** Reference point the eyes "live" at (viewport coords). Eyes look away from this toward the mouse. */
|
/** Reference point the eyes "live" at (viewport coords). Eyes look away from this toward the mouse. */
|
||||||
@@ -14,8 +14,8 @@ interface LazyEyesOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a smoothly-interpolated { x, y } offset for positioning irises
|
* Returns a register function to attach iris elements for direct DOM updates.
|
||||||
* that lazily track the mouse cursor relative to an anchor point.
|
* No React state is set per frame — transforms are applied directly.
|
||||||
*
|
*
|
||||||
* Movement model:
|
* Movement model:
|
||||||
* - Small mouse moves → slow, lazy drift (ease)
|
* - Small mouse moves → slow, lazy drift (ease)
|
||||||
@@ -29,10 +29,23 @@ export function useLazyEyes({
|
|||||||
saccadeThreshold = 0.8,
|
saccadeThreshold = 0.8,
|
||||||
saccadeEase = 0.35,
|
saccadeEase = 0.35,
|
||||||
}: LazyEyesOptions) {
|
}: LazyEyesOptions) {
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
|
||||||
const targetRef = useRef({ x: 0, y: 0 });
|
const targetRef = useRef({ x: 0, y: 0 });
|
||||||
const currentRef = useRef({ x: 0, y: 0 });
|
const currentRef = useRef({ x: 0, y: 0 });
|
||||||
const velocityRef = 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(() => {
|
useEffect(() => {
|
||||||
const onMouseMove = (e: MouseEvent) => {
|
const onMouseMove = (e: MouseEvent) => {
|
||||||
@@ -55,27 +68,30 @@ export function useLazyEyes({
|
|||||||
const et = targetRef.current;
|
const et = targetRef.current;
|
||||||
const vel = velocityRef.current;
|
const vel = velocityRef.current;
|
||||||
|
|
||||||
// Distance to target
|
|
||||||
const dx = et.x - ec.x;
|
const dx = et.x - ec.x;
|
||||||
const dy = et.y - ec.y;
|
const dy = et.y - ec.y;
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
|
||||||
// Saccade: snap fast when target jumps significantly
|
|
||||||
const e_ = dist > saccadeThreshold ? saccadeEase : ease;
|
const e_ = dist > saccadeThreshold ? saccadeEase : ease;
|
||||||
|
|
||||||
// Apply eased movement
|
|
||||||
vel.x = vel.x * 0.6 + dx * e_ * 0.4;
|
vel.x = vel.x * 0.6 + dx * e_ * 0.4;
|
||||||
vel.y = vel.y * 0.6 + dy * e_ * 0.4;
|
vel.y = vel.y * 0.6 + dy * e_ * 0.4;
|
||||||
ec.x += vel.x;
|
ec.x += vel.x;
|
||||||
ec.y += vel.y;
|
ec.y += vel.y;
|
||||||
|
|
||||||
// Micro-drift: tiny organic tremor when nearly still
|
|
||||||
if (dist < 0.1) {
|
if (dist < 0.1) {
|
||||||
ec.x += (Math.random() - 0.5) * 0.02;
|
ec.x += (Math.random() - 0.5) * 0.02;
|
||||||
ec.y += (Math.random() - 0.5) * 0.02;
|
ec.y += (Math.random() - 0.5) * 0.02;
|
||||||
}
|
}
|
||||||
|
|
||||||
setOffset({ x: ec.x, y: ec.y });
|
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);
|
||||||
};
|
};
|
||||||
raf = requestAnimationFrame(tick);
|
raf = requestAnimationFrame(tick);
|
||||||
@@ -86,5 +102,5 @@ export function useLazyEyes({
|
|||||||
};
|
};
|
||||||
}, [anchorRef, maxShift, ease, saccadeThreshold, saccadeEase]);
|
}, [anchorRef, maxShift, ease, saccadeThreshold, saccadeEase]);
|
||||||
|
|
||||||
return offset;
|
return { offsetRef, registerIris, unregisterIris };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export default function BrowseView() {
|
|||||||
const nodesMapRef = useRef<Map<string, NetworkNode>>(new Map());
|
const nodesMapRef = useRef<Map<string, NetworkNode>>(new Map());
|
||||||
const hashToIndexRef = useRef<Map<string, number>>(new Map());
|
const hashToIndexRef = useRef<Map<string, number>>(new Map());
|
||||||
const clusterNamesRef = useRef<string[]>([]);
|
const clusterNamesRef = useRef<string[]>([]);
|
||||||
|
const hasUserInteractedRef = useRef(false);
|
||||||
|
const initialFitDoneRef = useRef(false);
|
||||||
|
|
||||||
// Build graph data, preserving existing positions
|
// Build graph data, preserving existing positions
|
||||||
const graphData = useMemo(() => {
|
const graphData = useMemo(() => {
|
||||||
@@ -117,14 +119,29 @@ export default function BrowseView() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const pad = 8;
|
||||||
|
|
||||||
for (let i = 0; i < nClusters; i++) {
|
for (let i = 0; i < nClusters; i++) {
|
||||||
const x = positions[i * 2];
|
const x = positions[i * 2];
|
||||||
const y = positions[i * 2 + 1];
|
const y = positions[i * 2 + 1];
|
||||||
if (x === undefined || y === undefined) continue;
|
if (x === undefined || y === undefined) continue;
|
||||||
const screen = graph.spaceToScreenPosition([x, y]);
|
const screen = graph.spaceToScreenPosition([x, y]);
|
||||||
const div = labelDivsRef.current[i]!;
|
const div = labelDivsRef.current[i]!;
|
||||||
div.style.left = `${screen[0]}px`;
|
|
||||||
div.style.top = `${screen[1]}px`;
|
// Measure label so we can clamp within container
|
||||||
|
const lw = div.offsetWidth;
|
||||||
|
const lh = div.offsetHeight;
|
||||||
|
// Default transform is translate(-50%, -100%), so anchor is center-bottom
|
||||||
|
let left = screen[0] - lw / 2;
|
||||||
|
let top = screen[1] - lh;
|
||||||
|
// Clamp to container bounds
|
||||||
|
left = Math.max(pad, Math.min(left, rect.width - lw - pad));
|
||||||
|
top = Math.max(pad, Math.min(top, rect.height - lh - pad));
|
||||||
|
|
||||||
|
div.style.transform = "none";
|
||||||
|
div.style.left = `${left}px`;
|
||||||
|
div.style.top = `${top}px`;
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -226,8 +243,15 @@ export default function BrowseView() {
|
|||||||
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
|
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
|
||||||
},
|
},
|
||||||
onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
|
onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
|
||||||
onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); },
|
onSimulationEnd: () => {
|
||||||
onZoom: () => { updateClusterLabels(); updateSearchLabels(); },
|
// Only auto-fit on initial load; after that, respect user's viewport
|
||||||
|
if (!initialFitDoneRef.current) {
|
||||||
|
graphRef.current?.fitView(300, 0.2);
|
||||||
|
initialFitDoneRef.current = true;
|
||||||
|
}
|
||||||
|
updateClusterLabels(); updateSearchLabels();
|
||||||
|
},
|
||||||
|
onZoom: () => { hasUserInteractedRef.current = true; updateClusterLabels(); updateSearchLabels(); },
|
||||||
});
|
});
|
||||||
|
|
||||||
graphRef.current = graph;
|
graphRef.current = graph;
|
||||||
@@ -248,10 +272,13 @@ export default function BrowseView() {
|
|||||||
|
|
||||||
const n = graphData.entries.length;
|
const n = graphData.entries.length;
|
||||||
const scale = Math.log10(Math.max(10, n)) / Math.log10(10000);
|
const scale = Math.log10(Math.max(10, n)) / Math.log10(10000);
|
||||||
|
// Stronger gravity + lower repulsion for small graphs keeps them compact and centered
|
||||||
|
const isSmall = n <= 20;
|
||||||
graph.setConfig({
|
graph.setConfig({
|
||||||
simulationRepulsion: 0.5 + scale * 9.5,
|
simulationRepulsion: isSmall ? 0.3 : 0.5 + scale * 9.5,
|
||||||
simulationCluster: 1.0 - scale * 0.75,
|
simulationCluster: 1.0 - scale * 0.75,
|
||||||
simulationGravity: 0.25 + scale * 1.75,
|
simulationGravity: isSmall ? 1.0 : 0.25 + scale * 1.75,
|
||||||
|
simulationDecay: isSmall ? 3000 : 5000,
|
||||||
hoveredPointRingColor: cssVarToHex("--primary"),
|
hoveredPointRingColor: cssVarToHex("--primary"),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -420,7 +447,9 @@ export default function BrowseView() {
|
|||||||
const clearSearch = useCallback(() => {
|
const clearSearch = useCallback(() => {
|
||||||
setFilter("");
|
setFilter("");
|
||||||
graphRef.current?.unselectPoints();
|
graphRef.current?.unselectPoints();
|
||||||
|
// Re-center when explicitly clearing search
|
||||||
graphRef.current?.fitView(300, 0.2);
|
graphRef.current?.fitView(300, 0.2);
|
||||||
|
hasUserInteractedRef.current = false;
|
||||||
updateClusterLabels();
|
updateClusterLabels();
|
||||||
}, [updateClusterLabels]);
|
}, [updateClusterLabels]);
|
||||||
|
|
||||||
@@ -431,7 +460,7 @@ export default function BrowseView() {
|
|||||||
{/* Hover label */}
|
{/* Hover label */}
|
||||||
{hoveredLabel && (
|
{hoveredLabel && (
|
||||||
<div className="absolute pointer-events-none px-2 py-0.5 bg-popover border border-border rounded text-xs font-mono text-foreground whitespace-nowrap"
|
<div className="absolute pointer-events-none px-2 py-0.5 bg-popover border border-border rounded text-xs font-mono text-foreground whitespace-nowrap"
|
||||||
style={{ left: hoveredLabel.x + 12, top: hoveredLabel.y - 10, boxShadow: "0 2px 8px rgba(0,0,0,0.3)" }}>
|
style={{ left: Math.min(hoveredLabel.x + 12, (containerRef.current?.offsetWidth ?? 9999) - 160), top: Math.max(8, hoveredLabel.y - 10), boxShadow: "0 2px 8px rgba(0,0,0,0.3)" }}>
|
||||||
{hoveredLabel.text}
|
{hoveredLabel.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,189 @@
|
|||||||
|
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" | "nomadnet";
|
||||||
|
|
||||||
|
interface ConfigWinData {
|
||||||
|
kind: ConfigKind;
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsView() {
|
export default function SettingsView() {
|
||||||
|
const {
|
||||||
|
windows, focusedId,
|
||||||
|
open, update, close, focus,
|
||||||
|
} = useWindowManager<ConfigWinData>({ w: 560, h: 440 });
|
||||||
|
|
||||||
|
const [restarting, setRestarting] = useState(false);
|
||||||
|
|
||||||
|
const handleRestart = useCallback(async () => {
|
||||||
|
setRestarting(true);
|
||||||
|
try {
|
||||||
|
const result = await api.restartServices();
|
||||||
|
toast.success(
|
||||||
|
result.nomadnet_restarted
|
||||||
|
? "NomadNet restarted"
|
||||||
|
: "NomadNet container not found",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("Restart failed");
|
||||||
|
} finally {
|
||||||
|
setRestarting(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
<div className="flex flex-col items-center justify-center h-full gap-4">
|
||||||
Settings
|
<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
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => open("nomadnet", { kind: "nomadnet" })}
|
||||||
|
>
|
||||||
|
NomadNet
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
disabled={restarting}
|
||||||
|
onClick={handleRestart}
|
||||||
|
>
|
||||||
|
{restarting ? "Restarting..." : "Apply & Restart"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{windows.map((win) => (
|
||||||
|
<ConfigEditorWindow
|
||||||
|
key={win.id}
|
||||||
|
win={win}
|
||||||
|
focused={focusedId === win.id}
|
||||||
|
onUpdate={update}
|
||||||
|
onClose={close}
|
||||||
|
onFocus={focus}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Floating config editor window
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TITLES: Record<ConfigKind, string> = {
|
||||||
|
reticulum: "Reticulum Config",
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,16 +6,18 @@
|
|||||||
loglevel = 4
|
loglevel = 4
|
||||||
|
|
||||||
[interfaces]
|
[interfaces]
|
||||||
# Connect to NomadNet's TCP server for local traffic
|
# Connect to NomadNet's TCP server
|
||||||
[[NomadNet Link]]
|
[[NomadNet Link]]
|
||||||
type = TCPClientInterface
|
type = TCPClientInterface
|
||||||
enabled = Yes
|
enabled = Yes
|
||||||
target_host = nomadnet
|
target_host = nomadnet
|
||||||
target_port = 4242
|
target_port = 4242
|
||||||
|
|
||||||
# Connect to Quad4 directly for external node announces
|
|
||||||
[[Quad4]]
|
[[Quad4]]
|
||||||
type = TCPClientInterface
|
type = TCPClientInterface
|
||||||
enabled = Yes
|
interface_enabled = true
|
||||||
target_host = 62.151.179.77
|
target_host = 62.151.179.77
|
||||||
target_port = 45657
|
target_port = 45657
|
||||||
|
mode = full
|
||||||
|
name = Quad4
|
||||||
|
selected_interface_mode = 1
|
||||||
|
|||||||
@@ -14,11 +14,3 @@
|
|||||||
listen_ip = 0.0.0.0
|
listen_ip = 0.0.0.0
|
||||||
listen_port = 4242
|
listen_port = 4242
|
||||||
|
|
||||||
[[Quad4]]
|
|
||||||
type = TCPClientInterface
|
|
||||||
interface_enabled = true
|
|
||||||
target_host = 62.151.179.77
|
|
||||||
target_port = 45657
|
|
||||||
mode = full
|
|
||||||
name = Quad4
|
|
||||||
selected_interface_mode = 1
|
|
||||||
|
|||||||
Reference in New Issue
Block a user