feat: add Settings page with PAT management, theme, and shortcuts

This commit is contained in:
plenarius
2026-04-20 22:11:03 -04:00
parent 1a46baa7fe
commit 8df7a78c08
2 changed files with 325 additions and 0 deletions
+2
View File
@@ -6,6 +6,7 @@ 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 { Settings } from "./pages/Settings";
import { Setup } from "./pages/Setup"; import { Setup } from "./pages/Setup";
import { IDELayout } from "./layouts/IDELayout"; import { IDELayout } from "./layouts/IDELayout";
import { SetupLayout } from "./layouts/SetupLayout"; import { SetupLayout } from "./layouts/SetupLayout";
@@ -88,6 +89,7 @@ export function App() {
<Route path="server" element={<Server />} /> <Route path="server" element={<Server />} />
<Route path="toolset" element={<Toolset />} /> <Route path="toolset" element={<Toolset />} />
<Route path="repos" element={<Repos />} /> <Route path="repos" element={<Repos />} />
<Route path="settings" element={<Settings />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+323
View File
@@ -0,0 +1,323 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
import { useTheme } from "../hooks/useTheme";
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div
className="rounded-lg p-5"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
}}
>
<h3 className="mb-4 text-sm font-semibold" style={{ color: "var(--forge-accent)" }}>
{title}
</h3>
{children}
</div>
);
}
function GitHubSection({ config }: { config: Record<string, unknown> }) {
const [editing, setEditing] = useState(false);
const [pat, setPat] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState("");
const currentPat = (config.githubPat as string) || "";
const masked = currentPat ? currentPat.slice(0, 8) + "\u2022".repeat(20) : "Not set";
const save = async () => {
setSaving(true);
setMsg("");
try {
const { login } = await api.github.validatePat(pat);
await api.workspace.updateConfig({ githubPat: pat });
setMsg(`Updated \u2014 authenticated as ${login}`);
setEditing(false);
setPat("");
} catch (err) {
setMsg(err instanceof Error ? err.message : "Validation failed");
} finally {
setSaving(false);
}
};
return (
<Section title="GitHub">
<div className="space-y-3">
<div>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
Personal Access Token
</p>
<p className="mt-1 font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{masked}
</p>
</div>
{!editing ? (
<button
onClick={() => setEditing(true)}
className="rounded px-3 py-1.5 text-xs font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
Update PAT
</button>
) : (
<div className="flex gap-2">
<input
type="password"
value={pat}
onChange={(e) => setPat(e.target.value)}
placeholder="ghp_..."
className="flex-1 rounded px-3 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
}}
/>
<button
onClick={save}
disabled={!pat || saving}
className="rounded px-3 py-1.5 text-xs font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
{saving ? "..." : "Save"}
</button>
<button
onClick={() => {
setEditing(false);
setPat("");
}}
className="rounded px-3 py-1.5 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
Cancel
</button>
</div>
)}
{msg && (
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{msg}
</p>
)}
</div>
</Section>
);
}
function ThemeSection() {
const { theme, toggleTheme } = useTheme();
return (
<Section title="Theme">
<div className="flex items-center gap-4">
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
{theme === "dark" ? "Dark" : "Light"} Mode
</span>
<button
onClick={toggleTheme}
className="rounded px-3 py-1.5 text-xs font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
Switch to {theme === "dark" ? "Light" : "Dark"}
</button>
</div>
</Section>
);
}
function PathsSection({ config }: { config: Record<string, unknown> }) {
return (
<Section title="Paths">
<div className="space-y-3">
<div>
<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) || "Not set"}
</p>
</div>
<div>
<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) || "Not set"}
</p>
</div>
</div>
</Section>
);
}
function DockerSection() {
const images = ["layonara/unified", "mariadb", "layonara/builder"];
const [pulling, setPulling] = useState<Record<string, boolean>>({});
const [status, setStatus] = useState<Record<string, string>>({});
const pull = async (image: string) => {
setPulling((s) => ({ ...s, [image]: true }));
setStatus((s) => ({ ...s, [image]: "" }));
try {
await api.docker.pull(image);
setStatus((s) => ({ ...s, [image]: "Pulled" }));
} catch (err) {
setStatus((s) => ({
...s,
[image]: err instanceof Error ? err.message : "Failed",
}));
} finally {
setPulling((s) => ({ ...s, [image]: false }));
}
};
return (
<Section title="Docker Images">
<div className="space-y-2">
{images.map((image) => (
<div
key={image}
className="flex items-center justify-between rounded p-3"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{image}
</span>
<div className="flex items-center gap-2">
{status[image] && (
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{status[image]}
</span>
)}
<button
onClick={() => pull(image)}
disabled={pulling[image]}
className="rounded px-3 py-1 text-xs font-semibold disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
>
{pulling[image] ? "..." : "Pull Latest"}
</button>
</div>
</div>
))}
</div>
</Section>
);
}
const SHORTCUTS = [
{ keys: "Ctrl+Shift+B", action: "Go to Build" },
{ keys: "Ctrl+`", action: "Toggle Terminal" },
{ keys: "Ctrl+Shift+F", action: "Go to Editor / Search" },
{ keys: "Ctrl+Shift+G", action: "Go to Repos" },
{ keys: "Ctrl+,", action: "Go to Settings" },
];
function ShortcutsSection() {
return (
<Section title="Keyboard Shortcuts">
<div className="space-y-1">
{SHORTCUTS.map((s) => (
<div
key={s.keys}
className="flex items-center justify-between rounded px-3 py-2"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
{s.action}
</span>
<kbd
className="rounded px-2 py-0.5 font-mono text-xs"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)",
}}
>
{s.keys}
</kbd>
</div>
))}
</div>
</Section>
);
}
function AboutSection() {
return (
<Section title="About">
<p className="text-sm" style={{ color: "var(--forge-text)" }}>
Layonara Forge v0.0.1
</p>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
github.com/Layonara/layonara-forge
</p>
</Section>
);
}
function ResetSection() {
const navigate = useNavigate();
const reset = async () => {
try {
await api.workspace.updateConfig({ setupComplete: false });
} catch {
// continue anyway
}
navigate("/setup");
};
return (
<Section title="Reset">
<button
onClick={reset}
className="rounded px-4 py-2 text-sm font-semibold"
style={{ backgroundColor: "#7f1d1d", color: "#fca5a5" }}
>
Re-run Setup Wizard
</button>
</Section>
);
}
export function Settings() {
const [config, setConfig] = useState<Record<string, unknown>>({});
useEffect(() => {
api.workspace.getConfig().then(setConfig).catch(() => {});
}, []);
return (
<div className="h-full overflow-y-auto p-6">
<h2
className="mb-6 text-xl font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
Settings
</h2>
<div className="max-w-2xl space-y-4">
<GitHubSection config={config} />
<ThemeSection />
<PathsSection config={config} />
<DockerSection />
<ShortcutsSection />
<AboutSection />
<ResetSection />
</div>
</div>
);
}