feat: sync registry with suggestions
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
"""µFrame compile + DSL metadata endpoints."""
|
"""µFrame compile, DSL metadata, and image upload endpoints."""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import uframe
|
import uframe
|
||||||
@@ -10,6 +13,8 @@ from uframe.registry import get_dsl_meta
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
UPLOAD_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) / "images"
|
||||||
|
|
||||||
|
|
||||||
class CompileRequest(BaseModel):
|
class CompileRequest(BaseModel):
|
||||||
source: str
|
source: str
|
||||||
@@ -44,3 +49,36 @@ async def compile_source(req: CompileRequest):
|
|||||||
async def dsl_meta():
|
async def dsl_meta():
|
||||||
"""Return DSL metadata for frontend syntax highlighting and autocomplete."""
|
"""Return DSL metadata for frontend syntax highlighting and autocomplete."""
|
||||||
return get_dsl_meta()
|
return get_dsl_meta()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/upload-image")
|
||||||
|
async def upload_image(file: UploadFile = File(...)):
|
||||||
|
"""Upload an image for use in .uf pages."""
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Sanitize filename
|
||||||
|
name = file.filename or "upload.png"
|
||||||
|
safe_name = "".join(c for c in name if c.isalnum() or c in "._-").rstrip(".")
|
||||||
|
if not safe_name:
|
||||||
|
safe_name = "upload.png"
|
||||||
|
dest = UPLOAD_DIR / safe_name
|
||||||
|
content = await file.read()
|
||||||
|
dest.write_bytes(content)
|
||||||
|
# Return the path relative to backend working directory
|
||||||
|
rel_path = str(dest)
|
||||||
|
return {"path": rel_path, "filename": safe_name, "size": len(content)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/images")
|
||||||
|
async def list_images():
|
||||||
|
"""List uploaded images available for embedding."""
|
||||||
|
if not UPLOAD_DIR.is_dir():
|
||||||
|
return []
|
||||||
|
images = []
|
||||||
|
for f in sorted(UPLOAD_DIR.iterdir()):
|
||||||
|
if f.suffix.lower() in (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".gif"):
|
||||||
|
images.append({
|
||||||
|
"filename": f.name,
|
||||||
|
"path": str(f),
|
||||||
|
"size": f.stat().st_size,
|
||||||
|
})
|
||||||
|
return images
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ fastapi>=0.115
|
|||||||
uvicorn[standard]>=0.34
|
uvicorn[standard]>=0.34
|
||||||
docker>=7.0
|
docker>=7.0
|
||||||
Pillow>=10.0
|
Pillow>=10.0
|
||||||
|
python-multipart>=0.0.6
|
||||||
md2txt @ git+https://codeberg.org/randogoth/md2txt
|
md2txt @ git+https://codeberg.org/randogoth/md2txt
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { BookOpen } from "lucide-react";
|
import { BookOpen, ImagePlus } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -86,6 +87,10 @@ export default function ToolBar({
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
|
<UploadImageButton onUploaded={(path) => onInsertExample(
|
||||||
|
`image "${path}" braille 30\n align center`
|
||||||
|
)} />
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<BacklinkIndicator backlinks={backlinks} />
|
<BacklinkIndicator backlinks={backlinks} />
|
||||||
@@ -108,3 +113,51 @@ export default function ToolBar({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function UploadImageButton({ onUploaded }: { onUploaded: (path: string) => void }) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
const res = await fetch("/api/upload-image", { method: "POST", body: form });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const data = await res.json();
|
||||||
|
toast.success(`Uploaded ${data.filename}`);
|
||||||
|
onUploaded(data.path);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(`Upload failed: ${err}`);
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleUpload}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 rounded-md text-xs font-medium h-8 px-3 border border-input bg-background hover:bg-accent hover:text-accent-foreground transition-colors disabled:opacity-50"
|
||||||
|
title="Upload image for embedding"
|
||||||
|
>
|
||||||
|
<ImagePlus className="h-3.5 w-3.5" />
|
||||||
|
<span>{uploading ? "Uploading…" : "Image"}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ import type {
|
|||||||
} from "@codemirror/autocomplete";
|
} from "@codemirror/autocomplete";
|
||||||
import type { EditorView } from "@codemirror/view";
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* µFrame slash command palette — auto-populated from /api/dsl-meta.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
interface CmdEntry {
|
interface CmdEntry {
|
||||||
label: string;
|
label: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
@@ -13,6 +25,13 @@ interface CmdEntry {
|
|||||||
apply: Completion["apply"];
|
apply: Completion["apply"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__uframeCommands?: CmdEntry[];
|
||||||
|
__uframeCommandsLoaded?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function insert(text: string): Completion["apply"] {
|
function insert(text: string): Completion["apply"] {
|
||||||
return (view: EditorView, _c: Completion, from: number, to: number) => {
|
return (view: EditorView, _c: Completion, from: number, to: number) => {
|
||||||
view.dispatch({ changes: { from: from - 1, to, insert: text } });
|
view.dispatch({ changes: { from: from - 1, to, insert: text } });
|
||||||
@@ -26,223 +45,61 @@ function slashSnippet(template: string): Completion["apply"] {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMMANDS: CmdEntry[] = [
|
// Minimal fallback commands (used before API loads)
|
||||||
// Layout
|
const FALLBACK_COMMANDS: CmdEntry[] = [
|
||||||
{
|
{ label: "page", detail: 'page "Title" 64', section: "Layout", apply: slashSnippet('page "${title}" ${width:64}') },
|
||||||
label: "page",
|
{ label: "box", detail: 'box light "Title"', section: "Layout", apply: slashSnippet('box ${weight:light} "${title}"') },
|
||||||
detail: 'page "Title" 64',
|
{ label: "heading", detail: 'heading 1 "Text"', section: "Content", apply: slashSnippet('heading ${level:1} "${text}"') },
|
||||||
section: "Layout",
|
{ label: "text", detail: 'text "Content"', section: "Content", apply: slashSnippet('text "${content}"') },
|
||||||
apply: slashSnippet('page "${title}" ${width:64}'),
|
{ label: "gauge", detail: "gauge label val max", section: "Data", apply: slashSnippet('gauge "${label}" ${value} ${max:100} ${width:28}') },
|
||||||
},
|
{ label: "status", detail: "status label state", section: "Data", apply: slashSnippet('status "${label}" ${state:online}') },
|
||||||
{
|
|
||||||
label: "box",
|
|
||||||
detail: 'box light "Title"',
|
|
||||||
section: "Layout",
|
|
||||||
apply: slashSnippet('box ${weight:light} "${title}"'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "row",
|
|
||||||
detail: "row [gap]",
|
|
||||||
section: "Layout",
|
|
||||||
apply: slashSnippet("row ${gap:2}"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "col",
|
|
||||||
detail: "col [width]",
|
|
||||||
section: "Layout",
|
|
||||||
apply: slashSnippet("col ${width}"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "spacer",
|
|
||||||
detail: "spacer [lines]",
|
|
||||||
section: "Layout",
|
|
||||||
apply: insert("spacer"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "pad",
|
|
||||||
detail: "pad t r b l",
|
|
||||||
section: "Layout",
|
|
||||||
apply: slashSnippet("pad ${top:1} ${right:1} ${bottom:1} ${left:1}"),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Content
|
|
||||||
{
|
|
||||||
label: "heading",
|
|
||||||
detail: 'heading 1 "Text"',
|
|
||||||
section: "Content",
|
|
||||||
apply: slashSnippet('heading ${level:1} "${text}"'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "text",
|
|
||||||
detail: 'text "Content"',
|
|
||||||
section: "Content",
|
|
||||||
apply: slashSnippet('text "${content}"'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "label",
|
|
||||||
detail: 'label "Key" "Value"',
|
|
||||||
section: "Content",
|
|
||||||
apply: slashSnippet('label "${key}" "${value}"'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "divider",
|
|
||||||
detail: "divider heavy",
|
|
||||||
section: "Content",
|
|
||||||
apply: slashSnippet("divider ${style:light}"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "link",
|
|
||||||
detail: 'link "Text" "/path.mu"',
|
|
||||||
section: "Content",
|
|
||||||
apply: slashSnippet('link "${display}" "${dest}"'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "list",
|
|
||||||
detail: "list bullet",
|
|
||||||
section: "Content",
|
|
||||||
apply: slashSnippet("list ${style:bullet}\n item \"${entry}\""),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Data Viz
|
|
||||||
{
|
|
||||||
label: "gauge",
|
|
||||||
detail: "gauge label val max width",
|
|
||||||
section: "Data",
|
|
||||||
apply: slashSnippet(
|
|
||||||
'gauge "${label}" ${value} ${max:100} ${width:28} warn=${warn:75} crit=${crit:90}',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "sparkline",
|
|
||||||
detail: "sparkline label values width",
|
|
||||||
section: "Data",
|
|
||||||
apply: slashSnippet('sparkline "${label}" "${values}" ${width:20}'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "status",
|
|
||||||
detail: "status label state",
|
|
||||||
section: "Data",
|
|
||||||
apply: slashSnippet('status "${label}" ${state:online}'),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Style
|
|
||||||
{
|
|
||||||
label: "align",
|
|
||||||
detail: "align center",
|
|
||||||
section: "Style",
|
|
||||||
apply: slashSnippet("align ${align:center}"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "color",
|
|
||||||
detail: "color hex",
|
|
||||||
section: "Style",
|
|
||||||
apply: slashSnippet("color ${hex}"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "bold",
|
|
||||||
detail: "bold",
|
|
||||||
section: "Style",
|
|
||||||
apply: insert("bold"),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Table
|
|
||||||
{
|
|
||||||
label: "table",
|
|
||||||
detail: 'table + columns + rows',
|
|
||||||
section: "Data",
|
|
||||||
apply: insert(
|
|
||||||
`table "Title"\n columns "Name" 20 | "Value" 10\n row "entry" | "data"`,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Themes — type /theme to filter all 6
|
|
||||||
{
|
|
||||||
label: "theme_default",
|
|
||||||
detail: "clean box-drawing ┌─┐●█░",
|
|
||||||
section: "Theme",
|
|
||||||
apply: insert("theme default"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "theme_nouveau",
|
|
||||||
detail: "flowing ornament ☙❧❀▐",
|
|
||||||
section: "Theme",
|
|
||||||
apply: insert("theme nouveau"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "theme_gothic",
|
|
||||||
detail: "blackletter ╬═║⚑▓",
|
|
||||||
section: "Theme",
|
|
||||||
apply: insert("theme gothic"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "theme_bamboo",
|
|
||||||
detail: "minimal brush 〔〕◉┄",
|
|
||||||
section: "Theme",
|
|
||||||
apply: insert("theme bamboo"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "theme_circuit",
|
|
||||||
detail: "digital neon ╒▰◈⚡",
|
|
||||||
section: "Theme",
|
|
||||||
apply: insert("theme circuit"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "theme_brutalist",
|
|
||||||
detail: "raw blocks █▌■□",
|
|
||||||
section: "Theme",
|
|
||||||
apply: insert("theme brutalist"),
|
|
||||||
},
|
|
||||||
|
|
||||||
// Templates
|
|
||||||
{
|
|
||||||
label: "dashboard",
|
|
||||||
detail: "Full dashboard template",
|
|
||||||
section: "Template",
|
|
||||||
apply: insert(
|
|
||||||
`page "Dashboard" 64
|
|
||||||
box double "Node Status"
|
|
||||||
align center
|
|
||||||
text "Reticulum Network Node"
|
|
||||||
|
|
||||||
spacer
|
|
||||||
|
|
||||||
heading 1 "Resources"
|
|
||||||
|
|
||||||
gauge "CPU" 0 100 28 warn=75 crit=90
|
|
||||||
gauge "MEM" 0 100 28 warn=80 crit=95
|
|
||||||
|
|
||||||
spacer
|
|
||||||
|
|
||||||
heading 2 "Network"
|
|
||||||
|
|
||||||
status "Relay East" online
|
|
||||||
status "Bridge South" online
|
|
||||||
|
|
||||||
divider heavy
|
|
||||||
link "Home" "/page/index.mu"`,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Dynamic commands from /api/dsl-meta — merged into COMMANDS
|
function getCommands(): CmdEntry[] {
|
||||||
let dynamicCommands: CmdEntry[] = [];
|
return window.__uframeCommands ?? FALLBACK_COMMANDS;
|
||||||
|
}
|
||||||
|
|
||||||
/** Update slash commands from /api/dsl-meta response. */
|
/**
|
||||||
export function setDslCommands(
|
* Load the full command list from /api/dsl-meta.
|
||||||
commands: { label: string; detail: string; section: string; snippet: string }[],
|
* Called once on editor mount. Replaces the fallback set with the
|
||||||
) {
|
* complete registry-driven list.
|
||||||
// Build dynamic commands from API data, only for entries not already in COMMANDS
|
*/
|
||||||
const existingLabels = new Set(COMMANDS.map((c) => c.label));
|
export async function loadCommandsFromApi(): Promise<void> {
|
||||||
dynamicCommands = commands
|
if (window.__uframeCommandsLoaded) return;
|
||||||
.filter((c) => c.detail && c.snippet && !existingLabels.has(c.label))
|
|
||||||
.map((c) => ({
|
try {
|
||||||
label: c.label,
|
const res = await fetch("/api/dsl-meta");
|
||||||
detail: c.detail,
|
if (!res.ok) return;
|
||||||
section: c.section,
|
const data = await res.json();
|
||||||
apply: c.snippet.includes("${")
|
const apiCommands: CmdEntry[] = [];
|
||||||
? slashSnippet(c.snippet)
|
|
||||||
: insert(c.snippet),
|
for (const cmd of data.commands ?? []) {
|
||||||
}));
|
if (!cmd.detail || !cmd.snippet) continue;
|
||||||
|
apiCommands.push({
|
||||||
|
label: cmd.label,
|
||||||
|
detail: cmd.detail,
|
||||||
|
section: cmd.section,
|
||||||
|
apply: cmd.snippet.includes("${")
|
||||||
|
? slashSnippet(cmd.snippet)
|
||||||
|
: insert(cmd.snippet),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the dashboard template (not in registry)
|
||||||
|
apiCommands.push({
|
||||||
|
label: "dashboard",
|
||||||
|
detail: "Full dashboard template",
|
||||||
|
section: "Template",
|
||||||
|
apply: insert(
|
||||||
|
`page "Dashboard" 64\n box double "Node Status"\n align center\n text "Reticulum Network Node"\n\n spacer\n\n heading 1 "Resources"\n\n gauge "CPU" 0 100 28 warn=75 crit=90\n gauge "MEM" 0 100 28 warn=80 crit=95\n\n spacer\n\n heading 2 "Network"\n\n status "Relay East" online\n status "Bridge South" online\n\n divider heavy\n link "Home" "/page/index.mu"`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.__uframeCommands = apiCommands;
|
||||||
|
window.__uframeCommandsLoaded = true;
|
||||||
|
} catch {
|
||||||
|
// Keep using fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uframeCommandSource(
|
export function uframeCommandSource(
|
||||||
@@ -251,12 +108,10 @@ export function uframeCommandSource(
|
|||||||
const match = ctx.matchBefore(/\/\w*/);
|
const match = ctx.matchBefore(/\/\w*/);
|
||||||
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
||||||
|
|
||||||
const allCommands = [...COMMANDS, ...dynamicCommands];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
from: match.from + 1,
|
from: match.from + 1,
|
||||||
filter: true,
|
filter: true,
|
||||||
options: allCommands.map((cmd) => ({
|
options: getCommands().map((cmd) => ({
|
||||||
label: cmd.label,
|
label: cmd.label,
|
||||||
detail: cmd.detail,
|
detail: cmd.detail,
|
||||||
section: cmd.section,
|
section: cmd.section,
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import { useEditorStore } from "@/stores/editorStore";
|
|||||||
import { usePagesStore } from "@/stores/pagesStore";
|
import { usePagesStore } from "@/stores/pagesStore";
|
||||||
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
|
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
|
||||||
import { useCompile } from "@/hooks/useCompile";
|
import { useCompile } from "@/hooks/useCompile";
|
||||||
import { useDslMeta } from "@/hooks/useDslMeta";
|
import { uframeHighlight } from "@/components/editor/uframeHighlight";
|
||||||
import { uframeHighlight, setDslKeywords } from "@/components/editor/uframeHighlight";
|
import { uframeCommandSource, loadCommandsFromApi } from "@/components/editor/uframeCommands";
|
||||||
import { uframeCommandSource, setDslCommands } from "@/components/editor/uframeCommands";
|
|
||||||
import EditorPane from "@/components/editor/EditorPane";
|
import EditorPane from "@/components/editor/EditorPane";
|
||||||
import PreviewPane from "@/components/editor/PreviewPane";
|
import PreviewPane from "@/components/editor/PreviewPane";
|
||||||
import ToolBar from "@/components/editor/ToolBar";
|
import ToolBar from "@/components/editor/ToolBar";
|
||||||
@@ -48,14 +47,10 @@ export default function EditorView() {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Load DSL metadata (keywords, values, commands) from backend
|
// Load DSL commands from backend registry on mount
|
||||||
const dslMeta = useDslMeta();
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dslMeta.keywords.length > 0) {
|
loadCommandsFromApi();
|
||||||
setDslKeywords(dslMeta.keywords, dslMeta.values);
|
}, []);
|
||||||
setDslCommands(dslMeta.commands);
|
|
||||||
}
|
|
||||||
}, [dslMeta]);
|
|
||||||
|
|
||||||
// Auto-compile on source changes
|
// Auto-compile on source changes
|
||||||
useCompile();
|
useCompile();
|
||||||
|
|||||||
Reference in New Issue
Block a user