feat: sync registry with suggestions

This commit is contained in:
2026-04-01 12:48:46 +02:00
parent 528d2d8519
commit 0642d0f894
5 changed files with 173 additions and 231 deletions

View File

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

View File

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

View File

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