From 0642d0f894e58d4ed28ca6782a3ffb6e10652529 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 1 Apr 2026 12:48:46 +0200 Subject: [PATCH] feat: sync registry with suggestions --- backend/converter.py | 42 ++- backend/requirements.txt | 1 + frontend/src/components/editor/ToolBar.tsx | 57 +++- .../src/components/editor/uframeCommands.ts | 289 +++++------------- frontend/src/routes/EditorView.tsx | 15 +- 5 files changed, 173 insertions(+), 231 deletions(-) diff --git a/backend/converter.py b/backend/converter.py index c7fd535..b23480e 100644 --- a/backend/converter.py +++ b/backend/converter.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 0b98d19..ed8310f 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/components/editor/ToolBar.tsx b/frontend/src/components/editor/ToolBar.tsx index 1f933fe..a2a0c66 100644 --- a/frontend/src/components/editor/ToolBar.tsx +++ b/frontend/src/components/editor/ToolBar.tsx @@ -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({ + onInsertExample( + `image "${path}" braille 30\n align center` + )} /> +
@@ -108,3 +113,51 @@ export default function ToolBar({
); } + + +function UploadImageButton({ onUploaded }: { onUploaded: (path: string) => void }) { + const fileRef = useRef(null); + const [uploading, setUploading] = useState(false); + + const handleUpload = async (e: React.ChangeEvent) => { + 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 ( + <> + + + + ); +} diff --git a/frontend/src/components/editor/uframeCommands.ts b/frontend/src/components/editor/uframeCommands.ts index 6a4c93d..ff89248 100644 --- a/frontend/src/components/editor/uframeCommands.ts +++ b/frontend/src/components/editor/uframeCommands.ts @@ -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 { + 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, diff --git a/frontend/src/routes/EditorView.tsx b/frontend/src/routes/EditorView.tsx index 1a23889..180b7ac 100644 --- a/frontend/src/routes/EditorView.tsx +++ b/frontend/src/routes/EditorView.tsx @@ -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();