Files
layonara-forge/packages/frontend/src/pages/Build.tsx
T
plenarius cbe51a6e67 feat: complete UI/UX overhaul with Impeccable design system
Replace Inter/Baskerville with self-hosted Manrope/Alegreya/JetBrains Mono
variable fonts. Migrate all colors from hex to OKLCH tokens (30+ CSS custom
properties) with full dark/light mode support. Replace Unicode emoji with
lucide-react SVG icons throughout. Convert all page layouts to inline styles
(Tailwind CSS 4 flex/grid classes unreliable in this project). Code-split
routes via React.lazy (760KB → 15KB initial shell + 10 lazy chunks).

Add global styles: scrollbar theming, selection color, input/button bases,
:focus-visible ring, prefers-reduced-motion. Setup wizard gets 4-phase
indicator with numbered circles, PathInput and StatusDot components.
Toast container gets aria-live="polite". Tab close buttons changed to
proper <button> elements with aria-labels.

All 8 pages (Dashboard, Editor, Build, Server, Toolset, Repos, Settings,
Setup) rewritten with consistent card/section/button patterns.
2026-04-21 03:06:29 -04:00

469 lines
13 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from "react";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
Hammer,
Package,
Cpu,
Play,
Archive,
Upload,
ChevronDown,
ChevronUp,
AlertTriangle,
} from "lucide-react";
type BuildStatus = "idle" | "building" | "success" | "failed";
interface BuildSectionState {
status: BuildStatus;
output: string[];
collapsed: boolean;
}
function StatusBadge({ status }: { status: BuildStatus }) {
const styles: Record<BuildStatus, React.CSSProperties> = {
idle: {
backgroundColor: "var(--forge-surface-raised)",
color: "var(--forge-text-secondary)",
},
building: {
backgroundColor: "var(--forge-warning-bg)",
color: "var(--forge-warning)",
},
success: {
backgroundColor: "var(--forge-success-bg)",
color: "var(--forge-success)",
},
failed: {
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
},
};
return (
<span
style={{
...styles[status],
borderRadius: "9999px",
padding: "0.125rem 0.625rem",
fontSize: "var(--text-xs)",
fontWeight: 600,
fontFamily: "var(--font-mono)",
textTransform: "uppercase" as const,
letterSpacing: "0.03em",
}}
>
{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 style={{ marginTop: "0.75rem" }}>
<button
onClick={onToggle}
style={{
display: "flex",
alignItems: "center",
gap: "0.375rem",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
background: "none",
border: "none",
cursor: "pointer",
padding: "0.25rem 0",
fontFamily: "var(--font-sans)",
}}
>
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
<span>Output ({lines.length} lines)</span>
</button>
{!collapsed && (
<div
ref={scrollRef}
style={{
marginTop: "0.5rem",
maxHeight: "16rem",
overflowY: "auto",
borderRadius: "0.5rem",
padding: "0.875rem 1rem",
backgroundColor: "var(--forge-log-bg)",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
lineHeight: "1.6",
}}
>
{lines.length === 0 ? (
<span style={{ color: "var(--forge-text-secondary)", fontStyle: "italic" }}>
No output yet
</span>
) : (
lines.map((line, i) => (
<div key={i} style={{ color: "var(--forge-log-text)" }}>
{line}
</div>
))
)}
</div>
)}
</div>
);
}
function ActionButton({
label,
onClick,
disabled,
variant = "default",
icon,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "primary" | "warning";
icon?: React.ReactNode;
}) {
const variantStyles: Record<string, React.CSSProperties> = {
default: {
backgroundColor: "var(--forge-surface-raised)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
},
primary: {
backgroundColor: "var(--forge-accent)",
border: "none",
color: "var(--forge-accent-text)",
},
warning: {
backgroundColor: "var(--forge-warning-bg)",
border: "1px solid var(--forge-warning-border)",
color: "var(--forge-warning)",
},
};
return (
<button
onClick={onClick}
disabled={disabled}
style={{
...variantStyles[variant],
borderRadius: "0.375rem",
padding: "0.5rem 1rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-sans)",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
transition: "opacity 0.15s ease",
}}
>
{icon}
{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";
const cardStyle: React.CSSProperties = {
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
marginBottom: "1rem",
};
const sectionHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "1rem",
};
const sectionTitleStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "0.5rem",
textTransform: "uppercase",
fontSize: "var(--text-xs)",
fontWeight: 600,
letterSpacing: "0.05em",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-heading)",
};
const buttonRowStyle: React.CSSProperties = {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
alignItems: "center",
};
return (
<div
style={{
height: "100%",
overflowY: "auto",
padding: "1.5rem",
color: "var(--forge-text)",
}}
>
<div style={{ marginBottom: "1.5rem" }}>
<h2
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
>
Build Pipeline
</h2>
<p
style={{
fontFamily: "var(--font-sans)",
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
margin: "0.375rem 0 0 0",
}}
>
Compile, pack, and deploy module resources
</p>
</div>
{/* Module Section */}
<section style={cardStyle}>
<div style={sectionHeaderStyle}>
<div style={sectionTitleStyle}>
<Hammer size={14} />
<span>Module</span>
</div>
<StatusBadge status={module.status} />
</div>
<div style={buttonRowStyle}>
<ActionButton
label="Compile"
variant="primary"
icon={<Play size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.compileModule(), "module")}
/>
<ActionButton
label="Pack Module"
icon={<Archive size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.packModule(), "module")}
/>
<ActionButton
label="Deploy to Server"
variant="warning"
icon={<Upload size={14} />}
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 style={cardStyle}>
<div style={sectionHeaderStyle}>
<div style={sectionTitleStyle}>
<Package size={14} />
<span>Haks</span>
</div>
<StatusBadge status={haks.status} />
</div>
<div style={buttonRowStyle}>
<ActionButton
label="Build Haks"
variant="primary"
icon={<Play size={14} />}
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 style={cardStyle}>
<div style={sectionHeaderStyle}>
<div style={sectionTitleStyle}>
<Cpu size={14} />
<span>NWNX</span>
<span
style={{
fontWeight: 400,
textTransform: "none",
opacity: 0.6,
letterSpacing: "normal",
}}
>
(Advanced)
</span>
</div>
<StatusBadge status={nwnx.status} />
</div>
<div style={{ ...buttonRowStyle, marginBottom: "0.75rem" }}>
<ActionButton
label="Build All"
variant="primary"
icon={<Play size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")}
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<input
type="text"
value={nwnxTarget}
onChange={(e) => setNwnxTarget(e.target.value)}
placeholder="Target (e.g. Item, Creature)"
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
padding: "0.5rem 0.75rem",
fontSize: "var(--text-sm)",
color: "var(--forge-text)",
fontFamily: "var(--font-sans)",
outline: "none",
flex: "0 1 16rem",
}}
/>
<ActionButton
label="Build Target"
disabled={isBuilding || !nwnxTarget.trim()}
onClick={() =>
handleAction(() => api.build.buildNwnx(nwnxTarget.trim()), "nwnx")
}
/>
</div>
<p
style={{
marginTop: "0.75rem",
marginBottom: 0,
fontSize: "var(--text-xs)",
color: "var(--forge-warning)",
display: "flex",
alignItems: "center",
gap: "0.375rem",
}}
>
<AlertTriangle size={12} />
Requires server restart to pick up changes
</p>
<BuildOutput
lines={nwnx.output}
collapsed={nwnx.collapsed}
onToggle={() => setNwnx((prev) => ({ ...prev, collapsed: !prev.collapsed }))}
/>
</section>
</div>
);
}