f851d8b8f2
Electron desktop application for Neverwinter Nights module development. Clone, edit, build, and run a complete Layonara NWNX server with only Docker required. - React 19 + Vite frontend with Monaco editor and NWScript LSP - Node.js + Express backend managing Docker sibling containers - Electron shell with Docker availability check and auto-setup - Builder image auto-builds on first use from bundled Dockerfile - Cross-platform: Windows (.exe), macOS (.dmg), Linux (.AppImage) - Gitea Actions CI for automated release builds
288 lines
9.8 KiB
TypeScript
288 lines
9.8 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
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: 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 }) {
|
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
|
const [upstreamBehind, setUpstreamBehind] = useState(0);
|
|
const [pendingToolset, setPendingToolset] = useState(0);
|
|
const location = useLocation();
|
|
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) => {
|
|
const data = event.data as { behind: number };
|
|
if (data.behind > 0) {
|
|
setUpstreamBehind((prev) => prev + data.behind);
|
|
}
|
|
});
|
|
}, [subscribe]);
|
|
|
|
useEffect(() => {
|
|
return subscribe("toolset:changes", (event) => {
|
|
const data = event.data as { count?: number };
|
|
if (data.count !== undefined) setPendingToolset(data.count);
|
|
});
|
|
}, [subscribe]);
|
|
|
|
useEffect(() => {
|
|
if (location.pathname === "/repos") setUpstreamBehind(0);
|
|
}, [location.pathname]);
|
|
|
|
useEffect(() => {
|
|
if (location.pathname === "/toolset") setPendingToolset(0);
|
|
}, [location.pathname]);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: KeyboardEvent) => {
|
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.closest?.(".monaco-editor")) {
|
|
return;
|
|
}
|
|
|
|
if (e.ctrlKey && e.shiftKey && e.key === "B") {
|
|
e.preventDefault();
|
|
navigate("/build");
|
|
} else if (e.ctrlKey && e.key === "`") {
|
|
e.preventDefault();
|
|
setTerminalOpen((v) => !v);
|
|
} else if (e.ctrlKey && e.shiftKey && e.key === "F") {
|
|
e.preventDefault();
|
|
navigate("/editor");
|
|
} else if (e.ctrlKey && e.shiftKey && e.key === "G") {
|
|
e.preventDefault();
|
|
navigate("/repos");
|
|
} else if (e.ctrlKey && e.key === ",") {
|
|
e.preventDefault();
|
|
navigate("/settings");
|
|
}
|
|
},
|
|
[navigate],
|
|
);
|
|
|
|
useEffect(() => {
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [handleKeyDown]);
|
|
|
|
const getBadge = (path: string): number => {
|
|
if (path === "/repos") return upstreamBehind;
|
|
if (path === "/toolset") return pendingToolset;
|
|
return 0;
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "flex", height: "100vh", overflow: "hidden", backgroundColor: "var(--forge-bg)" }}>
|
|
{/* Left sidebar nav */}
|
|
<nav
|
|
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="/"
|
|
style={{ display: "flex", alignItems: "center", justifyContent: "center", padding: "0.75rem 0", textDecoration: "none" }}
|
|
title="Dashboard"
|
|
>
|
|
<img src="/layonara.png" alt="Layonara" style={{ width: "36px" }} />
|
|
</Link>
|
|
|
|
<div style={{ marginTop: "0.25rem", display: "flex", flexDirection: "column", flex: 1 }}>
|
|
{NAV_ITEMS.map((item) => {
|
|
const isActive =
|
|
item.path === "/"
|
|
? location.pathname === "/"
|
|
: location.pathname.startsWith(item.path);
|
|
const badge = getBadge(item.path);
|
|
|
|
return (
|
|
<Link
|
|
key={item.path}
|
|
to={item.path}
|
|
style={{
|
|
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}
|
|
>
|
|
<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
|
|
style={{
|
|
position: "absolute",
|
|
right: "0.25rem",
|
|
top: "0.25rem",
|
|
display: "flex",
|
|
height: "0.875rem",
|
|
minWidth: "0.875rem",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderRadius: "9999px",
|
|
padding: "0 0.125rem",
|
|
fontSize: "8px",
|
|
fontWeight: 700,
|
|
lineHeight: 1,
|
|
backgroundColor: "var(--forge-accent)",
|
|
color: "var(--forge-accent-text)",
|
|
}}
|
|
>
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<button
|
|
onClick={toggleTheme}
|
|
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" ? <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 style={{ display: "flex", flexDirection: "column", flex: 1, overflow: "hidden" }}>
|
|
<header
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "1rem",
|
|
padding: "0.375rem 1rem",
|
|
borderBottom: "1px solid var(--forge-border)",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontFamily: "var(--font-heading)",
|
|
fontSize: "var(--text-lg)",
|
|
fontWeight: 700,
|
|
color: "var(--forge-accent)",
|
|
}}
|
|
>
|
|
Layonara Forge
|
|
</span>
|
|
</header>
|
|
|
|
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
|
{sidebar && showSidebar && (
|
|
<aside
|
|
style={{
|
|
width: "250px",
|
|
flexShrink: 0,
|
|
overflow: "hidden",
|
|
borderRight: "1px solid var(--forge-border)",
|
|
}}
|
|
>
|
|
{sidebar}
|
|
</aside>
|
|
)}
|
|
<main style={{ flex: 1, overflow: "hidden" }}>
|
|
<Outlet />
|
|
</main>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setTerminalOpen((v) => !v)}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "0.5rem",
|
|
padding: "0.375rem 0.75rem",
|
|
color: "var(--forge-text-secondary)",
|
|
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 = ""; }}
|
|
>
|
|
<TerminalIcon size={12} />
|
|
<span>Terminal</span>
|
|
{terminalOpen ? <ChevronDown size={12} /> : <ChevronUp size={12} />}
|
|
</button>
|
|
|
|
{terminalOpen && (
|
|
<div
|
|
style={{
|
|
height: "300px",
|
|
flexShrink: 0,
|
|
overflow: "hidden",
|
|
borderTop: "1px solid var(--forge-border)",
|
|
}}
|
|
>
|
|
<Terminal sessionId="main" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|