feat: add Build page UI with real-time output streaming

This commit is contained in:
plenarius
2026-04-20 19:54:42 -04:00
parent 0e31277eec
commit 973310113c
3 changed files with 339 additions and 0 deletions
+2
View File
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useState, useCallback } from "react";
import { Dashboard } from "./pages/Dashboard";
import { Editor } from "./pages/Editor";
import { Build } from "./pages/Build";
import { IDELayout } from "./layouts/IDELayout";
import { FileExplorer } from "./components/editor/FileExplorer";
import { api } from "./services/api";
@@ -48,6 +49,7 @@ export function App() {
path="/editor"
element={<Editor editorState={editorState} />}
/>
<Route path="build" element={<Build />} />
</Route>
</Routes>
</BrowserRouter>
+324
View File
@@ -0,0 +1,324 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
type BuildStatus = "idle" | "building" | "success" | "failed";
interface BuildSectionState {
status: BuildStatus;
output: string[];
collapsed: boolean;
}
function StatusBadge({ status }: { status: BuildStatus }) {
const colors: Record<BuildStatus, string> = {
idle: "bg-gray-500/20 text-gray-400",
building: "bg-yellow-500/20 text-yellow-400",
success: "bg-green-500/20 text-green-400",
failed: "bg-red-500/20 text-red-400",
};
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${colors[status]}`}>
{status}
</span>
);
}
function BuildOutput({
lines,
collapsed,
onToggle,
}: {
lines: string[];
collapsed: boolean;
onToggle: () => void;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!collapsed && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [lines, collapsed]);
return (
<div className="mt-2">
<button
onClick={onToggle}
className="flex items-center gap-1 text-xs transition-colors hover:opacity-80"
style={{ color: "var(--forge-text-secondary)" }}
>
<span>{collapsed ? "\u25B6" : "\u25BC"}</span>
<span>Output ({lines.length} lines)</span>
</button>
{!collapsed && (
<div
ref={scrollRef}
className="mt-1 max-h-64 overflow-auto rounded p-3"
style={{
backgroundColor: "#0d1117",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "12px",
lineHeight: "1.5",
}}
>
{lines.length === 0 ? (
<span style={{ color: "var(--forge-text-secondary)" }}>No output yet</span>
) : (
lines.map((line, i) => (
<div key={i} style={{ color: "#c9d1d9" }}>
{line}
</div>
))
)}
</div>
)}
</div>
);
}
function ActionButton({
label,
onClick,
disabled,
variant = "default",
}: {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "primary" | "warning";
}) {
const styles = {
default: {
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
},
primary: {
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "#fff",
},
warning: {
backgroundColor: "#854d0e",
borderColor: "#a16207",
color: "#fef08a",
},
};
return (
<button
onClick={onClick}
disabled={disabled}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={styles[variant]}
>
{label}
</button>
);
}
export function Build() {
const { subscribe } = useWebSocket();
const [module, setModule] = useState<BuildSectionState>({
status: "idle",
output: [],
collapsed: true,
});
const [haks, setHaks] = useState<BuildSectionState>({
status: "idle",
output: [],
collapsed: true,
});
const [nwnx, setNwnx] = useState<BuildSectionState>({
status: "idle",
output: [],
collapsed: true,
});
const [nwnxTarget, setNwnxTarget] = useState("");
useEffect(() => {
const unsubs = [
subscribe("build:start", (event) => {
const data = event.data as Record<string, string>;
const section = data?.section;
const setter =
section === "haks" ? setHaks : section === "nwnx" ? setNwnx : setModule;
setter((prev) => ({ ...prev, status: "building", output: [], collapsed: false }));
}),
subscribe("build:complete", (event) => {
const data = event.data as Record<string, string>;
const section = data?.section;
const setter =
section === "haks" ? setHaks : section === "nwnx" ? setNwnx : setModule;
setter((prev) => ({ ...prev, status: "success" }));
}),
subscribe("build:failed", (event) => {
const data = event.data as Record<string, string>;
const section = data?.section;
const setter =
section === "haks" ? setHaks : section === "nwnx" ? setNwnx : setModule;
setter((prev) => ({ ...prev, status: "failed" }));
}),
subscribe("build:output", (event) => {
const data = event.data as { text?: string; section?: string };
if (!data?.text) return;
const section = data.section;
const setter =
section === "haks" ? setHaks : section === "nwnx" ? setNwnx : setModule;
setter((prev) => ({
...prev,
output: [...prev.output, ...data.text!.split("\n").filter(Boolean)],
}));
}),
];
return () => unsubs.forEach((u) => u());
}, [subscribe]);
const handleAction = useCallback(
async (action: () => Promise<unknown>, section: "module" | "haks" | "nwnx") => {
const setter =
section === "haks" ? setHaks : section === "nwnx" ? setNwnx : setModule;
setter((prev) => ({ ...prev, status: "building", output: [], collapsed: false }));
try {
await action();
} catch {
setter((prev) => ({ ...prev, status: "failed" }));
}
},
[],
);
const isBuilding = module.status === "building" || haks.status === "building" || nwnx.status === "building";
return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
Build Pipeline
</h2>
{/* Module Section */}
<section
className="mb-6 rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Module</h3>
<StatusBadge status={module.status} />
</div>
<div className="flex flex-wrap gap-2">
<ActionButton
label="Compile"
variant="primary"
disabled={isBuilding}
onClick={() => handleAction(() => api.build.compileModule(), "module")}
/>
<ActionButton
label="Pack Module"
disabled={isBuilding}
onClick={() => handleAction(() => api.build.packModule(), "module")}
/>
<ActionButton
label="Deploy to Server"
variant="warning"
disabled={isBuilding}
onClick={() => handleAction(() => api.build.deploy(), "module")}
/>
</div>
<BuildOutput
lines={module.output}
collapsed={module.collapsed}
onToggle={() => setModule((prev) => ({ ...prev, collapsed: !prev.collapsed }))}
/>
</section>
{/* Haks Section */}
<section
className="mb-6 rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Haks</h3>
<StatusBadge status={haks.status} />
</div>
<div className="flex gap-2">
<ActionButton
label="Build Haks"
variant="primary"
disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildHaks(), "haks")}
/>
</div>
<BuildOutput
lines={haks.output}
collapsed={haks.collapsed}
onToggle={() => setHaks((prev) => ({ ...prev, collapsed: !prev.collapsed }))}
/>
</section>
{/* NWNX Section */}
<section
className="rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">
NWNX <span className="text-xs font-normal opacity-60">(Advanced)</span>
</h3>
<StatusBadge status={nwnx.status} />
</div>
<div className="mb-3 flex flex-wrap gap-2">
<ActionButton
label="Build All"
variant="primary"
disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")}
/>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={nwnxTarget}
onChange={(e) => setNwnxTarget(e.target.value)}
placeholder="Target (e.g. Item, Creature)"
className="rounded border px-3 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
}}
/>
<ActionButton
label="Build Target"
disabled={isBuilding || !nwnxTarget.trim()}
onClick={() =>
handleAction(() => api.build.buildNwnx(nwnxTarget.trim()), "nwnx")
}
/>
</div>
<p
className="mt-2 text-xs"
style={{ color: "#f59e0b" }}
>
Requires server restart to pick up changes
</p>
<BuildOutput
lines={nwnx.output}
collapsed={nwnx.collapsed}
onToggle={() => setNwnx((prev) => ({ ...prev, collapsed: !prev.collapsed }))}
/>
</section>
</div>
);
}
+13
View File
@@ -48,6 +48,19 @@ export const api = {
init: () => request("/workspace/init", { method: "POST" }),
},
build: {
compileModule: (target?: string) =>
request("/build/module/compile", { method: "POST", body: JSON.stringify({ target }) }),
packModule: (target?: string) =>
request("/build/module/pack", { method: "POST", body: JSON.stringify({ target }) }),
deploy: () => request("/build/deploy", { method: "POST" }),
compileSingle: (filePath: string) =>
request("/build/compile-single", { method: "POST", body: JSON.stringify({ filePath }) }),
buildHaks: () => request("/build/haks", { method: "POST" }),
buildNwnx: (target?: string) =>
request("/build/nwnx", { method: "POST", body: JSON.stringify({ target }) }),
},
docker: {
containers: () => request<Array<Record<string, string>>>("/docker/containers"),
pull: (image: string) =>