From 8a3cb1b0a371c47646440d9fd63cfd8ac7352877 Mon Sep 17 00:00:00 2001 From: plenarius Date: Mon, 20 Apr 2026 22:08:07 -0400 Subject: [PATCH] feat: add Setup Wizard with 10-step contributor onboarding flow --- packages/frontend/src/App.tsx | 38 +- packages/frontend/src/layouts/SetupLayout.tsx | 23 + packages/frontend/src/pages/Setup.tsx | 689 ++++++++++++++++++ 3 files changed, 747 insertions(+), 3 deletions(-) create mode 100644 packages/frontend/src/layouts/SetupLayout.tsx create mode 100644 packages/frontend/src/pages/Setup.tsx diff --git a/packages/frontend/src/App.tsx b/packages/frontend/src/App.tsx index d5fe969..1fcc988 100644 --- a/packages/frontend/src/App.tsx +++ b/packages/frontend/src/App.tsx @@ -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 ; + return <>{children}; +} + export function App() { const editorState = useEditorState(); const [selectedFilePath, setSelectedFilePath] = useState(null); @@ -46,7 +69,16 @@ export function App() { return ( - }> + }> + } /> + + + + + } + > } /> +
+

+ Layonara Forge +

+ +
+ + ); +} diff --git a/packages/frontend/src/pages/Setup.tsx b/packages/frontend/src/pages/Setup.tsx new file mode 100644 index 0000000..f4d4173 --- /dev/null +++ b/packages/frontend/src/pages/Setup.tsx @@ -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 ( +
+
+ {STEP_NAMES.map((name, i) => ( +
+
+ {i < current ? "\u2713" : i + 1} +
+ {i < STEP_NAMES.length - 1 && ( +
+ )} +
+ ))} +
+

+ Step {current + 1} of {STEP_NAMES.length} — {STEP_NAMES[current]} +

+
+ ); +} + +function StepNav({ + onNext, + onBack, + step, + nextLabel = "Next", + nextDisabled = false, +}: { + onNext: () => void; + onBack: () => void; + step: number; + nextLabel?: string; + nextDisabled?: boolean; +}) { + return ( +
+ {step > 0 ? ( + + ) : ( +
+ )} + +
+ ); +} + +function ErrorBox({ error, onRetry }: { error: string; onRetry?: () => void }) { + return ( +
+

{error}

+ {onRetry && ( + + )} +
+ ); +} + +interface StepProps { + onNext: () => void; + onBack: () => void; +} + +function WelcomeStep({ onNext }: StepProps) { + return ( +
+

+ Welcome to Layonara Forge +

+

+ This wizard will walk you through setting up your local NWN development environment — Docker, GitHub + access, workspace initialization, repository cloning, and database seeding. +

+
+ +
+
+ ); +} + +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 ( +
+

+ Prerequisites +

+

+ Checking that Docker and the Forge backend are running. +

+
+ + {status === "checking" || status === "idle" ? "\u23F3" : status === "ok" ? "\u2705" : "\u274C"} + +
+

Docker & Backend

+

+ {status === "checking" || status === "idle" + ? "Checking..." + : status === "ok" + ? "Backend is healthy" + : "Backend unreachable"} +

+
+
+ {error && } + +
+ ); +} + +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 ( +
+

+ GitHub Personal Access Token +

+

+ A PAT with repo scope is needed to fork and push to Layonara repositories. +

+
+ 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)", + }} + /> + +
+ {username && ( +

+ {"\u2705"} Authenticated as {username} +

+ )} + {error && } + +
+ ); +} + +function WorkspaceStep({ onNext, onBack }: StepProps) { + const [config, setConfig] = useState>({}); + 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 ( +
+

+ Workspace +

+

+ Initialize the Forge workspace directory structure. +

+
+

+ Workspace Path +

+

+ {(config.WORKSPACE_PATH as string) || "/home/jmg/dev/layonara"} +

+
+
+ +
+ {error && } + +
+ ); +} + +function NwnHomeStep({ onNext, onBack }: StepProps) { + const [config, setConfig] = useState>({}); + + useEffect(() => { + api.workspace.getConfig().then(setConfig).catch(() => {}); + }, []); + + return ( +
+

+ NWN Home Directory +

+

+ Path to the NWN:EE local server home directory (contains modules/, hak/, tlk/). +

+
+

+ NWN Home Path +

+

+ {(config.NWN_HOME_PATH as string) || "/home/jmg/dev/nwn/local-server/home"} +

+
+ +
+ ); +} + +function ForkReposStep({ onNext, onBack }: StepProps) { + const repos = ["nwn-module", "nwn-haks", "unified"]; + const [forkStatus, setForkStatus] = useState>({}); + const [errors, setErrors] = useState>({}); + + useEffect(() => { + api.github + .forks() + .then((forks) => { + const s: Record = {}; + 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 ( +
+

+ Fork Repositories +

+

+ Fork the Layonara repositories to your GitHub account. +

+
+ {repos.map((repo) => ( +
+
+

+ Layonara/{repo} +

+ {errors[repo] && ( +

+ {errors[repo]} +

+ )} +
+ +
+ ))} +
+ +
+ ); +} + +function CloneReposStep({ onNext, onBack }: StepProps) { + const repos = ["nwn-module", "nwn-haks", "unified"]; + const [cloneStatus, setCloneStatus] = useState>({}); + const [errors, setErrors] = useState>({}); + 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 ( +
+

+ Clone Repositories +

+

+ Clone all forked repositories into the workspace. +

+
+ {repos.map((repo) => ( +
+ + {cloneStatus[repo] === "cloned" + ? "\u2705" + : cloneStatus[repo] === "cloning" + ? "\u23F3" + : cloneStatus[repo] === "error" + ? "\u274C" + : "\u25CB"} + + + {repo} + + {errors[repo] && ( + + {errors[repo]} + + )} +
+ ))} +
+
+ +
+ +
+ ); +} + +function PullImagesStep({ onNext, onBack }: StepProps) { + const images = ["layonara/unified", "mariadb"]; + const [pullStatus, setPullStatus] = useState>({}); + const [errors, setErrors] = useState>({}); + 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 ( +
+

+ Pull Docker Images +

+

+ Pull the required Docker images for the local dev environment. +

+
+ {images.map((image) => ( +
+ + {pullStatus[image] === "pulled" + ? "\u2705" + : pullStatus[image] === "pulling" + ? "\u23F3" + : pullStatus[image] === "error" + ? "\u274C" + : "\u25CB"} + + + {image} + + {errors[image] && ( + + {errors[image]} + + )} +
+ ))} +
+
+ +
+ +
+ ); +} + +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 ( +
+

+ Seed Database +

+

+ Add your NWN CD key and player name so the dev server recognizes you as a DM. +

+
+
+ + 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)", + }} + /> +
+
+ + 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)", + }} + /> +
+
+
+ +
+ {error && } + +
+ ); +} + +function CompleteStep({ onFinish }: { onFinish: () => void }) { + return ( +
+

+ Setup Complete! +

+

+ Your Layonara Forge environment is ready. Build modules, edit scripts, manage repositories, and run the local + dev server. +

+
+ +
+
+ ); +} + +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 = [ + , + , + , + , + , + , + , + , + , + , + ]; + + return ( + <> + +
+ {stepComponents[step]} +
+ + ); +}