feat: add Setup Wizard with 10-step contributor onboarding flow

This commit is contained in:
plenarius
2026-04-20 22:08:07 -04:00
parent 8849c25dff
commit 8a3cb1b0a3
3 changed files with 747 additions and 3 deletions
+35 -3
View File
@@ -1,18 +1,41 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useState, useCallback } from "react";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useState, useCallback, useEffect } from "react";
import { Dashboard } from "./pages/Dashboard";
import { Editor } from "./pages/Editor";
import { Build } from "./pages/Build";
import { Server } from "./pages/Server";
import { Toolset } from "./pages/Toolset";
import { Repos } from "./pages/Repos";
import { Setup } from "./pages/Setup";
import { IDELayout } from "./layouts/IDELayout";
import { SetupLayout } from "./layouts/SetupLayout";
import { FileExplorer } from "./components/editor/FileExplorer";
import { api } from "./services/api";
import { useEditorState } from "./hooks/useEditorState";
const DEFAULT_REPO = "nwn-module";
function SetupGuard({ children }: { children: React.ReactNode }) {
const [checking, setChecking] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false);
useEffect(() => {
api.workspace
.getConfig()
.then((config) => {
setNeedsSetup(config.setupComplete !== true);
})
.catch(() => {
setNeedsSetup(true);
})
.finally(() => setChecking(false));
}, []);
if (checking) return null;
if (needsSetup) return <Navigate to="/setup" replace />;
return <>{children}</>;
}
export function App() {
const editorState = useEditorState();
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
@@ -46,7 +69,16 @@ export function App() {
return (
<BrowserRouter>
<Routes>
<Route element={<IDELayout sidebar={sidebar} />}>
<Route path="/setup" element={<SetupLayout />}>
<Route index element={<Setup />} />
</Route>
<Route
element={
<SetupGuard>
<IDELayout sidebar={sidebar} />
</SetupGuard>
}
>
<Route path="/" element={<Dashboard />} />
<Route
path="/editor"
@@ -0,0 +1,23 @@
import { Outlet } from "react-router-dom";
export function SetupLayout() {
return (
<div
className="flex min-h-screen items-center justify-center p-4"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<div className="w-full max-w-2xl">
<h1
className="mb-8 text-center text-3xl font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
Layonara Forge
</h1>
<Outlet />
</div>
</div>
);
}
+689
View File
@@ -0,0 +1,689 @@
import { useState, useEffect, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
const STEP_NAMES = [
"Welcome",
"Prerequisites",
"GitHub PAT",
"Workspace",
"NWN Home",
"Fork Repos",
"Clone Repos",
"Pull Images",
"Seed Database",
"Complete",
];
function StepIndicator({ current }: { current: number }) {
return (
<div className="mb-2">
<div className="flex items-center justify-center gap-1">
{STEP_NAMES.map((name, i) => (
<div key={name} className="flex items-center gap-1">
<div
className="flex h-7 w-7 items-center justify-center rounded-full text-xs font-bold"
style={{
backgroundColor:
i < current ? "var(--forge-accent)" : i === current ? "var(--forge-accent)" : "var(--forge-surface)",
color: i <= current ? "#000" : "var(--forge-text-secondary)",
border: i === current ? "2px solid var(--forge-accent)" : "1px solid var(--forge-border)",
opacity: i < current ? 0.7 : 1,
}}
title={name}
>
{i < current ? "\u2713" : i + 1}
</div>
{i < STEP_NAMES.length - 1 && (
<div
className="h-px w-3"
style={{
backgroundColor: i < current ? "var(--forge-accent)" : "var(--forge-border)",
}}
/>
)}
</div>
))}
</div>
<p className="mt-3 text-center text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Step {current + 1} of {STEP_NAMES.length} &mdash; {STEP_NAMES[current]}
</p>
</div>
);
}
function StepNav({
onNext,
onBack,
step,
nextLabel = "Next",
nextDisabled = false,
}: {
onNext: () => void;
onBack: () => void;
step: number;
nextLabel?: string;
nextDisabled?: boolean;
}) {
return (
<div className="mt-6 flex justify-between">
{step > 0 ? (
<button
onClick={onBack}
className="rounded px-4 py-2 text-sm transition-colors hover:bg-white/10"
style={{ border: "1px solid var(--forge-border)", color: "var(--forge-text-secondary)" }}
>
Back
</button>
) : (
<div />
)}
<button
onClick={onNext}
disabled={nextDisabled}
className="rounded px-4 py-2 text-sm font-semibold transition-colors disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
{nextLabel}
</button>
</div>
);
}
function ErrorBox({ error, onRetry }: { error: string; onRetry?: () => void }) {
return (
<div className="mt-4 rounded p-3 text-sm" style={{ backgroundColor: "#3b1111", border: "1px solid #7f1d1d" }}>
<p style={{ color: "#fca5a5" }}>{error}</p>
{onRetry && (
<button onClick={onRetry} className="mt-2 text-xs underline" style={{ color: "#fca5a5" }}>
Retry
</button>
)}
</div>
);
}
interface StepProps {
onNext: () => void;
onBack: () => void;
}
function WelcomeStep({ onNext }: StepProps) {
return (
<div className="text-center">
<h2
className="text-2xl font-bold"
style={{ fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", color: "var(--forge-accent)" }}
>
Welcome to Layonara Forge
</h2>
<p className="mt-4" style={{ color: "var(--forge-text-secondary)" }}>
This wizard will walk you through setting up your local NWN development environment &mdash; Docker, GitHub
access, workspace initialization, repository cloning, and database seeding.
</p>
<div className="mt-8">
<button
onClick={onNext}
className="rounded px-6 py-2.5 text-sm font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
Get Started
</button>
</div>
</div>
);
}
function PrerequisitesStep({ onNext, onBack }: StepProps) {
const [status, setStatus] = useState<"idle" | "checking" | "ok" | "error">("idle");
const [error, setError] = useState("");
const check = useCallback(async () => {
setStatus("checking");
setError("");
try {
await api.health();
setStatus("ok");
} catch (err) {
setStatus("error");
setError(err instanceof Error ? err.message : "Health check failed");
}
}, []);
useEffect(() => {
check();
}, [check]);
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Prerequisites
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Checking that Docker and the Forge backend are running.
</p>
<div className="mt-4 flex items-center gap-3 rounded p-4" style={{ backgroundColor: "var(--forge-bg)" }}>
<span className="text-xl">
{status === "checking" || status === "idle" ? "\u23F3" : status === "ok" ? "\u2705" : "\u274C"}
</span>
<div>
<p style={{ color: "var(--forge-text)" }}>Docker &amp; Backend</p>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{status === "checking" || status === "idle"
? "Checking..."
: status === "ok"
? "Backend is healthy"
: "Backend unreachable"}
</p>
</div>
</div>
{error && <ErrorBox error={error} onRetry={check} />}
<StepNav onNext={onNext} onBack={onBack} step={1} nextDisabled={status !== "ok"} />
</div>
);
}
function GitHubPatStep({ onNext, onBack }: StepProps) {
const [pat, setPat] = useState("");
const [username, setUsername] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const validate = async () => {
setLoading(true);
setError("");
try {
const { login } = await api.github.validatePat(pat);
setUsername(login);
await api.workspace.updateConfig({ githubPat: pat });
} catch (err) {
setError(err instanceof Error ? err.message : "Validation failed");
} finally {
setLoading(false);
}
};
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
GitHub Personal Access Token
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
A PAT with <code>repo</code> scope is needed to fork and push to Layonara repositories.
</p>
<div className="mt-4 flex gap-2">
<input
type="password"
value={pat}
onChange={(e) => setPat(e.target.value)}
placeholder="ghp_..."
className="flex-1 rounded px-3 py-2 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
/>
<button
onClick={validate}
disabled={!pat || loading}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
{loading ? "..." : "Validate"}
</button>
</div>
{username && (
<p className="mt-3 text-sm" style={{ color: "#4ade80" }}>
{"\u2705"} Authenticated as <strong>{username}</strong>
</p>
)}
{error && <ErrorBox error={error} />}
<StepNav onNext={onNext} onBack={onBack} step={2} nextDisabled={!username} />
</div>
);
}
function WorkspaceStep({ onNext, onBack }: StepProps) {
const [config, setConfig] = useState<Record<string, unknown>>({});
const [initialized, setInitialized] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
api.workspace.getConfig().then(setConfig).catch(() => {});
}, []);
const init = async () => {
setLoading(true);
setError("");
try {
await api.workspace.init();
setInitialized(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Init failed");
} finally {
setLoading(false);
}
};
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Workspace
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Initialize the Forge workspace directory structure.
</p>
<div className="mt-4 rounded p-4" style={{ backgroundColor: "var(--forge-bg)" }}>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
Workspace Path
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{(config.WORKSPACE_PATH as string) || "/home/jmg/dev/layonara"}
</p>
</div>
<div className="mt-4">
<button
onClick={init}
disabled={loading || initialized}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
{initialized ? "\u2705 Initialized" : loading ? "Initializing..." : "Initialize"}
</button>
</div>
{error && <ErrorBox error={error} onRetry={init} />}
<StepNav onNext={onNext} onBack={onBack} step={3} />
</div>
);
}
function NwnHomeStep({ onNext, onBack }: StepProps) {
const [config, setConfig] = useState<Record<string, unknown>>({});
useEffect(() => {
api.workspace.getConfig().then(setConfig).catch(() => {});
}, []);
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
NWN Home Directory
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Path to the NWN:EE local server home directory (contains modules/, hak/, tlk/).
</p>
<div className="mt-4 rounded p-4" style={{ backgroundColor: "var(--forge-bg)" }}>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
NWN Home Path
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{(config.NWN_HOME_PATH as string) || "/home/jmg/dev/nwn/local-server/home"}
</p>
</div>
<StepNav onNext={onNext} onBack={onBack} step={4} />
</div>
);
}
function ForkReposStep({ onNext, onBack }: StepProps) {
const repos = ["nwn-module", "nwn-haks", "unified"];
const [forkStatus, setForkStatus] = useState<Record<string, "idle" | "forking" | "forked" | "error">>({});
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
api.github
.forks()
.then((forks) => {
const s: Record<string, "idle" | "forked"> = {};
for (const f of forks) s[f.repo] = f.forked ? "forked" : "idle";
setForkStatus(s);
})
.catch(() => {});
}, []);
const forkRepo = async (repo: string) => {
setForkStatus((s) => ({ ...s, [repo]: "forking" }));
setErrors((e) => ({ ...e, [repo]: "" }));
try {
await api.github.fork(repo);
setForkStatus((s) => ({ ...s, [repo]: "forked" }));
} catch (err) {
setForkStatus((s) => ({ ...s, [repo]: "error" }));
setErrors((e) => ({ ...e, [repo]: err instanceof Error ? err.message : "Fork failed" }));
}
};
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Fork Repositories
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Fork the Layonara repositories to your GitHub account.
</p>
<div className="mt-4 space-y-3">
{repos.map((repo) => (
<div
key={repo}
className="flex items-center justify-between rounded p-4"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<div>
<p className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
Layonara/{repo}
</p>
{errors[repo] && (
<p className="mt-1 text-xs" style={{ color: "#fca5a5" }}>
{errors[repo]}
</p>
)}
</div>
<button
onClick={() => forkRepo(repo)}
disabled={forkStatus[repo] === "forked" || forkStatus[repo] === "forking"}
className="rounded px-3 py-1.5 text-xs font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
{forkStatus[repo] === "forked"
? "\u2705 Forked"
: forkStatus[repo] === "forking"
? "..."
: "Fork"}
</button>
</div>
))}
</div>
<StepNav onNext={onNext} onBack={onBack} step={5} />
</div>
);
}
function CloneReposStep({ onNext, onBack }: StepProps) {
const repos = ["nwn-module", "nwn-haks", "unified"];
const [cloneStatus, setCloneStatus] = useState<Record<string, "idle" | "cloning" | "cloned" | "error">>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [cloning, setCloning] = useState(false);
const cloneAll = async () => {
setCloning(true);
for (const repo of repos) {
setCloneStatus((s) => ({ ...s, [repo]: "cloning" }));
setErrors((e) => ({ ...e, [repo]: "" }));
try {
await api.repos.clone(repo);
setCloneStatus((s) => ({ ...s, [repo]: "cloned" }));
} catch (err) {
setCloneStatus((s) => ({ ...s, [repo]: "error" }));
setErrors((e) => ({ ...e, [repo]: err instanceof Error ? err.message : "Clone failed" }));
}
}
setCloning(false);
};
const allDone = repos.every((r) => cloneStatus[r] === "cloned");
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Clone Repositories
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Clone all forked repositories into the workspace.
</p>
<div className="mt-4 space-y-2">
{repos.map((repo) => (
<div key={repo} className="flex items-center gap-3 rounded p-3" style={{ backgroundColor: "var(--forge-bg)" }}>
<span>
{cloneStatus[repo] === "cloned"
? "\u2705"
: cloneStatus[repo] === "cloning"
? "\u23F3"
: cloneStatus[repo] === "error"
? "\u274C"
: "\u25CB"}
</span>
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{repo}
</span>
{errors[repo] && (
<span className="text-xs" style={{ color: "#fca5a5" }}>
{errors[repo]}
</span>
)}
</div>
))}
</div>
<div className="mt-4">
<button
onClick={cloneAll}
disabled={cloning || allDone}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
{allDone ? "\u2705 All Cloned" : cloning ? "Cloning..." : "Clone All"}
</button>
</div>
<StepNav onNext={onNext} onBack={onBack} step={6} />
</div>
);
}
function PullImagesStep({ onNext, onBack }: StepProps) {
const images = ["layonara/unified", "mariadb"];
const [pullStatus, setPullStatus] = useState<Record<string, "idle" | "pulling" | "pulled" | "error">>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [pulling, setPulling] = useState(false);
const pullAll = async () => {
setPulling(true);
for (const image of images) {
setPullStatus((s) => ({ ...s, [image]: "pulling" }));
setErrors((e) => ({ ...e, [image]: "" }));
try {
await api.docker.pull(image);
setPullStatus((s) => ({ ...s, [image]: "pulled" }));
} catch (err) {
setPullStatus((s) => ({ ...s, [image]: "error" }));
setErrors((e) => ({ ...e, [image]: err instanceof Error ? err.message : "Pull failed" }));
}
}
setPulling(false);
};
const allDone = images.every((i) => pullStatus[i] === "pulled");
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Pull Docker Images
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Pull the required Docker images for the local dev environment.
</p>
<div className="mt-4 space-y-2">
{images.map((image) => (
<div key={image} className="flex items-center gap-3 rounded p-3" style={{ backgroundColor: "var(--forge-bg)" }}>
<span>
{pullStatus[image] === "pulled"
? "\u2705"
: pullStatus[image] === "pulling"
? "\u23F3"
: pullStatus[image] === "error"
? "\u274C"
: "\u25CB"}
</span>
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{image}
</span>
{errors[image] && (
<span className="text-xs" style={{ color: "#fca5a5" }}>
{errors[image]}
</span>
)}
</div>
))}
</div>
<div className="mt-4">
<button
onClick={pullAll}
disabled={pulling || allDone}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
{allDone ? "\u2705 All Pulled" : pulling ? "Pulling..." : "Pull Images"}
</button>
</div>
<StepNav onNext={onNext} onBack={onBack} step={7} />
</div>
);
}
function SeedDbStep({ onNext, onBack }: StepProps) {
const [cdKey, setCdKey] = useState("");
const [playerName, setPlayerName] = useState("");
const [seeded, setSeeded] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const seed = async () => {
setLoading(true);
setError("");
try {
await api.server.seedDb(cdKey, playerName);
setSeeded(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Seed failed");
} finally {
setLoading(false);
}
};
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Seed Database
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
Add your NWN CD key and player name so the dev server recognizes you as a DM.
</p>
<div className="mt-4 space-y-3">
<div>
<label className="block text-xs" style={{ color: "var(--forge-text-secondary)" }}>
CD Key
</label>
<input
type="text"
value={cdKey}
onChange={(e) => setCdKey(e.target.value.toUpperCase())}
placeholder="e.g. UPQNKG4R"
className="mt-1 w-full rounded px-3 py-2 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
<div>
<label className="block text-xs" style={{ color: "var(--forge-text-secondary)" }}>
Player Name
</label>
<input
type="text"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="e.g. contributor"
className="mt-1 w-full rounded px-3 py-2 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
/>
</div>
</div>
<div className="mt-4">
<button
onClick={seed}
disabled={!cdKey || !playerName || loading || seeded}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
{seeded ? "\u2705 Seeded" : loading ? "Seeding..." : "Seed"}
</button>
</div>
{error && <ErrorBox error={error} onRetry={seed} />}
<StepNav onNext={onNext} onBack={onBack} step={8} />
</div>
);
}
function CompleteStep({ onFinish }: { onFinish: () => void }) {
return (
<div className="text-center">
<h2
className="text-2xl font-bold"
style={{ fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", color: "var(--forge-accent)" }}
>
Setup Complete!
</h2>
<p className="mt-4" style={{ color: "var(--forge-text-secondary)" }}>
Your Layonara Forge environment is ready. Build modules, edit scripts, manage repositories, and run the local
dev server.
</p>
<div className="mt-8">
<button
onClick={onFinish}
className="rounded px-6 py-2.5 text-sm font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
Open Forge
</button>
</div>
</div>
);
}
export function Setup() {
const [step, setStep] = useState(0);
const navigate = useNavigate();
const next = () => setStep((s) => Math.min(s + 1, 9));
const back = () => setStep((s) => Math.max(s - 1, 0));
const finish = async () => {
try {
await api.workspace.updateConfig({ setupComplete: true });
} catch {
// continue to IDE even if config save fails
}
navigate("/");
};
const stepComponents = [
<WelcomeStep key="welcome" onNext={next} onBack={back} />,
<PrerequisitesStep key="prereqs" onNext={next} onBack={back} />,
<GitHubPatStep key="pat" onNext={next} onBack={back} />,
<WorkspaceStep key="workspace" onNext={next} onBack={back} />,
<NwnHomeStep key="nwnhome" onNext={next} onBack={back} />,
<ForkReposStep key="fork" onNext={next} onBack={back} />,
<CloneReposStep key="clone" onNext={next} onBack={back} />,
<PullImagesStep key="pull" onNext={next} onBack={back} />,
<SeedDbStep key="seed" onNext={next} onBack={back} />,
<CompleteStep key="complete" onFinish={finish} />,
];
return (
<>
<StepIndicator current={step} />
<div
className="mt-4 rounded-lg p-8"
style={{ backgroundColor: "var(--forge-surface)", border: "1px solid var(--forge-border)" }}
>
{stepComponents[step]}
</div>
</>
);
}