feat: added a twist
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
46
frontend/README.md
Normal file
46
frontend/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Micronomicon — Frontend
|
||||
|
||||
React 19 + Vite + TypeScript + shadcn/ui. See the [root README](../README.md) for full project docs.
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5173 — proxies /api to localhost:8080
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build # output → dist/ (copied into Docker image)
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
src/
|
||||
App.tsx ← routes
|
||||
routes/
|
||||
DashboardView.tsx ← page table
|
||||
EditorView.tsx ← split-pane editor
|
||||
GraphView.tsx ← React Flow graph
|
||||
components/
|
||||
dashboard/StatusBadge.tsx
|
||||
editor/
|
||||
EditorPane.tsx ← CodeMirror 6
|
||||
PreviewPane.tsx ← micron output (ScrollArea)
|
||||
ToolBar.tsx
|
||||
shared/
|
||||
AppShell.tsx ← TooltipProvider + Toaster wrapper
|
||||
NavBar.tsx
|
||||
ui/ ← shadcn components (auto-generated)
|
||||
lib/
|
||||
utils.ts ← cn() utility
|
||||
stores/
|
||||
editorStore.ts ← Zustand: markdown, micron, dirty state
|
||||
pagesStore.ts ← Zustand: pages list, fetch/delete
|
||||
hooks/
|
||||
useConversion.ts ← debounced POST /api/convert
|
||||
useGraph.ts ← GET /api/graph
|
||||
useUnsavedGuard.ts ← beforeunload + dirty guard
|
||||
```
|
||||
25
frontend/components.json
Normal file
25
frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Micronomicon</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
7444
frontend/package-lock.json
generated
Normal file
7444
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
frontend/package.json
Normal file
55
frontend/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "micronomicon",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.40.0",
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-resizable-panels": "^4.8.0",
|
||||
"react-router-dom": "^7.13.2",
|
||||
"shadcn": "^4.1.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.57.0",
|
||||
"vite": "^8.0.1"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
frontend/public/icons.svg
Normal file
24
frontend/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
18
frontend/src/App.tsx
Normal file
18
frontend/src/App.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import AppShell from "./components/shared/AppShell";
|
||||
import DashboardView from "./routes/DashboardView";
|
||||
import EditorView from "./routes/EditorView";
|
||||
import GraphView from "./routes/GraphView";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AppShell>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardView />} />
|
||||
<Route path="/editor/new" element={<EditorView />} />
|
||||
<Route path="/editor/:name" element={<EditorView />} />
|
||||
<Route path="/graph" element={<GraphView />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
12
frontend/src/components/dashboard/StatusBadge.tsx
Normal file
12
frontend/src/components/dashboard/StatusBadge.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
interface Props {
|
||||
published: boolean;
|
||||
hasSource: boolean;
|
||||
}
|
||||
|
||||
export default function StatusBadge({ published, hasSource }: Props) {
|
||||
if (published && hasSource) return <Badge>Published</Badge>;
|
||||
if (!published && hasSource) return <Badge variant="secondary">Draft</Badge>;
|
||||
return <Badge variant="outline">Orphan</Badge>;
|
||||
}
|
||||
44
frontend/src/components/editor/BacklinkIndicator.tsx
Normal file
44
frontend/src/components/editor/BacklinkIndicator.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { BacklinkPage } from "@/hooks/useBacklinks";
|
||||
|
||||
interface Props {
|
||||
backlinks: BacklinkPage[];
|
||||
}
|
||||
|
||||
export default function BacklinkIndicator({ backlinks }: Props) {
|
||||
if (backlinks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-xs text-muted-foreground h-7 px-2">
|
||||
← {backlinks.length} backlink{backlinks.length !== 1 ? "s" : ""}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="end">
|
||||
<p className="text-xs font-semibold text-muted-foreground mb-2 px-1">
|
||||
Pages linking here
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{backlinks.map((page) => (
|
||||
<li key={page.name}>
|
||||
<Link
|
||||
to={`/editor/${page.name}`}
|
||||
className="flex items-center gap-1.5 text-sm px-2 py-1 rounded hover:bg-accent"
|
||||
>
|
||||
<span>{page.title ?? page.name}</span>
|
||||
{page.title && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
({page.name})
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
61
frontend/src/components/editor/EditorPane.tsx
Normal file
61
frontend/src/components/editor/EditorPane.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||
import { searchKeymap } from "@codemirror/search";
|
||||
import { oneDark } from "./oneDarkTheme";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
extensions?: Extension[];
|
||||
}
|
||||
|
||||
export default function EditorPane({ value, onChange, extensions = [] }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]),
|
||||
oneDark,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChangeRef.current(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
"&": { height: "100%", fontSize: "14px" },
|
||||
".cm-scroller": { overflow: "auto" },
|
||||
".cm-content": { fontFamily: "monospace", padding: "16px" },
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({ state, parent: containerRef.current });
|
||||
viewRef.current = view;
|
||||
|
||||
return () => view.destroy();
|
||||
}, []); // Only mount once
|
||||
|
||||
// Sync external value changes (e.g. loading a page)
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (view && view.state.doc.toString() !== value) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return <div ref={containerRef} style={{ height: "100%" }} />;
|
||||
}
|
||||
88
frontend/src/components/editor/PreviewPane.tsx
Normal file
88
frontend/src/components/editor/PreviewPane.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import { renderMicron } from "./micronRenderer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PreviewMode = "ascii" | "micron" | "raw";
|
||||
|
||||
export default function PreviewPane() {
|
||||
const previewMode = useEditorStore((s) => s.previewMode);
|
||||
const setPreviewMode = useEditorStore((s) => s.setPreviewMode);
|
||||
const compiledAscii = useEditorStore((s) => s.compiledAscii);
|
||||
const compiledMicron = useEditorStore((s) => s.compiledMicron);
|
||||
const isCompiling = useEditorStore((s) => s.isCompiling);
|
||||
const compileError = useEditorStore((s) => s.compileError);
|
||||
|
||||
const tabs: { value: PreviewMode; label: string }[] = [
|
||||
{ value: "ascii", label: "ASCII" },
|
||||
{ value: "micron", label: "Micron" },
|
||||
{ value: "raw", label: "Raw" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center px-3 py-1.5 border-b shrink-0 gap-2">
|
||||
<span className="text-xs text-muted-foreground flex-1">
|
||||
Preview
|
||||
{isCompiling && (
|
||||
<span className="ml-2 text-yellow-500 animate-pulse">
|
||||
compiling…
|
||||
</span>
|
||||
)}
|
||||
{compileError && (
|
||||
<span className="ml-2 text-red-400" title={compileError}>
|
||||
✗ error
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-0.5 bg-muted rounded-md p-0.5">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setPreviewMode(tab.value)}
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded transition-colors",
|
||||
previewMode === tab.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 bg-background">
|
||||
{previewMode === "ascii" ? (
|
||||
<pre className="p-4 font-mono text-sm whitespace-pre leading-tight text-green-100/90">
|
||||
{compiledAscii || (
|
||||
<span className="text-muted-foreground">
|
||||
ASCII preview will appear here…
|
||||
</span>
|
||||
)}
|
||||
</pre>
|
||||
) : previewMode === "micron" ? (
|
||||
compiledMicron ? (
|
||||
<div
|
||||
className="p-4 font-mono text-sm whitespace-pre leading-tight"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: renderMicron(compiledMicron),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Micron preview will appear here…
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<pre className="p-4 font-mono text-sm whitespace-pre-wrap break-words text-muted-foreground">
|
||||
{compiledMicron || "Raw Micron output will appear here…"}
|
||||
</pre>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/editor/ToolBar.tsx
Normal file
56
frontend/src/components/editor/ToolBar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import { useBacklinks } from "@/hooks/useBacklinks";
|
||||
import BacklinkIndicator from "@/components/editor/BacklinkIndicator";
|
||||
|
||||
interface Props {
|
||||
pageName: string;
|
||||
onNameChange?: (name: string) => void;
|
||||
onSaveDraft: () => void;
|
||||
onPublish: () => void;
|
||||
saving: boolean;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
export default function ToolBar({
|
||||
pageName,
|
||||
onNameChange,
|
||||
onSaveDraft,
|
||||
onPublish,
|
||||
saving,
|
||||
isDirty,
|
||||
}: Props) {
|
||||
const currentPage = useEditorStore((s) => s.currentPage);
|
||||
const backlinks = useBacklinks(currentPage?.name);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b bg-card shrink-0">
|
||||
{onNameChange ? (
|
||||
<Input
|
||||
value={pageName}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="page-name"
|
||||
className="font-mono w-48 h-8 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-mono font-semibold text-sm">{pageName}</span>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<BacklinkIndicator backlinks={backlinks} />
|
||||
|
||||
{isDirty && (
|
||||
<span className="text-xs text-muted-foreground">Unsaved</span>
|
||||
)}
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onSaveDraft} disabled={saving}>
|
||||
Save Draft
|
||||
</Button>
|
||||
<Button size="sm" onClick={onPublish} disabled={saving}>
|
||||
Publish
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/editor/micronHighlight.ts
Normal file
57
frontend/src/components/editor/micronHighlight.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { StreamLanguage, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
const micronLanguage = StreamLanguage.define({
|
||||
token(stream) {
|
||||
if (stream.sol()) {
|
||||
// Depth-4+ indent (before >>> so ">>>> " doesn't match heading3)
|
||||
if (stream.match(/>>>>/)) { stream.skipToEnd(); return "keyword"; }
|
||||
// Headings — longest prefix first
|
||||
if (stream.match(/>>>/)) { stream.skipToEnd(); return "heading3"; }
|
||||
if (stream.match(/>>/)) { stream.skipToEnd(); return "heading2"; }
|
||||
if (stream.match(/>/)) { stream.skipToEnd(); return "heading1"; }
|
||||
// Dividers: line starting with - followed by a non-space, non-dash char
|
||||
if (stream.match(/-[^\s\-]/)) { stream.skipToEnd(); return "contentSeparator"; }
|
||||
// Comment lines
|
||||
if (stream.match(/#/)) { stream.skipToEnd(); return "lineComment"; }
|
||||
// Standalone depth-reset "<"
|
||||
if (stream.string.trim() === "<") { stream.next(); return "meta"; }
|
||||
}
|
||||
|
||||
// Backtick-based format tags: `! `* `_ `` `F `f `B `b `c `r `l `a `= `<
|
||||
if (stream.match(/`[!*_`FfBbCcRrLlAa=<]/)) return "meta";
|
||||
|
||||
// Hex color values (exactly 3 hex digits) — appear right after `F or `B tags
|
||||
if (stream.match(/[0-9a-fA-F]{3}(?![0-9a-fA-F])/)) return "number";
|
||||
|
||||
// Links [label`url] — consume the whole bracket expression
|
||||
if (stream.match(/\[[^\]]*\]/)) return "link";
|
||||
|
||||
// Form elements <fieldname`default> etc.
|
||||
if (stream.match(/<[^>]+>/)) return "string";
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
startState: () => ({}),
|
||||
copyState: (s) => ({ ...s }),
|
||||
blankLine: () => {},
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
const micronStyle = HighlightStyle.define([
|
||||
{ tag: tags.heading1, color: "#7ee8a2", fontWeight: "bold" },
|
||||
{ tag: tags.heading2, color: "#70c4e8", fontWeight: "bold" },
|
||||
{ tag: tags.heading3, color: "#a8c4e8", fontWeight: "bold" },
|
||||
{ tag: tags.keyword, color: "#c9d1d9", fontStyle: "italic" }, // depth-4+ indent
|
||||
{ tag: tags.contentSeparator, color: "#484f58", fontStyle: "italic" },
|
||||
{ tag: tags.lineComment, color: "#484f58", fontStyle: "italic" }, // # comments
|
||||
{ tag: tags.meta, color: "#d2a8ff" }, // backtick format codes
|
||||
{ tag: tags.number, color: "#f8d4a8" }, // hex color values
|
||||
{ tag: tags.link, color: "#7dc4e4", textDecoration: "underline" },
|
||||
{ tag: tags.string, color: "#d4a8f8" }, // form elements
|
||||
]);
|
||||
|
||||
export function micronHighlight() {
|
||||
return [micronLanguage, syntaxHighlighting(micronStyle)];
|
||||
}
|
||||
157
frontend/src/components/editor/micronRenderer.ts
Normal file
157
frontend/src/components/editor/micronRenderer.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Micron markup → HTML renderer for the editor preview pane.
|
||||
* Spec: https://github.com/fr33n0w/micron-composer
|
||||
*/
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/** Render inline Micron formatting codes within a line of text. */
|
||||
function renderInline(raw: string): string {
|
||||
let out = "";
|
||||
let i = 0;
|
||||
const openTags: string[] = [];
|
||||
|
||||
const closeAll = () => {
|
||||
while (openTags.length) out += openTags.pop()!;
|
||||
};
|
||||
|
||||
while (i < raw.length) {
|
||||
// Backtick formatting codes
|
||||
if (raw[i] === "`") {
|
||||
const code = raw[i + 1];
|
||||
if (code === "!") {
|
||||
out += "<strong>"; openTags.push("</strong>"); i += 2; continue;
|
||||
} else if (code === "*") {
|
||||
out += "<em>"; openTags.push("</em>"); i += 2; continue;
|
||||
} else if (code === "_") {
|
||||
out += "<u>"; openTags.push("</u>"); i += 2; continue;
|
||||
} else if (code === "`") {
|
||||
closeAll(); i += 2; continue;
|
||||
} else if (code === "f" || code === "b") {
|
||||
out += "</span>"; i += 2; continue;
|
||||
} else if (code === "a") {
|
||||
out += "</span>"; i += 2; continue;
|
||||
} else if (code === "F") {
|
||||
// Foreground color — 3-digit hex only per spec
|
||||
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/);
|
||||
if (hexMatch) {
|
||||
const [r, g, b] = hexMatch[1].split("");
|
||||
const hex = r + r + g + g + b + b;
|
||||
out += `<span style="color:#${hex}">`;
|
||||
openTags.push("</span>");
|
||||
i += 2 + hexMatch[1].length;
|
||||
continue;
|
||||
}
|
||||
} else if (code === "B") {
|
||||
// Background color — 3-digit hex only per spec
|
||||
const hexMatch = raw.slice(i + 2).match(/^([0-9a-fA-F]{3})(?![0-9a-fA-F])/);
|
||||
if (hexMatch) {
|
||||
const [r, g, b] = hexMatch[1].split("");
|
||||
const hex = r + r + g + g + b + b;
|
||||
out += `<span style="background:#${hex}">`;
|
||||
openTags.push("</span>");
|
||||
i += 2 + hexMatch[1].length;
|
||||
continue;
|
||||
}
|
||||
} else if (code === "c") {
|
||||
out += `<span style="display:block;text-align:center">`;
|
||||
openTags.push("</span>"); i += 2; continue;
|
||||
} else if (code === "r") {
|
||||
out += `<span style="display:block;text-align:right">`;
|
||||
openTags.push("</span>"); i += 2; continue;
|
||||
} else if (code === "l") {
|
||||
out += `<span style="display:block;text-align:left">`;
|
||||
openTags.push("</span>"); i += 2; continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Links: [label`slug] or [label`slug.mu]
|
||||
if (raw[i] === "[") {
|
||||
const close = raw.indexOf("]", i);
|
||||
if (close !== -1) {
|
||||
const inner = raw.slice(i + 1, close);
|
||||
const backtick = inner.indexOf("`");
|
||||
if (backtick !== -1) {
|
||||
const label = escapeHtml(inner.slice(0, backtick));
|
||||
const slug = escapeHtml(inner.slice(backtick + 1).replace(/\.mu$/, ""));
|
||||
out += `<a href="/view/${slug}" style="color:#7dc4e4;text-decoration:underline">${label}</a>`;
|
||||
i = close + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out += escapeHtml(raw[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
closeAll();
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Render a form element line as a styled badge. */
|
||||
function renderForm(line: string): string {
|
||||
const inner = escapeHtml(line);
|
||||
return `<span style="color:#d4a8f8;background:rgba(212,168,248,0.08);border:1px solid rgba(212,168,248,0.3);border-radius:3px;padding:0 4px;font-size:0.9em">${inner}</span>`;
|
||||
}
|
||||
|
||||
export function renderMicron(source: string): string {
|
||||
const lines = source.split("\n");
|
||||
const htmlLines: string[] = [];
|
||||
let literalMode = false;
|
||||
|
||||
for (const line of lines) {
|
||||
// Toggle literal mode on standalone `= line
|
||||
if (line.trimEnd() === "`=") {
|
||||
literalMode = !literalMode;
|
||||
continue;
|
||||
}
|
||||
|
||||
// In literal mode — render verbatim
|
||||
if (literalMode) {
|
||||
htmlLines.push(`<div style="font-family:monospace;opacity:0.75;white-space:pre">${escapeHtml(line)}</div>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Comment lines — hidden in output
|
||||
if (line.startsWith("#")) continue;
|
||||
|
||||
// All output uses inline spans — the parent container has white-space:pre
|
||||
// so newlines come from the \n join at the end.
|
||||
|
||||
// Depth-4+ indent (before >>> check)
|
||||
if (line.startsWith(">>>>")) {
|
||||
htmlLines.push(`<span style="color:#c9d1d9;font-style:italic">${renderInline(line.slice(4))}</span>`);
|
||||
// Headings
|
||||
} else if (line.startsWith(">>>")) {
|
||||
htmlLines.push(`<span style="color:#a8c4e8;font-weight:bold">${renderInline(line.slice(3))}</span>`);
|
||||
} else if (line.startsWith(">>")) {
|
||||
htmlLines.push(`<span style="color:#70c4e8;font-weight:bold">${renderInline(line.slice(2))}</span>`);
|
||||
} else if (line.startsWith(">")) {
|
||||
htmlLines.push(`<span style="color:#7ee8a2;font-weight:bold">${renderInline(line.slice(1))}</span>`);
|
||||
// Dividers: - followed by a non-space, non-dash character
|
||||
} else if (/^-[^\s-]/.test(line)) {
|
||||
const char = line[1];
|
||||
htmlLines.push(`<span style="color:#484f58">${char.repeat(40)}</span>`);
|
||||
// Standalone depth-reset "<"
|
||||
} else if (line.trim() === "<") {
|
||||
htmlLines.push(`<span style="color:#d2a8ff;opacity:0.4">↩ depth reset</span>`);
|
||||
// Empty line
|
||||
} else if (line.trim() === "") {
|
||||
htmlLines.push("");
|
||||
// Form elements on their own line
|
||||
} else if (/^`?<[^>]+>$/.test(line)) {
|
||||
htmlLines.push(renderForm(line));
|
||||
} else {
|
||||
htmlLines.push(renderInline(line));
|
||||
}
|
||||
}
|
||||
|
||||
return htmlLines.join("\n");
|
||||
}
|
||||
28
frontend/src/components/editor/oneDarkTheme.ts
Normal file
28
frontend/src/components/editor/oneDarkTheme.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
export const oneDark = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
backgroundColor: "#0d1117",
|
||||
color: "#c9d1d9",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderLeftColor: "#c9d1d9",
|
||||
},
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "#264f78",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "#161b2266",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "#0d1117",
|
||||
color: "#484f58",
|
||||
borderRight: "1px solid #21262d",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "#161b2266",
|
||||
},
|
||||
},
|
||||
{ dark: true }
|
||||
);
|
||||
84
frontend/src/components/editor/slashCommands.ts
Normal file
84
frontend/src/components/editor/slashCommands.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { snippet } from "@codemirror/autocomplete";
|
||||
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
|
||||
interface SlashEntry {
|
||||
label: string;
|
||||
detail: string;
|
||||
section: string;
|
||||
apply: Completion["apply"];
|
||||
}
|
||||
|
||||
// Insert text, replacing from the "/" character (from-1) through the cursor
|
||||
function insert(text: string): Completion["apply"] {
|
||||
return (view: EditorView, _completion: Completion, from: number, to: number) => {
|
||||
view.dispatch({ changes: { from: from - 1, to, insert: text } });
|
||||
};
|
||||
}
|
||||
|
||||
// Wrap snippet() to also replace the preceding "/" character
|
||||
function slashSnippet(template: string): Completion["apply"] {
|
||||
const snip = snippet(template);
|
||||
return (view: EditorView, completion: Completion, from: number, to: number) => {
|
||||
snip(view, completion, from - 1, to - 1);
|
||||
};
|
||||
}
|
||||
|
||||
const COMMANDS: SlashEntry[] = [
|
||||
// Headings
|
||||
{ label: "H1", detail: ">...", section: "Heading", apply: slashSnippet(">\${text}") },
|
||||
{ label: "H2", detail: ">>...", section: "Heading", apply: slashSnippet(">>\${text}") },
|
||||
{ label: "H3", detail: ">>>...", section: "Heading", apply: slashSnippet(">>>\${text}") },
|
||||
|
||||
// Text formatting
|
||||
{ label: "Bold", detail: "`!..`!", section: "Format", apply: slashSnippet("`!\${text}`!") },
|
||||
{ label: "Italic", detail: "`*..`*", section: "Format", apply: slashSnippet("`*\${text}`*") },
|
||||
{ label: "Underline", detail: "`_..`_", section: "Format", apply: slashSnippet("`_\${text}`_") },
|
||||
{ label: "Reset", detail: "``", section: "Format", apply: insert("``") },
|
||||
{ label: "Literal", detail: "`=...`=", section: "Format", apply: slashSnippet("`=\n\${content}\n`=") },
|
||||
|
||||
// Alignment
|
||||
{ label: "Center", detail: "`c..`a", section: "Align", apply: slashSnippet("`c\${text}`a") },
|
||||
{ label: "Right", detail: "`r..`a", section: "Align", apply: slashSnippet("`r\${text}`a") },
|
||||
{ label: "Left", detail: "`l..`a", section: "Align", apply: slashSnippet("`l\${text}`a") },
|
||||
|
||||
// Color (3-digit hex)
|
||||
{ label: "Color", detail: "`Fhex..`f", section: "Color", apply: slashSnippet("`F\${hex}\${text}`f") },
|
||||
{ label: "BgColor", detail: "`Bhex..`b", section: "Color", apply: slashSnippet("`B\${hex}\${text}`b") },
|
||||
|
||||
// Links
|
||||
{ label: "Link", detail: "[label`page]", section: "Link", apply: slashSnippet("[\${label}`\${page}]") },
|
||||
|
||||
// Dividers
|
||||
{ label: "Divider ─", detail: "-─", section: "Divider", apply: insert("-─") },
|
||||
{ label: "Divider ━", detail: "-━", section: "Divider", apply: insert("-━") },
|
||||
{ label: "Divider ═", detail: "-═", section: "Divider", apply: insert("-═") },
|
||||
{ label: "Divider ★", detail: "-★", section: "Divider", apply: insert("-★") },
|
||||
|
||||
// Forms — pipe separators per micron-composer spec
|
||||
{ label: "Field", detail: "<name`default>", section: "Form", apply: slashSnippet("<\${name}`\${default}>") },
|
||||
{ label: "Password", detail: "<!w|name`placeholder>", section: "Form", apply: slashSnippet("<!\${width}|\${name}`\${placeholder}>") },
|
||||
{ label: "Checkbox", detail: "<?|name|val`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}`\${label}>") },
|
||||
{ label: "Checked", detail: "<?|name|val|*`label>", section: "Form", apply: slashSnippet("<?\${name}|\${value}|*`\${label}>") },
|
||||
{ label: "Radio", detail: "<^|grp|val`label>", section: "Form", apply: slashSnippet("<^\${group}|\${value}`\${label}>") },
|
||||
|
||||
// Depth
|
||||
{ label: "Reset depth", detail: "<", section: "Depth", apply: insert("<\n") },
|
||||
];
|
||||
|
||||
export function slashCommandSource(ctx: CompletionContext): CompletionResult | null {
|
||||
const match = ctx.matchBefore(/\/\w*/);
|
||||
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
||||
return {
|
||||
// Start after "/" so the filter text doesn't include "/" (which would block all matches)
|
||||
from: match.from + 1,
|
||||
filter: true,
|
||||
options: COMMANDS.map((cmd) => ({
|
||||
label: cmd.label,
|
||||
detail: cmd.detail,
|
||||
section: cmd.section,
|
||||
apply: cmd.apply,
|
||||
boost: 99,
|
||||
})),
|
||||
};
|
||||
}
|
||||
195
frontend/src/components/editor/uframeCommands.ts
Normal file
195
frontend/src/components/editor/uframeCommands.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { snippet } from "@codemirror/autocomplete";
|
||||
import type {
|
||||
Completion,
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
} from "@codemirror/autocomplete";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
|
||||
interface CmdEntry {
|
||||
label: string;
|
||||
detail: string;
|
||||
section: string;
|
||||
apply: Completion["apply"];
|
||||
}
|
||||
|
||||
function insert(text: string): Completion["apply"] {
|
||||
return (view: EditorView, _c: Completion, from: number, to: number) => {
|
||||
view.dispatch({ changes: { from: from - 1, to, insert: text } });
|
||||
};
|
||||
}
|
||||
|
||||
function slashSnippet(template: string): Completion["apply"] {
|
||||
const snip = snippet(template);
|
||||
return (view: EditorView, c: Completion, from: number, to: number) => {
|
||||
snip(view, c, from - 1, to - 1);
|
||||
};
|
||||
}
|
||||
|
||||
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"),
|
||||
},
|
||||
|
||||
// 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"`,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function uframeCommandSource(
|
||||
ctx: CompletionContext,
|
||||
): CompletionResult | null {
|
||||
const match = ctx.matchBefore(/\/\w*/);
|
||||
if (!match || (match.from === match.to && !ctx.explicit)) return null;
|
||||
return {
|
||||
from: match.from + 1,
|
||||
filter: true,
|
||||
options: COMMANDS.map((cmd) => ({
|
||||
label: cmd.label,
|
||||
detail: cmd.detail,
|
||||
section: cmd.section,
|
||||
apply: cmd.apply,
|
||||
boost: 99,
|
||||
})),
|
||||
};
|
||||
}
|
||||
101
frontend/src/components/editor/uframeHighlight.ts
Normal file
101
frontend/src/components/editor/uframeHighlight.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
StreamLanguage,
|
||||
HighlightStyle,
|
||||
syntaxHighlighting,
|
||||
} from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
/**
|
||||
* CodeMirror 6 syntax highlighting for the µFrame .uf DSL.
|
||||
*
|
||||
* Keywords: page, box, row, col, spacer, pad, heading, text, label,
|
||||
* divider, link, list, item, gauge, sparkline, status,
|
||||
* table, columns, form, field, radio, checkbox, button,
|
||||
* source, let, if, elif, else, for, align, color, bg,
|
||||
* bold, italic, underline, cache, state, on_submit
|
||||
*/
|
||||
|
||||
const KEYWORDS = new Set([
|
||||
"page", "box", "row", "col", "spacer", "pad",
|
||||
"heading", "text", "label", "divider", "link",
|
||||
"list", "item", "gauge", "sparkline", "status",
|
||||
"table", "columns", "form", "field", "radio",
|
||||
"checkbox", "button", "source", "let", "if",
|
||||
"elif", "else", "for", "align", "color", "bg",
|
||||
"bold", "italic", "underline", "cache", "state",
|
||||
"on_submit", "meter", "bar_h", "bar_v", "bar",
|
||||
"heatmap", "component", "use",
|
||||
]);
|
||||
|
||||
const WEIGHT_VALS = new Set([
|
||||
"light", "heavy", "double", "rounded",
|
||||
"bullet", "dash", "number", "arrow",
|
||||
"left", "center", "right",
|
||||
"online", "offline", "degraded", "unknown",
|
||||
]);
|
||||
|
||||
const uframeLanguage = StreamLanguage.define({
|
||||
token(stream) {
|
||||
// Comments
|
||||
if (stream.sol() && stream.match(/\s*#/)) {
|
||||
stream.skipToEnd();
|
||||
return "lineComment";
|
||||
}
|
||||
|
||||
// Skip whitespace
|
||||
if (stream.eatSpace()) return null;
|
||||
|
||||
// Quoted strings
|
||||
if (stream.match(/"/)) {
|
||||
while (!stream.eol()) {
|
||||
if (stream.next() === '"') break;
|
||||
}
|
||||
return "string";
|
||||
}
|
||||
|
||||
// @modifier{} syntax
|
||||
if (stream.match(/@\w+/)) return "keyword";
|
||||
|
||||
// $variable references
|
||||
if (stream.match(/\$[\w.]+/)) return "variableName";
|
||||
|
||||
// Numbers (integers, floats, hex colors)
|
||||
if (stream.match(/\b\d+(\.\d+)?\b/)) return "number";
|
||||
|
||||
// warn=N, crit=N parameters
|
||||
if (stream.match(/\b(warn|crit)=/)) return "attributeName";
|
||||
|
||||
// Pipe separator for inline lists
|
||||
if (stream.match(/\|/)) return "punctuation";
|
||||
|
||||
// Keywords and values
|
||||
if (stream.match(/\b\w+\b/)) {
|
||||
const word = stream.current();
|
||||
if (KEYWORDS.has(word)) return "keyword";
|
||||
if (WEIGHT_VALS.has(word)) return "atom";
|
||||
return null;
|
||||
}
|
||||
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
startState: () => ({}),
|
||||
copyState: (s) => ({ ...s }),
|
||||
blankLine: () => {},
|
||||
languageData: {},
|
||||
});
|
||||
|
||||
const uframeStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#c792ea", fontWeight: "bold" },
|
||||
{ tag: tags.string, color: "#c3e88d" },
|
||||
{ tag: tags.lineComment, color: "#546e7a", fontStyle: "italic" },
|
||||
{ tag: tags.variableName, color: "#f78c6c" },
|
||||
{ tag: tags.number, color: "#f78c6c" },
|
||||
{ tag: tags.atom, color: "#89ddff" },
|
||||
{ tag: tags.attributeName, color: "#ffcb6b" },
|
||||
{ tag: tags.punctuation, color: "#89ddff" },
|
||||
]);
|
||||
|
||||
export function uframeHighlight() {
|
||||
return [uframeLanguage, syntaxHighlighting(uframeStyle)];
|
||||
}
|
||||
23
frontend/src/components/editor/wikiLinkCompletion.ts
Normal file
23
frontend/src/components/editor/wikiLinkCompletion.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { PageMeta } from "@/stores/editorStore";
|
||||
|
||||
export function wikiLinkSource(pagesRef: MutableRefObject<PageMeta[]>) {
|
||||
return (context: CompletionContext): CompletionResult | null => {
|
||||
const match = context.matchBefore(/\[\[[\w-]*/);
|
||||
if (!match || (match.from === match.to && !context.explicit)) return null;
|
||||
|
||||
const options: Completion[] = pagesRef.current.map((page) => ({
|
||||
label: page.title ?? page.name,
|
||||
detail: page.name,
|
||||
apply: (view, _completion, from, to) => {
|
||||
const title = page.title ?? page.name;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: `[${title}\`${page.name}]` },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
return { from: match.from, options, filter: true };
|
||||
};
|
||||
}
|
||||
16
frontend/src/components/shared/AppShell.tsx
Normal file
16
frontend/src/components/shared/AppShell.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
import NavBar from "./NavBar";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
export default function AppShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col h-screen bg-background text-foreground">
|
||||
<NavBar />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/shared/NavBar.tsx
Normal file
88
frontend/src/components/shared/NavBar.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
const navLink = ({ isActive }: { isActive: boolean }) =>
|
||||
cn(
|
||||
"px-3 py-1.5 text-sm rounded-md transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
);
|
||||
|
||||
export default function NavBar() {
|
||||
const [restartOpen, setRestartOpen] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
|
||||
const handleRestart = async () => {
|
||||
setRestarting(true);
|
||||
try {
|
||||
const res = await fetch("/api/restart", { method: "POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
toast.success("NomadNet restarted");
|
||||
} catch (e) {
|
||||
toast.error(`Restart failed: ${e}`);
|
||||
} finally {
|
||||
setRestarting(false);
|
||||
setRestartOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 px-4 h-12 border-b bg-card shrink-0">
|
||||
<span className="font-bold mr-6 text-foreground">Micronomicon</span>
|
||||
|
||||
<NavLink to="/" end className={navLink}>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink to="/editor/new" className={navLink}>
|
||||
New Page
|
||||
</NavLink>
|
||||
<NavLink to="/graph" className={navLink}>
|
||||
Graph
|
||||
</NavLink>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={restarting}
|
||||
onClick={() => setRestartOpen(true)}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Restart NomadNet
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={restartOpen} onOpenChange={setRestartOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Restart NomadNet?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will briefly interrupt mesh network connectivity.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRestart}>
|
||||
Restart
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
187
frontend/src/components/ui/alert-dialog.tsx
Normal file
187
frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
52
frontend/src/components/ui/badge.tsx
Normal file
52
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
58
frontend/src/components/ui/button.tsx
Normal file
58
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
20
frontend/src/components/ui/input.tsx
Normal file
20
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
88
frontend/src/components/ui/popover.tsx
Normal file
88
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("font-heading font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
48
frontend/src/components/ui/resizable.tsx
Normal file
48
frontend/src/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border ring-offset-background after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-6 w-1 shrink-0 rounded-lg bg-border" />
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
54
frontend/src/components/ui/scroll-area.tsx
Normal file
54
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
25
frontend/src/components/ui/separator.tsx
Normal file
25
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
47
frontend/src/components/ui/sonner.tsx
Normal file
47
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
114
frontend/src/components/ui/table.tsx
Normal file
114
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
89
frontend/src/components/ui/toggle-group.tsx
Normal file
89
frontend/src/components/ui/toggle-group.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
orientation: "horizontal",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
...props
|
||||
}: ToggleGroupPrimitive.Props &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
data-orientation={orientation}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider
|
||||
value={{ variant, size, spacing, orientation }}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TogglePrimitive>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
43
frontend/src/components/ui/toggle.tsx
Normal file
43
frontend/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
66
frontend/src/components/ui/tooltip.tsx
Normal file
66
frontend/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
21
frontend/src/hooks/useBacklinks.ts
Normal file
21
frontend/src/hooks/useBacklinks.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useMemo } from "react";
|
||||
import { useGraph } from "@/hooks/useGraph";
|
||||
|
||||
export interface BacklinkPage {
|
||||
name: string;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export function useBacklinks(currentSlug: string | undefined): BacklinkPage[] {
|
||||
const { data } = useGraph();
|
||||
return useMemo(() => {
|
||||
if (!data || !currentSlug) return [];
|
||||
const nodeMap = new Map(data.nodes.map((n) => [n.id, n]));
|
||||
return data.edges
|
||||
.filter((e) => e.target === currentSlug)
|
||||
.map((e) => ({
|
||||
name: e.source,
|
||||
title: nodeMap.get(e.source)?.title ?? null,
|
||||
}));
|
||||
}, [data, currentSlug]);
|
||||
}
|
||||
71
frontend/src/hooks/useCompile.ts
Normal file
71
frontend/src/hooks/useCompile.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
|
||||
const DEBOUNCE_MS = 400;
|
||||
|
||||
/**
|
||||
* Debounced hook that compiles µFrame source via POST /api/compile.
|
||||
* Automatically triggers on ufSource changes.
|
||||
*/
|
||||
export function useCompile() {
|
||||
const ufSource = useEditorStore((s) => s.ufSource);
|
||||
const setCompileResult = useEditorStore((s) => s.setCompileResult);
|
||||
const setCompiling = useEditorStore((s) => s.setCompiling);
|
||||
const setCompileError = useEditorStore((s) => s.setCompileError);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const compile = useCallback(
|
||||
async (source: string) => {
|
||||
if (!source.trim()) {
|
||||
setCompileResult("", "", []);
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort any in-flight request
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setCompiling(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/compile", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: "Compile failed" }));
|
||||
setCompileError(err.detail || "Compile failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setCompileResult(data.ascii, data.micron, data.warnings || []);
|
||||
} catch (e: unknown) {
|
||||
if (e instanceof DOMException && e.name === "AbortError") return;
|
||||
setCompileError(e instanceof Error ? e.message : "Compile failed");
|
||||
}
|
||||
},
|
||||
[setCompileResult, setCompiling, setCompileError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => compile(ufSource), DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [ufSource, compile]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
35
frontend/src/hooks/useGraph.ts
Normal file
35
frontend/src/hooks/useGraph.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
published: boolean;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
}
|
||||
|
||||
export function useGraph() {
|
||||
const [data, setData] = useState<GraphData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/graph");
|
||||
setData(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return { data, loading };
|
||||
}
|
||||
16
frontend/src/hooks/useUnsavedGuard.ts
Normal file
16
frontend/src/hooks/useUnsavedGuard.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
|
||||
export function useUnsavedGuard() {
|
||||
const isDirty = useEditorStore((s) => s.isDirty);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (isDirty) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [isDirty]);
|
||||
}
|
||||
130
frontend/src/index.css
Normal file
130
frontend/src/index.css
Normal file
@@ -0,0 +1,130 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
13
frontend/src/main.tsx
Normal file
13
frontend/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import "./index.css";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
149
frontend/src/routes/DashboardView.tsx
Normal file
149
frontend/src/routes/DashboardView.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { usePagesStore } from "@/stores/pagesStore";
|
||||
import StatusBadge from "@/components/dashboard/StatusBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
|
||||
export default function DashboardView() {
|
||||
const { pages, isLoading, fetchPages, deletePage } = usePagesStore();
|
||||
const navigate = useNavigate();
|
||||
const [pageToDelete, setPageToDelete] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPages();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!pageToDelete) return;
|
||||
await deletePage(pageToDelete);
|
||||
toast.success(`"${pageToDelete}" deleted`);
|
||||
setPageToDelete(null);
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-xl font-semibold">Pages</h1>
|
||||
<Button onClick={() => navigate("/editor/new")}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Page
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pages.map((p) => (
|
||||
<TableRow key={p.name}>
|
||||
<TableCell className="font-mono">
|
||||
{p.name}
|
||||
{p.name === "index" && (
|
||||
<span className="ml-2 text-xs text-primary">homepage</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{p.title ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge published={p.published} hasSource={p.has_source} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{p.size != null ? `${p.size} B` : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1 justify-end">
|
||||
{p.has_source && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/editor/${p.name}`)}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setPageToDelete(p.name)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{pages.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={5}
|
||||
className="text-center text-muted-foreground py-8"
|
||||
>
|
||||
No pages yet. Create one to get started.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<AlertDialog
|
||||
open={pageToDelete !== null}
|
||||
onOpenChange={(open) => !open && setPageToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This permanently deletes the page and its source. This cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
frontend/src/routes/EditorView.tsx
Normal file
150
frontend/src/routes/EditorView.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
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 { uframeHighlight } from "@/components/editor/uframeHighlight";
|
||||
import { uframeCommandSource } from "@/components/editor/uframeCommands";
|
||||
import EditorPane from "@/components/editor/EditorPane";
|
||||
import PreviewPane from "@/components/editor/PreviewPane";
|
||||
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],
|
||||
icons: false,
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
// 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);
|
||||
fetch(`/api/pages/${name}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.source != null) {
|
||||
useEditorStore.setState({
|
||||
ufSource: data.source,
|
||||
isDirty: false,
|
||||
currentPage: data,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
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 res = await fetch(`/api/pages/${slug}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source: ufSource, publish }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const meta = await res.json();
|
||||
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],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
handleSave(false);
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "p") {
|
||||
e.preventDefault();
|
||||
handleSave(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [handleSave]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<ToolBar
|
||||
pageName={pageName}
|
||||
onNameChange={isNew ? setPageName : undefined}
|
||||
onSaveDraft={() => handleSave(false)}
|
||||
onPublish={() => handleSave(true)}
|
||||
saving={saving}
|
||||
isDirty={isDirty}
|
||||
/>
|
||||
<ResizablePanelGroup orientation="horizontal" className="flex-1">
|
||||
<ResizablePanel defaultSize={50} minSize={20}>
|
||||
<EditorPane
|
||||
value={ufSource}
|
||||
onChange={setSource}
|
||||
extensions={extensions}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={50} minSize={20}>
|
||||
<PreviewPane />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
frontend/src/routes/GraphView.tsx
Normal file
101
frontend/src/routes/GraphView.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
type Node,
|
||||
type Edge,
|
||||
} from "@xyflow/react";
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import { useGraph } from "@/hooks/useGraph";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
const NODE_WIDTH = 160;
|
||||
const NODE_HEIGHT = 50;
|
||||
|
||||
function layoutGraph(
|
||||
nodes: Node[],
|
||||
edges: Edge[]
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "TB", nodesep: 50, ranksep: 80 });
|
||||
|
||||
nodes.forEach((n) =>
|
||||
g.setNode(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
|
||||
);
|
||||
edges.forEach((e) => g.setEdge(e.source, e.target));
|
||||
dagre.layout(g);
|
||||
|
||||
const laid = nodes.map((n) => {
|
||||
const pos = g.node(n.id);
|
||||
return {
|
||||
...n,
|
||||
position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 },
|
||||
};
|
||||
});
|
||||
|
||||
return { nodes: laid, edges };
|
||||
}
|
||||
|
||||
export default function GraphView() {
|
||||
const { data, loading } = useGraph();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { nodes, edges } = useMemo(() => {
|
||||
if (!data) return { nodes: [], edges: [] };
|
||||
|
||||
const rfNodes: Node[] = data.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
data: { label: n.title ?? n.id },
|
||||
position: { x: 0, y: 0 },
|
||||
style: {
|
||||
background: n.published
|
||||
? "oklch(0.488 0.14 145)"
|
||||
: "oklch(0.7 0.15 80)",
|
||||
color: "#fff",
|
||||
border:
|
||||
n.id === "index"
|
||||
? "2px solid oklch(0.6 0.2 250)"
|
||||
: "1px solid oklch(1 0 0 / 10%)",
|
||||
borderRadius: 8,
|
||||
padding: "8px 16px",
|
||||
fontSize: 13,
|
||||
fontWeight: n.id === "index" ? 700 : 400,
|
||||
width: NODE_WIDTH,
|
||||
},
|
||||
}));
|
||||
|
||||
const rfEdges: Edge[] = data.edges.map((e, i) => ({
|
||||
id: `e-${i}`,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
style: { stroke: "oklch(0.556 0 0)" },
|
||||
}));
|
||||
|
||||
return layoutGraph(rfNodes, rfEdges);
|
||||
}, [data]);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading graph...
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodeClick={(_, node) => navigate(`/editor/${node.id}`)}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background color="oklch(0.269 0 0)" gap={20} />
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/stores/editorStore.ts
Normal file
78
frontend/src/stores/editorStore.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export interface PageMeta {
|
||||
name: string;
|
||||
title: string | null;
|
||||
published: boolean;
|
||||
has_source: boolean;
|
||||
last_modified: number | null;
|
||||
size: number | null;
|
||||
}
|
||||
|
||||
interface EditorStore {
|
||||
// Source
|
||||
ufSource: string;
|
||||
isDirty: boolean;
|
||||
currentPage: PageMeta | null;
|
||||
|
||||
// Compiled output
|
||||
compiledAscii: string;
|
||||
compiledMicron: string;
|
||||
compileWarnings: string[];
|
||||
isCompiling: boolean;
|
||||
compileError: string | null;
|
||||
|
||||
// Preview
|
||||
previewMode: "ascii" | "micron" | "raw";
|
||||
|
||||
// Actions
|
||||
setSource: (s: string) => void;
|
||||
setCurrentPage: (p: PageMeta | null) => void;
|
||||
setDirty: (v: boolean) => void;
|
||||
setCompileResult: (ascii: string, micron: string, warnings: string[]) => void;
|
||||
setCompiling: (v: boolean) => void;
|
||||
setCompileError: (e: string | null) => void;
|
||||
setPreviewMode: (mode: "ascii" | "micron" | "raw") => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorStore>((set) => ({
|
||||
ufSource: "",
|
||||
isDirty: false,
|
||||
currentPage: null,
|
||||
|
||||
compiledAscii: "",
|
||||
compiledMicron: "",
|
||||
compileWarnings: [],
|
||||
isCompiling: false,
|
||||
compileError: null,
|
||||
|
||||
previewMode: "ascii",
|
||||
|
||||
setSource: (s) => set({ ufSource: s, isDirty: true }),
|
||||
setCurrentPage: (p) => set({ currentPage: p }),
|
||||
setDirty: (v) => set({ isDirty: v }),
|
||||
setCompileResult: (ascii, micron, warnings) =>
|
||||
set({
|
||||
compiledAscii: ascii,
|
||||
compiledMicron: micron,
|
||||
compileWarnings: warnings,
|
||||
isCompiling: false,
|
||||
compileError: null,
|
||||
}),
|
||||
setCompiling: (v) => set({ isCompiling: v }),
|
||||
setCompileError: (e) => set({ compileError: e, isCompiling: false }),
|
||||
setPreviewMode: (mode) => set({ previewMode: mode }),
|
||||
reset: () =>
|
||||
set({
|
||||
ufSource: "",
|
||||
isDirty: false,
|
||||
currentPage: null,
|
||||
compiledAscii: "",
|
||||
compiledMicron: "",
|
||||
compileWarnings: [],
|
||||
isCompiling: false,
|
||||
compileError: null,
|
||||
previewMode: "ascii",
|
||||
}),
|
||||
}));
|
||||
45
frontend/src/stores/pagesStore.ts
Normal file
45
frontend/src/stores/pagesStore.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { create } from "zustand";
|
||||
import type { PageMeta } from "@/stores/editorStore";
|
||||
|
||||
interface PagesStore {
|
||||
pages: PageMeta[];
|
||||
isLoading: boolean;
|
||||
fetchPages: () => Promise<void>;
|
||||
deletePage: (name: string) => Promise<void>;
|
||||
unpublishPage: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const usePagesStore = create<PagesStore>((set, get) => ({
|
||||
pages: [],
|
||||
isLoading: false,
|
||||
|
||||
fetchPages: async () => {
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const res = await fetch("/api/pages");
|
||||
const pages = await res.json();
|
||||
set({ pages });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
deletePage: async (name: string) => {
|
||||
await fetch(`/api/pages/${name}`, { method: "DELETE" });
|
||||
await get().fetchPages();
|
||||
},
|
||||
|
||||
unpublishPage: async (name: string) => {
|
||||
// Fetch current source, re-save as draft only
|
||||
const res = await fetch(`/api/pages/${name}`);
|
||||
const data = await res.json();
|
||||
if (data.source) {
|
||||
await fetch(`/api/pages/${name}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source: data.source, publish: false }),
|
||||
});
|
||||
}
|
||||
await get().fetchPages();
|
||||
},
|
||||
}));
|
||||
34
frontend/tsconfig.app.json
Normal file
34
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Path alias */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
13
frontend/tsconfig.json
Normal file
13
frontend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
frontend/vite.config.ts
Normal file
19
frontend/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: "/",
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user