feat: clean up

This commit is contained in:
2026-04-03 15:24:06 +02:00
parent 0d469f70bf
commit c820f06d1c
23 changed files with 272 additions and 912 deletions

View File

@@ -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)

View File

@@ -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")

View File

@@ -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() {
<Route path="/" element={<DashboardView />} />
<Route path="/editor/new" element={<EditorView />} />
<Route path="/editor/:name" element={<EditorView />} />
<Route path="/graph" element={<GraphView />} />
</Routes>
</AppShell>
);

131
frontend/src/api/client.ts Normal file
View File

@@ -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<PageMeta[]> {
const res = await fetch("/api/pages");
return res.json();
}
export async function fetchPage(name: string): Promise<PageDetail> {
const res = await fetch(`/api/pages/${name}`);
return res.json();
}
export async function savePage(
name: string,
source: string,
publish: boolean,
): Promise<PageMeta> {
const res = await fetch(`/api/pages/${name}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source, publish }),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function deletePage(name: string): Promise<void> {
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<CompileResult> {
const res = await fetch("/api/compile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source }),
signal,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: "Compile failed" }));
throw new Error(err.detail || "Compile failed");
}
return res.json();
}
// ---------------------------------------------------------------------------
// DSL Metadata
// ---------------------------------------------------------------------------
export interface DslCommand {
label: string;
detail: string;
section: string;
snippet: string;
}
export interface DslMeta {
keywords: string[];
values: string[];
commands: DslCommand[];
themes: string[];
}
export async function fetchDslMeta(): Promise<DslMeta> {
const res = await fetch("/api/dsl-meta");
return res.json();
}
// ---------------------------------------------------------------------------
// Node Management
// ---------------------------------------------------------------------------
export async function restartNode(): Promise<void> {
const res = await fetch("/api/restart", { method: "POST" });
if (!res.ok) throw new Error(await res.text());
}
// ---------------------------------------------------------------------------
// Images
// ---------------------------------------------------------------------------
export interface UploadResult {
filename: string;
path: string;
}
export async function uploadImage(file: File): Promise<UploadResult> {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/upload-image", { method: "POST", body: form });
if (!res.ok) throw new Error(await res.text());
return res.json();
}

View File

@@ -1,44 +0,0 @@
import { Link } from "react-router-dom";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import type { BacklinkPage } from "@/hooks/useBacklinks";
interface Props {
backlinks: BacklinkPage[];
}
export default function BacklinkIndicator({ backlinks }: Props) {
if (backlinks.length === 0) return null;
return (
<Popover>
<PopoverTrigger
render={<Button variant="ghost" size="sm" className="text-xs text-muted-foreground h-7 px-2" />}
>
{backlinks.length} backlink{backlinks.length !== 1 ? "s" : ""}
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="end">
<p className="text-xs font-semibold text-muted-foreground mb-2 px-1">
Pages linking here
</p>
<ul className="space-y-0.5">
{backlinks.map((page) => (
<li key={page.name}>
<Link
to={`/editor/${page.name}`}
className="flex items-center gap-1.5 text-sm px-2 py-1 rounded hover:bg-accent"
>
<span>{page.title ?? page.name}</span>
{page.title && (
<span className="text-xs text-muted-foreground font-mono">
({page.name})
</span>
)}
</Link>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,57 +0,0 @@
import { StreamLanguage, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
const micronLanguage = StreamLanguage.define({
token(stream) {
if (stream.sol()) {
// Depth-4+ indent (before >>> so ">>>> " doesn't match heading3)
if (stream.match(/>>>>/)) { stream.skipToEnd(); return "keyword"; }
// Headings — longest prefix first
if (stream.match(/>>>/)) { stream.skipToEnd(); return "heading3"; }
if (stream.match(/>>/)) { stream.skipToEnd(); return "heading2"; }
if (stream.match(/>/)) { stream.skipToEnd(); return "heading1"; }
// Dividers: line starting with - followed by a non-space, non-dash char
if (stream.match(/-[^\s\-]/)) { stream.skipToEnd(); return "contentSeparator"; }
// Comment lines
if (stream.match(/#/)) { stream.skipToEnd(); return "lineComment"; }
// Standalone depth-reset "<"
if (stream.string.trim() === "<") { stream.next(); return "meta"; }
}
// Backtick-based format tags: `! `* `_ `` `F `f `B `b `c `r `l `a `= `<
if (stream.match(/`[!*_`FfBbCcRrLlAa=<]/)) return "meta";
// Hex color values (exactly 3 hex digits) — appear right after `F or `B tags
if (stream.match(/[0-9a-fA-F]{3}(?![0-9a-fA-F])/)) return "number";
// Links [label`url] — consume the whole bracket expression
if (stream.match(/\[[^\]]*\]/)) return "link";
// Form elements <fieldname`default> etc.
if (stream.match(/<[^>]+>/)) return "string";
stream.next();
return null;
},
startState: () => ({}),
copyState: (s) => ({ ...s }),
blankLine: () => {},
languageData: {},
});
const micronStyle = HighlightStyle.define([
{ tag: tags.heading1, color: "#7ee8a2", fontWeight: "bold" },
{ tag: tags.heading2, color: "#70c4e8", fontWeight: "bold" },
{ tag: tags.heading3, color: "#a8c4e8", fontWeight: "bold" },
{ tag: tags.keyword, color: "#c9d1d9", fontStyle: "italic" }, // depth-4+ indent
{ tag: tags.contentSeparator, color: "#484f58", fontStyle: "italic" },
{ tag: tags.lineComment, color: "#484f58", fontStyle: "italic" }, // # comments
{ tag: tags.meta, color: "#d2a8ff" }, // backtick format codes
{ tag: tags.number, color: "#f8d4a8" }, // hex color values
{ tag: tags.link, color: "#7dc4e4", textDecoration: "underline" },
{ tag: tags.string, color: "#d4a8f8" }, // form elements
]);
export function micronHighlight() {
return [micronLanguage, syntaxHighlighting(micronStyle)];
}

View File

@@ -1,84 +0,0 @@
import { snippet } from "@codemirror/autocomplete";
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import type { EditorView } from "@codemirror/view";
interface SlashEntry {
label: string;
detail: string;
section: string;
apply: Completion["apply"];
}
// Insert text, replacing from the "/" character (from-1) through the cursor
function insert(text: string): Completion["apply"] {
return (view: EditorView, _completion: Completion, from: number, to: number) => {
view.dispatch({ changes: { from: from - 1, to, insert: text } });
};
}
// Wrap snippet() to also replace the preceding "/" character
function slashSnippet(template: string): Completion["apply"] {
const snip = snippet(template);
return (view: EditorView, completion: Completion, from: number, to: number) => {
snip(view, completion, from - 1, to - 1);
};
}
const COMMANDS: SlashEntry[] = [
// Headings
{ label: "H1", detail: ">...", section: "Heading", apply: slashSnippet(">\${text}") },
{ label: "H2", detail: ">>...", section: "Heading", apply: slashSnippet(">>\${text}") },
{ label: "H3", detail: ">>>...", section: "Heading", apply: slashSnippet(">>>\${text}") },
// Text formatting
{ label: "Bold", detail: "`!..`!", section: "Format", apply: slashSnippet("`!\${text}`!") },
{ label: "Italic", detail: "`*..`*", section: "Format", apply: slashSnippet("`*\${text}`*") },
{ label: "Underline", detail: "`_..`_", section: "Format", apply: slashSnippet("`_\${text}`_") },
{ label: "Reset", detail: "``", section: "Format", apply: insert("``") },
{ label: "Literal", detail: "`=...`=", section: "Format", apply: slashSnippet("`=\n\${content}\n`=") },
// Alignment
{ label: "Center", detail: "`c..`a", section: "Align", apply: slashSnippet("`c\${text}`a") },
{ label: "Right", detail: "`r..`a", section: "Align", apply: slashSnippet("`r\${text}`a") },
{ label: "Left", detail: "`l..`a", section: "Align", apply: slashSnippet("`l\${text}`a") },
// Color (3-digit hex)
{ label: "Color", detail: "`Fhex..`f", section: "Color", apply: slashSnippet("`F\${hex}\${text}`f") },
{ label: "BgColor", detail: "`Bhex..`b", section: "Color", apply: slashSnippet("`B\${hex}\${text}`b") },
// Links
{ label: "Link", detail: "[label`page]", section: "Link", apply: slashSnippet("[\${label}`\${page}]") },
// Dividers
{ label: "Divider ─", detail: "-─", section: "Divider", apply: insert("-─") },
{ label: "Divider ━", detail: "-━", section: "Divider", apply: insert("-━") },
{ label: "Divider ═", detail: "-═", section: "Divider", apply: insert("-═") },
{ label: "Divider ★", detail: "-★", section: "Divider", apply: insert("-★") },
// Forms — pipe separators per micron-composer spec
{ label: "Field", detail: "<name`default>", section: "Form", apply: slashSnippet("<\${name}`\${default}>") },
{ label: "Password", detail: "<!w|name`placeholder>", section: "Form", apply: slashSnippet("<!\${width}|\${name}`\${placeholder}>") },
{ label: "Checkbox", detail: "<?|name|val`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}`\${label}>") },
{ label: "Checked", detail: "<?|name|val|*`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}|*`\${label}>") },
{ label: "Radio", detail: "<^|grp|val`label>", section: "Form", apply: slashSnippet("<^\${group}|\${value}`\${label}>") },
// Depth
{ label: "Reset depth", detail: "<", section: "Depth", apply: insert("<\n") },
];
export function slashCommandSource(ctx: CompletionContext): CompletionResult | null {
const match = ctx.matchBefore(/\/\w*/);
if (!match || (match.from === match.to && !ctx.explicit)) return null;
return {
// Start after "/" so the filter text doesn't include "/" (which would block all matches)
from: match.from + 1,
filter: true,
options: COMMANDS.map((cmd) => ({
label: cmd.label,
detail: cmd.detail,
section: cmd.section,
apply: cmd.apply,
boost: 99,
})),
};
}

View File

@@ -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<void> {
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<void> {
});
}
// 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<void> {
),
});
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<string, { values: string[]; hint: string }> = {
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<string, { values: string[]; hint: string }> = {
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;

View File

@@ -1,23 +0,0 @@
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
import type { MutableRefObject } from "react";
import type { PageMeta } from "@/stores/editorStore";
export function wikiLinkSource(pagesRef: MutableRefObject<PageMeta[]>) {
return (context: CompletionContext): CompletionResult | null => {
const match = context.matchBefore(/\[\[[\w-]*/);
if (!match || (match.from === match.to && !context.explicit)) return null;
const options: Completion[] = pagesRef.current.map((page) => ({
label: page.title ?? page.name,
detail: page.name,
apply: (view, _completion, from, to) => {
const title = page.title ?? page.name;
view.dispatch({
changes: { from, to, insert: `[${title}\`${page.name}]` },
});
},
}));
return { from: match.from, options, filter: true };
};
}

View File

@@ -1,4 +0,0 @@
// NavBar removed — frame.svg is the visual wrapper now
export default function NavBar() {
return null;
}

View File

@@ -1,54 +0,0 @@
"use client"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -1,25 +0,0 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -1,89 +0,0 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 0,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }

View File

@@ -1,43 +0,0 @@
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View File

@@ -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]);
}

View File

@@ -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<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const compile = useCallback(
const doCompile = useCallback(
async (source: string) => {
if (!source.trim()) {
setCompileResult("", "", "", false, []);
return;
}
// Abort any in-flight request
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
@@ -31,21 +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();

View File

@@ -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);
})

View File

@@ -1,35 +0,0 @@
import { useEffect, useState } from "react";
export interface GraphNode {
id: string;
published: boolean;
title: string | null;
}
export interface GraphEdge {
source: string;
target: string;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
export function useGraph() {
const [data, setData] = useState<GraphData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
try {
const res = await fetch("/api/graph");
setData(await res.json());
} finally {
setLoading(false);
}
})();
}, []);
return { data, loading };
}

View File

@@ -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<string | null>(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() {
</div>
</div>
{/* Table inside bordered container */}
{/* Table */}
<Table>
<TableHeader>
<TableRow>
@@ -145,50 +128,22 @@ export default function DashboardView() {
)}
</TableCell>
<TableCell className="text-muted-foreground">
{p.title ?? ""}
{p.title ?? "\u2014"}
</TableCell>
<TableCell>
<StatusBadge published={p.published} hasSource={p.has_source} />
</TableCell>
<TableCell className="text-muted-foreground">
{p.size != null ? `${p.size} B` : ""}
{p.size != null ? `${p.size} B` : "\u2014"}
</TableCell>
<TableCell className="text-right w-8">
<Popover>
<PopoverTrigger
render={
<button
onClick={(e) => e.stopPropagation()}
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<MoreVertical className="w-4 h-4" />
</button>
}
<PageActions
name={p.name}
published={p.published}
onPublish={() => handlePublish(p.name)}
onUnpublish={() => handleUnpublish(p.name)}
onDelete={() => setPageToDelete(p.name)}
/>
<PopoverContent side="bottom" align="end" sideOffset={4}
className="w-36 p-1"
>
<button
onClick={(e) => { e.stopPropagation(); navigate(`/editor/${p.name}`); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Edit</button>
{p.published ? (
<button
onClick={(e) => { e.stopPropagation(); handleUnpublish(p.name); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Unpublish</button>
) : (
<button
onClick={(e) => { e.stopPropagation(); handlePublish(p.name); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Publish</button>
)}
<button
onClick={(e) => { e.stopPropagation(); setPageToDelete(p.name); }}
className="w-full text-left px-3 py-1.5 text-xs text-destructive hover:bg-accent transition-colors cursor-pointer"
>Delete</button>
</PopoverContent>
</Popover>
</TableCell>
</TableRow>
))}
@@ -204,7 +159,7 @@ export default function DashboardView() {
)}
</TableBody>
</Table>
</div>{/* end bordered container */}
</div>
<AlertDialog
open={pageToDelete !== null}
@@ -232,3 +187,57 @@ export default function DashboardView() {
</div>
);
}
/** 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 (
<Popover>
<PopoverTrigger
render={
<button
onClick={(e) => e.stopPropagation()}
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<MoreVertical className="w-4 h-4" />
</button>
}
/>
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-36 p-1">
<button
onClick={(e) => { e.stopPropagation(); navigate(`/editor/${name}`); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Edit</button>
{published ? (
<button
onClick={(e) => { e.stopPropagation(); onUnpublish(); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Unpublish</button>
) : (
<button
onClick={(e) => { e.stopPropagation(); onPublish(); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Publish</button>
)}
<button
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="w-full text-left px-3 py-1.5 text-xs text-destructive hover:bg-accent transition-colors cursor-pointer"
>Delete</button>
</PopoverContent>
</Popover>
);
}

View File

@@ -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,14 +78,11 @@ export default function EditorView() {
reset();
if (name) {
setPageName(name);
fetch(`/api/pages/${name}`)
.then((r) => r.json())
.then((data) => {
api.fetchPage(name).then((data) => {
if (data.source != null) {
useEditorStore.setState({
ufSource: data.source,
isDirty: false,
currentPage: data,
});
}
});
@@ -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) {

View File

@@ -1,101 +0,0 @@
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import {
ReactFlow,
Background,
Controls,
type Node,
type Edge,
} from "@xyflow/react";
import dagre from "@dagrejs/dagre";
import { useGraph } from "@/hooks/useGraph";
import "@xyflow/react/dist/style.css";
const NODE_WIDTH = 160;
const NODE_HEIGHT = 50;
function layoutGraph(
nodes: Node[],
edges: Edge[]
): { nodes: Node[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: "TB", nodesep: 50, ranksep: 80 });
nodes.forEach((n) =>
g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
);
edges.forEach((e) => g.setEdge(e.source, e.target));
dagre.layout(g);
const laid = nodes.map((n) => {
const pos = g.node(n.id);
return {
...n,
position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 },
};
});
return { nodes: laid, edges };
}
export default function GraphView() {
const { data, loading } = useGraph();
const navigate = useNavigate();
const { nodes, edges } = useMemo(() => {
if (!data) return { nodes: [], edges: [] };
const rfNodes: Node[] = data.nodes.map((n) => ({
id: n.id,
data: { label: n.title ?? n.id },
position: { x: 0, y: 0 },
style: {
background: n.published
? "oklch(0.488 0.14 145)"
: "oklch(0.7 0.15 80)",
color: "#fff",
border:
n.id === "index"
? "2px solid oklch(0.6 0.2 250)"
: "1px solid oklch(1 0 0 / 10%)",
borderRadius: 8,
padding: "8px 16px",
fontSize: 13,
fontWeight: n.id === "index" ? 700 : 400,
width: NODE_WIDTH,
},
}));
const rfEdges: Edge[] = data.edges.map((e, i) => ({
id: `e-${i}`,
source: e.source,
target: e.target,
style: { stroke: "oklch(0.556 0 0)" },
}));
return layoutGraph(rfNodes, rfEdges);
}, [data]);
if (loading)
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading graph...
</div>
);
return (
<div className="h-full bg-background">
<ReactFlow
nodes={nodes}
edges={edges}
onNodeClick={(_, node) => navigate(`/editor/${node.id}`)}
fitView
proOptions={{ hideAttribution: true }}
>
<Background color="oklch(0.269 0 0)" gap={20} />
<Controls />
</ReactFlow>
</div>
);
}

View File

@@ -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

View File

@@ -1,11 +1,12 @@
import { create } from "zustand";
import type { PageMeta } from "@/stores/editorStore";
import * as api from "@/api/client";
interface PagesStore {
pages: PageMeta[];
pages: api.PageMeta[];
isLoading: boolean;
fetchPages: () => Promise<void>;
deletePage: (name: string) => Promise<void>;
publishPage: (name: string) => Promise<void>;
unpublishPage: (name: string) => Promise<void>;
}
@@ -16,8 +17,7 @@ export const usePagesStore = create<PagesStore>((set, get) => ({
fetchPages: async () => {
set({ isLoading: true });
try {
const res = await fetch("/api/pages");
const pages = await res.json();
const pages = await api.fetchPages();
set({ pages });
} finally {
set({ isLoading: false });
@@ -25,20 +25,22 @@ export const usePagesStore = create<PagesStore>((set, get) => ({
},
deletePage: async (name: string) => {
await fetch(`/api/pages/${name}`, { method: "DELETE" });
await api.deletePage(name);
await get().fetchPages();
},
publishPage: async (name: string) => {
const page = await api.fetchPage(name);
if (page.source) {
await api.savePage(name, page.source, true);
}
await get().fetchPages();
},
unpublishPage: async (name: string) => {
// Fetch current source, re-save as draft only
const res = await fetch(`/api/pages/${name}`);
const data = await res.json();
if (data.source) {
await fetch(`/api/pages/${name}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: data.source, publish: false }),
});
const page = await api.fetchPage(name);
if (page.source) {
await api.savePage(name, page.source, false);
}
await get().fetchPages();
},