feat: clean up
This commit is contained in:
139
backend/graph.py
139
backend/graph.py
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
131
frontend/src/api/client.ts
Normal 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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// NavBar removed — frame.svg is the visual wrapper now
|
||||
export default function NavBar() {
|
||||
return 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 (
|
||||
<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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user