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 (
-
+
Preview {isDynamic && ( - - ⚡ dynamic - + )} {isCompiling && ( - - compiling… - + )} {compileError && ( - - ✗ error - + )} -
+
{tabs .filter((t) => t.show) - .map((tab) => ( - + {i < arr.length - 1 && ( + · )} - > - {tab.label} - + ))}
-
+
{previewMode === "ascii" ? (
             {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} +
+ + + + + + + {isDirty && ( + )} - - - - Examples - - } - /> - - - Insert Example - -
- {EXAMPLES.map((ex) => ( - - ))} -
-
-
- - onInsertExample( - `image "${path}" braille 30\n align center` - )} /> -
- - - {isDirty && ( - Unsaved - )} - - -
@@ -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 ( - <> - - - + ); } 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}
+
+ Built with hubris by the demiurge · MMXXVI + + +
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 ( - +
+
{TITLE}
+ +
); } diff --git a/frontend/src/index.css b/frontend/src/index.css index 5c2f528..2885524 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -171,6 +171,19 @@ cursor: pointer; } + /* 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"], @@ -188,12 +201,9 @@ dots at alternating positions. Works even with overflow:auto. */ - /* Panels and containers — checkerboard dot shadow */ - [data-slot="table-container"], + /* Panels — dithered shadow (dialogs, popovers) */ [data-slot="alert-dialog-content"], - [data-slot="popover-content"], - [data-slot="card"], - .panel-shadow { + [data-slot="popover-content"] { border: 2px solid var(--border); box-shadow: 3px 3px 0 0 var(--border), @@ -208,6 +218,12 @@ 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); diff --git a/frontend/src/routes/DashboardView.tsx b/frontend/src/routes/DashboardView.tsx index e498e47..4f8d02f 100644 --- a/frontend/src/routes/DashboardView.tsx +++ b/frontend/src/routes/DashboardView.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; -import { Pencil, Plus, Trash2 } from "lucide-react"; +import { MoreVertical, Plus, RotateCcw } from "lucide-react"; import { usePagesStore } from "@/stores/pagesStore"; import StatusBadge from "@/components/dashboard/StatusBadge"; import { Button } from "@/components/ui/button"; @@ -13,6 +13,11 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@/components/ui/popover"; import { AlertDialog, AlertDialogAction, @@ -28,11 +33,61 @@ export default function DashboardView() { const { pages, isLoading, fetchPages, deletePage } = usePagesStore(); const navigate = useNavigate(); const [pageToDelete, setPageToDelete] = useState(null); + const [restarting, setRestarting] = useState(false); + + const handleRestart = async () => { + setRestarting(true); + try { + const res = await fetch("/api/restart", { method: "POST" }); + if (!res.ok) throw new Error(await res.text()); + toast.success("NomadNet restarted"); + } catch (e) { + toast.error(`Restart failed: ${e}`); + } finally { + setRestarting(false); + } + }; useEffect(() => { fetchPages(); }, []); + const handleUnpublish = async (name: string) => { + try { + const res = await fetch(`/api/pages/${name}`); + const data = await res.json(); + if (data.source) { + await fetch(`/api/pages/${name}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source: data.source, publish: false }), + }); + toast.success(`"${name}" unpublished`); + fetchPages(); + } + } catch (e) { + toast.error(`Failed: ${e}`); + } + }; + + const handlePublish = async (name: string) => { + try { + const res = await fetch(`/api/pages/${name}`); + const data = await res.json(); + if (data.source) { + await fetch(`/api/pages/${name}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source: data.source, publish: true }), + }); + toast.success(`"${name}" published`); + fetchPages(); + } + } catch (e) { + toast.error(`Failed: ${e}`); + } + }; + const handleDelete = async () => { if (!pageToDelete) return; await deletePage(pageToDelete); @@ -48,28 +103,41 @@ export default function DashboardView() { ); return ( -
-
-

Pages

- +
+
+ {/* Header row */} +
+

Pages

+
+ + +
- - - + {/* Table inside bordered container */} +
+ + Name Title Status Size - + {pages.map((p) => ( - + navigate(`/editor/${p.name}`)} + > {p.name} {p.name === "index" && ( @@ -85,26 +153,42 @@ export default function DashboardView() { {p.size != null ? `${p.size} B` : "—"} - -
- {p.has_source && ( - - )} - + } + /> + - - -
+ + {p.published ? ( + + ) : ( + + )} + + +
))} @@ -119,7 +203,8 @@ export default function DashboardView() {
)}
-
+ +
{/* end bordered container */} [ ...uframeHighlight(), autocompletion({ - override: [uframeCommandSource], + override: [uframeCommandSource, uframeValueHintSource], icons: false, activateOnTyping: true, maxOptions: 50, }), + keywordHoverTooltip, ], [], ); @@ -130,29 +142,115 @@ export default function EditorView() { }, [handleSave]); return ( -
- handleSave(false)} - onPublish={() => handleSave(true)} - onInsertExample={(source) => setSource(source)} - saving={saving} - isDirty={isDirty} - /> - - - - - - - - - +
+
+ handleSave(false)} + onPublish={() => handleSave(true)} + saving={saving} + isDirty={isDirty} + /> + + + + + + + + + +
+
+ ); +} + + +/** Source pane — editor with header bar matching the Preview pane */ +function SourcePane({ + ufSource, + setSource, + extensions, +}: { + ufSource: string; + setSource: (s: string) => void; + extensions: Extension[]; +}) { + const [examplesOpen, setExamplesOpen] = useState(false); + const fileRef = useRef(null); + const [uploading, setUploading] = useState(false); + + 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}`); + 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 ( +
+
+ Source + + + + + Examples + + } + /> + + + Insert Example + +
+ {EXAMPLES.map((ex) => ( + + ))} +
+
+
+ + + +
+
+ +
); }