feat: added a twist
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user