cbe51a6e67
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.
469 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|