feat: add Settings page with PAT management, theme, and shortcuts
This commit is contained in:
@@ -6,6 +6,7 @@ import { Build } from "./pages/Build";
|
||||
import { Server } from "./pages/Server";
|
||||
import { Toolset } from "./pages/Toolset";
|
||||
import { Repos } from "./pages/Repos";
|
||||
import { Settings } from "./pages/Settings";
|
||||
import { Setup } from "./pages/Setup";
|
||||
import { IDELayout } from "./layouts/IDELayout";
|
||||
import { SetupLayout } from "./layouts/SetupLayout";
|
||||
@@ -88,6 +89,7 @@ export function App() {
|
||||
<Route path="server" element={<Server />} />
|
||||
<Route path="toolset" element={<Toolset />} />
|
||||
<Route path="repos" element={<Repos />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user