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 { 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>
|
||||||
|
|||||||
@@ -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