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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} — {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)" }}>
|
||||
✓
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return (
|
||||
<span style={{ ...base, backgroundColor: "var(--forge-danger)", color: "var(--forge-accent-text)" }}>
|
||||
✗
|
||||
</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)"; }}
|
||||
>
|
||||
← 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} →
|
||||
</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 — 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 — 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 →
|
||||
</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 & 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 & 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,
|
||||
}}
|
||||
>
|
||||
📁
|
||||
</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 →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user