feat: add Setup Wizard with 10-step contributor onboarding flow
This commit is contained in:
@@ -1,18 +1,41 @@
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
import { Editor } from "./pages/Editor";
|
import { Editor } from "./pages/Editor";
|
||||||
import { Build } from "./pages/Build";
|
import { Build } from "./pages/Build";
|
||||||
import { Server } from "./pages/Server";
|
import { Server } from "./pages/Server";
|
||||||
import { Toolset } from "./pages/Toolset";
|
import { Toolset } from "./pages/Toolset";
|
||||||
import { Repos } from "./pages/Repos";
|
import { Repos } from "./pages/Repos";
|
||||||
|
import { Setup } from "./pages/Setup";
|
||||||
import { IDELayout } from "./layouts/IDELayout";
|
import { IDELayout } from "./layouts/IDELayout";
|
||||||
|
import { SetupLayout } from "./layouts/SetupLayout";
|
||||||
import { FileExplorer } from "./components/editor/FileExplorer";
|
import { FileExplorer } from "./components/editor/FileExplorer";
|
||||||
import { api } from "./services/api";
|
import { api } from "./services/api";
|
||||||
import { useEditorState } from "./hooks/useEditorState";
|
import { useEditorState } from "./hooks/useEditorState";
|
||||||
|
|
||||||
const DEFAULT_REPO = "nwn-module";
|
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() {
|
export function App() {
|
||||||
const editorState = useEditorState();
|
const editorState = useEditorState();
|
||||||
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
|
const [selectedFilePath, setSelectedFilePath] = useState<string | null>(null);
|
||||||
@@ -46,7 +69,16 @@ export function App() {
|
|||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<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="/" element={<Dashboard />} />
|
||||||
<Route
|
<Route
|
||||||
path="/editor"
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} — {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 — 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 & 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user