Files
micronomicon/frontend/src/routes/EditorView.tsx
2026-04-05 10:24:01 +02:00

146 lines
4.3 KiB
TypeScript

import { useEffect, useState, useCallback, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import * as api from "@/api/client";
import { autocompletion } from "@codemirror/autocomplete";
import { useEditorStore } from "@/stores/editorStore";
import { usePagesStore } from "@/stores/pagesStore";
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
import { useCompile } from "@/hooks/useCompile";
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
import { uframeHighlight } from "@/components/editor/uframeHighlight";
import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "@/components/editor/uframeCommands";
import { keywordHoverTooltip } from "@/components/editor/uframeHover";
import EditorPointer from "@/components/editor/EditorPointer";
import PreviewPane from "@/components/editor/PreviewPane";
import SourcePane from "@/components/editor/SourcePane";
import ToolBar from "@/components/editor/ToolBar";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
export default function EditorView() {
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const isNew = !name;
const ufSource = useEditorStore((s) => s.ufSource);
const isDirty = useEditorStore((s) => s.isDirty);
const setSource = useEditorStore((s) => s.setSource);
const setDirty = useEditorStore((s) => s.setDirty);
const setCurrentPage = useEditorStore((s) => s.setCurrentPage);
const reset = useEditorStore((s) => s.reset);
const { fetchPages } = usePagesStore();
const [pageName, setPageName] = useState(name ?? "");
const [saving, setSaving] = useState(false);
// µFrame extensions: syntax highlighting + slash commands
const extensions = useMemo(
() => [
...uframeHighlight(),
autocompletion({
override: [uframeCommandSource, uframeValueHintSource],
icons: false,
activateOnTyping: true,
}),
keywordHoverTooltip,
],
[],
);
// Load DSL commands from backend registry on mount
useEffect(() => {
loadCommandsFromApi();
}, []);
// Auto-compile on source changes
useCompile();
useUnsavedGuard();
// Fetch pages for backlinks
useEffect(() => {
fetchPages();
}, []);
// Load page on mount / route change
useEffect(() => {
reset();
if (name) {
setPageName(name);
api.fetchPage(name).then((data) => {
if (data.source != null) {
setSource(data.source);
setDirty(false);
}
});
}
return () => reset();
}, [name]);
const handleSave = useCallback(
async (publish: boolean) => {
const slug = pageName
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-");
if (!slug) {
toast.error("Enter a page name.");
return;
}
setSaving(true);
try {
const meta = await api.savePage(slug, ufSource, publish);
setCurrentPage(meta);
setDirty(false);
fetchPages();
toast.success(publish ? "Published" : "Draft saved");
if (isNew) navigate(`/editor/${slug}`, { replace: true });
} catch (e) {
toast.error(`Save failed: ${e}`);
} finally {
setSaving(false);
}
},
[pageName, ufSource, isNew, navigate],
);
useKeyboardSave(
useCallback(() => handleSave(false), [handleSave]),
useCallback(() => handleSave(true), [handleSave]),
);
return (
<div className="flex flex-col h-full">
<EditorPointer />
<div className="flex flex-col flex-1 min-h-0">
<ToolBar
pageName={pageName}
onNameChange={isNew ? setPageName : undefined}
onSaveDraft={() => handleSave(false)}
onPublish={() => handleSave(true)}
saving={saving}
isDirty={isDirty}
/>
<ResizablePanelGroup orientation="horizontal" className="flex-1 min-h-0">
<ResizablePanel defaultSize={50} minSize={20}>
<SourcePane
ufSource={ufSource}
setSource={setSource}
extensions={extensions}
/>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={50} minSize={20}>
<PreviewPane />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
);
}