diff --git a/backend/graph.py b/backend/graph.py deleted file mode 100644 index 2c8eec5..0000000 --- a/backend/graph.py +++ /dev/null @@ -1,139 +0,0 @@ -import os -import re -import shlex -from pathlib import Path - -from fastapi import APIRouter -from pydantic import BaseModel - -router = APIRouter() - -SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) -PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages")) - -# Match µFrame link nodes: link "display" "/page/slug.mu" or link "display" "slug" -_UF_LINK = re.compile(r'^\s*link\s+', re.IGNORECASE) -# Fallback: Micron links [label`slug] or [label`slug.mu] -_MICRON_LINK = re.compile(r'\[([^`\]]+)`([a-zA-Z0-9_-]+)(?:\.mu)?\]') - - -class GraphNode(BaseModel): - id: str - published: bool - title: str | None = None - - -class GraphEdge(BaseModel): - source: str - target: str - - -class GraphData(BaseModel): - nodes: list[GraphNode] - edges: list[GraphEdge] - - -def _all_page_names() -> set[str]: - names: set[str] = set() - if PAGES_DIR.is_dir(): - for f in PAGES_DIR.iterdir(): - if f.suffix == ".mu" and f.is_file(): - names.add(f.stem) - if SOURCES_DIR.is_dir(): - for f in SOURCES_DIR.iterdir(): - if f.suffix in (".uf", ".mu") and f.is_file(): - names.add(f.stem) - return names - - -def _extract_title(source: str) -> str | None: - """Extract title from µFrame page or heading, or legacy Micron >Title.""" - for line in source.splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - if stripped.lower().startswith("page "): - try: - parts = shlex.split(stripped) - if len(parts) >= 2: - return parts[1] - except ValueError: - pass - break - if stripped.lower().startswith("heading "): - try: - parts = shlex.split(stripped) - if len(parts) >= 3: - return parts[2] - except ValueError: - pass - break - if stripped.startswith(">") and not stripped.startswith(">>"): - return stripped[1:].strip() - break - return None - - -def _extract_links(source: str, all_names: set[str]) -> list[str]: - """Extract internal link targets from µFrame or Micron source.""" - targets: list[str] = [] - - for line in source.splitlines(): - stripped = line.strip() - - # µFrame: link "display" "/page/slug.mu" or link "display" "slug" - if _UF_LINK.match(stripped): - try: - parts = shlex.split(stripped) - if len(parts) >= 3: - dest = parts[2] - # Normalize: /page/slug.mu → slug - slug = dest.rsplit("/", 1)[-1].removesuffix(".mu") - if slug in all_names: - targets.append(slug) - except ValueError: - pass - continue - - # Fallback: Micron link syntax [label`slug] - for m in _MICRON_LINK.finditer(stripped): - slug = m.group(2) - if slug in all_names: - targets.append(slug) - - return targets - - -def _source_path(name: str) -> Path | None: - """Get source file path, preferring .uf over .mu.""" - uf = SOURCES_DIR / f"{name}.uf" - if uf.is_file(): - return uf - mu = SOURCES_DIR / f"{name}.mu" - return mu if mu.is_file() else None - - -@router.get("/graph", response_model=GraphData) -async def get_graph(): - all_names = _all_page_names() - nodes: list[GraphNode] = [] - edges: list[GraphEdge] = [] - - for name in sorted(all_names): - src_path = _source_path(name) - mu_path = PAGES_DIR / f"{name}.mu" - - title = None - if src_path: - content = src_path.read_text(encoding="utf-8") - title = _extract_title(content) - for target in _extract_links(content, all_names): - edges.append(GraphEdge(source=name, target=target)) - - nodes.append(GraphNode( - id=name, - published=mu_path.is_file(), - title=title, - )) - - return GraphData(nodes=nodes, edges=edges) diff --git a/backend/main.py b/backend/main.py index 67a15d5..9696ad1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,7 +5,6 @@ from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from pages import router as pages_router, ensure_default_pages -from graph import router as graph_router from docker_utils import router as docker_router from converter import router as converter_router @@ -13,7 +12,6 @@ app = FastAPI(title="µFrame Editor") app.include_router(converter_router, prefix="/api") app.include_router(pages_router, prefix="/api") -app.include_router(graph_router, prefix="/api") app.include_router(docker_router, prefix="/api") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index af23375..6e681a1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,7 +2,6 @@ import { Routes, Route } from "react-router-dom"; import AppShell from "./components/shared/AppShell"; import DashboardView from "./routes/DashboardView"; import EditorView from "./routes/EditorView"; -import GraphView from "./routes/GraphView"; export default function App() { return ( @@ -11,7 +10,6 @@ export default function App() { } /> } /> } /> - } /> ); diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..c999092 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,131 @@ +/** + * Centralized API client for all backend communication. + * + * Every fetch call in the app should go through here so that + * endpoint URLs live in one place and are easy to update + * (e.g. when adding multi-node support with /api/nodes/{id}/...). + */ + +// --------------------------------------------------------------------------- +// Pages +// --------------------------------------------------------------------------- + +export interface PageMeta { + name: string; + title: string | null; + published: boolean; + has_source: boolean; + last_modified: number | null; + size: number | null; +} + +export interface PageDetail { + name: string; + source: string | null; +} + +export async function fetchPages(): Promise { + const res = await fetch("/api/pages"); + return res.json(); +} + +export async function fetchPage(name: string): Promise { + const res = await fetch(`/api/pages/${name}`); + return res.json(); +} + +export async function savePage( + name: string, + source: string, + publish: boolean, +): Promise { + const res = await fetch(`/api/pages/${name}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source, publish }), + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function deletePage(name: string): Promise { + await fetch(`/api/pages/${name}`, { method: "DELETE" }); +} + +// --------------------------------------------------------------------------- +// Compile +// --------------------------------------------------------------------------- + +export interface CompileResult { + ascii: string; + micron: string; + script: string; + is_dynamic: boolean; + warnings: string[]; +} + +export async function compile( + source: string, + signal?: AbortSignal, +): Promise { + const res = await fetch("/api/compile", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source }), + signal, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: "Compile failed" })); + throw new Error(err.detail || "Compile failed"); + } + return res.json(); +} + +// --------------------------------------------------------------------------- +// DSL Metadata +// --------------------------------------------------------------------------- + +export interface DslCommand { + label: string; + detail: string; + section: string; + snippet: string; +} + +export interface DslMeta { + keywords: string[]; + values: string[]; + commands: DslCommand[]; + themes: string[]; +} + +export async function fetchDslMeta(): Promise { + const res = await fetch("/api/dsl-meta"); + return res.json(); +} + +// --------------------------------------------------------------------------- +// Node Management +// --------------------------------------------------------------------------- + +export async function restartNode(): Promise { + const res = await fetch("/api/restart", { method: "POST" }); + if (!res.ok) throw new Error(await res.text()); +} + +// --------------------------------------------------------------------------- +// Images +// --------------------------------------------------------------------------- + +export interface UploadResult { + filename: string; + path: string; +} + +export async function uploadImage(file: File): Promise { + 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()); + return res.json(); +} diff --git a/frontend/src/components/editor/BacklinkIndicator.tsx b/frontend/src/components/editor/BacklinkIndicator.tsx deleted file mode 100644 index d43c0b8..0000000 --- a/frontend/src/components/editor/BacklinkIndicator.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Link } from "react-router-dom"; -import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; -import { Button } from "@/components/ui/button"; -import type { BacklinkPage } from "@/hooks/useBacklinks"; - -interface Props { - backlinks: BacklinkPage[]; -} - -export default function BacklinkIndicator({ backlinks }: Props) { - if (backlinks.length === 0) return null; - - return ( - - } - > - ← {backlinks.length} backlink{backlinks.length !== 1 ? "s" : ""} - - -

- Pages linking here -

-
    - {backlinks.map((page) => ( -
  • - - {page.title ?? page.name} - {page.title && ( - - ({page.name}) - - )} - -
  • - ))} -
-
-
- ); -} diff --git a/frontend/src/components/editor/micronHighlight.ts b/frontend/src/components/editor/micronHighlight.ts deleted file mode 100644 index 9e2455a..0000000 --- a/frontend/src/components/editor/micronHighlight.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { StreamLanguage, HighlightStyle, syntaxHighlighting } from "@codemirror/language"; -import { tags } from "@lezer/highlight"; - -const micronLanguage = StreamLanguage.define({ - token(stream) { - if (stream.sol()) { - // Depth-4+ indent (before >>> so ">>>> " doesn't match heading3) - if (stream.match(/>>>>/)) { stream.skipToEnd(); return "keyword"; } - // Headings — longest prefix first - if (stream.match(/>>>/)) { stream.skipToEnd(); return "heading3"; } - if (stream.match(/>>/)) { stream.skipToEnd(); return "heading2"; } - if (stream.match(/>/)) { stream.skipToEnd(); return "heading1"; } - // Dividers: line starting with - followed by a non-space, non-dash char - if (stream.match(/-[^\s\-]/)) { stream.skipToEnd(); return "contentSeparator"; } - // Comment lines - if (stream.match(/#/)) { stream.skipToEnd(); return "lineComment"; } - // Standalone depth-reset "<" - if (stream.string.trim() === "<") { stream.next(); return "meta"; } - } - - // Backtick-based format tags: `! `* `_ `` `F `f `B `b `c `r `l `a `= `< - if (stream.match(/`[!*_`FfBbCcRrLlAa=<]/)) return "meta"; - - // Hex color values (exactly 3 hex digits) — appear right after `F or `B tags - if (stream.match(/[0-9a-fA-F]{3}(?![0-9a-fA-F])/)) return "number"; - - // Links [label`url] — consume the whole bracket expression - if (stream.match(/\[[^\]]*\]/)) return "link"; - - // Form elements etc. - if (stream.match(/<[^>]+>/)) return "string"; - - stream.next(); - return null; - }, - startState: () => ({}), - copyState: (s) => ({ ...s }), - blankLine: () => {}, - languageData: {}, -}); - -const micronStyle = HighlightStyle.define([ - { tag: tags.heading1, color: "#7ee8a2", fontWeight: "bold" }, - { tag: tags.heading2, color: "#70c4e8", fontWeight: "bold" }, - { tag: tags.heading3, color: "#a8c4e8", fontWeight: "bold" }, - { tag: tags.keyword, color: "#c9d1d9", fontStyle: "italic" }, // depth-4+ indent - { tag: tags.contentSeparator, color: "#484f58", fontStyle: "italic" }, - { tag: tags.lineComment, color: "#484f58", fontStyle: "italic" }, // # comments - { tag: tags.meta, color: "#d2a8ff" }, // backtick format codes - { tag: tags.number, color: "#f8d4a8" }, // hex color values - { tag: tags.link, color: "#7dc4e4", textDecoration: "underline" }, - { tag: tags.string, color: "#d4a8f8" }, // form elements -]); - -export function micronHighlight() { - return [micronLanguage, syntaxHighlighting(micronStyle)]; -} diff --git a/frontend/src/components/editor/slashCommands.ts b/frontend/src/components/editor/slashCommands.ts deleted file mode 100644 index 4a13fcf..0000000 --- a/frontend/src/components/editor/slashCommands.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { snippet } from "@codemirror/autocomplete"; -import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete"; -import type { EditorView } from "@codemirror/view"; - -interface SlashEntry { - label: string; - detail: string; - section: string; - apply: Completion["apply"]; -} - -// Insert text, replacing from the "/" character (from-1) through the cursor -function insert(text: string): Completion["apply"] { - return (view: EditorView, _completion: Completion, from: number, to: number) => { - view.dispatch({ changes: { from: from - 1, to, insert: text } }); - }; -} - -// Wrap snippet() to also replace the preceding "/" character -function slashSnippet(template: string): Completion["apply"] { - const snip = snippet(template); - return (view: EditorView, completion: Completion, from: number, to: number) => { - snip(view, completion, from - 1, to - 1); - }; -} - -const COMMANDS: SlashEntry[] = [ - // Headings - { label: "H1", detail: ">...", section: "Heading", apply: slashSnippet(">\${text}") }, - { label: "H2", detail: ">>...", section: "Heading", apply: slashSnippet(">>\${text}") }, - { label: "H3", detail: ">>>...", section: "Heading", apply: slashSnippet(">>>\${text}") }, - - // Text formatting - { label: "Bold", detail: "`!..`!", section: "Format", apply: slashSnippet("`!\${text}`!") }, - { label: "Italic", detail: "`*..`*", section: "Format", apply: slashSnippet("`*\${text}`*") }, - { label: "Underline", detail: "`_..`_", section: "Format", apply: slashSnippet("`_\${text}`_") }, - { label: "Reset", detail: "``", section: "Format", apply: insert("``") }, - { label: "Literal", detail: "`=...`=", section: "Format", apply: slashSnippet("`=\n\${content}\n`=") }, - - // Alignment - { label: "Center", detail: "`c..`a", section: "Align", apply: slashSnippet("`c\${text}`a") }, - { label: "Right", detail: "`r..`a", section: "Align", apply: slashSnippet("`r\${text}`a") }, - { label: "Left", detail: "`l..`a", section: "Align", apply: slashSnippet("`l\${text}`a") }, - - // Color (3-digit hex) - { label: "Color", detail: "`Fhex..`f", section: "Color", apply: slashSnippet("`F\${hex}\${text}`f") }, - { label: "BgColor", detail: "`Bhex..`b", section: "Color", apply: slashSnippet("`B\${hex}\${text}`b") }, - - // Links - { label: "Link", detail: "[label`page]", section: "Link", apply: slashSnippet("[\${label}`\${page}]") }, - - // Dividers - { label: "Divider ─", detail: "-─", section: "Divider", apply: insert("-─") }, - { label: "Divider ━", detail: "-━", section: "Divider", apply: insert("-━") }, - { label: "Divider ═", detail: "-═", section: "Divider", apply: insert("-═") }, - { label: "Divider ★", detail: "-★", section: "Divider", apply: insert("-★") }, - - // Forms — pipe separators per micron-composer spec - { label: "Field", detail: "", section: "Form", apply: slashSnippet("<\${name}`\${default}>") }, - { label: "Password", detail: "", section: "Form", apply: slashSnippet("") }, - { label: "Checkbox", detail: "", section: "Form", apply: slashSnippet("") }, - { label: "Checked", detail: "", section: "Form", apply: slashSnippet("") }, - { label: "Radio", detail: "<^|grp|val`label>", section: "Form", apply: slashSnippet("<^\${group}|\${value}`\${label}>") }, - - // Depth - { label: "Reset depth", detail: "<", section: "Depth", apply: insert("<\n") }, -]; - -export function slashCommandSource(ctx: CompletionContext): CompletionResult | null { - const match = ctx.matchBefore(/\/\w*/); - if (!match || (match.from === match.to && !ctx.explicit)) return null; - return { - // Start after "/" so the filter text doesn't include "/" (which would block all matches) - from: match.from + 1, - filter: true, - options: COMMANDS.map((cmd) => ({ - label: cmd.label, - detail: cmd.detail, - section: cmd.section, - apply: cmd.apply, - boost: 99, - })), - }; -} diff --git a/frontend/src/components/editor/uframeCommands.ts b/frontend/src/components/editor/uframeCommands.ts index 89e5ac1..e61054d 100644 --- a/frontend/src/components/editor/uframeCommands.ts +++ b/frontend/src/components/editor/uframeCommands.ts @@ -5,17 +5,13 @@ import type { CompletionResult, } from "@codemirror/autocomplete"; import type { EditorView } from "@codemirror/view"; +import { fetchDslMeta } from "@/api/client"; /** - * µFrame slash command palette — auto-populated from /api/dsl-meta. + * µFrame slash command palette — auto-populated from the backend DSL registry. * * On first load, uses a minimal fallback set. Once the API responds, - * the full command list (including bigtitle, image, themes, etc.) - * replaces it via loadCommandsFromApi(). - * - * Uses window.__uframeCommands as the shared mutable store so that - * both the original module instance and any HMR-reloaded copies - * read from the same array. + * the full command list replaces it via loadCommandsFromApi(). */ interface CmdEntry { @@ -25,12 +21,9 @@ interface CmdEntry { apply: Completion["apply"]; } -declare global { - interface Window { - __uframeCommands?: CmdEntry[]; - __uframeCommandsLoaded?: boolean; - } -} +// Module-level cache — survives re-renders but not full page reload. +let commands: CmdEntry[] | null = null; +let loaded = false; function insert(text: string): Completion["apply"] { return (view: EditorView, _c: Completion, from: number, to: number) => { @@ -45,7 +38,6 @@ function slashSnippet(template: string): Completion["apply"] { }; } -// Minimal fallback commands (used before API loads) const FALLBACK_COMMANDS: CmdEntry[] = [ { label: "page", detail: 'page "Title" 64', section: "Layout", apply: slashSnippet('page "${title}" ${width:64}') }, { label: "box", detail: 'box light "Title"', section: "Layout", apply: slashSnippet('box ${weight:light} "${title}"') }, @@ -56,20 +48,18 @@ const FALLBACK_COMMANDS: CmdEntry[] = [ ]; function getCommands(): CmdEntry[] { - return window.__uframeCommands ?? FALLBACK_COMMANDS; + return commands ?? FALLBACK_COMMANDS; } /** - * Load the full command list from /api/dsl-meta. - * Called once on editor mount. Replaces the fallback set with the - * complete registry-driven list. + * Load the full command list from the backend DSL registry. + * Called once on editor mount. */ export async function loadCommandsFromApi(): Promise { + if (loaded) return; try { - const res = await fetch("/api/dsl-meta"); - if (!res.ok) return; - const data = await res.json(); + const data = await fetchDslMeta(); const apiCommands: CmdEntry[] = []; for (const cmd of data.commands ?? []) { @@ -84,7 +74,7 @@ export async function loadCommandsFromApi(): Promise { }); } - // Add the dashboard template (not in registry) + // Dashboard template (not in registry) apiCommands.push({ label: "dashboard", detail: "Full dashboard template", @@ -94,8 +84,8 @@ export async function loadCommandsFromApi(): Promise { ), }); - window.__uframeCommands = apiCommands; - window.__uframeCommandsLoaded = true; + commands = apiCommands; + loaded = true; } catch { // Keep using fallback } @@ -122,14 +112,8 @@ 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 + * on the current line. */ - -// 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" }, @@ -149,14 +133,11 @@ const KEYWORD_VALUES: Record = { 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; @@ -164,20 +145,14 @@ export function uframeValueHintSource( 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 + from: colonWordMatch.from, + filter: false, options: entry.values.map((v) => ({ label: v, detail: entry.hint, @@ -186,7 +161,6 @@ export function uframeValueHintSource( }; } - // Plain word match (no colon) const wordMatch = ctx.matchBefore(/\w+/); if (!wordMatch) { if (!ctx.explicit) return null; diff --git a/frontend/src/components/editor/wikiLinkCompletion.ts b/frontend/src/components/editor/wikiLinkCompletion.ts deleted file mode 100644 index c10cb9d..0000000 --- a/frontend/src/components/editor/wikiLinkCompletion.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete"; -import type { MutableRefObject } from "react"; -import type { PageMeta } from "@/stores/editorStore"; - -export function wikiLinkSource(pagesRef: MutableRefObject) { - return (context: CompletionContext): CompletionResult | null => { - const match = context.matchBefore(/\[\[[\w-]*/); - if (!match || (match.from === match.to && !context.explicit)) return null; - - const options: Completion[] = pagesRef.current.map((page) => ({ - label: page.title ?? page.name, - detail: page.name, - apply: (view, _completion, from, to) => { - const title = page.title ?? page.name; - view.dispatch({ - changes: { from, to, insert: `[${title}\`${page.name}]` }, - }); - }, - })); - - return { from: match.from, options, filter: true }; - }; -} diff --git a/frontend/src/components/shared/NavBar.tsx b/frontend/src/components/shared/NavBar.tsx deleted file mode 100644 index b051cb0..0000000 --- a/frontend/src/components/shared/NavBar.tsx +++ /dev/null @@ -1,4 +0,0 @@ -// NavBar removed — frame.svg is the visual wrapper now -export default function NavBar() { - return null; -} diff --git a/frontend/src/components/ui/scroll-area.tsx b/frontend/src/components/ui/scroll-area.tsx deleted file mode 100644 index 72000e5..0000000 --- a/frontend/src/components/ui/scroll-area.tsx +++ /dev/null @@ -1,54 +0,0 @@ -"use client" - -import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area" - -import { cn } from "@/lib/utils" - -function ScrollArea({ - className, - children, - ...props -}: ScrollAreaPrimitive.Root.Props) { - return ( - - - {children} - - - - - ) -} - -function ScrollBar({ - className, - orientation = "vertical", - ...props -}: ScrollAreaPrimitive.Scrollbar.Props) { - return ( - - - - ) -} - -export { ScrollArea, ScrollBar } diff --git a/frontend/src/components/ui/separator.tsx b/frontend/src/components/ui/separator.tsx deleted file mode 100644 index 6e1369e..0000000 --- a/frontend/src/components/ui/separator.tsx +++ /dev/null @@ -1,25 +0,0 @@ -"use client" - -import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" - -import { cn } from "@/lib/utils" - -function Separator({ - className, - orientation = "horizontal", - ...props -}: SeparatorPrimitive.Props) { - return ( - - ) -} - -export { Separator } diff --git a/frontend/src/components/ui/toggle-group.tsx b/frontend/src/components/ui/toggle-group.tsx deleted file mode 100644 index 4a5b000..0000000 --- a/frontend/src/components/ui/toggle-group.tsx +++ /dev/null @@ -1,89 +0,0 @@ -"use client" - -import * as React from "react" -import { Toggle as TogglePrimitive } from "@base-ui/react/toggle" -import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group" -import { type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" -import { toggleVariants } from "@/components/ui/toggle" - -const ToggleGroupContext = React.createContext< - VariantProps & { - spacing?: number - orientation?: "horizontal" | "vertical" - } ->({ - size: "default", - variant: "default", - spacing: 0, - orientation: "horizontal", -}) - -function ToggleGroup({ - className, - variant, - size, - spacing = 0, - orientation = "horizontal", - children, - ...props -}: ToggleGroupPrimitive.Props & - VariantProps & { - spacing?: number - orientation?: "horizontal" | "vertical" - }) { - return ( - - - {children} - - - ) -} - -function ToggleGroupItem({ - className, - children, - variant = "default", - size = "default", - ...props -}: TogglePrimitive.Props & VariantProps) { - const context = React.useContext(ToggleGroupContext) - - return ( - - {children} - - ) -} - -export { ToggleGroup, ToggleGroupItem } diff --git a/frontend/src/components/ui/toggle.tsx b/frontend/src/components/ui/toggle.tsx deleted file mode 100644 index 2b626f6..0000000 --- a/frontend/src/components/ui/toggle.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Toggle as TogglePrimitive } from "@base-ui/react/toggle" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const toggleVariants = cva( - "group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-transparent", - outline: "border border-input bg-transparent hover:bg-muted", - }, - size: { - default: - "h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", - }, - }, - defaultVariants: { - variant: "default", - size: "default", - }, - } -) - -function Toggle({ - className, - variant = "default", - size = "default", - ...props -}: TogglePrimitive.Props & VariantProps) { - return ( - - ) -} - -export { Toggle, toggleVariants } diff --git a/frontend/src/hooks/useBacklinks.ts b/frontend/src/hooks/useBacklinks.ts deleted file mode 100644 index 49fb403..0000000 --- a/frontend/src/hooks/useBacklinks.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useMemo } from "react"; -import { useGraph } from "@/hooks/useGraph"; - -export interface BacklinkPage { - name: string; - title: string | null; -} - -export function useBacklinks(currentSlug: string | undefined): BacklinkPage[] { - const { data } = useGraph(); - return useMemo(() => { - if (!data || !currentSlug) return []; - const nodeMap = new Map(data.nodes.map((n) => [n.id, n])); - return data.edges - .filter((e) => e.target === currentSlug) - .map((e) => ({ - name: e.source, - title: nodeMap.get(e.source)?.title ?? null, - })); - }, [data, currentSlug]); -} diff --git a/frontend/src/hooks/useCompile.ts b/frontend/src/hooks/useCompile.ts index 6860c69..8c44ee7 100644 --- a/frontend/src/hooks/useCompile.ts +++ b/frontend/src/hooks/useCompile.ts @@ -1,10 +1,11 @@ import { useCallback, useEffect, useRef } from "react"; import { useEditorStore } from "@/stores/editorStore"; +import { compile } from "@/api/client"; const DEBOUNCE_MS = 400; /** - * Debounced hook that compiles µFrame source via POST /api/compile. + * Debounced hook that compiles µFrame source via the API. * Automatically triggers on ufSource changes. */ export function useCompile() { @@ -16,14 +17,13 @@ export function useCompile() { const timerRef = useRef | null>(null); const abortRef = useRef(null); - const compile = useCallback( + const doCompile = useCallback( async (source: string) => { if (!source.trim()) { setCompileResult("", "", "", false, []); return; } - // Abort any in-flight request abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; @@ -31,21 +31,14 @@ export function useCompile() { setCompiling(true); try { - const res = await fetch("/api/compile", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source }), - signal: controller.signal, - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({ detail: "Compile failed" })); - setCompileError(err.detail || "Compile failed"); - return; - } - - const data = await res.json(); - setCompileResult(data.ascii, data.micron, data.script || "", data.is_dynamic || false, data.warnings || []); + const data = await compile(source, controller.signal); + setCompileResult( + data.ascii, + data.micron, + data.script || "", + data.is_dynamic || false, + data.warnings || [], + ); } catch (e: unknown) { if (e instanceof DOMException && e.name === "AbortError") return; setCompileError(e instanceof Error ? e.message : "Compile failed"); @@ -56,13 +49,12 @@ export function useCompile() { useEffect(() => { if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => compile(ufSource), DEBOUNCE_MS); + timerRef.current = setTimeout(() => doCompile(ufSource), DEBOUNCE_MS); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; - }, [ufSource, compile]); + }, [ufSource, doCompile]); - // Cleanup on unmount useEffect(() => { return () => { abortRef.current?.abort(); diff --git a/frontend/src/hooks/useDslMeta.ts b/frontend/src/hooks/useDslMeta.ts index 26c3a9f..72728a0 100644 --- a/frontend/src/hooks/useDslMeta.ts +++ b/frontend/src/hooks/useDslMeta.ts @@ -1,11 +1,7 @@ import { useEffect, useState } from "react"; +import { fetchDslMeta, type DslMeta } from "@/api/client"; -export interface DslMeta { - keywords: string[]; - values: string[]; - commands: { label: string; detail: string; section: string; snippet: string }[]; - themes: string[]; -} +export type { DslMeta } from "@/api/client"; const DEFAULT_META: DslMeta = { keywords: [], @@ -22,9 +18,8 @@ export function useDslMeta(): DslMeta { useEffect(() => { if (cachedMeta) return; - fetch("/api/dsl-meta") - .then((r) => r.json()) - .then((data: DslMeta) => { + fetchDslMeta() + .then((data) => { cachedMeta = data; setMeta(data); }) diff --git a/frontend/src/hooks/useGraph.ts b/frontend/src/hooks/useGraph.ts deleted file mode 100644 index b606204..0000000 --- a/frontend/src/hooks/useGraph.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect, useState } from "react"; - -export interface GraphNode { - id: string; - published: boolean; - title: string | null; -} - -export interface GraphEdge { - source: string; - target: string; -} - -export interface GraphData { - nodes: GraphNode[]; - edges: GraphEdge[]; -} - -export function useGraph() { - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - (async () => { - try { - const res = await fetch("/api/graph"); - setData(await res.json()); - } finally { - setLoading(false); - } - })(); - }, []); - - return { data, loading }; -} diff --git a/frontend/src/routes/DashboardView.tsx b/frontend/src/routes/DashboardView.tsx index 3bc52c4..b8fcf87 100644 --- a/frontend/src/routes/DashboardView.tsx +++ b/frontend/src/routes/DashboardView.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { MoreVertical, Plus, RotateCcw } from "lucide-react"; import { usePagesStore } from "@/stores/pagesStore"; +import { restartNode } from "@/api/client"; import StatusBadge from "@/components/dashboard/StatusBadge"; import { Button } from "@/components/ui/button"; import { @@ -30,16 +31,20 @@ import { } from "@/components/ui/alert-dialog"; export default function DashboardView() { - const { pages, isLoading, fetchPages, deletePage } = usePagesStore(); + const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } = + usePagesStore(); const navigate = useNavigate(); const [pageToDelete, setPageToDelete] = useState(null); const [restarting, setRestarting] = useState(false); + useEffect(() => { + fetchPages(); + }, []); + const handleRestart = async () => { setRestarting(true); try { - const res = await fetch("/api/restart", { method: "POST" }); - if (!res.ok) throw new Error(await res.text()); + await restartNode(); toast.success("NomadNet restarted"); } catch (e) { toast.error(`Restart failed: ${e}`); @@ -48,41 +53,19 @@ export default function DashboardView() { } }; - useEffect(() => { - fetchPages(); - }, []); - - const handleUnpublish = async (name: string) => { + 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: false }), - }); - toast.success(`"${name}" unpublished`); - fetchPages(); - } + await publishPage(name); + toast.success(`"${name}" published`); } catch (e) { toast.error(`Failed: ${e}`); } }; - const handlePublish = async (name: string) => { + 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: true }), - }); - toast.success(`"${name}" published`); - fetchPages(); - } + await unpublishPage(name); + toast.success(`"${name}" unpublished`); } catch (e) { toast.error(`Failed: ${e}`); } @@ -120,7 +103,7 @@ export default function DashboardView() { - {/* Table inside bordered container */} + {/* Table */} @@ -145,50 +128,22 @@ export default function DashboardView() { )} - {p.title ?? "—"} + {p.title ?? "\u2014"} - {p.size != null ? `${p.size} B` : "—"} + {p.size != null ? `${p.size} B` : "\u2014"} - - e.stopPropagation()} - className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" - > - - - } - /> - - - {p.published ? ( - - ) : ( - - )} - - - + handlePublish(p.name)} + onUnpublish={() => handleUnpublish(p.name)} + onDelete={() => setPageToDelete(p.name)} + /> ))} @@ -204,7 +159,7 @@ export default function DashboardView() { )}
- {/* end bordered container */} + ); } + + +/** Per-row action menu for a page. */ +function PageActions({ + name, + published, + onPublish, + onUnpublish, + onDelete, +}: { + name: string; + published: boolean; + onPublish: () => void; + onUnpublish: () => void; + onDelete: () => void; +}) { + const navigate = useNavigate(); + + return ( + + e.stopPropagation()} + className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" + > + + + } + /> + + + {published ? ( + + ) : ( + + )} + + + + ); +} diff --git a/frontend/src/routes/EditorView.tsx b/frontend/src/routes/EditorView.tsx index 4ec1d7e..11d3a2e 100644 --- a/frontend/src/routes/EditorView.tsx +++ b/frontend/src/routes/EditorView.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { BookOpen, Upload } from "lucide-react"; +import * as api from "@/api/client"; import { autocompletion } from "@codemirror/autocomplete"; import type { Extension } from "@codemirror/state"; import { useEditorStore } from "@/stores/editorStore"; @@ -77,17 +78,14 @@ export default function EditorView() { reset(); if (name) { setPageName(name); - fetch(`/api/pages/${name}`) - .then((r) => r.json()) - .then((data) => { - if (data.source != null) { - useEditorStore.setState({ - ufSource: data.source, - isDirty: false, - currentPage: data, - }); - } - }); + api.fetchPage(name).then((data) => { + if (data.source != null) { + useEditorStore.setState({ + ufSource: data.source, + isDirty: false, + }); + } + }); } return () => reset(); }, [name]); @@ -104,13 +102,7 @@ export default function EditorView() { } setSaving(true); try { - const res = await fetch(`/api/pages/${slug}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source: ufSource, publish }), - }); - if (!res.ok) throw new Error(await res.text()); - const meta = await res.json(); + const meta = await api.savePage(slug, ufSource, publish); setCurrentPage(meta); setDirty(false); fetchPages(); @@ -189,11 +181,7 @@ function SourcePane({ 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(); + const data = await api.uploadImage(file); toast.success(`Uploaded ${data.filename}`); setSource(`image "${data.path}" braille 30\n align center`); } catch (err) { diff --git a/frontend/src/routes/GraphView.tsx b/frontend/src/routes/GraphView.tsx deleted file mode 100644 index 57cf492..0000000 --- a/frontend/src/routes/GraphView.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useMemo } from "react"; -import { useNavigate } from "react-router-dom"; -import { - ReactFlow, - Background, - Controls, - type Node, - type Edge, -} from "@xyflow/react"; -import dagre from "@dagrejs/dagre"; -import { useGraph } from "@/hooks/useGraph"; -import "@xyflow/react/dist/style.css"; - -const NODE_WIDTH = 160; -const NODE_HEIGHT = 50; - -function layoutGraph( - nodes: Node[], - edges: Edge[] -): { nodes: Node[]; edges: Edge[] } { - const g = new dagre.graphlib.Graph(); - g.setDefaultEdgeLabel(() => ({})); - g.setGraph({ rankdir: "TB", nodesep: 50, ranksep: 80 }); - - nodes.forEach((n) => - g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT }) - ); - edges.forEach((e) => g.setEdge(e.source, e.target)); - dagre.layout(g); - - const laid = nodes.map((n) => { - const pos = g.node(n.id); - return { - ...n, - position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 }, - }; - }); - - return { nodes: laid, edges }; -} - -export default function GraphView() { - const { data, loading } = useGraph(); - const navigate = useNavigate(); - - const { nodes, edges } = useMemo(() => { - if (!data) return { nodes: [], edges: [] }; - - const rfNodes: Node[] = data.nodes.map((n) => ({ - id: n.id, - data: { label: n.title ?? n.id }, - position: { x: 0, y: 0 }, - style: { - background: n.published - ? "oklch(0.488 0.14 145)" - : "oklch(0.7 0.15 80)", - color: "#fff", - border: - n.id === "index" - ? "2px solid oklch(0.6 0.2 250)" - : "1px solid oklch(1 0 0 / 10%)", - borderRadius: 8, - padding: "8px 16px", - fontSize: 13, - fontWeight: n.id === "index" ? 700 : 400, - width: NODE_WIDTH, - }, - })); - - const rfEdges: Edge[] = data.edges.map((e, i) => ({ - id: `e-${i}`, - source: e.source, - target: e.target, - style: { stroke: "oklch(0.556 0 0)" }, - })); - - return layoutGraph(rfNodes, rfEdges); - }, [data]); - - if (loading) - return ( -
- Loading graph... -
- ); - - return ( -
- navigate(`/editor/${node.id}`)} - fitView - proOptions={{ hideAttribution: true }} - > - - - -
- ); -} diff --git a/frontend/src/stores/editorStore.ts b/frontend/src/stores/editorStore.ts index e287e25..4969fec 100644 --- a/frontend/src/stores/editorStore.ts +++ b/frontend/src/stores/editorStore.ts @@ -1,13 +1,5 @@ import { create } from "zustand"; - -export interface PageMeta { - name: string; - title: string | null; - published: boolean; - has_source: boolean; - last_modified: number | null; - size: number | null; -} +import type { PageMeta } from "@/api/client"; interface EditorStore { // Source diff --git a/frontend/src/stores/pagesStore.ts b/frontend/src/stores/pagesStore.ts index 9f91fd6..138aada 100644 --- a/frontend/src/stores/pagesStore.ts +++ b/frontend/src/stores/pagesStore.ts @@ -1,11 +1,12 @@ import { create } from "zustand"; -import type { PageMeta } from "@/stores/editorStore"; +import * as api from "@/api/client"; interface PagesStore { - pages: PageMeta[]; + pages: api.PageMeta[]; isLoading: boolean; fetchPages: () => Promise; deletePage: (name: string) => Promise; + publishPage: (name: string) => Promise; unpublishPage: (name: string) => Promise; } @@ -16,8 +17,7 @@ export const usePagesStore = create((set, get) => ({ fetchPages: async () => { set({ isLoading: true }); try { - const res = await fetch("/api/pages"); - const pages = await res.json(); + const pages = await api.fetchPages(); set({ pages }); } finally { set({ isLoading: false }); @@ -25,20 +25,22 @@ export const usePagesStore = create((set, get) => ({ }, deletePage: async (name: string) => { - await fetch(`/api/pages/${name}`, { method: "DELETE" }); + await api.deletePage(name); + await get().fetchPages(); + }, + + publishPage: async (name: string) => { + const page = await api.fetchPage(name); + if (page.source) { + await api.savePage(name, page.source, true); + } await get().fetchPages(); }, unpublishPage: async (name: string) => { - // Fetch current source, re-save as draft only - const res = await fetch(`/api/pages/${name}`); - const data = await res.json(); - if (data.source) { - await fetch(`/api/pages/${name}`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ source: data.source, publish: false }), - }); + const page = await api.fetchPage(name); + if (page.source) { + await api.savePage(name, page.source, false); } await get().fetchPages(); },