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
|
||||
|
||||
import uframe
|
||||
@@ -10,6 +13,8 @@ from uframe.registry import get_dsl_meta
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
UPLOAD_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) / "images"
|
||||
|
||||
|
||||
class CompileRequest(BaseModel):
|
||||
source: str
|
||||
@@ -44,3 +49,36 @@ async def compile_source(req: CompileRequest):
|
||||
async def dsl_meta():
|
||||
"""Return DSL metadata for frontend syntax highlighting and autocomplete."""
|
||||
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
|
||||
docker>=7.0
|
||||
Pillow>=10.0
|
||||
python-multipart>=0.0.6
|
||||
md2txt @ git+https://codeberg.org/randogoth/md2txt
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { BookOpen, ImagePlus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -86,6 +87,10 @@ export default function ToolBar({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<UploadImageButton onUploaded={(path) => onInsertExample(
|
||||
`image "${path}" braille 30\n align center`
|
||||
)} />
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<BacklinkIndicator backlinks={backlinks} />
|
||||
@@ -108,3 +113,51 @@ export default function ToolBar({
|
||||
</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";
|
||||
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 {
|
||||
label: string;
|
||||
detail: string;
|
||||
@@ -13,6 +25,13 @@ interface CmdEntry {
|
||||
apply: Completion["apply"];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__uframeCommands?: CmdEntry[];
|
||||
__uframeCommandsLoaded?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
function insert(text: string): Completion["apply"] {
|
||||
return (view: EditorView, _c: Completion, from: number, to: number) => {
|
||||
view.dispatch({ changes: { from: from - 1, to, insert: text } });
|
||||
@@ -26,223 +45,61 @@ function slashSnippet(template: string): Completion["apply"] {
|
||||
};
|
||||
}
|
||||
|
||||
const COMMANDS: CmdEntry[] = [
|
||||
// Layout
|
||||
{
|
||||
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}"'),
|
||||
},
|
||||
{
|
||||
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"`,
|
||||
),
|
||||
},
|
||||
// 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}"') },
|
||||
{ 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: "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}') },
|
||||
];
|
||||
|
||||
// Dynamic commands from /api/dsl-meta — merged into COMMANDS
|
||||
let dynamicCommands: CmdEntry[] = [];
|
||||
function getCommands(): CmdEntry[] {
|
||||
return window.__uframeCommands ?? FALLBACK_COMMANDS;
|
||||
}
|
||||
|
||||
/** Update slash commands from /api/dsl-meta response. */
|
||||
export function setDslCommands(
|
||||
commands: { label: string; detail: string; section: string; snippet: string }[],
|
||||
) {
|
||||
// Build dynamic commands from API data, only for entries not already in COMMANDS
|
||||
const existingLabels = new Set(COMMANDS.map((c) => c.label));
|
||||
dynamicCommands = commands
|
||||
.filter((c) => c.detail && c.snippet && !existingLabels.has(c.label))
|
||||
.map((c) => ({
|
||||
label: c.label,
|
||||
detail: c.detail,
|
||||
section: c.section,
|
||||
apply: c.snippet.includes("${")
|
||||
? slashSnippet(c.snippet)
|
||||
: insert(c.snippet),
|
||||
}));
|
||||
/**
|
||||
* Load the full command list from /api/dsl-meta.
|
||||
* Called once on editor mount. Replaces the fallback set with the
|
||||
* complete registry-driven list.
|
||||
*/
|
||||
export async function loadCommandsFromApi(): Promise<void> {
|
||||
if (window.__uframeCommandsLoaded) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/dsl-meta");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const apiCommands: CmdEntry[] = [];
|
||||
|
||||
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(
|
||||
@@ -251,12 +108,10 @@ export function uframeCommandSource(
|
||||
const match = ctx.matchBefore(/\/\w*/);
|
||||
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
||||
|
||||
const allCommands = [...COMMANDS, ...dynamicCommands];
|
||||
|
||||
return {
|
||||
from: match.from + 1,
|
||||
filter: true,
|
||||
options: allCommands.map((cmd) => ({
|
||||
options: getCommands().map((cmd) => ({
|
||||
label: cmd.label,
|
||||
detail: cmd.detail,
|
||||
section: cmd.section,
|
||||
|
||||
@@ -6,9 +6,8 @@ import { useEditorStore } from "@/stores/editorStore";
|
||||
import { usePagesStore } from "@/stores/pagesStore";
|
||||
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
|
||||
import { useCompile } from "@/hooks/useCompile";
|
||||
import { useDslMeta } from "@/hooks/useDslMeta";
|
||||
import { uframeHighlight, setDslKeywords } from "@/components/editor/uframeHighlight";
|
||||
import { uframeCommandSource, setDslCommands } from "@/components/editor/uframeCommands";
|
||||
import { uframeHighlight } from "@/components/editor/uframeHighlight";
|
||||
import { uframeCommandSource, loadCommandsFromApi } from "@/components/editor/uframeCommands";
|
||||
import EditorPane from "@/components/editor/EditorPane";
|
||||
import PreviewPane from "@/components/editor/PreviewPane";
|
||||
import ToolBar from "@/components/editor/ToolBar";
|
||||
@@ -48,14 +47,10 @@ export default function EditorView() {
|
||||
[],
|
||||
);
|
||||
|
||||
// Load DSL metadata (keywords, values, commands) from backend
|
||||
const dslMeta = useDslMeta();
|
||||
// Load DSL commands from backend registry on mount
|
||||
useEffect(() => {
|
||||
if (dslMeta.keywords.length > 0) {
|
||||
setDslKeywords(dslMeta.keywords, dslMeta.values);
|
||||
setDslCommands(dslMeta.commands);
|
||||
}
|
||||
}, [dslMeta]);
|
||||
loadCommandsFromApi();
|
||||
}, []);
|
||||
|
||||
// Auto-compile on source changes
|
||||
useCompile();
|
||||
|
||||
Reference in New Issue
Block a user