feat: complete UI/UX overhaul with Impeccable design system

Replace Inter/Baskerville with self-hosted Manrope/Alegreya/JetBrains Mono
variable fonts. Migrate all colors from hex to OKLCH tokens (30+ CSS custom
properties) with full dark/light mode support. Replace Unicode emoji with
lucide-react SVG icons throughout. Convert all page layouts to inline styles
(Tailwind CSS 4 flex/grid classes unreliable in this project). Code-split
routes via React.lazy (760KB → 15KB initial shell + 10 lazy chunks).

Add global styles: scrollbar theming, selection color, input/button bases,
:focus-visible ring, prefers-reduced-motion. Setup wizard gets 4-phase
indicator with numbered circles, PathInput and StatusDot components.
Toast container gets aria-live="polite". Tab close buttons changed to
proper <button> elements with aria-labels.

All 8 pages (Dashboard, Editor, Build, Server, Toolset, Repos, Settings,
Setup) rewritten with consistent card/section/button patterns.
This commit is contained in:
plenarius
2026-04-21 03:06:29 -04:00
parent 8b35c41a52
commit cbe51a6e67
29 changed files with 3531 additions and 1206 deletions
-2
View File
@@ -5,8 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Layonara Forge</title>
<link rel="icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
+4
View File
@@ -9,9 +9,13 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource-variable/alegreya": "^5.2.8",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"@monaco-editor/react": "^4.7.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.8.0",
"monaco-editor": "^0.55.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+47 -32
View File
@@ -1,13 +1,14 @@
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 { Settings } from "./pages/Settings";
import { Setup } from "./pages/Setup";
import { useState, useCallback, useEffect, lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./pages/Dashboard").then(m => ({ default: m.Dashboard })));
const Editor = lazy(() => import("./pages/Editor").then(m => ({ default: m.Editor })));
const Build = lazy(() => import("./pages/Build").then(m => ({ default: m.Build })));
const Server = lazy(() => import("./pages/Server").then(m => ({ default: m.Server })));
const Toolset = lazy(() => import("./pages/Toolset").then(m => ({ default: m.Toolset })));
const Repos = lazy(() => import("./pages/Repos").then(m => ({ default: m.Repos })));
const Settings = lazy(() => import("./pages/Settings").then(m => ({ default: m.Settings })));
const Setup = lazy(() => import("./pages/Setup").then(m => ({ default: m.Setup })));
import { IDELayout } from "./layouts/IDELayout";
import { SetupLayout } from "./layouts/SetupLayout";
import { FileExplorer } from "./components/editor/FileExplorer";
@@ -18,6 +19,14 @@ import { useEditorState } from "./hooks/useEditorState";
const DEFAULT_REPO = "nwn-module";
function PageLoader() {
return (
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}>
<span className="text-sm">Loading</span>
</div>
);
}
function SetupGuard({ children }: { children: React.ReactNode }) {
const [checking, setChecking] = useState(true);
const [needsSetup, setNeedsSetup] = useState(false);
@@ -34,7 +43,11 @@ function SetupGuard({ children }: { children: React.ReactNode }) {
.finally(() => setChecking(false));
}, []);
if (checking) return null;
if (checking) return (
<div className="flex h-screen items-center justify-center" style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}>
<span style={{ fontFamily: "var(--font-heading)" }}>Loading Forge</span>
</div>
);
if (needsSetup) return <Navigate to="/setup" replace />;
return <>{children}</>;
}
@@ -73,29 +86,31 @@ export function App() {
<ToastProvider>
<ErrorBoundary>
<BrowserRouter>
<Routes>
<Route path="/setup" element={<SetupLayout />}>
<Route index element={<Setup />} />
</Route>
<Route
element={
<SetupGuard>
<IDELayout sidebar={sidebar} />
</SetupGuard>
}
>
<Route path="/" element={<Dashboard />} />
<Suspense fallback={<PageLoader />}>
<Routes>
<Route path="/setup" element={<SetupLayout />}>
<Route index element={<Setup />} />
</Route>
<Route
path="/editor"
element={<Editor editorState={editorState} />}
/>
<Route path="build" element={<Build />} />
<Route path="server" element={<Server />} />
<Route path="toolset" element={<Toolset />} />
<Route path="repos" element={<Repos />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
element={
<SetupGuard>
<IDELayout sidebar={sidebar} />
</SetupGuard>
}
>
<Route path="/" element={<Dashboard />} />
<Route
path="/editor"
element={<Editor editorState={editorState} />}
/>
<Route path="build" element={<Build />} />
<Route path="server" element={<Server />} />
<Route path="toolset" element={<Toolset />} />
<Route path="repos" element={<Repos />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</ErrorBoundary>
</ToastProvider>
@@ -104,13 +104,13 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
/>
<div className="mb-4 rounded p-3 text-xs" style={{ backgroundColor: "var(--forge-bg)", fontFamily: "'JetBrains Mono', monospace" }}>
<div className="mb-4 rounded p-3 text-xs" style={{ backgroundColor: "var(--forge-bg)", fontFamily: "var(--font-mono)" }}>
<div style={{ color: "var(--forge-text-secondary)" }}>Preview:</div>
<pre className="mt-1 whitespace-pre-wrap" style={{ color: "var(--forge-text)" }}>{preview}</pre>
</div>
{error && (
<div className="mb-3 rounded bg-red-500/10 px-3 py-2 text-sm text-red-400">{error}</div>
<div className="mb-3 rounded px-3 py-2 text-sm" style={{ backgroundColor: "var(--forge-danger-bg)", color: "var(--forge-danger)" }}>{error}</div>
)}
<div className="flex justify-end gap-2">
@@ -125,7 +125,7 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
onClick={() => handleSubmit(false)}
disabled={!isValid || loading}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }}
>
Commit
</button>
@@ -133,7 +133,7 @@ export function CommitDialog({ repo, onClose, onCommitted }: CommitDialogProps)
onClick={() => handleSubmit(true)}
disabled={!isValid || loading}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
style={{ backgroundColor: "var(--forge-warning-bg)", borderColor: "var(--forge-warning-border)", color: "var(--forge-warning)" }}
>
Commit & Push
</button>
@@ -25,10 +25,10 @@ export function ErrorDisplay({
className="rounded-lg p-6"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid #7f1d1d",
border: "1px solid var(--forge-danger-border)",
}}
>
<h3 className="text-lg font-semibold" style={{ color: "#fca5a5" }}>
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-danger)" }}>
{title}
</h3>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text)" }}>
@@ -50,7 +50,7 @@ export function ErrorDisplay({
style={{
backgroundColor: "var(--forge-bg)",
color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
}}
>
{fullLog}
@@ -64,7 +64,7 @@ export function ErrorDisplay({
<button
onClick={onRetry}
className="rounded px-4 py-2 text-sm font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{ backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }}
>
Retry
</button>
+5 -5
View File
@@ -29,9 +29,9 @@ export function useToast() {
}
const COLORS: Record<ToastType, { bg: string; border: string; text: string }> = {
success: { bg: "#052e16", border: "#166534", text: "#4ade80" },
error: { bg: "#3b1111", border: "#7f1d1d", text: "#fca5a5" },
info: { bg: "#1c1403", border: "#946200", text: "#fbbf24" },
success: { bg: "var(--forge-success-bg)", border: "var(--forge-success-border)", text: "var(--forge-success)" },
error: { bg: "var(--forge-danger-bg)", border: "var(--forge-danger-border)", text: "var(--forge-danger)" },
info: { bg: "var(--forge-warning-bg)", border: "var(--forge-warning-border)", text: "var(--forge-warning)" },
};
const AUTO_DISMISS: Record<ToastType, number | null> = {
@@ -49,7 +49,7 @@ function ToastItem({
}) {
const { bg, border, text } = COLORS[toast.type];
const timeout = AUTO_DISMISS[toast.type];
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (timeout) {
@@ -92,7 +92,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
return (
<ToastContext.Provider value={{ showToast }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" style={{ maxWidth: "360px" }}>
<div aria-live="polite" role="status" className="fixed bottom-4 right-4 z-50 flex flex-col gap-2" style={{ maxWidth: "360px" }}>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
))}
@@ -56,16 +56,18 @@ export function EditorTabs({
/>
)}
</span>
<span
<button
type="button"
aria-label="Close tab"
onClick={(e) => {
e.stopPropagation();
onClose(tab.path);
}}
className="ml-1 rounded p-0.5 opacity-0 transition-opacity hover:bg-white/10 group-hover:opacity-100"
style={{ color: "var(--forge-text-secondary)" }}
style={{ appearance: "none", border: "none", background: "transparent", cursor: "pointer", color: "var(--forge-text-secondary)" }}
>
×
</span>
</button>
</button>
);
})}
@@ -87,7 +87,7 @@ function FileTreeNode({
paddingLeft: `${depth * 16 + 8}px`,
backgroundColor: isSelected ? "var(--forge-surface)" : undefined,
color: isSelected ? "var(--forge-text)" : "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "13px",
}}
>
@@ -186,8 +186,17 @@ export function FileExplorer({
)}
{error && (
<div className="px-3 py-4 text-sm text-red-400">
{error}
<div style={{ padding: "1rem 0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
{error.includes("ENOENT") ? (
<div>
<p style={{ margin: 0, fontWeight: 500, color: "var(--forge-text)" }}>Repository not cloned</p>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)" }}>
Clone repositories from the Repos page or run the setup wizard.
</p>
</div>
) : (
<p style={{ margin: 0, color: "var(--forge-danger)" }}>{error}</p>
)}
</div>
)}
@@ -167,13 +167,14 @@ function registerNWScript(monaco: Parameters<OnMount>[1]) {
function defineForgeTheme(monaco: Parameters<OnMount>[1]) {
const style = getComputedStyle(document.documentElement);
const bg = style.getPropertyValue("--forge-bg").trim() || "#121212";
const surface = style.getPropertyValue("--forge-surface").trim() || "#1e1e2e";
const accent = style.getPropertyValue("--forge-accent").trim() || "#946200";
const text = style.getPropertyValue("--forge-text").trim() || "#f2f2f2";
const bg = style.getPropertyValue("--forge-bg").trim() || "#1f1a14";
const surface = style.getPropertyValue("--forge-surface").trim() || "#2a2419";
const accent = style.getPropertyValue("--forge-accent").trim() || "#b07a1a";
const text = style.getPropertyValue("--forge-text").trim() || "#ede8e0";
const textSecondary =
style.getPropertyValue("--forge-text-secondary").trim() || "#888888";
const border = style.getPropertyValue("--forge-border").trim() || "#2e2e3e";
style.getPropertyValue("--forge-text-secondary").trim() || "#9a9080";
const border = style.getPropertyValue("--forge-border").trim() || "#3d3528";
const accentSubtle = style.getPropertyValue("--forge-accent-subtle").trim() || "#2e2818";
monaco.editor.defineTheme("forge-dark", {
base: "vs-dark",
@@ -196,7 +197,7 @@ function defineForgeTheme(monaco: Parameters<OnMount>[1]) {
"editor.lineHighlightBackground": surface,
"editorLineNumber.foreground": textSecondary,
"editorLineNumber.activeForeground": text,
"editor.selectionBackground": "#264f7840",
"editor.selectionBackground": accentSubtle,
"editorWidget.background": surface,
"editorWidget.border": border,
"editorSuggestWidget.background": surface,
@@ -82,9 +82,9 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
const toggleBtnStyle = (active: boolean): React.CSSProperties => ({
backgroundColor: active ? "var(--forge-accent)" : "transparent",
color: active ? "#121212" : "var(--forge-text-secondary)",
color: active ? "var(--forge-accent-text)" : "var(--forge-text-secondary)",
border: `1px solid ${active ? "var(--forge-accent)" : "var(--forge-border)"}`,
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "12px",
lineHeight: "1",
});
@@ -119,7 +119,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text)",
border: "1px solid var(--forge-border)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "13px",
}}
/>
@@ -172,7 +172,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
className="w-full rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-50"
style={{
backgroundColor: "var(--forge-accent)",
color: "#121212",
color: "var(--forge-accent-text)",
}}
>
{loading ? "Searching..." : "Search"}
@@ -181,7 +181,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
<div className="flex-1 overflow-y-auto">
{error && (
<div className="px-3 py-2 text-sm text-red-400">{error}</div>
<div className="px-3 py-2 text-sm" style={{ color: "var(--forge-danger)" }}>{error}</div>
)}
{searched && !loading && !error && (
@@ -213,7 +213,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
</span>
<span
className="flex-1 truncate font-medium"
style={{ fontFamily: "'JetBrains Mono', 'Fira Code', monospace", fontSize: "12px" }}
style={{ fontFamily: "var(--font-mono)", fontSize: "12px" }}
>
{group.file}
</span>
@@ -240,7 +240,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
className="shrink-0 text-xs"
style={{
color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "11px",
minWidth: "32px",
textAlign: "right",
@@ -251,7 +251,7 @@ export function SearchPanel({ repo, onResultClick }: SearchPanelProps) {
<span
className="truncate text-xs"
style={{
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "12px",
color: "var(--forge-text-secondary)",
}}
@@ -129,8 +129,8 @@ function DialogNodeItem({
<span
className="shrink-0 rounded px-1 py-0.5 font-mono text-xs"
style={{
backgroundColor: type === "entry" ? "#2563eb20" : "#16a34a20",
color: type === "entry" ? "#60a5fa" : "#4ade80",
backgroundColor: type === "entry" ? "var(--forge-info-bg)" : "var(--forge-success-bg)",
color: type === "entry" ? "var(--forge-info)" : "var(--forge-success)",
}}
>
{type === "entry" ? "E" : "R"}{index}
@@ -287,7 +287,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
onClick={handleSave}
disabled={!dirty || saving}
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40"
style={{ backgroundColor: "var(--forge-accent)", color: "#fff" }}
style={{ backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }}
>
{saving ? "Saving..." : "Save"}
</button>
@@ -317,7 +317,7 @@ export function DialogEditor({ repo, filePath, content, onSave, onSwitchToRaw }:
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{error && (
<p className="mb-4 text-sm" style={{ color: "#ef4444" }}>{error}</p>
<p className="mb-4 text-sm" style={{ color: "var(--forge-danger)" }}>{error}</p>
)}
{activeTab === "tree" && (
@@ -215,13 +215,13 @@ function FieldRenderer({ field, value, onChange, onLocStringChange }: FieldRende
className="flex-1 rounded border px-2 py-1.5 font-mono text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: valid ? "var(--forge-border)" : "#ef4444",
borderColor: valid ? "var(--forge-border)" : "var(--forge-danger)",
color: "var(--forge-text)",
}}
/>
<span
className="text-xs"
style={{ color: valid ? "var(--forge-text-secondary)" : "#ef4444" }}
style={{ color: valid ? "var(--forge-text-secondary)" : "var(--forge-danger)" }}
>
{str.length}/16
</span>
@@ -421,7 +421,7 @@ export function GffEditor({
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-sm" style={{ color: "#ef4444" }}>{error}</p>
<p className="text-sm" style={{ color: "var(--forge-danger)" }}>{error}</p>
{onSwitchToRaw && (
<button
onClick={onSwitchToRaw}
@@ -461,7 +461,7 @@ export function GffEditor({
</span>
)}
{error && (
<span className="text-xs" style={{ color: "#ef4444" }}>{error}</span>
<span className="text-xs" style={{ color: "var(--forge-danger)" }}>{error}</span>
)}
</div>
<div className="flex items-center gap-2">
@@ -480,7 +480,7 @@ export function GffEditor({
className="rounded px-3 py-1 text-sm font-medium transition-colors disabled:opacity-40"
style={{
backgroundColor: "var(--forge-accent)",
color: "#fff",
color: "var(--forge-accent-text)",
}}
>
{saving ? "Saving..." : "Save"}
@@ -141,7 +141,7 @@ function PropertiesListOverride({ value }: FieldOverrideProps) {
</span>
<button
className="text-xs"
style={{ color: "#ef4444" }}
style={{ color: "var(--forge-danger)" }}
>
Remove
</button>
@@ -15,21 +15,28 @@ export function Terminal({ sessionId }: TerminalProps) {
useEffect(() => {
if (!containerRef.current) return;
const style = getComputedStyle(document.documentElement);
const bg = style.getPropertyValue("--forge-bg").trim();
const fg = style.getPropertyValue("--forge-text").trim();
const accent = style.getPropertyValue("--forge-accent").trim();
const secondary = style.getPropertyValue("--forge-text-secondary").trim();
const accentHover = style.getPropertyValue("--forge-accent-hover").trim();
const term = new XTerm({
theme: {
background: "#121212",
foreground: "#f2f2f2",
cursor: "#946200",
selectionBackground: "#946200",
selectionForeground: "#f2f2f2",
black: "#121212",
brightBlack: "#666666",
white: "#f2f2f2",
brightWhite: "#ffffff",
yellow: "#946200",
brightYellow: "#c48800",
background: bg,
foreground: fg,
cursor: accent,
selectionBackground: accent,
selectionForeground: fg,
black: bg,
brightBlack: secondary,
white: fg,
brightWhite: fg,
yellow: accent,
brightYellow: accentHover,
},
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: 13,
cursorBlink: true,
});
@@ -80,7 +87,7 @@ export function Terminal({ sessionId }: TerminalProps) {
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: "#121212" }}
style={{ backgroundColor: "var(--forge-bg)" }}
/>
);
}
+102 -47
View File
@@ -3,14 +3,28 @@ import { Outlet, Link, useLocation, useNavigate } from "react-router-dom";
import { Terminal } from "../components/terminal/Terminal";
import { useWebSocket } from "../hooks/useWebSocket";
import { useTheme } from "../hooks/useTheme";
import {
Code2,
Wrench,
Hammer,
Play,
GitBranch,
Settings,
Sun,
Moon,
Terminal as TerminalIcon,
ChevronDown,
ChevronUp,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
const NAV_ITEMS = [
{ path: "/editor", label: "Editor", icon: "\u270E" },
{ path: "/toolset", label: "Toolset", icon: "\u2699" },
{ path: "/build", label: "Build", icon: "\u2692" },
{ path: "/server", label: "Server", icon: "\u25B6" },
{ path: "/repos", label: "Repos", icon: "\u2387" },
{ path: "/settings", label: "Settings", icon: "\u2318" },
const NAV_ITEMS: { path: string; label: string; Icon: LucideIcon }[] = [
{ path: "/editor", label: "Editor", Icon: Code2 },
{ path: "/toolset", label: "Toolset", Icon: Wrench },
{ path: "/build", label: "Build", Icon: Hammer },
{ path: "/server", label: "Server", Icon: Play },
{ path: "/repos", label: "Repos", Icon: GitBranch },
{ path: "/settings", label: "Settings", Icon: Settings },
];
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
@@ -21,6 +35,7 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
const navigate = useNavigate();
const { subscribe } = useWebSocket();
const { theme, toggleTheme } = useTheme();
const showSidebar = location.pathname === "/editor" || location.pathname.startsWith("/editor/");
useEffect(() => {
return subscribe("git:upstream-update", (event) => {
@@ -85,25 +100,28 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
};
return (
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
<div style={{ display: "flex", height: "100vh", overflow: "hidden", backgroundColor: "var(--forge-bg)" }}>
{/* Left sidebar nav */}
<nav
className="flex shrink-0 flex-col"
aria-label="Main navigation"
style={{
display: "flex",
flexDirection: "column",
width: "56px",
flexShrink: 0,
borderRight: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface)",
}}
>
<Link
to="/"
className="flex items-center justify-center py-3"
style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0.75rem 0", textDecoration: "none" }}
title="Dashboard"
>
<img src="/layonara.png" alt="Layonara" style={{ width: "40px" }} />
<img src="/layonara.png" alt="Layonara" style={{ width: "36px" }} />
</Link>
<div className="mt-2 flex flex-1 flex-col">
<div style={{ marginTop: "0.25rem", display: "flex", flexDirection: "column", flex: 1 }}>
{NAV_ITEMS.map((item) => {
const isActive =
item.path === "/"
@@ -115,20 +133,26 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
<Link
key={item.path}
to={item.path}
className="relative flex flex-col items-center justify-center py-2.5 text-center transition-colors hover:bg-white/5"
style={{
borderLeft: isActive
? "3px solid var(--forge-accent)"
: "3px solid transparent",
backgroundColor: isActive ? "rgba(148, 98, 0, 0.1)" : undefined,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "0.625rem 0",
position: "relative",
textDecoration: "none",
transition: "background-color 150ms, color 150ms",
backgroundColor: isActive ? "var(--forge-accent-subtle)" : undefined,
color: isActive ? "var(--forge-accent)" : "var(--forge-text-secondary)",
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = isActive ? "var(--forge-accent-subtle)" : ""; }}
title={item.label}
>
<span className="text-base">{item.icon}</span>
<span className="mt-0.5 text-[9px] leading-tight">{item.label}</span>
<item.Icon size={18} strokeWidth={isActive ? 2.5 : 2} />
<span style={{ marginTop: "0.25rem", fontSize: "0.625rem", fontWeight: 500, lineHeight: 1 }}>{item.label}</span>
{badge > 0 && (
<span className="absolute right-1 top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-amber-500 px-0.5 text-[8px] font-bold text-black">
<span className="absolute right-1 top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full px-0.5 text-[8px] font-bold" style={{ backgroundColor: "var(--forge-accent)", color: "var(--forge-accent-text)" }}>
{badge}
</span>
)}
@@ -139,69 +163,100 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
<button
onClick={toggleTheme}
className="flex items-center justify-center py-3 text-sm transition-colors hover:bg-white/5"
style={{ color: "var(--forge-text-secondary)" }}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "0.625rem 0",
color: "var(--forge-text-secondary)",
background: "none",
border: "none",
width: "100%",
transition: "background-color 150ms, color 150ms",
}}
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
>
{theme === "dark" ? "\u2600\uFE0F" : "\uD83C\uDF19"}
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
<span style={{ marginTop: "0.25rem", fontSize: "0.625rem", fontWeight: 500, lineHeight: 1 }}>{theme === "dark" ? "Light" : "Dark"}</span>
</button>
</nav>
{/* Main content area */}
<div className="flex flex-1 flex-col overflow-hidden">
<div style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
<header
className="flex shrink-0 items-center gap-4 px-4 py-1.5"
style={{ borderBottom: "1px solid var(--forge-border)" }}
style={{
display: "flex",
alignItems: "center",
gap: "1rem",
padding: "0.375rem 1rem",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
>
<div className="flex items-center gap-2">
<span
className="text-lg font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
Layonara Forge
</span>
</div>
<div className="flex-1" />
<span
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-lg)",
fontWeight: 700,
color: "var(--forge-accent)",
}}
>
Layonara Forge
</span>
</header>
<div className="flex flex-1 overflow-hidden">
{sidebar && (
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
{sidebar && showSidebar && (
<aside
className="shrink-0 overflow-hidden"
style={{
width: "250px",
flexShrink: 0,
overflow: "hidden",
borderRight: "1px solid var(--forge-border)",
}}
>
{sidebar}
</aside>
)}
<main className="flex-1 overflow-hidden">
<main style={{ flex: 1, overflow: "hidden" }}>
<Outlet />
</main>
</div>
<button
onClick={() => setTerminalOpen((v) => !v)}
className="flex shrink-0 items-center gap-1 px-3 py-0.5 text-xs transition-colors hover:bg-white/5"
style={{
borderTop: "1px solid var(--forge-border)",
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.375rem 0.75rem",
color: "var(--forge-text-secondary)",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
background: "none",
border: "none",
borderTop: "1px solid var(--forge-border)",
width: "100%",
cursor: "pointer",
transition: "background-color 150ms",
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; }}
>
<span>{terminalOpen ? "\u25BC" : "\u25B2"}</span>
<TerminalIcon size={12} />
<span>Terminal</span>
{terminalOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
</button>
{terminalOpen && (
<div
className="shrink-0 overflow-hidden"
style={{
height: "300px",
flexShrink: 0,
overflow: "hidden",
borderTop: "1px solid var(--forge-border)",
}}
>
+25 -12
View File
@@ -3,22 +3,35 @@ import { Outlet } from "react-router-dom";
export function SetupLayout() {
return (
<div
className="flex min-h-screen items-center justify-center bg-cover bg-center bg-no-repeat p-4"
style={{
minHeight: "100vh",
backgroundColor: "var(--forge-bg)",
backgroundImage: "linear-gradient(rgba(0,0,0,0.75), rgba(0,0,0,0.85)), url('/page-bg.jpg')",
backgroundImage: "linear-gradient(oklch(15% 0.015 65 / 0.85), oklch(12% 0.01 65 / 0.92)), url('/page-bg.jpg')",
backgroundSize: "cover",
backgroundPosition: "center",
padding: "2rem",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
}}
>
<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>
<div style={{ width: "100%", maxWidth: "52rem", marginTop: "4vh" }}>
<div style={{ marginBottom: "2rem" }}>
<h1
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-2xl)",
fontWeight: 700,
color: "var(--forge-accent)",
margin: 0,
}}
>
Layonara Forge
</h1>
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Development environment setup
</p>
</div>
<Outlet />
</div>
</div>
+214 -70
View File
@@ -1,6 +1,17 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
Hammer,
Package,
Cpu,
Play,
Archive,
Upload,
ChevronDown,
ChevronUp,
AlertTriangle,
} from "lucide-react";
type BuildStatus = "idle" | "building" | "success" | "failed";
@@ -11,15 +22,38 @@ interface BuildSectionState {
}
function StatusBadge({ status }: { status: BuildStatus }) {
const colors: Record<BuildStatus, string> = {
idle: "bg-gray-500/20 text-gray-400",
building: "bg-yellow-500/20 text-yellow-400",
success: "bg-green-500/20 text-green-400",
failed: "bg-red-500/20 text-red-400",
const styles: Record<BuildStatus, React.CSSProperties> = {
idle: {
backgroundColor: "var(--forge-surface-raised)",
color: "var(--forge-text-secondary)",
},
building: {
backgroundColor: "var(--forge-warning-bg)",
color: "var(--forge-warning)",
},
success: {
backgroundColor: "var(--forge-success-bg)",
color: "var(--forge-success)",
},
failed: {
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
},
};
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${colors[status]}`}>
<span
style={{
...styles[status],
borderRadius: "9999px",
padding: "0.125rem 0.625rem",
fontSize: "var(--text-xs)",
fontWeight: 600,
fontFamily: "var(--font-mono)",
textTransform: "uppercase" as const,
letterSpacing: "0.03em",
}}
>
{status}
</span>
);
@@ -43,31 +77,47 @@ function BuildOutput({
}, [lines, collapsed]);
return (
<div className="mt-2">
<div style={{ marginTop: "0.75rem" }}>
<button
onClick={onToggle}
className="flex items-center gap-1 text-xs transition-colors hover:opacity-80"
style={{ color: "var(--forge-text-secondary)" }}
style={{
display: "flex",
alignItems: "center",
gap: "0.375rem",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
background: "none",
border: "none",
cursor: "pointer",
padding: "0.25rem 0",
fontFamily: "var(--font-sans)",
}}
>
<span>{collapsed ? "\u25B6" : "\u25BC"}</span>
{collapsed ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
<span>Output ({lines.length} lines)</span>
</button>
{!collapsed && (
<div
ref={scrollRef}
className="mt-1 max-h-64 overflow-auto rounded p-3"
style={{
backgroundColor: "#0d1117",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "12px",
lineHeight: "1.5",
marginTop: "0.5rem",
maxHeight: "16rem",
overflowY: "auto",
borderRadius: "0.5rem",
padding: "0.875rem 1rem",
backgroundColor: "var(--forge-log-bg)",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
lineHeight: "1.6",
}}
>
{lines.length === 0 ? (
<span style={{ color: "var(--forge-text-secondary)" }}>No output yet</span>
<span style={{ color: "var(--forge-text-secondary)", fontStyle: "italic" }}>
No output yet
</span>
) : (
lines.map((line, i) => (
<div key={i} style={{ color: "#c9d1d9" }}>
<div key={i} style={{ color: "var(--forge-log-text)" }}>
{line}
</div>
))
@@ -83,27 +133,29 @@ function ActionButton({
onClick,
disabled,
variant = "default",
icon,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "primary" | "warning";
icon?: React.ReactNode;
}) {
const styles = {
const variantStyles: Record<string, React.CSSProperties> = {
default: {
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text)",
},
primary: {
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "#fff",
border: "none",
color: "var(--forge-accent-text)",
},
warning: {
backgroundColor: "#854d0e",
borderColor: "#a16207",
color: "#fef08a",
backgroundColor: "var(--forge-warning-bg)",
border: "1px solid var(--forge-warning-border)",
color: "var(--forge-warning)",
},
};
@@ -111,9 +163,22 @@ function ActionButton({
<button
onClick={onClick}
disabled={disabled}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={styles[variant]}
style={{
...variantStyles[variant],
borderRadius: "0.375rem",
padding: "0.5rem 1rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-sans)",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
transition: "opacity 0.15s ease",
}}
>
{icon}
{label}
</button>
);
@@ -191,41 +256,103 @@ export function Build() {
[],
);
const isBuilding = module.status === "building" || haks.status === "building" || nwnx.status === "building";
const isBuilding =
module.status === "building" || haks.status === "building" || nwnx.status === "building";
const cardStyle: React.CSSProperties = {
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
marginBottom: "1rem",
};
const sectionHeaderStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "1rem",
};
const sectionTitleStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "0.5rem",
textTransform: "uppercase",
fontSize: "var(--text-xs)",
fontWeight: 600,
letterSpacing: "0.05em",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-heading)",
};
const buttonRowStyle: React.CSSProperties = {
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
alignItems: "center",
};
return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
Build Pipeline
</h2>
<div
style={{
height: "100%",
overflowY: "auto",
padding: "1.5rem",
color: "var(--forge-text)",
}}
>
<div style={{ marginBottom: "1.5rem" }}>
<h2
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
>
Build Pipeline
</h2>
<p
style={{
fontFamily: "var(--font-sans)",
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
margin: "0.375rem 0 0 0",
}}
>
Compile, pack, and deploy module resources
</p>
</div>
{/* Module Section */}
<section
className="mb-6 rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Module</h3>
<section style={cardStyle}>
<div style={sectionHeaderStyle}>
<div style={sectionTitleStyle}>
<Hammer size={14} />
<span>Module</span>
</div>
<StatusBadge status={module.status} />
</div>
<div className="flex flex-wrap gap-2">
<div style={buttonRowStyle}>
<ActionButton
label="Compile"
variant="primary"
icon={<Play size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.compileModule(), "module")}
/>
<ActionButton
label="Pack Module"
icon={<Archive size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.packModule(), "module")}
/>
<ActionButton
label="Deploy to Server"
variant="warning"
icon={<Upload size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.deploy(), "module")}
/>
@@ -238,21 +365,19 @@ export function Build() {
</section>
{/* Haks Section */}
<section
className="mb-6 rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Haks</h3>
<section style={cardStyle}>
<div style={sectionHeaderStyle}>
<div style={sectionTitleStyle}>
<Package size={14} />
<span>Haks</span>
</div>
<StatusBadge status={haks.status} />
</div>
<div className="flex gap-2">
<div style={buttonRowStyle}>
<ActionButton
label="Build Haks"
variant="primary"
icon={<Play size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildHaks(), "haks")}
/>
@@ -265,38 +390,49 @@ export function Build() {
</section>
{/* NWNX Section */}
<section
className="rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">
NWNX <span className="text-xs font-normal opacity-60">(Advanced)</span>
</h3>
<section style={cardStyle}>
<div style={sectionHeaderStyle}>
<div style={sectionTitleStyle}>
<Cpu size={14} />
<span>NWNX</span>
<span
style={{
fontWeight: 400,
textTransform: "none",
opacity: 0.6,
letterSpacing: "normal",
}}
>
(Advanced)
</span>
</div>
<StatusBadge status={nwnx.status} />
</div>
<div className="mb-3 flex flex-wrap gap-2">
<div style={{ ...buttonRowStyle, marginBottom: "0.75rem" }}>
<ActionButton
label="Build All"
variant="primary"
icon={<Play size={14} />}
disabled={isBuilding}
onClick={() => handleAction(() => api.build.buildNwnx(), "nwnx")}
/>
</div>
<div className="flex items-center gap-2">
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<input
type="text"
value={nwnxTarget}
onChange={(e) => setNwnxTarget(e.target.value)}
placeholder="Target (e.g. Item, Creature)"
className="rounded border px-3 py-1.5 text-sm"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
padding: "0.5rem 0.75rem",
fontSize: "var(--text-sm)",
color: "var(--forge-text)",
fontFamily: "var(--font-sans)",
outline: "none",
flex: "0 1 16rem",
}}
/>
<ActionButton
@@ -308,10 +444,18 @@ export function Build() {
/>
</div>
<p
className="mt-2 text-xs"
style={{ color: "#f59e0b" }}
style={{
marginTop: "0.75rem",
marginBottom: 0,
fontSize: "var(--text-xs)",
color: "var(--forge-warning)",
display: "flex",
alignItems: "center",
gap: "0.375rem",
}}
>
Requires server restart to pick up changes
<AlertTriangle size={12} />
Requires server restart to pick up changes
</p>
<BuildOutput
lines={nwnx.output}
+119 -103
View File
@@ -1,21 +1,68 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
import { Server, GitBranch, Hammer, Code2, Terminal, Database, ArrowRight } from "lucide-react";
const card: React.CSSProperties = {
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
};
const cardTitle: React.CSSProperties = {
fontSize: "var(--text-xs)",
fontWeight: 600,
textTransform: "uppercase" as const,
letterSpacing: "0.05em",
color: "var(--forge-text-secondary)",
margin: 0,
display: "flex",
alignItems: "center",
gap: "0.5rem",
};
const statusDot = (color: string): React.CSSProperties => ({
width: "0.5rem",
height: "0.5rem",
borderRadius: "50%",
backgroundColor: color,
flexShrink: 0,
});
const primaryBtn: React.CSSProperties = {
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.5rem 1rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
cursor: "pointer",
width: "100%",
transition: "background-color 150ms",
};
function StatusBadge({ status }: { status: string }) {
const color =
status === "running"
? "#4ade80"
: status === "stopped"
? "#f87171"
: "#fbbf24";
? "var(--forge-success)"
: status === "stopped" || status === "exited" || status === "not created"
? "var(--forge-danger)"
: "var(--forge-warning)";
return (
<span
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold"
style={{ backgroundColor: `${color}20`, color }}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
fontSize: "var(--text-xs)",
fontWeight: 500,
color,
}}
>
<span className="inline-block h-2 w-2 rounded-full" style={{ backgroundColor: color }} />
<span style={statusDot(color)} />
{status}
</span>
);
@@ -57,49 +104,36 @@ function ServerCard() {
}
};
const isRunning = status.nwserver === "running";
return (
<div
className="rounded-lg p-6"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
}}
>
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
Server Status
</h3>
<div className="mt-4 flex items-center gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
NWServer
</span>
<StatusBadge status={status.nwserver} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm" style={{ color: "var(--forge-text)" }}>
MariaDB
</span>
<StatusBadge status={status.mariadb} />
</div>
<div style={card}>
<h3 style={cardTitle}><Server size={14} /> Server</h3>
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.625rem" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>NWServer</span>
<StatusBadge status={status.nwserver} />
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>MariaDB</span>
<StatusBadge status={status.mariadb} />
</div>
</div>
<div className="mt-4">
<div style={{ marginTop: "1rem" }}>
<button
onClick={toggle}
disabled={loading}
className="rounded px-4 py-2 text-sm font-semibold disabled:opacity-40"
style={{
backgroundColor:
status.nwserver === "running" ? "#7f1d1d" : "var(--forge-accent)",
color: status.nwserver === "running" ? "#fca5a5" : "#000",
...primaryBtn,
backgroundColor: isRunning ? "var(--forge-danger-bg)" : "var(--forge-accent)",
color: isRunning ? "var(--forge-danger)" : "var(--forge-accent-text)",
border: isRunning ? "1px solid var(--forge-danger-border)" : "none",
opacity: loading ? 0.5 : 1,
}}
onMouseEnter={(e) => { if (!loading) e.currentTarget.style.opacity = "0.85"; }}
onMouseLeave={(e) => { e.currentTarget.style.opacity = loading ? "0.5" : "1"; }}
>
{loading
? "..."
: status.nwserver === "running"
? "Stop Server"
: "Start Server"}
{loading ? "..." : isRunning ? "Stop Server" : "Start Server"}
</button>
</div>
</div>
@@ -120,39 +154,32 @@ function ReposSummary() {
}, []);
return (
<div
className="rounded-lg p-6"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
}}
>
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
Repositories
</h3>
<div className="mt-4 space-y-2">
{repos.map((repo) => {
<div style={card}>
<h3 style={cardTitle}><GitBranch size={14} /> Repositories</h3>
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column" }}>
{repos.map((repo, i) => {
const s = repoStatus[repo];
const branch = (s?.branch as string) || "\u2014";
const clean = s?.clean !== false;
return (
<div
key={repo}
className="flex items-center justify-between rounded p-3"
style={{ backgroundColor: "var(--forge-bg)" }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.5rem 0",
borderTop: i > 0 ? "1px solid var(--forge-border)" : undefined,
}}
>
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
<span style={{ fontFamily: "var(--font-mono)", fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
{repo}
</span>
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{branch}
</span>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: clean ? "#4ade80" : "#fbbf24" }}
title={clean ? "Clean" : "Uncommitted changes"}
/>
<span style={statusDot(clean ? "var(--forge-success)" : "var(--forge-warning)")} title={clean ? "Clean" : "Uncommitted changes"} />
</div>
</div>
);
@@ -166,41 +193,38 @@ function QuickActions() {
const navigate = useNavigate();
const actions = [
{ label: "Build Module", onClick: () => navigate("/build") },
{ label: "Build Haks", onClick: () => navigate("/build") },
{ label: "Open Editor", onClick: () => navigate("/editor") },
{
label: "Open Terminal",
onClick: () => {
/* terminal is toggled from IDELayout via Ctrl+` */
navigate("/editor");
},
},
{ label: "Build Module", Icon: Hammer, onClick: () => navigate("/build") },
{ label: "Open Editor", Icon: Code2, onClick: () => navigate("/editor") },
{ label: "Server Logs", Icon: Database, onClick: () => navigate("/server") },
];
return (
<div
className="rounded-lg p-6"
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
}}
>
<h3 className="text-sm font-semibold" style={{ color: "var(--forge-text-secondary)" }}>
Quick Actions
</h3>
<div className="mt-4 grid grid-cols-2 gap-2">
<div style={card}>
<h3 style={cardTitle}><ArrowRight size={14} /> Quick Actions</h3>
<div style={{ marginTop: "1rem", display: "flex", flexDirection: "column", gap: "0.375rem" }}>
{actions.map((a) => (
<button
key={a.label}
onClick={a.onClick}
className="rounded p-3 text-sm font-medium transition-colors hover:bg-white/5"
style={{
backgroundColor: "var(--forge-bg)",
color: "var(--forge-text)",
display: "flex",
alignItems: "center",
gap: "0.625rem",
padding: "0.5rem 0.75rem",
borderRadius: "0.375rem",
border: "1px solid var(--forge-border)",
background: "none",
color: "var(--forge-text)",
fontSize: "var(--text-sm)",
fontWeight: 500,
cursor: "pointer",
textAlign: "left" as const,
transition: "background-color 150ms, border-color 150ms",
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-surface-raised)"; e.currentTarget.style.borderColor = "var(--forge-text-secondary)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = ""; e.currentTarget.style.borderColor = "var(--forge-border)"; }}
>
<a.Icon size={15} style={{ color: "var(--forge-text-secondary)" }} />
{a.label}
</button>
))}
@@ -211,24 +235,16 @@ function QuickActions() {
export function Dashboard() {
return (
<div className="h-full overflow-y-auto p-6">
<div className="mb-8 text-center">
<h1
className="text-3xl font-bold"
style={{
fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif",
color: "var(--forge-accent)",
}}
>
Layonara Forge
<div style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
<div style={{ maxWidth: "56rem", margin: "0 auto" }}>
<h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
Dashboard
</h1>
<p className="mt-1 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
NWN Development Environment
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Server, repositories, and quick actions
</p>
</div>
<div className="mx-auto max-w-3xl space-y-4">
<ServerCard />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div style={{ marginTop: "1.5rem", display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1rem" }}>
<ServerCard />
<ReposSummary />
<QuickActions />
</div>
+57 -11
View File
@@ -6,6 +6,7 @@ import { ItemEditor } from "../components/gff/ItemEditor";
import { CreatureEditor } from "../components/gff/CreatureEditor";
import { AreaEditor } from "../components/gff/AreaEditor";
import { DialogEditor } from "../components/gff/DialogEditor";
import { FileCode, Code2, Eye } from "lucide-react";
const GFF_EXTENSIONS = [".uti.json", ".utc.json", ".are.json", ".dlg.json", ".utp.json", ".utm.json"];
@@ -50,7 +51,6 @@ export function Editor({ editorState }: EditorProps) {
markClean,
} = editorState;
// Track per-tab editor mode: "visual" or "raw". GFF files default to visual.
const [editorModes, setEditorModes] = useState<Record<string, "visual" | "raw">>({});
const tabs = useMemo(
@@ -105,8 +105,28 @@ export function Editor({ editorState }: EditorProps) {
const renderEditor = () => {
if (!activeTab) {
return (
<div className="flex h-full items-center justify-center">
<p style={{ color: "var(--forge-text-secondary)" }} className="text-lg">
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
height: "100%",
gap: 12,
}}
>
<FileCode
size={48}
style={{ color: "var(--forge-text-secondary)", opacity: 0.4 }}
/>
<p
style={{
color: "var(--forge-text-secondary)",
fontSize: "var(--text-lg)",
fontFamily: "var(--font-heading)",
margin: 0,
}}
>
Open a file from the File Explorer to start editing
</p>
</div>
@@ -139,22 +159,41 @@ export function Editor({ editorState }: EditorProps) {
}
return (
<div className="flex h-full flex-col">
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{isActiveGff && activeMode === "raw" && (
<div
className="flex shrink-0 items-center justify-end border-b px-4 py-1"
style={{ borderColor: "var(--forge-border)", backgroundColor: "var(--forge-surface)" }}
style={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
flexShrink: 0,
padding: "4px 16px",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
}}
>
<button
onClick={handleSwitchToVisual}
className="rounded px-3 py-1 text-xs transition-colors hover:opacity-80"
style={{ backgroundColor: "var(--forge-bg)", color: "var(--forge-text-secondary)" }}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 12px",
borderRadius: 4,
border: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text-secondary)",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-mono)",
cursor: "pointer",
}}
>
<Eye size={13} />
Switch to Visual Editor
</button>
</div>
)}
<div className="flex-1 overflow-hidden">
<div style={{ flex: 1, overflow: "hidden" }}>
<MonacoEditor
key={activeTab}
filePath={activeFilePath}
@@ -167,14 +206,21 @@ export function Editor({ editorState }: EditorProps) {
};
return (
<div className="flex h-full flex-col" style={{ backgroundColor: "var(--forge-bg)" }}>
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
backgroundColor: "var(--forge-bg)",
}}
>
<EditorTabs
tabs={tabs}
activeTab={activeTab}
onSelect={selectTab}
onClose={closeFile}
/>
<div className="flex-1 overflow-hidden">
<div style={{ flex: 1, overflow: "hidden" }}>
{renderEditor()}
</div>
</div>
+473 -89
View File
@@ -2,6 +2,18 @@ import { useState, useEffect, useCallback } from "react";
import { api } from "../services/api";
import { CommitDialog } from "../components/CommitDialog";
import { useWebSocket } from "../hooks/useWebSocket";
import {
GitBranch,
GitCommit,
GitPullRequest,
Download,
Upload,
Copy,
FileCode,
AlertCircle,
CheckCircle,
X,
} from "lucide-react";
interface RepoStatus {
modified: string[];
@@ -26,6 +38,54 @@ interface PrForm {
body: string;
}
const badge = (
bg: string,
fg: string,
extra?: React.CSSProperties,
): React.CSSProperties => ({
display: "inline-flex",
alignItems: "center",
gap: "0.3rem",
padding: "0.15rem 0.55rem",
borderRadius: "9999px",
fontSize: "var(--text-xs)",
fontWeight: 600,
lineHeight: 1.4,
backgroundColor: bg,
color: fg,
whiteSpace: "nowrap",
...extra,
});
const btnBase: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
padding: "0.4rem 0.85rem",
borderRadius: "0.5rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-sans)",
border: "1px solid",
cursor: "pointer",
transition: "opacity 0.15s",
lineHeight: 1.4,
};
const outlineBtn: React.CSSProperties = {
...btnBase,
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
};
const accentBtn: React.CSSProperties = {
...btnBase,
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
};
export function Repos() {
const [repos, setRepos] = useState<RepoInfo[]>([]);
const [loading, setLoading] = useState(true);
@@ -133,106 +193,247 @@ export function Repos() {
const isDirty = (status?: RepoStatus) =>
status && (status.modified.length > 0 || status.staged.length > 0 || status.untracked.length > 0);
const disabledStyle = (disabled: boolean | undefined): React.CSSProperties =>
disabled ? { opacity: 0.45, pointerEvents: "none" } : {};
if (loading) {
return (
<div className="flex h-full items-center justify-center" style={{ color: "var(--forge-text-secondary)" }}>
Loading repositories...
<div style={{
display: "flex",
height: "100%",
alignItems: "center",
justifyContent: "center",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-sans)",
fontSize: "var(--text-base)",
}}>
Loading repositories
</div>
);
}
return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
Repositories
</h2>
<div style={{
height: "100%",
overflowY: "auto",
padding: "1.75rem 2rem",
color: "var(--forge-text)",
fontFamily: "var(--font-sans)",
}}>
{/* Page heading */}
<div style={{ marginBottom: "1.75rem" }}>
<h2 style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}>
Repositories
</h2>
<p style={{
margin: "0.3rem 0 0",
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
}}>
Clone, sync, and manage your Layonara repos
</p>
</div>
{/* Error banner */}
{error && (
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
{error}
<button onClick={() => setError("")} className="ml-2 underline">dismiss</button>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
padding: "0.65rem 1rem",
borderRadius: "0.5rem",
border: "1px solid var(--forge-danger-border)",
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
fontSize: "var(--text-sm)",
}}>
<AlertCircle size={16} />
<span style={{ flex: 1 }}>{error}</span>
<button
onClick={() => setError("")}
style={{
background: "none",
border: "none",
color: "var(--forge-danger)",
cursor: "pointer",
padding: "0.2rem",
display: "flex",
}}
>
<X size={14} />
</button>
</div>
)}
{/* PR success banner */}
{prResult && (
<div className="mb-4 rounded bg-green-500/10 px-4 py-2 text-sm text-green-400">
PR created: <a href={prResult.url} target="_blank" rel="noreferrer" className="underline">{prResult.url}</a>
<button onClick={() => setPrResult(null)} className="ml-2 underline">dismiss</button>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
padding: "0.65rem 1rem",
borderRadius: "0.5rem",
backgroundColor: "var(--forge-success-bg)",
color: "var(--forge-success)",
fontSize: "var(--text-sm)",
}}>
<CheckCircle size={16} />
<span style={{ flex: 1 }}>
PR created:{" "}
<a
href={prResult.url}
target="_blank"
rel="noreferrer"
style={{ color: "inherit", textDecoration: "underline" }}
>
{prResult.url}
</a>
</span>
<button
onClick={() => setPrResult(null)}
style={{
background: "none",
border: "none",
color: "var(--forge-success)",
cursor: "pointer",
padding: "0.2rem",
display: "flex",
}}
>
<X size={14} />
</button>
</div>
)}
<div className="space-y-4">
{/* Repo cards */}
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{repos.map((repo) => (
<section
key={repo.name}
className="rounded-lg border p-4"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
}}
>
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold">{repo.name}</h3>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{/* Card header */}
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "0.5rem",
marginBottom: "0.85rem",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem", flexWrap: "wrap" }}>
<h3 style={{
margin: 0,
fontSize: "var(--text-lg)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
}}>
{repo.name}
</h3>
<span style={badge("var(--forge-accent-subtle)", "var(--forge-accent)")}>
<GitBranch size={12} />
{repo.branch}
</span>
{repo.cloned && repo.status && (
<>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${isDirty(repo.status) ? "bg-yellow-500/20 text-yellow-400" : "bg-green-500/20 text-green-400"}`}>
{isDirty(repo.status) ? "dirty" : "clean"}
</span>
{repo.status.behind > 0 && (
<span className="rounded-full bg-amber-500/20 px-2 py-0.5 text-xs font-medium text-amber-400">
{repo.status.behind} commit{repo.status.behind !== 1 ? "s" : ""} behind upstream
{isDirty(repo.status) ? (
<span style={badge("var(--forge-warning-bg)", "var(--forge-warning)")}>
dirty
</span>
) : (
<span style={badge("var(--forge-success-bg)", "var(--forge-success)")}>
<CheckCircle size={11} />
clean
</span>
)}
{repo.status.behind > 0 && (
<span style={badge("var(--forge-warning-bg)", "var(--forge-warning)")}>
<Download size={11} />
{repo.status.behind} behind
</span>
)}
{repo.status.ahead > 0 && (
<span className="rounded-full bg-blue-500/20 px-2 py-0.5 text-xs font-medium text-blue-400">
<span style={badge("var(--forge-info-bg)", "var(--forge-info)")}>
<Upload size={11} />
{repo.status.ahead} ahead
</span>
)}
</>
)}
</div>
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<span style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-mono)",
}}>
{repo.upstream}
</span>
</div>
{/* Actions */}
{!repo.cloned ? (
<button
onClick={() => handleClone(repo.name)}
disabled={actionLoading[repo.name]}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
style={{ ...accentBtn, ...disabledStyle(actionLoading[repo.name]) }}
>
{actionLoading[repo.name] ? "Cloning..." : "Clone"}
<Copy size={14} />
{actionLoading[repo.name] ? "Cloning…" : "Clone"}
</button>
) : (
<>
<div className="mb-3 flex flex-wrap gap-2">
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", marginBottom: "0.85rem" }}>
<button
onClick={() => handlePull(repo.name)}
disabled={actionLoading[repo.name]}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
style={{ ...outlineBtn, ...disabledStyle(actionLoading[repo.name]) }}
>
<Download size={14} />
Pull
</button>
<button
onClick={() => handlePush(repo.name)}
disabled={actionLoading[repo.name] || !repo.status?.ahead}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
Push
</button>
<button
onClick={() => setCommitRepo(repo.name)}
disabled={!isDirty(repo.status)}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
style={{
...accentBtn,
...disabledStyle(!isDirty(repo.status)),
}}
>
<GitCommit size={14} />
Commit
</button>
<button
onClick={() => handlePush(repo.name)}
disabled={actionLoading[repo.name] || !repo.status?.ahead}
style={{
...outlineBtn,
...disabledStyle(actionLoading[repo.name] || !repo.status?.ahead),
}}
>
<Upload size={14} />
Push
</button>
<button
onClick={() =>
setPrForm({
@@ -241,39 +442,104 @@ export function Repos() {
body: `## Summary\n\n\n\n## Test Plan\n\n- [ ] \n\nFixes #`,
})
}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity"
style={{ backgroundColor: "#854d0e", borderColor: "#a16207", color: "#fef08a" }}
style={outlineBtn}
>
<GitPullRequest size={14} />
Create PR
</button>
</div>
{/* Changed files list */}
{repo.status && isDirty(repo.status) && (
<div className="mt-2">
<div className="mb-1 text-xs font-medium" style={{ color: "var(--forge-text-secondary)" }}>
Changes
<div style={{
border: "1px solid var(--forge-border)",
borderRadius: "0.5rem",
overflow: "hidden",
}}>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.4rem",
padding: "0.5rem 0.75rem",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
}}>
<FileCode size={13} style={{ color: "var(--forge-text-secondary)" }} />
<span style={{
fontSize: "var(--text-xs)",
fontWeight: 600,
color: "var(--forge-text-secondary)",
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
}}>
Changes
</span>
</div>
<div className="space-y-0.5">
<div>
{repo.status.modified.map((f) => (
<div
key={f}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-0.5 text-xs transition-colors hover:bg-white/5"
onClick={() => handleShowDiff(repo.name, f)}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.35rem 0.75rem",
fontSize: "var(--text-xs)",
cursor: "pointer",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span className="font-medium text-yellow-400">M</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
<span style={{
fontWeight: 700,
color: "var(--forge-warning)",
width: "1rem",
textAlign: "center",
}}>M</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
</div>
))}
{repo.status.staged.map((f) => (
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="font-medium text-green-400">S</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
<div
key={f}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.35rem 0.75rem",
fontSize: "var(--text-xs)",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span style={{
fontWeight: 700,
color: "var(--forge-success)",
width: "1rem",
textAlign: "center",
}}>S</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
</div>
))}
{repo.status.untracked.map((f) => (
<div key={f} className="flex items-center gap-2 px-2 py-0.5 text-xs">
<span className="font-medium text-gray-400">?</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace" }}>{f}</span>
<div
key={f}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.35rem 0.75rem",
fontSize: "var(--text-xs)",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span style={{
fontWeight: 700,
color: "var(--forge-text-secondary)",
width: "1rem",
textAlign: "center",
}}>?</span>
<span style={{ fontFamily: "var(--font-mono)" }}>{f}</span>
</div>
))}
</div>
@@ -285,6 +551,7 @@ export function Repos() {
))}
</div>
{/* Commit dialog */}
{commitRepo && (
<CommitDialog
repo={commitRepo}
@@ -296,71 +563,188 @@ export function Repos() {
/>
)}
{/* PR form modal */}
{prForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setPrForm(null)}>
<div
onClick={() => setPrForm(null)}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.6)",
}}
>
<div
className="w-full max-w-lg rounded-lg border p-6"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: "32rem",
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.5rem",
}}
>
<h3 className="mb-4 text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
Create Pull Request {prForm.repo}
</h3>
<div style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1.25rem",
}}>
<GitPullRequest size={18} style={{ color: "var(--forge-accent)" }} />
<h3 style={{
margin: 0,
fontSize: "var(--text-lg)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
color: "var(--forge-accent)",
}}>
Create Pull Request
</h3>
<span style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
marginLeft: "0.25rem",
}}>
{prForm.repo}
</span>
</div>
<input
type="text"
value={prForm.title}
onChange={(e) => setPrForm({ ...prForm, title: e.target.value })}
placeholder="PR Title"
className="mb-3 w-full rounded border px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
style={{
width: "100%",
padding: "0.5rem 0.75rem",
marginBottom: "0.75rem",
borderRadius: "0.5rem",
border: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-bg)",
color: "var(--forge-text)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-sans)",
boxSizing: "border-box",
outline: "none",
}}
/>
<textarea
value={prForm.body}
onChange={(e) => setPrForm({ ...prForm, body: e.target.value })}
rows={8}
className="mb-4 w-full rounded border px-3 py-1.5 text-sm"
style={{ backgroundColor: "var(--forge-bg)", borderColor: "var(--forge-border)", color: "var(--forge-text)", fontFamily: "'JetBrains Mono', monospace" }}
style={{
width: "100%",
padding: "0.5rem 0.75rem",
marginBottom: "1rem",
borderRadius: "0.5rem",
border: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-bg)",
color: "var(--forge-text)",
fontSize: "var(--text-sm)",
fontFamily: "var(--font-mono)",
boxSizing: "border-box",
resize: "vertical",
outline: "none",
}}
/>
<div className="flex justify-end gap-2">
<button
onClick={() => setPrForm(null)}
className="rounded border px-3 py-1.5 text-sm"
style={{ borderColor: "var(--forge-border)", color: "var(--forge-text)" }}
>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "0.5rem" }}>
<button onClick={() => setPrForm(null)} style={outlineBtn}>
Cancel
</button>
<button
onClick={handleCreatePr}
disabled={!prForm.title.trim() || actionLoading.pr}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--forge-accent)", borderColor: "var(--forge-accent)", color: "#fff" }}
style={{
...accentBtn,
...disabledStyle(!prForm.title.trim() || actionLoading.pr),
}}
>
{actionLoading.pr ? "Creating..." : "Submit PR"}
<GitPullRequest size={14} />
{actionLoading.pr ? "Creating…" : "Submit PR"}
</button>
</div>
</div>
</div>
)}
{/* Diff modal */}
{diffView && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setDiffView(null)}>
<div
onClick={() => setDiffView(null)}
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.6)",
}}
>
<div
className="h-3/4 w-3/4 overflow-auto rounded-lg border p-6"
style={{ backgroundColor: "var(--forge-surface)", borderColor: "var(--forge-border)" }}
onClick={(e) => e.stopPropagation()}
style={{
width: "75%",
height: "75%",
overflowY: "auto",
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.5rem",
display: "flex",
flexDirection: "column",
}}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-accent)" }}>
Diff {diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""}
</h3>
<button onClick={() => setDiffView(null)} className="text-sm underline" style={{ color: "var(--forge-text-secondary)" }}>
<div style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: "1rem",
flexShrink: 0,
}}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<FileCode size={18} style={{ color: "var(--forge-accent)" }} />
<h3 style={{
margin: 0,
fontSize: "var(--text-lg)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
color: "var(--forge-accent)",
}}>
{diffView.repo}{diffView.file ? ` / ${diffView.file}` : ""}
</h3>
</div>
<button
onClick={() => setDiffView(null)}
style={{
...outlineBtn,
padding: "0.3rem 0.6rem",
}}
>
<X size={14} />
Close
</button>
</div>
<pre
className="whitespace-pre-wrap text-xs"
style={{ fontFamily: "'JetBrains Mono', monospace", color: "var(--forge-text)" }}
>
<pre style={{
flex: 1,
overflowY: "auto",
margin: 0,
padding: "1rem",
borderRadius: "0.5rem",
backgroundColor: "var(--forge-log-bg)",
color: "var(--forge-log-text)",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
whiteSpace: "pre-wrap",
lineHeight: 1.6,
}}>
{diffView.diff || "No changes"}
</pre>
</div>
+446 -160
View File
@@ -2,29 +2,151 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { Editor as ReactMonacoEditor } from "@monaco-editor/react";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
Server as ServerIcon,
Play,
Square,
RotateCcw,
FileCode,
ScrollText,
Database,
Search,
Trash2,
} from "lucide-react";
type ServerState = "running" | "exited" | "not created" | string;
function StatusBadge({ label, state }: { label: string; state: ServerState }) {
const color =
const dotColor =
state === "running"
? "bg-green-500/20 text-green-400"
? "var(--forge-success)"
: state === "exited"
? "bg-red-500/20 text-red-400"
: "bg-gray-500/20 text-gray-400";
? "var(--forge-danger)"
: "var(--forge-warning)";
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: "var(--forge-text)" }}>
{label}:
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
backgroundColor: dotColor,
flexShrink: 0,
}}
/>
<span
style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text)",
fontFamily: "var(--font-sans)",
}}
>
{label}
</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${color}`}>
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
fontFamily: "var(--font-mono)",
}}
>
{state}
</span>
</div>
);
}
function HoverButton({
children,
onClick,
disabled,
bg,
bgHover,
border,
color,
style,
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
bg: string;
bgHover: string;
border: string;
color: string;
style?: React.CSSProperties;
}) {
const [hovered, setHovered] = useState(false);
return (
<button
onClick={onClick}
disabled={disabled}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.375rem",
padding: "0.4rem 0.85rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-sans)",
borderRadius: "0.5rem",
border: `1px solid ${border}`,
backgroundColor: hovered && !disabled ? bgHover : bg,
color,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
transition: "background-color 0.15s, opacity 0.15s",
...style,
}}
>
{children}
</button>
);
}
function SectionHeader({
icon,
label,
}: {
icon: React.ReactNode;
label: string;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "1rem",
}}
>
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
{icon}
</span>
<span
style={{
fontSize: "var(--text-xs)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--forge-text-secondary)",
}}
>
{label}
</span>
</div>
);
}
function ControlsPanel() {
const [status, setStatus] = useState<{ nwserver: string; mariadb: string }>({
nwserver: "unknown",
@@ -63,49 +185,75 @@ function ControlsPanel() {
return (
<section
className="rounded-lg border p-4"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
}}
>
<h3 className="mb-3 text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
Server Controls
</h3>
<div className="mb-4 flex gap-4">
<SectionHeader icon={<ServerIcon size={16} />} label="Server Controls" />
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "1.25rem",
marginBottom: "1.25rem",
}}
>
<StatusBadge label="NWN Server" state={status.nwserver} />
<StatusBadge label="MariaDB" state={status.mariadb} />
</div>
<div className="flex flex-wrap gap-2">
{(["start", "stop", "restart", "config"] as const).map((action) => (
<button
key={action}
onClick={() => handleAction(action)}
disabled={loading !== null}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{
backgroundColor:
action === "start"
? "var(--forge-accent)"
: action === "stop"
? "#991b1b"
: "var(--forge-surface)",
borderColor:
action === "start"
? "var(--forge-accent)"
: action === "stop"
? "#dc2626"
: "var(--forge-border)",
color: action === "start" || action === "stop" ? "#fff" : "var(--forge-text)",
}}
>
{loading === action
? "..."
: action === "config"
? "Generate Config"
: action.charAt(0).toUpperCase() + action.slice(1)}
</button>
))}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
<HoverButton
onClick={() => handleAction("start")}
disabled={loading !== null}
bg="var(--forge-accent)"
bgHover="var(--forge-accent-hover)"
border="var(--forge-accent)"
color="var(--forge-accent-text)"
>
<Play size={14} />
{loading === "start" ? "Starting..." : "Start"}
</HoverButton>
<HoverButton
onClick={() => handleAction("stop")}
disabled={loading !== null}
bg="var(--forge-danger-bg)"
bgHover="var(--forge-danger-border)"
border="var(--forge-danger-border)"
color="var(--forge-danger)"
>
<Square size={14} />
{loading === "stop" ? "Stopping..." : "Stop"}
</HoverButton>
<HoverButton
onClick={() => handleAction("restart")}
disabled={loading !== null}
bg="transparent"
bgHover="var(--forge-surface-raised)"
border="var(--forge-border)"
color="var(--forge-text)"
>
<RotateCcw size={14} />
{loading === "restart" ? "Restarting..." : "Restart"}
</HoverButton>
<HoverButton
onClick={() => handleAction("config")}
disabled={loading !== null}
bg="transparent"
bgHover="var(--forge-surface-raised)"
border="var(--forge-border)"
color="var(--forge-text)"
>
<FileCode size={14} />
{loading === "config" ? "Generating..." : "Generate Config"}
</HoverButton>
</div>
</section>
);
@@ -141,65 +289,119 @@ function LogViewer() {
return (
<section
className="flex flex-col rounded-lg border"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
height: "350px",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
display: "flex",
flexDirection: "column",
}}
>
<div
className="flex shrink-0 items-center gap-2 px-4 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1.25rem",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
>
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
Server Logs
</h3>
<div className="flex-1" />
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter logs..."
className="rounded border px-2 py-1 text-xs"
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
<ScrollText size={16} />
</span>
<span
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
width: "200px",
}}
/>
<button
onClick={() => setAutoScroll((v) => !v)}
className="rounded border px-2 py-1 text-xs"
style={{
backgroundColor: autoScroll ? "var(--forge-accent)" : "var(--forge-bg)",
borderColor: "var(--forge-border)",
color: autoScroll ? "#fff" : "var(--forge-text-secondary)",
}}
>
Auto-scroll
</button>
<button
onClick={() => setLines([])}
className="rounded border px-2 py-1 text-xs"
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
fontSize: "var(--text-xs)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--forge-text-secondary)",
}}
>
Server Logs
</span>
<div style={{ flex: 1 }} />
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
}}
>
<Search
size={13}
style={{
position: "absolute",
left: 8,
color: "var(--forge-text-secondary)",
pointerEvents: "none",
}}
/>
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter logs..."
style={{
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
padding: "0.3rem 0.5rem 0.3rem 1.75rem",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-sans)",
color: "var(--forge-text)",
width: 180,
outline: "none",
}}
/>
</div>
<HoverButton
onClick={() => setAutoScroll((v) => !v)}
bg={autoScroll ? "var(--forge-accent)" : "var(--forge-bg)"}
bgHover={
autoScroll ? "var(--forge-accent-hover)" : "var(--forge-surface-raised)"
}
border={autoScroll ? "var(--forge-accent)" : "var(--forge-border)"}
color={
autoScroll
? "var(--forge-accent-text)"
: "var(--forge-text-secondary)"
}
style={{ fontSize: "var(--text-xs)", padding: "0.25rem 0.6rem" }}
>
Auto-scroll
</HoverButton>
<HoverButton
onClick={() => setLines([])}
bg="var(--forge-bg)"
bgHover="var(--forge-surface-raised)"
border="var(--forge-border)"
color="var(--forge-text-secondary)"
style={{ fontSize: "var(--text-xs)", padding: "0.25rem 0.5rem" }}
>
<Trash2 size={12} />
Clear
</button>
</HoverButton>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-auto p-3"
style={{
backgroundColor: "#0d1117",
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontSize: "12px",
lineHeight: "1.5",
backgroundColor: "var(--forge-log-bg)",
color: "var(--forge-log-text)",
fontFamily: "var(--font-mono)",
fontSize: 12,
lineHeight: 1.6,
padding: "0.75rem 1rem",
overflowY: "auto",
height: 350,
borderRadius: "0 0 0.75rem 0.75rem",
}}
>
{filteredLines.length === 0 ? (
@@ -208,7 +410,7 @@ function LogViewer() {
</span>
) : (
filteredLines.map((line, i) => (
<div key={i} style={{ color: "#c9d1d9" }}>
<div key={i} style={{ color: "var(--forge-log-text)" }}>
{line}
</div>
))
@@ -245,31 +447,57 @@ function SQLConsole() {
return (
<section
className="flex flex-col rounded-lg border"
style={{
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
display: "flex",
flexDirection: "column",
}}
>
<div
className="flex shrink-0 items-center gap-2 px-4 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.75rem 1.25rem",
borderBottom: "1px solid var(--forge-border)",
flexShrink: 0,
}}
>
<h3 className="text-lg font-semibold" style={{ color: "var(--forge-text)" }}>
<span style={{ color: "var(--forge-accent)", display: "flex" }}>
<Database size={16} />
</span>
<span
style={{
fontSize: "var(--text-xs)",
fontWeight: 700,
fontFamily: "var(--font-heading)",
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "var(--forge-text-secondary)",
}}
>
SQL Console
</h3>
<div className="flex-1" />
</span>
<div style={{ flex: 1 }} />
{history.length > 0 && (
<select
onChange={(e) => setQuery(e.target.value)}
className="rounded border px-2 py-1 text-xs"
value=""
style={{
backgroundColor: "var(--forge-bg)",
borderColor: "var(--forge-border)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
padding: "0.3rem 0.5rem",
fontSize: "var(--text-xs)",
fontFamily: "var(--font-sans)",
color: "var(--forge-text-secondary)",
maxWidth: "200px",
maxWidth: 200,
outline: "none",
}}
value=""
>
<option value="" disabled>
History ({history.length})
@@ -281,21 +509,21 @@ function SQLConsole() {
))}
</select>
)}
<button
<HoverButton
onClick={execute}
disabled={loading || !query.trim()}
className="rounded border px-3 py-1 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "#fff",
}}
bg="var(--forge-accent)"
bgHover="var(--forge-accent-hover)"
border="var(--forge-accent)"
color="var(--forge-accent-text)"
>
<Play size={14} />
{loading ? "Running..." : "Execute"}
</button>
</HoverButton>
</div>
<div style={{ height: "100px" }}>
<div style={{ height: 100, borderBottom: "1px solid var(--forge-border)" }}>
<ReactMonacoEditor
value={query}
language="sql"
@@ -319,71 +547,95 @@ function SQLConsole() {
{error && (
<div
className="px-4 py-2 text-sm"
style={{ color: "#ef4444", borderTop: "1px solid var(--forge-border)" }}
style={{
padding: "0.75rem 1.25rem",
fontSize: "var(--text-sm)",
color: "var(--forge-danger)",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-danger-bg)",
}}
>
{error}
</div>
)}
{result && (
<div
className="max-h-64 overflow-auto"
style={{ borderTop: "1px solid var(--forge-border)" }}
>
<div style={{ overflowX: "auto" }}>
{result.columns.length === 0 ? (
<div className="px-4 py-3 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
<div
style={{
padding: "1rem 1.25rem",
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
}}
>
Query executed successfully (no results)
</div>
) : (
<table className="w-full text-left text-xs">
<thead>
<tr>
{result.columns.map((col) => (
<th
key={col}
className="sticky top-0 px-3 py-2 font-medium"
style={{
backgroundColor: "var(--forge-surface)",
borderBottom: "1px solid var(--forge-border)",
color: "var(--forge-accent)",
fontFamily: "'JetBrains Mono', monospace",
}}
>
{col}
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.map((row, i) => (
<tr
key={i}
className="transition-colors hover:bg-white/5"
style={{
borderBottom: "1px solid var(--forge-border)",
}}
>
<div style={{ maxHeight: 280, overflowY: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: "var(--text-xs)",
}}
>
<thead>
<tr>
{result.columns.map((col) => (
<td
<th
key={col}
className="px-3 py-1.5"
style={{
color: "var(--forge-text)",
fontFamily: "'JetBrains Mono', monospace",
position: "sticky",
top: 0,
padding: "0.5rem 0.75rem",
fontWeight: 600,
textAlign: "left",
backgroundColor: "var(--forge-surface)",
borderBottom: "1px solid var(--forge-border)",
color: "var(--forge-accent)",
fontFamily: "var(--font-mono)",
whiteSpace: "nowrap",
}}
>
{row[col]}
</td>
{col}
</th>
))}
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{result.rows.map((row, i) => (
<tr
key={i}
style={{
borderBottom: "1px solid var(--forge-border)",
}}
>
{result.columns.map((col) => (
<td
key={col}
style={{
padding: "0.4rem 0.75rem",
color: "var(--forge-text)",
fontFamily: "var(--font-mono)",
}}
>
{row[col]}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
<div
className="px-3 py-1 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
style={{
padding: "0.4rem 1rem",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
borderTop: "1px solid var(--forge-border)",
}}
>
{result.rows.length} row{result.rows.length !== 1 ? "s" : ""}
</div>
@@ -395,11 +647,45 @@ function SQLConsole() {
export function Server() {
return (
<div className="h-full overflow-auto p-6" style={{ color: "var(--forge-text)" }}>
<h2 className="mb-6 text-2xl font-bold" style={{ color: "var(--forge-accent)" }}>
Server Management
</h2>
<div className="flex flex-col gap-6">
<div
style={{
height: "100%",
overflowY: "auto",
padding: "1.5rem",
color: "var(--forge-text)",
}}
>
<div style={{ marginBottom: "1.5rem" }}>
<h2
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-accent)",
margin: 0,
}}
>
Server Management
</h2>
<p
style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
margin: "0.25rem 0 0",
fontFamily: "var(--font-sans)",
}}
>
Control server processes, view logs, and query the database
</p>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.25rem",
}}
>
<ControlsPanel />
<LogViewer />
<SQLConsole />
+264 -135
View File
@@ -2,25 +2,95 @@ import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../services/api";
import { useTheme } from "../hooks/useTheme";
import {
Key,
Sun,
Moon,
FolderOpen,
Container,
Keyboard,
Info,
RotateCcw,
Download,
} from "lucide-react";
const sectionCard: React.CSSProperties = {
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "1.25rem",
};
const sectionTitle: React.CSSProperties = {
fontSize: "var(--text-xs)",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--forge-text-secondary)",
margin: "0 0 1rem 0",
display: "flex",
alignItems: "center",
gap: "0.5rem",
};
const fieldLabel: React.CSSProperties = {
fontSize: "var(--text-xs)",
fontWeight: 500,
color: "var(--forge-text-secondary)",
margin: "0 0 0.25rem 0",
};
const fieldValue: React.CSSProperties = {
fontFamily: "var(--font-mono)",
fontSize: "var(--text-sm)",
color: "var(--forge-text)",
margin: 0,
};
const primaryBtn: React.CSSProperties = {
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.4rem 0.875rem",
fontSize: "var(--text-xs)",
fontWeight: 600,
cursor: "pointer",
};
const ghostBtn: React.CSSProperties = {
background: "none",
border: "none",
color: "var(--forge-text-secondary)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
padding: "0.4rem 0.625rem",
borderRadius: "0.375rem",
};
const listRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0.625rem 0.875rem",
borderRadius: "0.5rem",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
};
function Section({
title,
icon,
children,
}: {
title: string;
icon: React.ReactNode;
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>
<div style={sectionCard}>
<h3 style={sectionTitle}>{icon} {title}</h3>
{children}
</div>
);
@@ -32,8 +102,8 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
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 hasPat = Boolean(config.githubPat && config.githubPat !== "***");
const masked = config.githubPat ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : "Not set";
const save = async () => {
setSaving(true);
@@ -52,64 +122,43 @@ function GitHubSection({ config }: { config: Record<string, unknown> }) {
};
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>
<Section title="Gitea Token" icon={<Key size={14} />}>
<div>
<p style={fieldLabel}>Personal Access Token</p>
<p style={fieldValue}>{masked}</p>
</div>
<div style={{ marginTop: "0.75rem" }}>
{!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 onClick={() => setEditing(true)} style={primaryBtn}>
Update Token
</button>
) : (
<div className="flex gap-2">
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<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)",
}}
placeholder="Paste token"
style={{ flex: 1 }}
/>
<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" }}
style={{ ...primaryBtn, opacity: !pat || saving ? 0.4 : 1 }}
>
{saving ? "..." : "Save"}
{saving ? "Saving\u2026" : "Save"}
</button>
<button
onClick={() => {
setEditing(false);
setPat("");
}}
className="rounded px-3 py-1.5 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
<button onClick={() => { setEditing(false); setPat(""); }} style={ghostBtn}>
Cancel
</button>
</div>
)}
{msg && (
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
{msg}
</p>
)}
</div>
{msg && (
<p style={{ marginTop: "0.5rem", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{msg}
</p>
)}
</Section>
);
}
@@ -118,16 +167,17 @@ 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" }}
>
<Section title="Theme" icon={theme === "dark" ? <Moon size={14} /> : <Sun size={14} />}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
{theme === "dark" ? "Dark" : "Light"} Mode
</p>
<p style={{ margin: "0.125rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{theme === "dark" ? "Warm amber-tinted dark surfaces" : "Light surfaces with warm tones"}
</p>
</div>
<button onClick={toggleTheme} style={primaryBtn}>
Switch to {theme === "dark" ? "Light" : "Dark"}
</button>
</div>
@@ -135,25 +185,100 @@ function ThemeSection() {
);
}
function PathsSection({ config }: { config: Record<string, unknown> }) {
function PathInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
return (
<Section title="Paths">
<div className="space-y-3">
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
overflow: "hidden",
}}
>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "2.5rem",
alignSelf: "stretch",
backgroundColor: "var(--forge-surface-raised)",
borderRight: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)",
flexShrink: 0,
}}
>
<FolderOpen size={14} />
</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
flex: 1,
border: "none",
background: "none",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-sm)",
padding: "0.5rem 0.75rem",
color: "var(--forge-text)",
outline: "none",
}}
/>
</div>
);
}
function PathsSection({ config, onUpdate }: { config: Record<string, unknown>; onUpdate: () => void }) {
const [wsPath, setWsPath] = useState((config.workspacePath as string) || (config.WORKSPACE_PATH as string) || "");
const [nwnPath, setNwnPath] = useState((config.nwnHomePath as string) || (config.NWN_HOME_PATH as string) || "");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState("");
useEffect(() => {
setWsPath((config.workspacePath as string) || (config.WORKSPACE_PATH as string) || "");
setNwnPath((config.nwnHomePath as string) || (config.NWN_HOME_PATH as string) || "");
}, [config]);
const save = async () => {
setSaving(true);
setMsg("");
try {
await api.workspace.updateConfig({ workspacePath: wsPath, nwnHomePath: nwnPath });
setMsg("Paths saved");
onUpdate();
} catch (err) {
setMsg(err instanceof Error ? err.message : "Failed to save");
} finally {
setSaving(false);
}
};
return (
<Section title="Paths" icon={<FolderOpen size={14} />}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<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>
<p style={fieldLabel}>Workspace Path</p>
<PathInput value={wsPath} onChange={setWsPath} placeholder="/workspace" />
</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>
<p style={fieldLabel}>NWN Home Path</p>
<PathInput value={nwnPath} onChange={setNwnPath} placeholder="/nwn-home" />
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<button
onClick={save}
disabled={saving}
style={{ ...primaryBtn, opacity: saving ? 0.4 : 1 }}
>
{saving ? "Saving\u2026" : "Save Paths"}
</button>
{msg && (
<span style={{ fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>{msg}</span>
)}
</div>
</div>
</Section>
@@ -172,40 +297,31 @@ function DockerSection() {
await api.docker.pull(image);
setStatus((s) => ({ ...s, [image]: "Pulled" }));
} catch (err) {
setStatus((s) => ({
...s,
[image]: err instanceof Error ? err.message : "Failed",
}));
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">
<Section title="Docker Images" icon={<Container size={14} />}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
{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">
<div key={image} style={listRow}>
<span style={fieldValue}>{image}</span>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{status[image] && (
<span className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<span style={{ fontSize: "var(--text-xs)", 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" }}
style={{ ...primaryBtn, opacity: pulling[image] ? 0.4 : 1, display: "flex", alignItems: "center", gap: "0.375rem" }}
>
{pulling[image] ? "..." : "Pull Latest"}
<Download size={12} />
{pulling[image] ? "Pulling\u2026" : "Pull"}
</button>
</div>
</div>
@@ -225,21 +341,20 @@ const SHORTCUTS = [
function ShortcutsSection() {
return (
<Section title="Keyboard Shortcuts">
<div className="space-y-1">
<Section title="Keyboard Shortcuts" icon={<Keyboard size={14} />}>
<div style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
{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)" }}>
<div key={s.keys} style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.375rem 0" }}>
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-text)" }}>
{s.action}
</span>
<kbd
className="rounded px-2 py-0.5 font-mono text-xs"
style={{
backgroundColor: "var(--forge-surface)",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
padding: "0.2rem 0.5rem",
borderRadius: "0.25rem",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)",
}}
@@ -255,11 +370,11 @@ function ShortcutsSection() {
function AboutSection() {
return (
<Section title="About">
<p className="text-sm" style={{ color: "var(--forge-text)" }}>
<Section title="About" icon={<Info size={14} />}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
Layonara Forge v0.0.1
</p>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
github.com/Layonara/layonara-forge
</p>
</Section>
@@ -270,6 +385,7 @@ function ResetSection() {
const navigate = useNavigate();
const reset = async () => {
if (!window.confirm("Reset setup? This will clear all configuration.")) return;
try {
await api.workspace.updateConfig({ setupComplete: false });
} catch {
@@ -279,14 +395,28 @@ function ResetSection() {
};
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 title="Reset" icon={<RotateCcw size={14} />}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Clear configuration and re-run the setup wizard
</p>
<button
onClick={reset}
style={{
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
border: "1px solid var(--forge-danger-border)",
borderRadius: "0.375rem",
padding: "0.4rem 0.875rem",
fontSize: "var(--text-xs)",
fontWeight: 600,
cursor: "pointer",
flexShrink: 0,
}}
>
Reset Setup
</button>
</div>
</Section>
);
}
@@ -299,24 +429,23 @@ export function Settings() {
}, []);
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 style={{ height: "100%", overflowY: "auto", padding: "1.5rem" }}>
<div style={{ maxWidth: "40rem" }}>
<h1 style={{ fontFamily: "var(--font-heading)", fontSize: "var(--text-xl)", fontWeight: 700, color: "var(--forge-text)", margin: 0 }}>
Settings
</h1>
<p style={{ marginTop: "0.25rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)" }}>
Configuration, theme, and environment
</p>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<GitHubSection config={config} />
<ThemeSection />
<PathsSection config={config} onUpdate={() => api.workspace.getConfig().then(setConfig).catch(() => {})} />
<DockerSection />
<ShortcutsSection />
<AboutSection />
<ResetSection />
</div>
</div>
</div>
);
+452 -260
View File
@@ -5,9 +5,9 @@ import { api } from "../services/api";
const STEP_NAMES = [
"Welcome",
"Prerequisites",
"Gitea Token",
"Workspace",
"NWN Home",
"Gitea Token",
"Fork Repos",
"Clone Repos",
"Pull Images",
@@ -15,43 +15,144 @@ const STEP_NAMES = [
"Complete",
];
function StepIndicator({ current }: { current: number }) {
const PHASES = [
{ label: "Environment", icon: "\u2699", steps: [0, 1, 2, 3] },
{ label: "Authentication", icon: "\u26BF", steps: [4] },
{ label: "Repositories", icon: "\u2387", steps: [5, 6, 7] },
{ label: "Finalize", icon: "\u2692", steps: [8, 9] },
];
function getPhaseIndex(step: number): number {
return PHASES.findIndex((p) => p.steps.includes(step));
}
function PhaseIndicator({ current }: { current: number }) {
const currentPhase = getPhaseIndex(current);
const isLast = (i: number) => i === PHASES.length - 1;
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}
<nav aria-label="Setup progress" style={{ paddingTop: "1.25rem", paddingBottom: "1.25rem", borderBottom: "1px solid var(--forge-border)", marginBottom: "1.5rem" }}>
<ol style={{ display: "flex", alignItems: "center", width: "100%", listStyle: "none", margin: 0, padding: 0 }}>
{PHASES.map((phase, i) => {
const isComplete = i < currentPhase;
const isCurrent = i === currentPhase;
return (
<li
key={phase.label}
style={{ display: "flex", alignItems: "center", flex: isLast(i) ? "none" : 1 }}
aria-current={isCurrent ? "step" : undefined}
>
{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} &mdash; {STEP_NAMES[current]}
</p>
</div>
<div style={{ display: "flex", alignItems: "center", width: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexShrink: 0 }}>
<div
style={{
width: "2rem",
height: "2rem",
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.8125rem",
fontWeight: 600,
flexShrink: 0,
backgroundColor: isComplete
? "var(--forge-success)"
: isCurrent
? "var(--forge-accent)"
: "var(--forge-surface-raised)",
color: isComplete || isCurrent
? "var(--forge-accent-text)"
: "var(--forge-text-secondary)",
}}
>
{isComplete ? "\u2713" : i + 1}
</div>
<span
style={{
fontSize: "var(--text-sm)",
fontWeight: isCurrent ? 600 : 400,
whiteSpace: "nowrap",
color: isCurrent
? "var(--forge-text)"
: isComplete
? "var(--forge-text)"
: "var(--forge-text-secondary)",
}}
>
{phase.label}
</span>
</div>
{!isLast(i) && (
<div
style={{
flex: 1,
height: "2px",
marginLeft: "0.75rem",
marginRight: "0.75rem",
minWidth: "1rem",
borderRadius: "1px",
backgroundColor: isComplete
? "var(--forge-success)"
: "var(--forge-border)",
}}
/>
)}
</div>
</li>
);
})}
</ol>
</nav>
);
}
function StatusDot({ status }: { status: "idle" | "working" | "ok" | "error" }) {
const base: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: "1.25rem",
height: "1.25rem",
borderRadius: "50%",
fontSize: "0.625rem",
fontWeight: 700,
flexShrink: 0,
lineHeight: 1,
};
if (status === "working") {
return (
<span
style={{
...base,
border: "2px solid var(--forge-accent)",
borderTopColor: "transparent",
animation: "spin 0.8s linear infinite",
}}
/>
);
}
if (status === "ok") {
return (
<span style={{ ...base, backgroundColor: "var(--forge-success)", color: "var(--forge-accent-text)" }}>
&#x2713;
</span>
);
}
if (status === "error") {
return (
<span style={{ ...base, backgroundColor: "var(--forge-danger)", color: "var(--forge-accent-text)" }}>
&#x2717;
</span>
);
}
return null;
}
function StepNav({
onNext,
onBack,
@@ -66,25 +167,46 @@ function StepNav({
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 />
)}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", paddingTop: "1.5rem", marginTop: "2rem", borderTop: "1px solid var(--forge-border)" }}>
<div>
{step > 0 && (
<button
onClick={onBack}
style={{
color: "var(--forge-text-secondary)",
background: "none",
border: "none",
fontSize: "var(--text-sm)",
fontWeight: 500,
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
cursor: "pointer",
}}
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--forge-text)"; }}
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--forge-text-secondary)"; }}
>
&larr; 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" }}
style={{
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.625rem 1.5rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
cursor: nextDisabled ? "not-allowed" : "pointer",
opacity: nextDisabled ? 0.4 : 1,
}}
onMouseEnter={(e) => { if (!nextDisabled) e.currentTarget.style.backgroundColor = "var(--forge-accent-hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-accent)"; }}
>
{nextLabel}
{nextLabel} &rarr;
</button>
</div>
);
@@ -92,10 +214,10 @@ function StepNav({
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>
<div className="mt-4 rounded p-3 text-sm" style={{ backgroundColor: "var(--forge-danger-bg)", border: "1px solid var(--forge-danger-border)" }}>
<p style={{ color: "var(--forge-danger)" }}>{error}</p>
{onRetry && (
<button onClick={onRetry} className="mt-2 text-xs underline" style={{ color: "#fca5a5" }}>
<button onClick={onRetry} className="mt-2 text-xs underline" style={{ color: "var(--forge-danger)" }}>
Retry
</button>
)}
@@ -103,6 +225,50 @@ function ErrorBox({ error, onRetry }: { error: string; onRetry?: () => void }) {
);
}
const stepHeading: React.CSSProperties = {
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
};
const stepDesc: React.CSSProperties = {
marginTop: "0.5rem",
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
lineHeight: 1.6,
};
const fieldLabel: React.CSSProperties = {
display: "block",
fontSize: "var(--text-xs)",
fontWeight: 500,
color: "var(--forge-text-secondary)",
marginBottom: "0.375rem",
};
const statusRow: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.875rem 1.25rem",
borderRadius: "0.5rem",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
};
const primaryBtn: React.CSSProperties = {
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.5rem 1.25rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
cursor: "pointer",
};
interface StepProps {
onNext: () => void;
onBack: () => void;
@@ -110,24 +276,39 @@ interface StepProps {
function WelcomeStep({ onNext }: StepProps) {
return (
<div className="text-center">
<div style={{ padding: "2rem 0" }}>
<h2
className="text-2xl font-bold"
style={{ fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", color: "var(--forge-accent)" }}
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
>
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 &mdash; Docker, Gitea
access, workspace initialization, repository cloning, and database seeding.
<p style={{ marginTop: "0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)", maxWidth: "40rem", lineHeight: 1.6 }}>
This wizard will walk you through setting up your local NWN development environment &mdash; Docker,
Gitea access, workspace initialization, repository cloning, and database seeding.
</p>
<div className="mt-8">
<div style={{ marginTop: "2rem" }}>
<button
onClick={onNext}
className="rounded px-6 py-2.5 text-sm font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.625rem 1.5rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
cursor: "pointer",
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-accent-hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-accent)"; }}
>
Get Started
Get Started &rarr;
</button>
</div>
</div>
@@ -156,21 +337,17 @@ function PrerequisitesStep({ onNext, onBack }: StepProps) {
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>
<h2 style={stepHeading}>Prerequisites</h2>
<p style={stepDesc}>Checking that Docker and the Forge backend are running.</p>
<div style={{ ...statusRow, marginTop: "1.5rem" }}>
<StatusDot status={status === "checking" || status === "idle" ? "working" : status === "ok" ? "ok" : "error"} />
<div>
<p style={{ color: "var(--forge-text)" }}>Docker &amp; Backend</p>
<p className="text-xs" style={{ color: "var(--forge-text-secondary)" }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 500, color: "var(--forge-text)" }}>
Docker &amp; Backend
</p>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-xs)", color: "var(--forge-text-secondary)" }}>
{status === "checking" || status === "idle"
? "Checking..."
? "Checking\u2026"
: status === "ok"
? "Backend is healthy"
: "Backend unreachable"}
@@ -205,67 +382,113 @@ function GiteaTokenStep({ onNext, onBack }: StepProps) {
return (
<div>
<h2 className="text-xl font-bold" style={{ color: "var(--forge-text)" }}>
Gitea Access Token
</h2>
<p className="mt-2 text-sm" style={{ color: "var(--forge-text-secondary)" }}>
<h2 style={stepHeading}>Gitea Access Token</h2>
<p style={stepDesc}>
A Gitea token is needed to fork and push to Layonara repositories. Generate one at{" "}
<a
href="https://gitea.layonara.com/user/settings/applications"
target="_blank"
rel="noreferrer"
style={{ color: "var(--forge-accent)" }}
>
gitea.layonara.com/user/settings/applications
<a href="https://gitea.layonara.com/user/settings/applications" target="_blank" rel="noreferrer" style={{ color: "var(--forge-accent)" }}>
gitea.layonara.com
</a>
</p>
<div className="mt-4 flex gap-2">
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Enter your Gitea token"
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={!token || 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 style={{ marginTop: "1.5rem" }}>
<label style={fieldLabel}>Access Token</label>
<div style={{ display: "flex", gap: "0.5rem" }}>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Paste your Gitea token"
style={{ flex: 1 }}
/>
<button
onClick={validate}
disabled={!token || loading}
style={{ ...primaryBtn, opacity: !token || loading ? 0.4 : 1, cursor: !token || loading ? "not-allowed" : "pointer" }}
>
{loading ? "Validating\u2026" : "Validate"}
</button>
</div>
</div>
{username && (
<p className="mt-3 text-sm" style={{ color: "#4ade80" }}>
{"\u2705"} Authenticated as <strong>{username}</strong>
</p>
<div style={{ ...statusRow, marginTop: "1rem" }}>
<StatusDot status="ok" />
<span style={{ fontSize: "var(--text-sm)", color: "var(--forge-success)" }}>
Authenticated as <strong>{username}</strong>
</span>
</div>
)}
{error && <ErrorBox error={error} />}
<StepNav onNext={onNext} onBack={onBack} step={2} nextDisabled={!username} />
<StepNav onNext={onNext} onBack={onBack} step={4} nextDisabled={!username} />
</div>
);
}
function PathInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder: string }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
backgroundColor: "var(--forge-bg)",
border: "1px solid var(--forge-border)",
borderRadius: "0.375rem",
overflow: "hidden",
}}
>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "2.5rem",
alignSelf: "stretch",
backgroundColor: "var(--forge-surface-raised)",
borderRight: "1px solid var(--forge-border)",
color: "var(--forge-text-secondary)",
fontSize: "1rem",
flexShrink: 0,
}}
>
&#x1F4C1;
</span>
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{
flex: 1,
border: "none",
background: "none",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-sm)",
padding: "0.625rem 0.75rem",
color: "var(--forge-text)",
outline: "none",
}}
/>
</div>
);
}
function WorkspaceStep({ onNext, onBack }: StepProps) {
const [config, setConfig] = useState<Record<string, unknown>>({});
const [wsPath, setWsPath] = useState("");
const [initialized, setInitialized] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
api.workspace.getConfig().then(setConfig).catch(() => {});
api.workspace.getConfig().then((c) => {
setConfig(c);
setWsPath((c.WORKSPACE_PATH as string) || "/workspace");
}).catch(() => {});
}, []);
const init = async () => {
setLoading(true);
setError("");
try {
if (wsPath) await api.workspace.updateConfig({ WORKSPACE_PATH: wsPath });
await api.workspace.init();
setInitialized(true);
} catch (err) {
@@ -277,60 +500,58 @@ function WorkspaceStep({ onNext, onBack }: StepProps) {
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>
<h2 style={stepHeading}>Workspace</h2>
<p style={stepDesc}>Choose the directory where Forge stores repos, server data, and configuration.</p>
<div style={{ marginTop: "1.5rem" }}>
<label style={fieldLabel}>Workspace Path</label>
<PathInput value={wsPath} onChange={setWsPath} placeholder="/workspace" />
</div>
<div className="mt-4">
<div style={{ marginTop: "1.25rem" }}>
<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" }}
disabled={loading || initialized || !wsPath}
style={{ ...primaryBtn, opacity: loading || initialized || !wsPath ? 0.5 : 1, cursor: loading || initialized || !wsPath ? "not-allowed" : "pointer" }}
>
{initialized ? "\u2705 Initialized" : loading ? "Initializing..." : "Initialize"}
{initialized ? "\u2713 Initialized" : loading ? "Initializing\u2026" : "Initialize"}
</button>
</div>
{error && <ErrorBox error={error} onRetry={init} />}
<StepNav onNext={onNext} onBack={onBack} step={3} />
<StepNav onNext={onNext} onBack={onBack} step={2} />
</div>
);
}
function NwnHomeStep({ onNext, onBack }: StepProps) {
const [config, setConfig] = useState<Record<string, unknown>>({});
const [nwnPath, setNwnPath] = useState("");
useEffect(() => {
api.workspace.getConfig().then(setConfig).catch(() => {});
api.workspace.getConfig().then((c) => {
setConfig(c);
setNwnPath((c.NWN_HOME_PATH as string) || "/nwn-home");
}).catch(() => {});
}, []);
const handleNext = async () => {
if (nwnPath) {
try {
await api.workspace.updateConfig({ NWN_HOME_PATH: nwnPath });
} catch {
// continue even if save fails
}
}
onNext();
};
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>
<h2 style={stepHeading}>NWN Home Directory</h2>
<p style={stepDesc}>Path to the NWN:EE local server home directory (contains modules/, hak/, tlk/).</p>
<div style={{ marginTop: "1.5rem" }}>
<label style={fieldLabel}>NWN Home Path</label>
<PathInput value={nwnPath} onChange={setNwnPath} placeholder="/nwn-home" />
</div>
<StepNav onNext={onNext} onBack={onBack} step={4} />
<StepNav onNext={handleNext} onBack={onBack} step={3} />
</div>
);
}
@@ -365,25 +586,17 @@ function ForkReposStep({ onNext, onBack }: StepProps) {
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 Gitea account.
</p>
<div className="mt-4 space-y-3">
<h2 style={stepHeading}>Fork Repositories</h2>
<p style={stepDesc}>Fork the Layonara repositories to your Gitea account.</p>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{forkableRepos.map((repo) => (
<div
key={repo}
className="flex items-center justify-between rounded p-4"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<div key={repo} style={{ ...statusRow, justifyContent: "space-between" }}>
<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" }}>
<p className="mt-1 text-xs" style={{ color: "var(--forge-danger)" }}>
{errors[repo]}
</p>
)}
@@ -391,21 +604,17 @@ function ForkReposStep({ onNext, onBack }: StepProps) {
<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" }}
style={{ ...primaryBtn, fontSize: "var(--text-xs)", padding: "0.375rem 0.875rem", opacity: forkStatus[repo] === "forked" || forkStatus[repo] === "forking" ? 0.5 : 1 }}
>
{forkStatus[repo] === "forked"
? "\u2705 Forked"
? "\u2713 Forked"
: forkStatus[repo] === "forking"
? "..."
? "Forking\u2026"
: "Fork"}
</button>
</div>
))}
<div
className="flex items-center justify-between rounded p-4"
style={{ backgroundColor: "var(--forge-bg)" }}
>
<div style={{ ...statusRow, justifyContent: "space-between" }}>
<div>
<p className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
plenarius/unified
@@ -450,25 +659,19 @@ function CloneReposStep({ onNext, onBack }: StepProps) {
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 repositories into the workspace. Gitea repos clone from your fork; unified clones directly from
GitHub (public, read-only).
<h2 style={stepHeading}>Clone Repositories</h2>
<p style={stepDesc}>
Clone all repositories into the workspace. Gitea repos use your fork; unified clones from GitHub.
</p>
<div className="mt-4 space-y-2">
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{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>
<div key={repo} style={{ ...statusRow }}>
<StatusDot status={
cloneStatus[repo] === "cloned" ? "ok"
: cloneStatus[repo] === "cloning" ? "working"
: cloneStatus[repo] === "error" ? "error"
: "idle"
} />
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{repo}
</span>
@@ -478,21 +681,20 @@ function CloneReposStep({ onNext, onBack }: StepProps) {
</span>
)}
{errors[repo] && (
<span className="text-xs" style={{ color: "#fca5a5" }}>
<span className="text-xs" style={{ color: "var(--forge-danger)" }}>
{errors[repo]}
</span>
)}
</div>
))}
</div>
<div className="mt-4">
<div style={{ marginTop: "1.25rem" }}>
<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" }}
style={{ ...primaryBtn, opacity: cloning || allDone ? 0.5 : 1, cursor: cloning || allDone ? "not-allowed" : "pointer" }}
>
{allDone ? "\u2705 All Cloned" : cloning ? "Cloning..." : "Clone All"}
{allDone ? "\u2713 All Cloned" : cloning ? "Cloning\u2026" : "Clone All"}
</button>
</div>
<StepNav onNext={onNext} onBack={onBack} step={6} />
@@ -526,43 +728,35 @@ function PullImagesStep({ onNext, onBack }: StepProps) {
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">
<h2 style={stepHeading}>Pull Docker Images</h2>
<p style={stepDesc}>Pull the required Docker images for the local dev environment.</p>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{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>
<div key={image} style={{ ...statusRow }}>
<StatusDot status={
pullStatus[image] === "pulled" ? "ok"
: pullStatus[image] === "pulling" ? "working"
: pullStatus[image] === "error" ? "error"
: "idle"
} />
<span className="font-mono text-sm" style={{ color: "var(--forge-text)" }}>
{image}
</span>
{errors[image] && (
<span className="text-xs" style={{ color: "#fca5a5" }}>
<span className="text-xs" style={{ color: "var(--forge-danger)" }}>
{errors[image]}
</span>
)}
</div>
))}
</div>
<div className="mt-4">
<div style={{ marginTop: "1.25rem" }}>
<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" }}
style={{ ...primaryBtn, opacity: pulling || allDone ? 0.5 : 1, cursor: pulling || allDone ? "not-allowed" : "pointer" }}
>
{allDone ? "\u2705 All Pulled" : pulling ? "Pulling..." : "Pull Images"}
{allDone ? "\u2713 All Pulled" : pulling ? "Pulling\u2026" : "Pull Images"}
</button>
</div>
<StepNav onNext={onNext} onBack={onBack} step={7} />
@@ -592,56 +786,37 @@ function SeedDbStep({ onNext, onBack }: StepProps) {
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">
<h2 style={stepHeading}>Seed Database</h2>
<p style={stepDesc}>Add your NWN CD key and player name so the dev server recognizes you as a DM.</p>
<div style={{ marginTop: "1.5rem", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
<div>
<label className="block text-xs" style={{ color: "var(--forge-text-secondary)" }}>
CD Key
</label>
<label style={fieldLabel}>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)",
}}
style={{ width: "100%" }}
/>
</div>
<div>
<label className="block text-xs" style={{ color: "var(--forge-text-secondary)" }}>
Player Name
</label>
<label style={fieldLabel}>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)",
}}
style={{ width: "100%" }}
/>
</div>
</div>
<div className="mt-4">
<div style={{ marginTop: "1rem" }}>
<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" }}
style={{ ...primaryBtn, opacity: !cdKey || !playerName || loading || seeded ? 0.5 : 1, cursor: !cdKey || !playerName || loading || seeded ? "not-allowed" : "pointer" }}
>
{seeded ? "\u2705 Seeded" : loading ? "Seeding..." : "Seed"}
{seeded ? "\u2713 Seeded" : loading ? "Seeding\u2026" : "Seed Database"}
</button>
</div>
{error && <ErrorBox error={error} onRetry={seed} />}
@@ -652,24 +827,39 @@ function SeedDbStep({ onNext, onBack }: StepProps) {
function CompleteStep({ onFinish }: { onFinish: () => void }) {
return (
<div className="text-center">
<div style={{ padding: "2rem 0" }}>
<h2
className="text-2xl font-bold"
style={{ fontFamily: "'Baskerville', 'Georgia', 'Palatino', serif", color: "var(--forge-accent)" }}
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
>
Setup Complete!
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 style={{ marginTop: "0.75rem", fontSize: "var(--text-sm)", color: "var(--forge-text-secondary)", maxWidth: "40rem", lineHeight: 1.6 }}>
Your Layonara Forge environment is ready. Build modules, edit scripts, manage repositories, and run the
local dev server.
</p>
<div className="mt-8">
<div style={{ marginTop: "2rem" }}>
<button
onClick={onFinish}
className="rounded px-6 py-2.5 text-sm font-semibold"
style={{ backgroundColor: "var(--forge-accent)", color: "#000" }}
style={{
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
border: "none",
borderRadius: "0.375rem",
padding: "0.625rem 1.5rem",
fontSize: "var(--text-sm)",
fontWeight: 600,
cursor: "pointer",
}}
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-accent-hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = "var(--forge-accent)"; }}
>
Open Forge
Open Forge &rarr;
</button>
</div>
</div>
@@ -695,9 +885,9 @@ export function Setup() {
const stepComponents = [
<WelcomeStep key="welcome" onNext={next} onBack={back} />,
<PrerequisitesStep key="prereqs" onNext={next} onBack={back} />,
<GiteaTokenStep key="token" onNext={next} onBack={back} />,
<WorkspaceStep key="workspace" onNext={next} onBack={back} />,
<NwnHomeStep key="nwnhome" onNext={next} onBack={back} />,
<GiteaTokenStep key="token" 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} />,
@@ -706,14 +896,16 @@ export function Setup() {
];
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>
</>
<div
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: "0.75rem",
padding: "0 2.5rem 2.5rem",
}}
>
<PhaseIndicator current={step} />
{stepComponents[step]}
</div>
);
}
+528 -212
View File
@@ -2,6 +2,16 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { api } from "../services/api";
import { useWebSocket } from "../hooks/useWebSocket";
import {
Eye,
EyeOff,
FileCode,
Check,
X,
RefreshCw,
Trash2,
ArrowUpCircle,
} from "lucide-react";
interface ChangeEntry {
filename: string;
@@ -16,61 +26,6 @@ interface DiffData {
filename: string;
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
active
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-400"
}`}
>
{active ? "Active" : "Inactive"}
</span>
);
}
function ActionButton({
label,
onClick,
disabled,
variant = "default",
}: {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: "default" | "primary" | "danger";
}) {
const styles = {
default: {
backgroundColor: "var(--forge-surface)",
borderColor: "var(--forge-border)",
color: "var(--forge-text)",
},
primary: {
backgroundColor: "var(--forge-accent)",
borderColor: "var(--forge-accent)",
color: "#fff",
},
danger: {
backgroundColor: "#7f1d1d",
borderColor: "#991b1b",
color: "#fca5a5",
},
};
return (
<button
onClick={onClick}
disabled={disabled}
className="rounded border px-3 py-1.5 text-sm font-medium transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={styles[variant]}
>
{label}
</button>
);
}
function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleTimeString();
}
@@ -222,217 +177,578 @@ export function Toolset() {
};
const handleDiscardAll = async () => {
if (!window.confirm("Discard all changes? This cannot be undone.")) return;
await api.toolset.discardAll();
refresh();
};
return (
<div
className="flex h-full flex-col overflow-hidden"
style={{ color: "var(--forge-text)" }}
style={{
display: "flex",
flexDirection: "column",
height: "100%",
overflow: "hidden",
color: "var(--forge-text)",
}}
>
{/* Status bar */}
<div
className="flex shrink-0 items-center justify-between px-6 py-3"
style={{ borderBottom: "1px solid var(--forge-border)" }}
>
<div className="flex items-center gap-4">
<h2
className="text-xl font-bold"
style={{ color: "var(--forge-accent)" }}
{/* Page heading */}
<div style={{ padding: "24px 28px 0" }}>
<h1
style={{
fontFamily: "var(--font-heading)",
fontSize: "var(--text-xl)",
fontWeight: 700,
color: "var(--forge-text)",
margin: 0,
}}
>
Toolset
</h1>
<p
style={{
fontSize: "var(--text-sm)",
color: "var(--forge-text-secondary)",
margin: "4px 0 0",
}}
>
Watch for NWN Toolset changes and apply them to the repository
</p>
</div>
{/* Watcher status card */}
<div style={{ padding: "16px 28px" }}>
<div
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: 8,
padding: "16px 20px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 16 }}
>
Toolset
</h2>
<StatusBadge active={active} />
<span
className="text-xs"
style={{ color: "var(--forge-text-secondary)" }}
>
{changes.length} pending
</span>
{lastChange && (
<span
className="text-xs"
style={{ color: "var(--forge-text-secondary)" }}
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
Last: {formatTimestamp(lastChange)}
{active ? (
<Eye size={16} style={{ color: "var(--forge-success)" }} />
) : (
<EyeOff
size={16}
style={{ color: "var(--forge-text-secondary)" }}
/>
)}
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "3px 10px",
borderRadius: 999,
fontSize: "var(--text-xs)",
fontWeight: 600,
backgroundColor: active
? "var(--forge-success-bg)"
: "var(--forge-surface-raised)",
color: active
? "var(--forge-success)"
: "var(--forge-text-secondary)",
}}
>
{active ? "Active" : "Inactive"}
</span>
</div>
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{changes.length} pending change{changes.length !== 1 && "s"}
</span>
)}
</div>
<div className="flex gap-2">
{active ? (
<ActionButton label="Stop Watcher" onClick={handleStop} />
) : (
<ActionButton
label="Start Watcher"
onClick={handleStart}
variant="primary"
/>
)}
{lastChange && (
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
Last change: {formatTimestamp(lastChange)}
</span>
)}
</div>
<button
onClick={active ? handleStop : handleStart}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 14px",
borderRadius: 6,
border: "1px solid",
fontSize: "var(--text-sm)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: active
? "var(--forge-surface)"
: "var(--forge-accent)",
borderColor: active
? "var(--forge-border)"
: "var(--forge-accent)",
color: active
? "var(--forge-text)"
: "var(--forge-accent-text)",
}}
>
{active ? (
<>
<EyeOff size={14} />
Stop Watcher
</>
) : (
<>
<Eye size={14} />
Start Watcher
</>
)}
</button>
</div>
</div>
{/* Action bar */}
{changes.length > 0 && (
{/* Main content area */}
<div
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
overflow: "hidden",
padding: "0 28px 20px",
}}
>
{/* Changes card */}
<div
className="flex shrink-0 items-center gap-2 px-6 py-2"
style={{ borderBottom: "1px solid var(--forge-border)" }}
style={{
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: 8,
overflow: "hidden",
display: "flex",
flexDirection: "column",
flex: diffData ? "0 0 auto" : 1,
maxHeight: diffData ? "40%" : undefined,
}}
>
<ActionButton
label="Apply Selected"
variant="primary"
disabled={selected.size === 0}
onClick={handleApplySelected}
/>
<ActionButton
label="Apply All"
variant="primary"
onClick={handleApplyAll}
/>
<ActionButton
label="Discard Selected"
variant="danger"
disabled={selected.size === 0}
onClick={handleDiscardSelected}
/>
<ActionButton
label="Discard All"
variant="danger"
onClick={handleDiscardAll}
/>
<span
className="ml-2 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
{/* Card header with action bar */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 16px",
borderBottom: "1px solid var(--forge-border)",
backgroundColor: "var(--forge-surface-raised)",
}}
>
{selected.size} selected
</span>
</div>
)}
{/* Main content: table + diff */}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{/* Changes table */}
<div
className="shrink-0 overflow-auto"
style={{ maxHeight: diffData ? "40%" : "100%" }}
>
{changes.length === 0 ? (
<div
className="flex h-40 items-center justify-center text-sm"
style={{ color: "var(--forge-text-secondary)" }}
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
{active
? "Watching for changes in temp0/..."
: "Start the watcher to detect Toolset changes"}
</div>
) : (
<table className="w-full text-sm">
<thead>
<tr
<FileCode
size={16}
style={{ color: "var(--forge-accent)" }}
/>
<span
style={{
fontSize: "var(--text-sm)",
fontWeight: 600,
color: "var(--forge-text)",
}}
>
Pending Changes
</span>
{changes.length > 0 && (
<span
style={{
borderBottom: "1px solid var(--forge-border)",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
marginLeft: 4,
}}
>
<th className="px-6 py-2 text-left font-medium">
<input
type="checkbox"
checked={selected.size === changes.length}
onChange={toggleAll}
className="cursor-pointer"
{selected.size} of {changes.length} selected
</span>
)}
</div>
{changes.length > 0 && (
<div
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
<button
onClick={handleApplySelected}
disabled={selected.size === 0}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "none",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: selected.size === 0 ? "not-allowed" : "pointer",
opacity: selected.size === 0 ? 0.5 : 1,
backgroundColor: "var(--forge-accent)",
color: "var(--forge-accent-text)",
}}
>
<Check size={12} />
Apply Selected
</button>
<button
onClick={handleApplyAll}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "1px solid var(--forge-accent)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--forge-accent)",
}}
>
<ArrowUpCircle size={12} />
Apply All
</button>
<button
onClick={handleDiscardSelected}
disabled={selected.size === 0}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "none",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: selected.size === 0 ? "not-allowed" : "pointer",
opacity: selected.size === 0 ? 0.5 : 1,
backgroundColor: "var(--forge-danger-bg)",
color: "var(--forge-danger)",
}}
>
<Trash2 size={12} />
Discard Selected
</button>
<button
onClick={handleDiscardAll}
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
padding: "5px 12px",
borderRadius: 5,
border: "1px solid var(--forge-danger-border)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: "transparent",
color: "var(--forge-danger)",
}}
>
<X size={12} />
Discard All
</button>
</div>
)}
</div>
{/* Table or empty state */}
<div style={{ overflow: "auto", flex: 1 }}>
{changes.length === 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "48px 24px",
color: "var(--forge-text-secondary)",
}}
>
{active ? (
<>
<RefreshCw
size={28}
style={{
marginBottom: 12,
opacity: 0.4,
animation: "spin 3s linear infinite",
}}
/>
</th>
<th className="px-2 py-2 text-left font-medium">Filename</th>
<th className="px-2 py-2 text-left font-medium">Type</th>
<th className="px-2 py-2 text-left font-medium">
Repo Path
</th>
<th className="px-2 py-2 text-left font-medium">Time</th>
</tr>
</thead>
<tbody>
{changes.map((change) => (
<span style={{ fontSize: "var(--text-sm)" }}>
Watching for changes in temp0/...
</span>
</>
) : (
<>
<EyeOff
size={28}
style={{ marginBottom: 12, opacity: 0.4 }}
/>
<span style={{ fontSize: "var(--text-sm)" }}>
Start the watcher to detect Toolset changes
</span>
</>
)}
</div>
) : (
<table
style={{
width: "100%",
fontSize: "var(--text-sm)",
borderCollapse: "collapse",
}}
>
<thead>
<tr
key={change.filename}
className="cursor-pointer transition-colors hover:bg-white/5"
style={{
borderBottom: "1px solid var(--forge-border)",
backgroundColor:
diffData?.filename === change.filename
? "var(--forge-surface)"
: undefined,
backgroundColor: "var(--forge-surface-raised)",
color: "var(--forge-text-secondary)",
}}
onClick={() => viewDiff(change)}
>
<td className="px-6 py-2">
<th
style={{
padding: "8px 16px",
textAlign: "left",
fontWeight: 500,
width: 40,
}}
>
<input
type="checkbox"
checked={selected.has(change.filename)}
onChange={(e) => {
e.stopPropagation();
toggleSelect(change.filename);
}}
onClick={(e) => e.stopPropagation()}
className="cursor-pointer"
checked={
selected.size === changes.length &&
changes.length > 0
}
onChange={toggleAll}
style={{ cursor: "pointer" }}
/>
</td>
<td className="px-2 py-2 font-mono">{change.filename}</td>
<td className="px-2 py-2">
<span className="rounded bg-white/10 px-1.5 py-0.5 text-xs">
{change.gffType}
</span>
</td>
<td
className="px-2 py-2 font-mono text-xs"
style={{ color: "var(--forge-text-secondary)" }}
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
{change.repoPath ?? "—"}
</td>
<td
className="px-2 py-2 text-xs"
style={{ color: "var(--forge-text-secondary)" }}
Filename
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
{formatTimestamp(change.timestamp)}
</td>
Type
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Repo Path
</th>
<th
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 500,
}}
>
Time
</th>
</tr>
))}
</tbody>
</table>
)}
</thead>
<tbody>
{changes.map((change) => (
<tr
key={change.filename}
onClick={() => viewDiff(change)}
style={{
borderBottom: "1px solid var(--forge-border)",
cursor: "pointer",
backgroundColor:
diffData?.filename === change.filename
? "var(--forge-accent-subtle)"
: undefined,
}}
>
<td style={{ padding: "8px 16px" }}>
<input
type="checkbox"
checked={selected.has(change.filename)}
onChange={(e) => {
e.stopPropagation();
toggleSelect(change.filename);
}}
onClick={(e) => e.stopPropagation()}
style={{ cursor: "pointer" }}
/>
</td>
<td
style={{
padding: "8px 10px",
fontFamily: "var(--font-mono)",
color: "var(--forge-text)",
}}
>
{change.filename}
</td>
<td style={{ padding: "8px 10px" }}>
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 4,
fontSize: "var(--text-xs)",
fontWeight: 500,
backgroundColor: "var(--forge-accent-subtle)",
color: "var(--forge-accent)",
}}
>
{change.gffType}
</span>
</td>
<td
style={{
padding: "8px 10px",
fontFamily: "var(--font-mono)",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{change.repoPath ?? "—"}
</td>
<td
style={{
padding: "8px 10px",
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
{formatTimestamp(change.timestamp)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
{/* Diff panel */}
{/* Diff viewer panel */}
{diffData && (
<div
ref={diffContainerRef}
className="flex min-h-0 flex-1 flex-col"
style={{ borderTop: "1px solid var(--forge-border)" }}
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
marginTop: 16,
backgroundColor: "var(--forge-surface)",
border: "1px solid var(--forge-border)",
borderRadius: 8,
overflow: "hidden",
}}
>
{/* Diff header */}
<div
className="flex shrink-0 items-center justify-between px-4 py-1.5"
style={{
backgroundColor: "var(--forge-surface)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 16px",
backgroundColor: "var(--forge-surface-raised)",
borderBottom: "1px solid var(--forge-border)",
}}
>
<span className="text-xs font-medium">
Diff: {diffData.filename}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<FileCode
size={14}
style={{ color: "var(--forge-accent)" }}
/>
<span
style={{
fontSize: "var(--text-sm)",
fontWeight: 600,
fontFamily: "var(--font-mono)",
color: "var(--forge-text)",
}}
>
{diffData.filename}
</span>
{loading && (
<span style={{ color: "var(--forge-text-secondary)" }}>
{" "}
(loading...)
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--forge-text-secondary)",
}}
>
Loading...
</span>
)}
</span>
</div>
<button
onClick={() => setDiffData(null)}
className="text-xs transition-opacity hover:opacity-80"
style={{ color: "var(--forge-text-secondary)" }}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
padding: "4px 10px",
borderRadius: 5,
border: "1px solid var(--forge-border)",
fontSize: "var(--text-xs)",
fontWeight: 500,
cursor: "pointer",
backgroundColor: "var(--forge-surface)",
color: "var(--forge-text-secondary)",
}}
>
<X size={12} />
Close
</button>
</div>
<div className="min-h-0 flex-1">
{/* Diff content */}
<div
style={{
flex: 1,
minHeight: 0,
backgroundColor: "var(--forge-log-bg)",
}}
>
<DiffEditor
original={diffData.original}
modified={diffData.modified}
+173 -13
View File
@@ -1,25 +1,185 @@
@import "tailwindcss";
@import "@fontsource-variable/manrope";
@import "@fontsource-variable/alegreya";
@import "@fontsource-variable/jetbrains-mono";
:root {
--forge-bg: #121212;
--forge-surface: #1e1e2e;
--forge-border: #2e2e3e;
--forge-accent: #946200;
--forge-text: #f2f2f2;
--forge-text-secondary: #888888;
--forge-bg: oklch(15% 0.01 65);
--forge-surface: oklch(20% 0.012 65);
--forge-surface-raised: oklch(24% 0.014 65);
--forge-border: oklch(30% 0.014 65);
--forge-accent: oklch(58% 0.155 65);
--forge-accent-hover: oklch(63% 0.16 65);
--forge-accent-subtle: oklch(25% 0.04 65);
--forge-accent-text: oklch(15% 0.03 65);
--forge-text: oklch(93% 0.006 65);
--forge-text-secondary: oklch(68% 0.01 65);
--forge-success: oklch(62% 0.14 150);
--forge-success-bg: oklch(22% 0.03 150);
--forge-success-border: oklch(35% 0.06 150);
--forge-danger: oklch(68% 0.14 25);
--forge-danger-bg: oklch(22% 0.04 25);
--forge-danger-border: oklch(35% 0.08 25);
--forge-danger-strong: oklch(55% 0.18 25);
--forge-warning: oklch(72% 0.14 80);
--forge-warning-bg: oklch(25% 0.04 80);
--forge-warning-border: oklch(40% 0.07 80);
--forge-info: oklch(62% 0.08 230);
--forge-info-bg: oklch(22% 0.02 230);
--forge-log-bg: oklch(13% 0.008 65);
--forge-log-text: oklch(82% 0.008 65);
--font-sans: "Manrope Variable", system-ui, sans-serif;
--font-heading: "Alegreya Variable", Georgia, serif;
--font-mono: "JetBrains Mono Variable", "Fira Code", monospace;
--text-xs: 0.6875rem;
--text-sm: 0.8125rem;
--text-base: 0.875rem;
--text-lg: 1.0625rem;
--text-xl: 1.25rem;
--text-2xl: 1.75rem;
--leading-tight: 1.2;
--leading-normal: 1.55;
--leading-relaxed: 1.7;
}
:root.light {
--forge-bg: #f2f2f2;
--forge-surface: #ffffff;
--forge-border: #cbcbcb;
--forge-accent: #946200;
--forge-text: #252525;
--forge-text-secondary: #666666;
--forge-bg: oklch(95% 0.008 65);
--forge-surface: oklch(99% 0.004 65);
--forge-surface-raised: oklch(100% 0.002 65);
--forge-border: oklch(82% 0.012 65);
--forge-accent: oklch(50% 0.155 65);
--forge-accent-hover: oklch(45% 0.16 65);
--forge-accent-subtle: oklch(90% 0.04 65);
--forge-accent-text: oklch(99% 0.005 65);
--forge-text: oklch(20% 0.012 65);
--forge-text-secondary: oklch(45% 0.015 65);
--forge-success: oklch(45% 0.14 150);
--forge-success-bg: oklch(92% 0.03 150);
--forge-success-border: oklch(70% 0.08 150);
--forge-danger: oklch(50% 0.16 25);
--forge-danger-bg: oklch(92% 0.03 25);
--forge-danger-border: oklch(70% 0.08 25);
--forge-danger-strong: oklch(45% 0.18 25);
--forge-warning: oklch(55% 0.14 80);
--forge-warning-bg: oklch(92% 0.04 80);
--forge-warning-border: oklch(70% 0.07 80);
--forge-info: oklch(45% 0.08 230);
--forge-info-bg: oklch(92% 0.02 230);
--forge-log-bg: oklch(96% 0.006 65);
--forge-log-text: oklch(30% 0.01 65);
}
body {
background-color: var(--forge-bg);
color: var(--forge-text);
font-family: "Inter", system-ui, sans-serif;
font-family: var(--font-sans);
font-size: var(--text-base);
line-height: var(--leading-normal);
font-kerning: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.font-heading {
font-family: var(--font-heading);
}
.tabular-nums {
font-variant-numeric: tabular-nums;
}
::selection {
background-color: var(--forge-accent-subtle);
color: var(--forge-text);
}
:focus-visible {
outline: 2px solid var(--forge-accent);
outline-offset: 2px;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--forge-border) transparent;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--forge-border);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--forge-text-secondary);
}
input[type="text"],
input[type="password"],
input[type="url"],
input[type="email"],
input[type="number"],
input[type="search"],
textarea,
select {
background-color: var(--forge-bg);
color: var(--forge-text);
border: 1px solid var(--forge-border);
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: var(--text-sm);
font-family: inherit;
transition: border-color 150ms ease-out;
}
input:focus,
textarea:focus,
select:focus {
border-color: var(--forge-accent);
}
input::placeholder,
textarea::placeholder {
color: var(--forge-text-secondary);
opacity: 0.6;
}
button {
font-family: inherit;
font-size: inherit;
cursor: pointer;
transition: background-color 150ms ease-out, color 150ms ease-out, opacity 150ms ease-out, border-color 150ms ease-out;
}
button:disabled {
cursor: not-allowed;
}
a {
transition: color 150ms ease-out;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+29 -3
View File
@@ -9,16 +9,42 @@ export default {
forge: {
bg: "var(--forge-bg)",
surface: "var(--forge-surface)",
"surface-raised": "var(--forge-surface-raised)",
border: "var(--forge-border)",
accent: "var(--forge-accent)",
"accent-hover": "var(--forge-accent-hover)",
"accent-subtle": "var(--forge-accent-subtle)",
"accent-text": "var(--forge-accent-text)",
text: "var(--forge-text)",
"text-secondary": "var(--forge-text-secondary)",
success: "var(--forge-success)",
"success-bg": "var(--forge-success-bg)",
"success-border": "var(--forge-success-border)",
danger: "var(--forge-danger)",
"danger-bg": "var(--forge-danger-bg)",
"danger-border": "var(--forge-danger-border)",
"danger-strong": "var(--forge-danger-strong)",
warning: "var(--forge-warning)",
"warning-bg": "var(--forge-warning-bg)",
"warning-border": "var(--forge-warning-border)",
info: "var(--forge-info)",
"info-bg": "var(--forge-info-bg)",
"log-bg": "var(--forge-log-bg)",
"log-text": "var(--forge-log-text)",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
mono: ["JetBrains Mono", "Fira Code", "monospace"],
serif: ["Baskerville", "Georgia", "Palatino", "serif"],
sans: ["Manrope Variable", "system-ui", "sans-serif"],
heading: ["Alegreya Variable", "Georgia", "serif"],
mono: ["JetBrains Mono Variable", "Fira Code", "monospace"],
},
fontSize: {
xs: "var(--text-xs)",
sm: "var(--text-sm)",
base: "var(--text-base)",
lg: "var(--text-lg)",
xl: "var(--text-xl)",
"2xl": "var(--text-2xl)",
},
},
},