Files
layonara-forge/packages/frontend/src/layouts/IDELayout.tsx
T
plenarius f851d8b8f2 Layonara Forge — NWN Development IDE
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
2026-04-21 12:14:38 -04:00

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>
);
}