feat: clean up

This commit is contained in:
2026-04-03 15:24:06 +02:00
parent 0d469f70bf
commit c820f06d1c
23 changed files with 272 additions and 912 deletions

View File

@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { MoreVertical, Plus, RotateCcw } from "lucide-react";
import { usePagesStore } from "@/stores/pagesStore";
import { restartNode } from "@/api/client";
import StatusBadge from "@/components/dashboard/StatusBadge";
import { Button } from "@/components/ui/button";
import {
@@ -30,16 +31,20 @@ import {
} from "@/components/ui/alert-dialog";
export default function DashboardView() {
const { pages, isLoading, fetchPages, deletePage } = usePagesStore();
const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } =
usePagesStore();
const navigate = useNavigate();
const [pageToDelete, setPageToDelete] = useState<string | null>(null);
const [restarting, setRestarting] = useState(false);
useEffect(() => {
fetchPages();
}, []);
const handleRestart = async () => {
setRestarting(true);
try {
const res = await fetch("/api/restart", { method: "POST" });
if (!res.ok) throw new Error(await res.text());
await restartNode();
toast.success("NomadNet restarted");
} catch (e) {
toast.error(`Restart failed: ${e}`);
@@ -48,41 +53,19 @@ export default function DashboardView() {
}
};
useEffect(() => {
fetchPages();
}, []);
const handleUnpublish = async (name: string) => {
const handlePublish = async (name: string) => {
try {
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 }),
});
toast.success(`"${name}" unpublished`);
fetchPages();
}
await publishPage(name);
toast.success(`"${name}" published`);
} catch (e) {
toast.error(`Failed: ${e}`);
}
};
const handlePublish = async (name: string) => {
const handleUnpublish = async (name: string) => {
try {
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: true }),
});
toast.success(`"${name}" published`);
fetchPages();
}
await unpublishPage(name);
toast.success(`"${name}" unpublished`);
} catch (e) {
toast.error(`Failed: ${e}`);
}
@@ -120,7 +103,7 @@ export default function DashboardView() {
</div>
</div>
{/* Table inside bordered container */}
{/* Table */}
<Table>
<TableHeader>
<TableRow>
@@ -145,50 +128,22 @@ export default function DashboardView() {
)}
</TableCell>
<TableCell className="text-muted-foreground">
{p.title ?? ""}
{p.title ?? "\u2014"}
</TableCell>
<TableCell>
<StatusBadge published={p.published} hasSource={p.has_source} />
</TableCell>
<TableCell className="text-muted-foreground">
{p.size != null ? `${p.size} B` : ""}
{p.size != null ? `${p.size} B` : "\u2014"}
</TableCell>
<TableCell className="text-right w-8">
<Popover>
<PopoverTrigger
render={
<button
onClick={(e) => e.stopPropagation()}
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<MoreVertical className="w-4 h-4" />
</button>
}
/>
<PopoverContent side="bottom" align="end" sideOffset={4}
className="w-36 p-1"
>
<button
onClick={(e) => { e.stopPropagation(); navigate(`/editor/${p.name}`); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Edit</button>
{p.published ? (
<button
onClick={(e) => { e.stopPropagation(); handleUnpublish(p.name); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Unpublish</button>
) : (
<button
onClick={(e) => { e.stopPropagation(); handlePublish(p.name); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Publish</button>
)}
<button
onClick={(e) => { e.stopPropagation(); setPageToDelete(p.name); }}
className="w-full text-left px-3 py-1.5 text-xs text-destructive hover:bg-accent transition-colors cursor-pointer"
>Delete</button>
</PopoverContent>
</Popover>
<PageActions
name={p.name}
published={p.published}
onPublish={() => handlePublish(p.name)}
onUnpublish={() => handleUnpublish(p.name)}
onDelete={() => setPageToDelete(p.name)}
/>
</TableCell>
</TableRow>
))}
@@ -204,7 +159,7 @@ export default function DashboardView() {
)}
</TableBody>
</Table>
</div>{/* end bordered container */}
</div>
<AlertDialog
open={pageToDelete !== null}
@@ -232,3 +187,57 @@ export default function DashboardView() {
</div>
);
}
/** Per-row action menu for a page. */
function PageActions({
name,
published,
onPublish,
onUnpublish,
onDelete,
}: {
name: string;
published: boolean;
onPublish: () => void;
onUnpublish: () => void;
onDelete: () => void;
}) {
const navigate = useNavigate();
return (
<Popover>
<PopoverTrigger
render={
<button
onClick={(e) => e.stopPropagation()}
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<MoreVertical className="w-4 h-4" />
</button>
}
/>
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-36 p-1">
<button
onClick={(e) => { e.stopPropagation(); navigate(`/editor/${name}`); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Edit</button>
{published ? (
<button
onClick={(e) => { e.stopPropagation(); onUnpublish(); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Unpublish</button>
) : (
<button
onClick={(e) => { e.stopPropagation(); onPublish(); }}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>Publish</button>
)}
<button
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="w-full text-left px-3 py-1.5 text-xs text-destructive hover:bg-accent transition-colors cursor-pointer"
>Delete</button>
</PopoverContent>
</Popover>
);
}

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { BookOpen, Upload } from "lucide-react";
import * as api from "@/api/client";
import { autocompletion } from "@codemirror/autocomplete";
import type { Extension } from "@codemirror/state";
import { useEditorStore } from "@/stores/editorStore";
@@ -77,17 +78,14 @@ export default function EditorView() {
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,
});
}
});
api.fetchPage(name).then((data) => {
if (data.source != null) {
useEditorStore.setState({
ufSource: data.source,
isDirty: false,
});
}
});
}
return () => reset();
}, [name]);
@@ -104,13 +102,7 @@ export default function EditorView() {
}
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();
const meta = await api.savePage(slug, ufSource, publish);
setCurrentPage(meta);
setDirty(false);
fetchPages();
@@ -189,11 +181,7 @@ function SourcePane({
if (!file) return;
setUploading(true);
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/upload-image", { method: "POST", body: form });
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
const data = await api.uploadImage(file);
toast.success(`Uploaded ${data.filename}`);
setSource(`image "${data.path}" braille 30\n align center`);
} catch (err) {

View File

@@ -1,101 +0,0 @@
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>
);
}