feat: polish IDE layout with sidebar nav, resizable panels, and routing
This commit is contained in:
@@ -5,17 +5,18 @@ import { useWebSocket } from "../hooks/useWebSocket";
|
|||||||
import { useTheme } from "../hooks/useTheme";
|
import { useTheme } from "../hooks/useTheme";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ path: "/", label: "Home" },
|
{ path: "/editor", label: "Editor", icon: "\u270E" },
|
||||||
{ path: "/editor", label: "Editor" },
|
{ path: "/toolset", label: "Toolset", icon: "\u2699" },
|
||||||
{ path: "/build", label: "Build" },
|
{ path: "/build", label: "Build", icon: "\u2692" },
|
||||||
{ path: "/server", label: "Server" },
|
{ path: "/server", label: "Server", icon: "\u25B6" },
|
||||||
{ path: "/toolset", label: "Toolset" },
|
{ path: "/repos", label: "Repos", icon: "\u2387" },
|
||||||
{ path: "/repos", label: "Repos" },
|
{ path: "/settings", label: "Settings", icon: "\u2318" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
||||||
const [terminalOpen, setTerminalOpen] = useState(false);
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||||
const [upstreamBehind, setUpstreamBehind] = useState(0);
|
const [upstreamBehind, setUpstreamBehind] = useState(0);
|
||||||
|
const [pendingToolset, setPendingToolset] = useState(0);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { subscribe } = useWebSocket();
|
const { subscribe } = useWebSocket();
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { theme, toggleTheme } = useTheme();
|
||||||
@@ -30,19 +31,96 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
|||||||
}, [subscribe]);
|
}, [subscribe]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.pathname === "/repos") {
|
return subscribe("toolset:changes", (event) => {
|
||||||
setUpstreamBehind(0);
|
const data = event.data as { count?: number };
|
||||||
}
|
if (data.count !== undefined) setPendingToolset(data.count);
|
||||||
|
});
|
||||||
|
}, [subscribe]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname === "/repos") setUpstreamBehind(0);
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.pathname === "/toolset") setPendingToolset(0);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
const getBadge = (path: string): number => {
|
||||||
|
if (path === "/repos") return upstreamBehind;
|
||||||
|
if (path === "/toolset") return pendingToolset;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
|
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--forge-bg)" }}>
|
||||||
|
{/* Left sidebar nav */}
|
||||||
|
<nav
|
||||||
|
className="flex shrink-0 flex-col"
|
||||||
|
style={{
|
||||||
|
width: "56px",
|
||||||
|
borderRight: "1px solid var(--forge-border)",
|
||||||
|
backgroundColor: "var(--forge-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center justify-center py-3"
|
||||||
|
title="Dashboard"
|
||||||
|
>
|
||||||
|
<img src="/layonara.png" alt="Layonara" style={{ width: "40px" }} />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-1 flex-col">
|
||||||
|
{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}
|
||||||
|
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,
|
||||||
|
color: isActive ? "var(--forge-accent)" : "var(--forge-text-secondary)",
|
||||||
|
}}
|
||||||
|
title={item.label}
|
||||||
|
>
|
||||||
|
<span className="text-base">{item.icon}</span>
|
||||||
|
<span className="mt-0.5 text-[9px] leading-tight">{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">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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)" }}
|
||||||
|
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? "\u2600\uFE0F" : "\uD83C\uDF19"}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<header
|
<header
|
||||||
className="flex shrink-0 items-center gap-6 px-4 py-2"
|
className="flex shrink-0 items-center gap-4 px-4 py-1.5"
|
||||||
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
style={{ borderBottom: "1px solid var(--forge-border)" }}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src="/layonara.png" alt="Layonara" style={{ width: "130px" }} />
|
|
||||||
<span
|
<span
|
||||||
className="text-lg font-bold"
|
className="text-lg font-bold"
|
||||||
style={{
|
style={{
|
||||||
@@ -50,44 +128,12 @@ export function IDELayout({ sidebar }: { sidebar?: React.ReactNode }) {
|
|||||||
color: "var(--forge-accent)",
|
color: "var(--forge-accent)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Forge
|
Layonara Forge
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex flex-1 items-center gap-1">
|
<div className="flex-1" />
|
||||||
{NAV_ITEMS.map((item) => {
|
|
||||||
const isActive =
|
|
||||||
item.path === "/" ? location.pathname === "/" : location.pathname.startsWith(item.path);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.path}
|
|
||||||
to={item.path}
|
|
||||||
className="relative rounded px-2.5 py-1 text-sm transition-colors hover:bg-white/5"
|
|
||||||
style={{
|
|
||||||
color: isActive ? "var(--forge-accent)" : "var(--forge-text-secondary)",
|
|
||||||
fontWeight: isActive ? 600 : 400,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
{item.path === "/repos" && upstreamBehind > 0 && (
|
|
||||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-bold text-black">
|
|
||||||
{upstreamBehind}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className="rounded px-2 py-1 text-sm transition-colors hover:bg-white/10"
|
|
||||||
style={{ color: "var(--forge-text-secondary)" }}
|
|
||||||
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
|
||||||
>
|
|
||||||
{theme === "dark" ? "\u2600\uFE0F" : "\uD83C\uDF19"}
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{sidebar && (
|
{sidebar && (
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
Reference in New Issue
Block a user