feat: added a twist

This commit is contained in:
2026-04-01 00:53:55 +02:00
parent 0b7deee59e
commit b40c6436cd
76 changed files with 15121 additions and 64 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}