diff --git a/frontend/src/assets/baldoth.min.svg b/frontend/src/assets/baldoth.min.svg new file mode 100644 index 0000000..6bd18ee --- /dev/null +++ b/frontend/src/assets/baldoth.min.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/baldoth.svg b/frontend/src/assets/baldoth.svg new file mode 100644 index 0000000..2586d57 --- /dev/null +++ b/frontend/src/assets/baldoth.svg @@ -0,0 +1,43 @@ + + + + diff --git a/frontend/src/components/editor/PreviewPane.tsx b/frontend/src/components/editor/PreviewPane.tsx index 8b696ab..8f5b740 100644 --- a/frontend/src/components/editor/PreviewPane.tsx +++ b/frontend/src/components/editor/PreviewPane.tsx @@ -23,45 +23,43 @@ export default function PreviewPane() { return (
{compiledAscii || (
diff --git a/frontend/src/components/editor/ToolBar.tsx b/frontend/src/components/editor/ToolBar.tsx
index eadddfe..d3adb35 100644
--- a/frontend/src/components/editor/ToolBar.tsx
+++ b/frontend/src/components/editor/ToolBar.tsx
@@ -1,26 +1,14 @@
import { useRef, useState } from "react";
-import { BookOpen, Upload } from "lucide-react";
-import { toast } from "sonner";
+import { useNavigate } from "react-router-dom";
+import { ChevronLeft, 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;
}
@@ -30,84 +18,35 @@ 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);
+ const navigate = useNavigate();
return (
-
- {onNameChange ? (
- onNameChange(e.target.value)}
- placeholder="page-name"
- className="font-mono w-48 h-8 text-sm"
- />
- ) : (
- {pageName}
+
+ navigate("/")}
+ className="text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1 text-xs uppercase tracking-wider"
+ >
+
+ Pages
+
+
+ │
+
+
+
+ {isDirty && (
+ ●
)}
-
-
-
- Examples
-
- }
- />
-
-
- Insert Example
-
-
- {EXAMPLES.map((ex) => (
- {
- 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"
- >
- {ex.name}
-
- {ex.description}
-
-
- ))}
-
-
-
-
- onInsertExample(
- `image "${path}" braille 30\n align center`
- )} />
-
-
-
- {isDirty && (
- Unsaved
- )}
-
-
+
Save Draft
-
+
Publish
@@ -115,49 +54,38 @@ export default function ToolBar({
}
-function UploadImageButton({ onUploaded }: { onUploaded: (path: string) => void }) {
- const fileRef = useRef(null);
- const [uploading, setUploading] = useState(false);
+function PageNameField({
+ pageName,
+ onNameChange,
+}: {
+ pageName: string;
+ onNameChange?: (name: string) => void;
+}) {
+ const [editing, setEditing] = useState(!pageName || !!onNameChange);
+ const inputRef = useRef(null);
- const handleUpload = async (e: React.ChangeEvent) => {
- const file = e.target.files?.[0];
- if (!file) return;
-
- setUploading(true);
- try {
- const form = new FormData();
- form.append("file", file);
- const res = await fetch("/api/upload-image", { method: "POST", body: form });
- if (!res.ok) throw new Error(await res.text());
- const data = await res.json();
- toast.success(`Uploaded ${data.filename}`);
- onUploaded(data.path);
- } catch (err) {
- toast.error(`Upload failed: ${err}`);
- } finally {
- setUploading(false);
- if (fileRef.current) fileRef.current.value = "";
- }
- };
+ if (editing || onNameChange) {
+ return (
+ 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 (
- <>
-
- fileRef.current?.click()}
- disabled={uploading}
- className="inline-flex items-center justify-center gap-1.5 text-xs font-medium h-8 px-3 border-2 border-border bg-background hover:bg-accent hover:text-accent-foreground transition-all uppercase tracking-wider disabled:opacity-50"
- title="Upload image for embedding"
- >
-
- {uploading ? "Uploading…" : "Image"}
-
- >
+ setEditing(true)}
+ className="flex items-center gap-1 font-mono text-xs font-semibold hover:text-primary transition-colors cursor-pointer"
+ >
+ {pageName}
+
+
);
}
diff --git a/frontend/src/components/editor/uframeCommands.ts b/frontend/src/components/editor/uframeCommands.ts
index ff89248..ed0a419 100644
--- a/frontend/src/components/editor/uframeCommands.ts
+++ b/frontend/src/components/editor/uframeCommands.ts
@@ -41,7 +41,7 @@ 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);
};
}
@@ -120,3 +120,98 @@ export function uframeCommandSource(
})),
};
}
+
+/**
+ * Attribute value hints — suggests valid values based on the keyword
+ * on the current line. Triggers when typing a word after a keyword.
+ *
+ * e.g. typing `box ` suggests: light, heavy, double, rounded
+ * typing `status "Server" ` suggests: online, offline, degraded
+ * typing `hnav ` suggests: bar, tabs, pills, breadcrumb, underline
+ */
+
+// keyword → list of valid attribute values
+const KEYWORD_VALUES: Record = {
+ 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 {
+ // Get the current line text up to cursor
+ const line = ctx.state.doc.lineAt(ctx.pos);
+ const textBefore = line.text.slice(0, ctx.pos - line.from);
+
+ // Don't trigger if we're typing a slash command
+ if (textBefore.trimStart().startsWith("/")) return null;
+
+ // Extract the keyword (first word on the line, after indentation)
+ 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;
+
+ // Don't suggest if we're inside quotes
+ const quotesBefore = (textBefore.match(/"/g) || []).length;
+ if (quotesBefore % 2 !== 0) return null;
+
+ // Match: `word:partial` OR `partial` — the colon acts as a trigger
+ // e.g. `bigtitle "text" font:` → match `font:` → suggest block, thin, pixel
+ // e.g. `bigtitle "text" font:b` → match `font:b` → filter to block
+ // e.g. `box l` → match `l` → suggest light
+ // Colon trigger: `font:` or `font:b` → replace entire `font:...` with the value
+ const colonWordMatch = ctx.matchBefore(/\w+:\w*/);
+ if (colonWordMatch) {
+ return {
+ from: colonWordMatch.from, // replace from start of `font:`
+ filter: false, // show all options, we handle filtering
+ options: entry.values.map((v) => ({
+ label: v,
+ detail: entry.hint,
+ type: "enum" as const,
+ })),
+ };
+ }
+
+ // Plain word match (no colon)
+ 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,
+ })),
+ };
+}
diff --git a/frontend/src/components/editor/uframeHover.ts b/frontend/src/components/editor/uframeHover.ts
new file mode 100644
index 0000000..0af854f
--- /dev/null
+++ b/frontend/src/components/editor/uframeHover.ts
@@ -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 = {
+ // 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 = `
+
+ ${escHtml(doc.syntax)}
+ ${escHtml(doc.attrs)}
+ ${escHtml(doc.example)}
+
+ `;
+ return { dom };
+ },
+ } satisfies Tooltip;
+});
+
+function escHtml(s: string): string {
+ return s.replace(/&/g, "&").replace(//g, ">");
+}
diff --git a/frontend/src/components/shared/AppShell.tsx b/frontend/src/components/shared/AppShell.tsx
index 03bc226..50f841b 100644
--- a/frontend/src/components/shared/AppShell.tsx
+++ b/frontend/src/components/shared/AppShell.tsx
@@ -1,14 +1,43 @@
-import type { ReactNode } from "react";
+import { useEffect, useState, type ReactNode } from "react";
import NavBar from "./NavBar";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/sonner";
+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 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 (
+ {toRoman(h || 12)}:{toRoman(m || 1).padStart(2, " ")}:{toRoman(s || 1).padStart(3, " ")}
+ );
+}
+
export default function AppShell({ children }: { children: ReactNode }) {
return (
{children}
+
diff --git a/frontend/src/components/shared/NavBar.tsx b/frontend/src/components/shared/NavBar.tsx
index 7c8aaa0..8e9070d 100644
--- a/frontend/src/components/shared/NavBar.tsx
+++ b/frontend/src/components/shared/NavBar.tsx
@@ -1,90 +1,46 @@
-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";
+import { useNavigate } from "react-router-dom";
+import baldothSvg from "@/assets/baldoth.min.svg";
-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"
- );
+const TITLE = ` █████ ██ ██
+ ██████ █████ █████ █
+ ██ █ █ █████ █████ ███
+ █ █ █ █ ██ █ ██ █
+ █ █ █ █ ███ ████ ████ ████
+ ██ ██ █ █ ███ ████ ████ ████ █ █ ███ ████ ████ █ ███ ████ ████ ████ ███ ████ █ ███ ████ ████
+ ██ ██ █ █ ███ █ ███ █ ██ ████ █ ████ ████ ████ █ █ ████ ███ ████ ███ █ ███ █ ███ █ █ ████ ████ ████ █
+ ██ ██ █ █ ██ █ ████ ██ ██ ██ ██ ████ ██ ██ ██ ████ ████ ██ █ ████ ██ ██ ██ ████
+ ██ ██ █ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
+ ██ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
+ █ ██ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
+ █ █ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
+ ████ █ ██ ██ ███ █ ███ ██████ ██ ██ ██████ ██ ██ ██ ██ ███ █ ██████ ██ ██
+ █ █████ ██ ███ █ ███████ ███ ████ ███ ███ ████ ███ ███ ███ ███ █ ███████ ████ ███ ███
+ █ ██ ███ █████ ███ ███ ███ ███ ███ ███ █████ ███ ███
+ █
+ █
+ ██`;
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);
- }
- };
+ const navigate = useNavigate();
return (
-