From 973310113c7ba4803cc9942ec12d5cd53c6d46d1 Mon Sep 17 00:00:00 2001 From: plenarius Date: Mon, 20 Apr 2026 19:54:42 -0400 Subject: [PATCH] feat: add Build page UI with real-time output streaming --- packages/frontend/src/App.tsx | 2 + packages/frontend/src/pages/Build.tsx | 324 ++++++++++++++++++++++++++ packages/frontend/src/services/api.ts | 13 ++ 3 files changed, 339 insertions(+) create mode 100644 packages/frontend/src/pages/Build.tsx diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index 701bc42..12caeab 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -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={} /> + } /> diff --git a/packages/frontend/src/pages/Build.tsx b/packages/frontend/src/pages/Build.tsx new file mode 100644 index 0000000..7ef15a5 --- /dev/null +++ b/packages/frontend/src/pages/Build.tsx @@ -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 = { + 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 ( + + {status} + + ); +} + +function BuildOutput({ + lines, + collapsed, + onToggle, +}: { + lines: string[]; + collapsed: boolean; + onToggle: () => void; +}) { + const scrollRef = useRef(null); + + useEffect(() => { + if (!collapsed && scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [lines, collapsed]); + + return ( +
+ + {!collapsed && ( +
+ {lines.length === 0 ? ( + No output yet + ) : ( + lines.map((line, i) => ( +
+ {line} +
+ )) + )} +
+ )} +
+ ); +} + +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 ( + + ); +} + +export function Build() { + const { subscribe } = useWebSocket(); + + const [module, setModule] = useState({ + status: "idle", + output: [], + collapsed: true, + }); + const [haks, setHaks] = useState({ + status: "idle", + output: [], + collapsed: true, + }); + const [nwnx, setNwnx] = useState({ + status: "idle", + output: [], + collapsed: true, + }); + const [nwnxTarget, setNwnxTarget] = useState(""); + + useEffect(() => { + const unsubs = [ + subscribe("build:start", (event) => { + const data = event.data as Record; + 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; + 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; + 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, 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 ( +
+

+ Build Pipeline +

+ + {/* Module Section */} +
+
+

Module

+ +
+
+ handleAction(() => api.build.compileModule(), "module")} + /> + handleAction(() => api.build.packModule(), "module")} + /> + handleAction(() => api.build.deploy(), "module")} + /> +
+ setModule((prev) => ({ ...prev, collapsed: !prev.collapsed }))} + /> +
+ + {/* Haks Section */} +
+
+

Haks

+ +
+
+ handleAction(() => api.build.buildHaks(), "haks")} + /> +
+ setHaks((prev) => ({ ...prev, collapsed: !prev.collapsed }))} + /> +
+ + {/* NWNX Section */} +
+
+

+ NWNX (Advanced) +

+ +
+
+ handleAction(() => api.build.buildNwnx(), "nwnx")} + /> +
+
+ 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)", + }} + /> + + handleAction(() => api.build.buildNwnx(nwnxTarget.trim()), "nwnx") + } + /> +
+

+ ⚠ Requires server restart to pick up changes +

+ setNwnx((prev) => ({ ...prev, collapsed: !prev.collapsed }))} + /> +
+
+ ); +} diff --git a/packages/frontend/src/services/api.ts b/packages/frontend/src/services/api.ts index c8c6ab9..161b271 100644 --- a/packages/frontend/src/services/api.ts +++ b/packages/frontend/src/services/api.ts @@ -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>>("/docker/containers"), pull: (image: string) =>