Tile windowing system

This commit is contained in:
Jack Mechem 2026-05-22 02:19:57 -07:00
parent c6e6c5ca48
commit 43318fb8cd
35 changed files with 4659 additions and 360 deletions

View file

@ -12,12 +12,12 @@ export default function NetworkCard({ iface, speed, delay = 0 }: NetworkCardProp
className="bg-primary border border-secondary rounded-2xl p-6 animate-fade-up"
style={{ animationDelay: `${delay}ms` }}
>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec mb-4">
<p className="text-xs font-medium text-foreground-sec mb-4">
Network
</p>
{iface && speed ? (
<>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec mb-3">
<p className="text-xs font-medium text-foreground-sec mb-3">
{iface}
</p>
<div className="flex gap-6">
@ -25,7 +25,7 @@ export default function NetworkCard({ iface, speed, delay = 0 }: NetworkCardProp
<span className="text-lg font-medium text-blue">
{formatBytes(speed.rx)}/s
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
<span className="text-[0.7rem] text-foreground-sec">
Download
</span>
</div>
@ -33,7 +33,7 @@ export default function NetworkCard({ iface, speed, delay = 0 }: NetworkCardProp
<span className="text-lg font-medium text-blue/70">
{formatBytes(speed.tx)}/s
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
<span className="text-[0.7rem] text-foreground-sec">
Upload
</span>
</div>

View file

@ -27,13 +27,13 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
style={{ animationDelay: `${delay}ms` }}
>
<div className="flex items-center justify-between mb-3">
<span className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec">
<span className="text-xs font-medium text-foreground-sec">
{label}
</span>
{device ? (
<div className="flex items-center gap-2">
<span
className={`flex items-center gap-1.5 text-[0.62rem] font-medium uppercase tracking-widest ${
className={`flex items-center gap-1.5 text-[0.7rem] font-medium ${
device.on ? "text-green" : "text-foreground-sec"
}`}
>
@ -48,7 +48,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
<button
onClick={() => onToggle(!device.on)}
disabled={toggling}
className={`text-[0.6rem] font-medium uppercase tracking-widest px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
className={`text-[0.7rem] font-medium px-2 py-0.5 rounded-full border transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
device.on
? "border-red-500/20 text-red-400 hover:bg-red-500/10"
: "border-green/20 text-green hover:bg-green/10"
@ -89,7 +89,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
{(device.today_energy_wh / 1000).toFixed(3)}
<span className="text-foreground-sec text-xs ml-0.5">kWh</span>
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
<span className="text-[0.7rem] text-foreground-sec">
Today
</span>
</div>
@ -98,7 +98,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
{(device.month_energy_wh / 1000).toFixed(2)}
<span className="text-foreground-sec text-xs ml-0.5">kWh</span>
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
<span className="text-[0.7rem] text-foreground-sec">
Month
</span>
</div>
@ -106,7 +106,7 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
<span className="text-sm font-medium text-foreground">
{runtimeHours}h {runtimeMins}m
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
<span className="text-[0.7rem] text-foreground-sec">
Runtime
</span>
</div>

View file

@ -7,9 +7,10 @@ import PowerCard from "./PowerCard";
interface PowerGridProps {
power: PowerData | null;
onRefresh: () => void;
showControls?: boolean;
}
export default function PowerGrid({ power, onRefresh }: PowerGridProps) {
export default function PowerGrid({ power, onRefresh, showControls = true }: PowerGridProps) {
const [toggling, setToggling] = useState<string | null>(null);
const server = power?.devices.find((d) => d.name === "server") ?? null;
@ -34,14 +35,14 @@ export default function PowerGrid({ power, onRefresh }: PowerGridProps) {
label="Server"
delay={0}
toggling={toggling === "server"}
onToggle={(on) => handleToggle("server", on)}
onToggle={showControls ? (on) => handleToggle("server", on) : undefined}
/>
<PowerCard
device={desktop}
label="Desktop"
delay={60}
toggling={toggling === "desktop"}
onToggle={(on) => handleToggle("desktop", on)}
onToggle={showControls ? (on) => handleToggle("desktop", on) : undefined}
/>
</div>
);

View file

@ -4,58 +4,186 @@ import { useState, useRef, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
IconHome2,
IconMoon,
IconSun,
IconChevronsLeft,
IconChevronsRight,
IconMenu2,
IconX,
IconCode,
IconKey,
IconLogout,
IconHome2, IconMoon, IconSun, IconChevronsLeft, IconChevronsRight,
IconMenu2, IconX, IconCode, IconKey, IconLogout, IconUsers, IconChartLine,
IconChevronDown, IconBolt, IconChartBar, IconChartCandle,
} from "@tabler/icons-react";
import { useSetTheme } from "@/stores/useThemeStore";
const LINKS = [
{ href: "/", label: "Dashboard", icon: IconHome2 },
{ href: "/auth", label: "Auth", icon: IconKey },
];
import { useFocusedWindowState, requestViewChange } from "@/stores/windowStore";
import { PANEL_SECTIONS, type PanelId } from "@/app/components/windows/types";
import { SideNavWidgets } from "./SideNavWidgets";
const COLLAPSED_W = 52;
const SECTION_ICONS: Record<string, React.ElementType> = {
"power-analytics": IconBolt,
};
const ANALYTICS_ICONS: Record<PanelId, React.ElementType> = {
dashboard: IconHome2,
"analytics-line": IconChartLine,
"analytics-bar": IconChartBar,
"analytics-candle": IconChartCandle,
};
interface SideNavProps {
online: boolean;
devConsoleOpen: boolean;
onToggleDevConsole: () => void;
isAuthed?: boolean;
}
const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) => {
// ── Window nav (only shown on /) ──────────────────────────────────────────────
function WindowNav({ collapsed, focusedPanelId }: { collapsed: boolean; focusedPanelId: PanelId | null }) {
const [openSections, setOpenSections] = useState<Set<string>>(
new Set(PANEL_SECTIONS.map((s) => s.id))
);
const toggleSection = (id: string) => {
setOpenSections((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
if (collapsed) {
return (
<div className="flex flex-col gap-[2px] px-[8px] mt-1">
{/* Dashboard icon */}
<button
onClick={() => requestViewChange("dashboard")}
title="Dashboard"
className={[
"w-full flex items-center justify-center py-[7px] rounded-[8px] transition-colors cursor-pointer",
focusedPanelId === "dashboard"
? "bg-blue/10 text-blue"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground",
].join(" ")}
>
<IconHome2 size={15} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
</button>
{/* Section items */}
{PANEL_SECTIONS.flatMap((s) =>
s.items.map(({ panelId, label }) => {
const Icon = ANALYTICS_ICONS[panelId];
const active = focusedPanelId === panelId;
return (
<button
key={panelId}
onClick={() => requestViewChange(panelId)}
title={label}
className={[
"w-full flex items-center justify-center py-[7px] rounded-[8px] transition-colors cursor-pointer",
active
? "bg-blue/10 text-blue"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground",
].join(" ")}
>
<Icon size={15} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
</button>
);
})
)}
</div>
);
}
return (
<div className="flex flex-col gap-[1px] px-[8px] mt-1">
{/* Dashboard standalone */}
<button
onClick={() => requestViewChange("dashboard")}
className={[
"w-full flex items-center gap-[8px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer font-medium",
focusedPanelId === "dashboard"
? "bg-blue/10 text-blue font-semibold"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground",
].join(" ")}
>
<IconHome2 size={14} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
Dashboard
</button>
{/* Power Analytics section */}
{PANEL_SECTIONS.map((section) => {
const SectionIcon = SECTION_ICONS[section.id] ?? IconBolt;
const isOpen = openSections.has(section.id);
return (
<div key={section.id}>
<button
onClick={() => toggleSection(section.id)}
className="w-full flex items-center gap-[8px] px-[10px] py-[5px] rounded-[8px] text-[11px] font-semibold text-foreground-sec hover:bg-secondary/40 hover:text-foreground transition-colors cursor-pointer select-none"
>
<SectionIcon size={13} className="shrink-0" />
<span className="flex-1 text-left">{section.label}</span>
<IconChevronDown
size={11}
className="shrink-0 transition-transform duration-200"
style={{ transform: isOpen ? "rotate(0deg)" : "rotate(-90deg)" }}
/>
</button>
{isOpen && (
<div className="flex flex-col gap-[1px] pl-[6px]">
{section.items.map(({ panelId, label }) => {
const Icon = ANALYTICS_ICONS[panelId];
const active = focusedPanelId === panelId;
return (
<button
key={panelId}
onClick={() => requestViewChange(panelId)}
className={[
"w-full flex items-center gap-[8px] px-[10px] py-[6px] text-[12px] rounded-[8px] transition-colors cursor-pointer",
active
? "bg-blue/10 text-blue font-semibold"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium",
].join(" ")}
>
<Icon size={13} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
{label}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
);
}
// ── Desktop sidebar ───────────────────────────────────────────────────────────
const SideNav = ({ online, devConsoleOpen, onToggleDevConsole, isAuthed }: SideNavProps) => {
const pathname = usePathname();
const router = useRouter();
const setTheme = useSetTheme();
const [collapsed, setCollapsed] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(168);
const [sidebarWidth, setSidebarWidth] = useState(220);
const [menuOpen, setMenuOpen] = useState(false);
const [auth, setAuth] = useState(false);
const [auth, setAuth] = useState<boolean | null>(isAuthed ?? null);
const isDragging = useRef(false);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMenuOpen(false);
}, [pathname]);
const { panelId: focusedPanelId } = useFocusedWindowState();
const isHome = pathname === "/";
useEffect(() => { setMenuOpen(false); }, [pathname]);
useEffect(() => {
fetch("/api/auth/check")
.then((r) => setAuth(r.ok))
.catch(() => setAuth(false));
}, []);
if (isAuthed !== undefined) return;
fetch("/api/auth/check").then((r) => setAuth(r.ok)).catch(() => setAuth(false));
}, [isAuthed]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
if (!isDragging.current || !wrapperRef.current) return;
const left = wrapperRef.current.getBoundingClientRect().left;
setSidebarWidth(Math.max(120, Math.min(320, e.clientX - left)));
setSidebarWidth(Math.max(180, Math.min(400, e.clientX - left)));
};
const onUp = () => { isDragging.current = false; };
window.addEventListener("mousemove", onMove);
@ -71,100 +199,105 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) =
router.push("/auth");
}
const navItemClass = (active: boolean, col: boolean) =>
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer " +
(col
? "justify-center py-[7px] "
: "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") +
(active
? "bg-blue/10 text-blue font-semibold"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium");
return (
<>
{/* Desktop sidebar + drag handle */}
{/* Desktop sidebar */}
<div ref={wrapperRef} className="hidden lg:flex flex-row shrink-0 select-none">
<div
style={{ width: collapsed ? COLLAPSED_W : sidebarWidth }}
style={{ width: collapsed ? COLLAPSED_W : sidebarWidth, minWidth: collapsed ? COLLAPSED_W : 180 }}
className="flex flex-col py-[16px] overflow-hidden transition-[width] duration-200"
>
{/* Logo */}
<div className={collapsed ? "flex justify-center mb-[16px] shrink-0" : "px-[16px] mb-[24px] shrink-0"}>
<div className={collapsed ? "flex justify-center mb-[12px] shrink-0" : "px-[16px] mb-[16px] shrink-0"}>
<Link href="/">
<img
src="/logo.svg"
alt="logo"
className={collapsed ? "max-h-[24px]" : "max-h-[36px]"}
/>
<img src="/logo.svg" alt="logo" className={collapsed ? "max-h-[24px]" : "max-h-[36px]"} />
</Link>
</div>
{/* Nav links */}
<nav className="flex flex-col gap-[2px] px-[8px] flex-1">
{LINKS.map(({ href, label, icon: Icon }) => {
const active = pathname === href || (href !== "/" && pathname?.startsWith(href));
{/* Auth/users route links */}
<nav className="flex flex-col gap-[2px] px-[8px] shrink-0">
{auth === false && (() => {
const active = pathname === "/auth";
return (
<Link
key={href}
href={href}
title={collapsed ? label : undefined}
className={
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer " +
(collapsed
? "justify-center py-[7px] "
: "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") +
(active
? "bg-blue/10 text-blue font-semibold"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")
}
>
<Icon size={16} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
{!collapsed && label}
<Link href="/auth" title={collapsed ? "Auth" : undefined} className={navItemClass(active, collapsed)}>
<IconKey size={16} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
{!collapsed && "Auth"}
</Link>
);
})}
})()}
{auth && (() => {
const active = pathname === "/users";
return (
<Link href="/users" title={collapsed ? "User Management" : undefined} className={navItemClass(active, collapsed)}>
<IconUsers size={16} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
{!collapsed && "User Management"}
</Link>
);
})()}
</nav>
{/* Window sections — only on home route */}
{isHome && (
<>
{(auth !== null) && <div className="mx-[8px] my-[8px] border-t border-secondary shrink-0" />}
{!collapsed && (
<p className="px-[18px] mb-[4px] text-[10px] font-semibold text-foreground-sec/60 uppercase tracking-wider shrink-0">
Views
</p>
)}
<div className="flex-none overflow-y-auto">
<WindowNav collapsed={collapsed} focusedPanelId={focusedPanelId} />
</div>
</>
)}
{/* Widgets */}
{!collapsed && (
<>
<div className="mx-[8px] my-[8px] border-t border-secondary shrink-0" />
<SideNavWidgets />
</>
)}
{/* Online status */}
<div className="px-[8px] mb-[2px] shrink-0">
<div
title={collapsed ? (online ? "Online" : "Connecting...") : undefined}
className={
"w-full flex items-center rounded-[8px] font-medium text-foreground-sec " +
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")
}
>
<span
className="w-[7px] h-[7px] rounded-full shrink-0"
style={{
background: online ? "#5dd776" : "#7b899a",
animation: online ? "pulse-dot 2s infinite" : "none",
}}
/>
<div className="px-[8px] mb-[2px] shrink-0 mt-auto">
<div title={collapsed ? (online ? "Online" : "Connecting...") : undefined}
className={"w-full flex items-center rounded-[8px] font-medium text-foreground-sec " +
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
<span className="w-[7px] h-[7px] rounded-full shrink-0"
style={{ background: online ? "#5dd776" : "#7b899a", animation: online ? "pulse-dot 2s infinite" : "none" }} />
{!collapsed && (online ? "Online" : "Connecting...")}
</div>
</div>
{/* Dev console toggle */}
<div className="px-[8px] shrink-0">
<button
onClick={onToggleDevConsole}
title={collapsed ? "Dev Console" : undefined}
className={
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium " +
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") +
(devConsoleOpen
? "bg-blue/10 text-blue"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground")
}
>
<IconCode size={16} className="shrink-0" />
{!collapsed && "Dev Console"}
</button>
</div>
{/* Dev console */}
{auth && (
<div className="px-[8px] shrink-0">
<button onClick={onToggleDevConsole} title={collapsed ? "Dev Console" : undefined}
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium " +
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap ") +
(devConsoleOpen ? "bg-blue/10 text-blue" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground")}>
<IconCode size={16} className="shrink-0" />
{!collapsed && "Dev Console"}
</button>
</div>
)}
{/* Logout */}
{auth && (
<div className="px-[8px] shrink-0">
<button
onClick={handleLogout}
title={collapsed ? "Log out" : undefined}
className={
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium text-red-400 hover:bg-red-500/10 " +
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")
}
>
<button onClick={handleLogout} title={collapsed ? "Log out" : undefined}
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer font-medium text-red-400 hover:bg-red-500/10 " +
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
<IconLogout size={16} className="shrink-0" />
{!collapsed && "Log out"}
</button>
@ -173,36 +306,22 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) =
{/* Theme toggle */}
<div className="px-[8px] mt-[4px] shrink-0">
<button
onClick={setTheme}
title={collapsed ? "Toggle theme" : undefined}
className={
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " +
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")
}
>
<button onClick={setTheme} title={collapsed ? "Toggle theme" : undefined}
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " +
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
<IconMoon size={16} className="shrink-0 dark-theme:hidden" />
<IconSun size={16} className="shrink-0 hidden dark-theme:block" />
{!collapsed && (
<>
<span className="dark-theme:hidden">Dark mode</span>
<span className="hidden dark-theme:block">Light mode</span>
</>
)}
{!collapsed && <><span className="dark-theme:hidden">Dark mode</span><span className="hidden dark-theme:block">Light mode</span></>}
</button>
</div>
{/* Divider + collapse toggle */}
{/* Divider + collapse */}
<div className="mx-[8px] my-[8px] border-t border-secondary shrink-0" />
<div className="px-[8px] shrink-0">
<button
onClick={() => setCollapsed((c) => !c)}
<button onClick={() => setCollapsed((c) => !c)}
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
className={
"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " +
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")
}
>
className={"w-full flex items-center rounded-[8px] transition-colors cursor-pointer text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium " +
(collapsed ? "justify-center py-[7px]" : "gap-[10px] px-[10px] py-[7px] text-[13px] whitespace-nowrap")}>
{collapsed
? <IconChevronsRight size={16} className="shrink-0" />
: <IconChevronsLeft size={16} className="shrink-0" />}
@ -213,10 +332,8 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) =
{/* Drag handle */}
{!collapsed && (
<div
onMouseDown={(e) => { isDragging.current = true; e.preventDefault(); }}
className="w-[10px] shrink-0 flex items-center justify-center cursor-col-resize group"
>
<div onMouseDown={(e) => { isDragging.current = true; e.preventDefault(); }}
className="w-[10px] shrink-0 flex items-center justify-center cursor-col-resize group">
<div className="w-[3px] h-[40px] rounded-full bg-blue/20 transition-colors" />
</div>
)}
@ -227,63 +344,96 @@ const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) =
<Link href="/" onClick={() => setMenuOpen(false)}>
<img src="/logo.svg" alt="logo" className="max-h-[22px]" />
</Link>
<button
onClick={() => setMenuOpen((o) => !o)}
className="ml-auto p-[7px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer"
>
<button onClick={() => setMenuOpen((o) => !o)}
className="ml-auto p-[7px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer">
{menuOpen ? <IconX size={18} /> : <IconMenu2 size={18} />}
</button>
</div>
{/* Mobile dropdown menu */}
{menuOpen && (
<div className="lg:hidden fixed top-[52px] left-0 right-0 z-[997] bg-primary border-b border-secondary shadow-xl">
<nav className="flex flex-col gap-[2px] p-[8px]">
{LINKS.map(({ href, label, icon: Icon }) => {
const active = pathname === href || (href !== "/" && pathname?.startsWith(href));
return (
<Link
key={href}
href={href}
onClick={() => setMenuOpen(false)}
className={
"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
(active
? "bg-blue/10 text-blue font-semibold"
: "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")
}
>
<Icon size={16} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
{label}
</Link>
);
})}
</nav>
<div className="lg:hidden fixed top-[52px] left-0 right-0 z-[997] bg-primary border-b border-secondary shadow-xl max-h-[80vh] overflow-y-auto">
{auth === false && (
<nav className="flex flex-col gap-[2px] p-[8px]">
<Link href="/auth" onClick={() => setMenuOpen(false)}
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
(pathname === "/auth" ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
<IconKey size={16} strokeWidth={pathname === "/auth" ? 2.5 : 2} className="shrink-0" />
Auth
</Link>
</nav>
)}
{auth && (
<nav className="flex flex-col gap-[2px] p-[8px]">
<Link href="/users" onClick={() => setMenuOpen(false)}
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
(pathname === "/users" ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
<IconUsers size={16} strokeWidth={pathname === "/users" ? 2.5 : 2} className="shrink-0" />
User Management
</Link>
</nav>
)}
{/* Mobile window sections */}
{isHome && (
<>
<div className="mx-[8px] border-t border-secondary" />
<div className="p-[8px]">
<p className="px-[10px] mb-[4px] text-[10px] font-semibold text-foreground-sec/60 uppercase tracking-wider">
Views
</p>
{/* Dashboard */}
<button
onClick={() => { requestViewChange("dashboard"); setMenuOpen(false); }}
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
(focusedPanelId === "dashboard" ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
<IconHome2 size={15} strokeWidth={focusedPanelId === "dashboard" ? 2.5 : 2} className="shrink-0" />
Dashboard
</button>
{/* Sections */}
{PANEL_SECTIONS.map((section) => (
<div key={section.id} className="mb-1 mt-2">
<p className="px-[10px] py-[4px] text-[10px] font-semibold text-foreground-sec uppercase tracking-wider">
{section.label}
</p>
{section.items.map(({ panelId, label }) => {
const Icon = ANALYTICS_ICONS[panelId];
const active = focusedPanelId === panelId;
return (
<button key={panelId}
onClick={() => { requestViewChange(panelId); setMenuOpen(false); }}
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
(active ? "bg-blue/10 text-blue font-semibold" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
<Icon size={15} strokeWidth={active ? 2.5 : 2} className="shrink-0" />
{label}
</button>
);
})}
</div>
))}
</div>
</>
)}
<div className="mx-[8px] border-t border-secondary" />
<div className="p-[8px] flex flex-col gap-[2px]">
<button
onClick={() => { onToggleDevConsole(); setMenuOpen(false); }}
className={
"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
(devConsoleOpen ? "bg-blue/10 text-blue font-medium" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")
}
>
<IconCode size={16} className="shrink-0" />
Dev Console
</button>
{auth && (
<button
onClick={handleLogout}
className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer font-medium"
>
<button onClick={() => { onToggleDevConsole(); setMenuOpen(false); }}
className={"w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] transition-colors cursor-pointer " +
(devConsoleOpen ? "bg-blue/10 text-blue font-medium" : "text-foreground-sec hover:bg-secondary/50 hover:text-foreground font-medium")}>
<IconCode size={16} className="shrink-0" />
Dev Console
</button>
)}
{auth && (
<button onClick={handleLogout}
className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer font-medium">
<IconLogout size={16} className="shrink-0" />
Log out
</button>
)}
<button
onClick={setTheme}
className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer font-medium"
>
<button onClick={setTheme}
className="w-full flex items-center gap-[10px] px-[10px] py-[7px] text-[13px] rounded-[8px] text-foreground-sec hover:bg-secondary/50 hover:text-foreground transition-colors cursor-pointer font-medium">
<IconMoon size={16} className="shrink-0 dark-theme:hidden" />
<IconSun size={16} className="shrink-0 hidden dark-theme:block" />
<span className="dark-theme:hidden">Dark mode</span>

View file

@ -0,0 +1,234 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { IconLayoutGrid } from "@tabler/icons-react";
import { type Stats, type NetworkInterface } from "../lib/getStats";
import { type PowerData } from "../lib/getPower";
import { formatBytes, statColor } from "../lib/utils";
export type WidgetId =
| "cpu" | "memory" | "disk" | "temp"
| "power-server" | "power-desktop"
| "network" | "uptime" | "empty";
const WIDGET_OPTIONS: { id: WidgetId; label: string }[] = [
{ id: "empty", label: "Empty" },
{ id: "cpu", label: "CPU" },
{ id: "memory", label: "Memory" },
{ id: "disk", label: "Disk" },
{ id: "temp", label: "Temp" },
{ id: "power-server", label: "Server Power" },
{ id: "power-desktop", label: "Desktop Power" },
{ id: "network", label: "Network" },
{ id: "uptime", label: "Uptime" },
];
const DEFAULT_WIDGETS: WidgetId[] = ["power-server", "power-desktop", "cpu", "memory"];
const STORAGE_KEY = "sidenav-widgets";
const card = "flex flex-col gap-2 p-3 bg-secondary/30 border border-secondary rounded-xl";
function MiniStatWidget({ label, value, percent }: { label: string; value: string; percent?: number }) {
const color = statColor(percent ?? 0);
return (
<div className={card}>
<span className="text-[11px] text-foreground-sec">{label}</span>
<span className="text-sm font-medium text-foreground leading-none">{value}</span>
{percent !== undefined && (
<div className="h-[3px] bg-secondary rounded-full overflow-hidden">
<div className="h-full rounded-full transition-all duration-700" style={{ width: `${percent}%`, background: color }} />
</div>
)}
</div>
);
}
function MiniPowerWidget({ label, device }: { label: string; device: { on: boolean; current_power_w: number } | null }) {
return (
<div className={card}>
<div className="flex items-center justify-between">
<span className="text-[11px] text-foreground-sec truncate">{label}</span>
{device && (
<span className="w-[6px] h-[6px] rounded-full shrink-0 ml-1" style={{ background: device.on ? "#5dd776" : "rgba(125,140,155,0.3)" }} />
)}
</div>
<span className="text-sm font-medium text-foreground leading-none">
{device ? `${device.current_power_w.toFixed(1)} W` : "—"}
</span>
<span className="text-[11px] text-foreground-sec leading-none">
{device ? (device.on ? "On" : "Off") : "—"}
</span>
</div>
);
}
function MiniNetworkWidget({ speed }: { speed: { rx: number; tx: number } | null }) {
return (
<div className={card}>
<span className="text-[11px] text-foreground-sec">Network</span>
{speed ? (
<>
<span className="text-[11px] font-medium text-blue leading-none truncate"> {formatBytes(speed.rx)}/s</span>
<span className="text-[11px] font-medium text-blue/70 leading-none truncate"> {formatBytes(speed.tx)}/s</span>
</>
) : (
<span className="text-sm font-medium text-foreground-sec"></span>
)}
</div>
);
}
function MiniUptimeWidget({ uptime }: { uptime: { days: number; hours: number; minutes: number } | null }) {
return (
<div className={card}>
<span className="text-[11px] text-foreground-sec">Uptime</span>
{uptime ? (
<>
<span className="text-sm font-medium text-foreground leading-none">{uptime.days}d {uptime.hours}h</span>
<span className="text-[11px] text-foreground-sec leading-none">{uptime.minutes}m</span>
</>
) : (
<span className="text-sm font-medium text-foreground"></span>
)}
</div>
);
}
function WidgetSlot({
id, stats, power, netSpeed, editing, onChange,
}: {
id: WidgetId;
stats: Stats | null;
power: PowerData | null;
netSpeed: { rx: number; tx: number } | null;
editing: boolean;
onChange: (id: WidgetId) => void;
}) {
if (editing) {
return (
<select
value={id}
onChange={(e) => onChange(e.target.value as WidgetId)}
className="text-[11px] bg-secondary border border-secondary rounded-xl px-2 py-2 text-foreground w-full cursor-pointer"
>
{WIDGET_OPTIONS.map((o) => (
<option key={o.id} value={o.id}>{o.label}</option>
))}
</select>
);
}
if (id === "empty") return <div className="rounded-xl border border-secondary/40 border-dashed" />;
if (id === "cpu") return <MiniStatWidget label="CPU" value={stats ? `${stats.cpu.percent.toFixed(1)}%` : "—"} percent={stats?.cpu.percent} />;
if (id === "memory") return <MiniStatWidget label="Memory" value={stats ? `${stats.memory.percent}%` : "—"} percent={stats?.memory.percent} />;
if (id === "disk") return <MiniStatWidget label="Disk" value={stats ? `${stats.disk.percent}%` : "—"} percent={stats?.disk.percent} />;
if (id === "temp") return <MiniStatWidget label="Temp" value={stats?.temperature != null ? `${stats.temperature}°C` : "—"} />;
if (id === "power-server") return <MiniPowerWidget label="Server" device={power?.devices.find((d) => d.name === "server") ?? null} />;
if (id === "power-desktop") return <MiniPowerWidget label="Desktop" device={power?.devices.find((d) => d.name === "desktop") ?? null} />;
if (id === "network") return <MiniNetworkWidget speed={netSpeed} />;
if (id === "uptime") return <MiniUptimeWidget uptime={stats?.uptime ?? null} />;
return null;
}
export function SideNavWidgets() {
const [widgets, setWidgets] = useState<WidgetId[]>(DEFAULT_WIDGETS);
const [editing, setEditing] = useState(false);
const [mounted, setMounted] = useState(false);
const [stats, setStats] = useState<Stats | null>(null);
const [power, setPower] = useState<PowerData | null>(null);
const [netSpeed, setNetSpeed] = useState<{ rx: number; tx: number } | null>(null);
const prevNetRef = useRef<Record<string, NetworkInterface> | null>(null);
const lastFetchRef = useRef<number>(0);
useEffect(() => {
setMounted(true);
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) setWidgets(JSON.parse(saved));
} catch {}
}, []);
useEffect(() => {
const fetchStats = async () => {
try {
const now = Date.now();
const res = await fetch("/api/stats");
if (!res.ok) return;
const data: Stats = await res.json();
if (prevNetRef.current && lastFetchRef.current > 0) {
const elapsed = (now - lastFetchRef.current) / 1000;
const primary = Object.keys(data.network).find(
(k) => !k.startsWith("docker") && !k.startsWith("br-") && data.network[k].rx > 0,
);
if (primary && prevNetRef.current[primary]) {
setNetSpeed({
rx: Math.max(0, (data.network[primary].rx - prevNetRef.current[primary].rx) / elapsed),
tx: Math.max(0, (data.network[primary].tx - prevNetRef.current[primary].tx) / elapsed),
});
}
}
prevNetRef.current = data.network;
lastFetchRef.current = now;
setStats(data);
} catch {}
};
fetchStats();
const id = setInterval(fetchStats, 4000);
return () => clearInterval(id);
}, []);
useEffect(() => {
const fetchPower = async () => {
try {
const res = await fetch("/api/power");
if (!res.ok) return;
setPower(await res.json());
} catch {}
};
fetchPower();
const id = setInterval(fetchPower, 3000);
return () => clearInterval(id);
}, []);
function updateWidget(index: number, id: WidgetId) {
setWidgets((prev) => {
const next = [...prev] as WidgetId[];
next[index] = id;
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } catch {}
return next;
});
}
if (!mounted) return null;
return (
<div className="px-[8px] mb-[4px] shrink-0">
<div className="flex items-center justify-between mb-2 px-[2px]">
<span className="text-xs text-foreground-sec font-medium">Widgets</span>
<button
onClick={() => setEditing((e) => !e)}
title={editing ? "Done" : "Customize widgets"}
className={`p-1 rounded-md transition-colors cursor-pointer ${editing ? "text-blue bg-blue/10" : "text-foreground-sec hover:text-foreground hover:bg-secondary/50"}`}
>
<IconLayoutGrid size={13} />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{widgets.map((id, i) => (
<WidgetSlot
key={i}
id={id}
stats={stats}
power={power}
netSpeed={netSpeed}
editing={editing}
onChange={(newId) => updateWidget(i, newId)}
/>
))}
</div>
</div>
);
}

View file

@ -17,7 +17,7 @@ export default function StatCard({ label, value, sub, percent, delay = 0 }: Stat
className="bg-primary border border-secondary rounded-2xl p-5 flex flex-col gap-1 hover:-translate-y-0.5 transition-all duration-200 animate-fade-up"
style={{ animationDelay: `${delay}ms` }}
>
<span className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec">
<span className="text-xs font-medium text-foreground-sec">
{label}
</span>
<span className="text-3xl font-medium tracking-tight text-foreground leading-none mt-1">

View file

@ -12,7 +12,7 @@ export default function UptimeCard({ uptime, delay = 0 }: UptimeCardProps) {
className="bg-primary border border-secondary rounded-2xl p-6 animate-fade-up"
style={{ animationDelay: `${delay}ms` }}
>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec mb-4">
<p className="text-xs font-medium text-foreground-sec mb-4">
Uptime
</p>
{uptime ? (
@ -31,7 +31,7 @@ export default function UptimeCard({ uptime, delay = 0 }: UptimeCardProps) {
<span className="text-3xl font-medium tracking-tight text-foreground leading-none">
{pad(val)}
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec mt-1.5">
<span className="text-[0.7rem] text-foreground-sec mt-1.5">
{unit}
</span>
</div>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,362 @@
"use client";
import React, { useState, useEffect, useRef, useMemo } from "react";
import { getStats, type Stats, type NetworkInterface } from "../../lib/getStats";
import AnalyticsPanel from "./AnalyticsPanel";
// ── Types / constants ─────────────────────────────────────────────────────────
const COST_PER_KWH = 0.24;
interface DeviceReading { name: string; watts: number; on: boolean; today_wh: number; month_wh: number; }
interface HistoryEntry { ts: string; devices: DeviceReading[]; }
const PRESETS = [
{ label: "1h", h: 1 },
{ label: "6h", h: 6 },
{ label: "24h", h: 24 },
{ label: "3d", h: 72 },
{ label: "7d", h: 168 },
{ label: "30d", h: 720 },
];
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtBytes(mb: number): string {
if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
return `${mb.toFixed(0)} MB`;
}
function fmtUptime(up: Stats["uptime"]): string {
const parts: string[] = [];
if (up.days > 0) parts.push(`${up.days}d`);
if (up.hours > 0 || up.days > 0) parts.push(`${up.hours}h`);
parts.push(`${up.minutes}m`);
return parts.join(" ");
}
function fmtNetBytes(bytes: number): string {
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(2)} GB/s`;
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB/s`;
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(0)} KB/s`;
return `${bytes.toFixed(0)} B/s`;
}
// ── Sub-components ────────────────────────────────────────────────────────────
function StatCard({ label, value, sub, hot }: { label: string; value: string; sub?: string; hot?: boolean }) {
return (
<div style={{
border: "1px solid var(--color-secondary)",
borderRadius: 12,
padding: "16px 20px",
display: "flex",
flexDirection: "column",
gap: 4,
background: "var(--color-primary)",
}}>
<p style={{ fontSize: "9pt", color: "var(--color-foreground-sec)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.07em", margin: 0 }}>
{label}
</p>
<p style={{ fontSize: "24pt", fontWeight: 700, color: hot ? "var(--color-blue)" : "var(--color-foreground)", lineHeight: 1, margin: 0 }}>
{value}
</p>
{sub && <p style={{ fontSize: "9pt", color: "var(--color-foreground-sec)", margin: 0 }}>{sub}</p>}
</div>
);
}
function PowerSummaryCard({ label, value, sub, accent }: { label: string; value: string; sub?: string; accent?: string }) {
return (
<div style={{
border: "1px solid var(--color-secondary)",
borderRadius: 12,
padding: "14px 18px",
display: "flex",
flexDirection: "column",
gap: 3,
}}>
<p style={{ fontSize: "9pt", color: "var(--color-foreground-sec)", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.07em", margin: 0 }}>
{label}
</p>
<p style={{ fontSize: "20pt", fontWeight: 700, color: accent ?? "var(--color-foreground)", lineHeight: 1.1, margin: 0 }}>
{value}
</p>
{sub && <p style={{ fontSize: "9pt", color: "var(--color-foreground-sec)", margin: 0 }}>{sub}</p>}
</div>
);
}
function BreakdownBar({ label, count, total, color }: { label: string; count: number; total: number; color?: string }) {
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "9.5pt" }}>
<span style={{ color: "var(--color-foreground)", fontWeight: 500 }}>{label}</span>
<span style={{ color: "var(--color-foreground-sec)" }}>{count} · {pct}%</span>
</div>
<div style={{ height: 4, borderRadius: 2, background: "var(--color-secondary)", overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", borderRadius: 2, background: color ?? "var(--color-blue)", transition: "width 0.4s ease" }} />
</div>
</div>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<p style={{ fontSize: "10pt", fontWeight: 600, color: "var(--color-foreground)", margin: "0 0 12px 0" }}>
{children}
</p>
);
}
// ── DashboardPanel ────────────────────────────────────────────────────────────
export default function DashboardPanel({ isAuthed }: { isAuthed: boolean }) {
const [stats, setStats] = useState<Stats | null>(null);
const [netSpeed, setNetSpeed] = useState<{ rx: number; tx: number } | null>(null);
const prevNetRef = useRef<Record<string, NetworkInterface> | null>(null);
const prevTimeRef = useRef<number>(0);
const [hours, setHours] = useState(24);
const [readings, setReadings] = useState<HistoryEntry[]>([]);
const [powerLoading, setPowerLoading] = useState(true);
// System stats poll
useEffect(() => {
const go = async () => {
try {
const now = Date.now();
const data = await getStats();
setStats(data);
const primary = Object.keys(data.network).find(
(k) => !k.startsWith("docker") && !k.startsWith("br-") && data.network[k].rx > 0,
);
if (primary && prevNetRef.current?.[primary] && prevTimeRef.current > 0) {
const elapsed = (now - prevTimeRef.current) / 1000;
const prev = prevNetRef.current[primary];
setNetSpeed({
rx: Math.max(0, (data.network[primary].rx - prev.rx) / elapsed),
tx: Math.max(0, (data.network[primary].tx - prev.tx) / elapsed),
});
}
prevNetRef.current = data.network;
prevTimeRef.current = now;
} catch {}
};
go();
const id = setInterval(go, 4000);
return () => clearInterval(id);
}, []);
// Power history fetch
useEffect(() => {
let cancelled = false;
setPowerLoading(true);
fetch(`/api/power/history?hours=${hours}`)
.then(r => r.ok ? r.json() : { readings: [] })
.then(d => { if (!cancelled) { setReadings(d.readings ?? []); setPowerLoading(false); } })
.catch(() => { if (!cancelled) setPowerLoading(false); });
return () => { cancelled = true; };
}, [hours]);
// Power summary stats
const powerStats = useMemo(() => {
if (readings.length === 0) return null;
const deviceNames = [...new Set(readings.flatMap(r => r.devices.map(d => d.name)))];
let totalWh = 0;
let totalAvgW = 0;
for (const name of deviceNames) {
const pts = readings
.map(r => { const d = r.devices.find(x => x.name === name); return d ? { ts: new Date(r.ts).getTime(), w: d.watts } : null; })
.filter(Boolean).sort((a, b) => a!.ts - b!.ts) as { ts: number; w: number }[];
let wh = 0;
for (let i = 1; i < pts.length; i++) {
wh += (pts[i].w + pts[i - 1].w) / 2 * (pts[i].ts - pts[i - 1].ts) / 3_600_000;
}
totalWh += wh;
totalAvgW += pts.length ? pts.reduce((s, p) => s + p.w, 0) / pts.length : 0;
}
const cost = totalWh / 1000 * COST_PER_KWH;
const latest = readings[readings.length - 1];
const activeDevices = latest ? latest.devices.filter(d => d.on).length : 0;
const totalDevices = latest ? latest.devices.length : 0;
return { totalWh, totalAvgW, cost, activeDevices, totalDevices };
}, [readings]);
const s = stats;
const svcEntries = isAuthed && s ? Object.entries(s.services) : [];
const svcRunning = svcEntries.filter(([, v]) => v === "running" || v === "active").length;
const svcStopped = svcEntries.filter(([, v]) => v === "inactive" || v === "stopped" || v === "dead").length;
const svcFailed = svcEntries.filter(([, v]) => v === "failed").length;
const svcTotal = svcEntries.length;
return (
<div style={{ padding: "20px", display: "flex", flexDirection: "column", gap: 20 }}>
{/* System stat cards */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))", gap: 12 }}>
<StatCard label="CPU" value={s ? `${s.cpu.percent.toFixed(1)}%` : "—"} sub={s?.cpu.model.replace(/\(R\)/g, "").replace(/\(TM\)/g, "").trim().split(" ").slice(0, 4).join(" ")} hot={s != null && s.cpu.percent > 80} />
<StatCard label="Memory" value={s ? `${s.memory.percent.toFixed(0)}%` : "—"} sub={s ? `${fmtBytes(s.memory.used)} / ${fmtBytes(s.memory.total)}` : ""} hot={s != null && s.memory.percent > 85} />
<StatCard label="Disk" value={s ? `${s.disk.percent}%` : "—"} sub={s ? `${(s.disk.used / 1024).toFixed(1)} GB / ${(s.disk.total / 1024).toFixed(0)} GB` : ""} hot={s != null && s.disk.percent > 90} />
<StatCard label="Temp" value={s?.temperature != null ? `${s.temperature}°` : "—"} sub={s?.temperature != null ? (s.temperature > 80 ? "Running hot" : s.temperature > 60 ? "Warm" : "Cool") : ""} hot={s?.temperature != null && s.temperature > 75} />
</div>
{/* Power analytics section */}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{/* Header row: title + time range pills */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" }}>
<p style={{ fontSize: "10pt", fontWeight: 600, color: "var(--color-foreground)", margin: 0 }}>
Power Analytics
</p>
<div style={{ display: "flex", gap: 4 }}>
{PRESETS.map(p => (
<button key={p.h} onClick={() => setHours(p.h)} style={{
padding: "4px 10px", borderRadius: 7, fontSize: "10pt", fontWeight: 500, cursor: "pointer",
border: `1px solid ${hours === p.h ? "var(--color-blue)" : "var(--color-secondary)"}`,
background: hours === p.h ? "color-mix(in srgb, var(--color-blue) 14%, transparent)" : "transparent",
color: hours === p.h ? "var(--color-blue)" : "var(--color-foreground-sec)",
transition: "all 120ms",
}}>
{p.label}
</button>
))}
</div>
</div>
{/* Summary cards */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 10 }}>
<PowerSummaryCard
label="Avg Power"
value={powerStats ? `${powerStats.totalAvgW.toFixed(1)} W` : "—"}
sub="combined average"
accent="var(--color-blue)"
/>
<PowerSummaryCard
label="Total Energy"
value={powerStats ? (powerStats.totalWh >= 1000 ? `${(powerStats.totalWh / 1000).toFixed(2)} kWh` : `${powerStats.totalWh.toFixed(1)} Wh`) : "—"}
sub={`over ${PRESETS.find(p => p.h === hours)?.label ?? `${hours}h`}`}
/>
<PowerSummaryCard
label="Est. Cost"
value={powerStats ? `$${powerStats.cost.toFixed(3)}` : "—"}
sub={`@ $${COST_PER_KWH}/kWh`}
accent="#5dd776"
/>
<PowerSummaryCard
label="Active Devices"
value={powerStats ? `${powerStats.activeDevices}` : "—"}
sub={powerStats ? `of ${powerStats.totalDevices} total` : ""}
/>
</div>
{/* Mini charts */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 12 }}>
{(["line", "bar", "candle"] as const).map((type) => (
<div key={type} style={{ border: "1px solid var(--color-secondary)", borderRadius: 12, overflow: "hidden", height: 220 }}>
<div style={{ padding: "10px 14px 6px", borderBottom: "1px solid color-mix(in srgb, var(--color-secondary) 60%, transparent)" }}>
<p style={{ fontSize: "9pt", fontWeight: 600, color: "var(--color-foreground-sec)", margin: 0 }}>
{type === "line" ? "Line" : type === "bar" ? "Bar" : "Candlestick"}
</p>
</div>
<div style={{ height: "calc(100% - 37px)" }}>
<AnalyticsPanel key={hours} chartType={type} readOnly defaultHours={hours} />
</div>
</div>
))}
</div>
</div>
{/* Authenticated: services + system info */}
{isAuthed && (
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12 }}>
{svcTotal > 0 && (
<div style={{ border: "1px solid var(--color-secondary)", borderRadius: 12, padding: "16px 20px" }}>
<SectionTitle>Services ({svcTotal})</SectionTitle>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<BreakdownBar label="Running" count={svcRunning} total={svcTotal} color="#5dd776" />
<BreakdownBar label="Stopped" count={svcStopped} total={svcTotal} color="var(--color-foreground-sec)" />
{svcFailed > 0 && <BreakdownBar label="Failed" count={svcFailed} total={svcTotal} color="#ef4444" />}
</div>
</div>
)}
{s?.loadAvg && (
<div style={{ border: "1px solid var(--color-secondary)", borderRadius: 12, padding: "16px 20px" }}>
<SectionTitle>Load Average</SectionTitle>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{([["1 min", s.loadAvg["1m"]], ["5 min", s.loadAvg["5m"]], ["15 min", s.loadAvg["15m"]]] as [string, number][]).map(([label, val]) => (
<div key={label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "9.5pt", color: "var(--color-foreground)", fontWeight: 500 }}>{label}</span>
<span style={{ fontSize: "9.5pt", color: "var(--color-foreground-sec)" }}>{val.toFixed(2)}</span>
</div>
))}
</div>
</div>
)}
{s?.uptime && (
<div style={{ border: "1px solid var(--color-secondary)", borderRadius: 12, padding: "16px 20px" }}>
<SectionTitle>Uptime</SectionTitle>
<p style={{ fontSize: "22pt", fontWeight: 700, color: "var(--color-foreground)", lineHeight: 1, margin: "0 0 8px 0" }}>
{fmtUptime(s.uptime)}
</p>
<p style={{ fontSize: "9pt", color: "var(--color-foreground-sec)", margin: 0 }}>
{s.uptime.days > 0
? `${s.uptime.days} days, ${s.uptime.hours} hours, ${s.uptime.minutes} min`
: `${s.uptime.hours} hours, ${s.uptime.minutes} min`}
</p>
</div>
)}
{netSpeed && (
<div style={{ border: "1px solid var(--color-secondary)", borderRadius: 12, padding: "16px 20px" }}>
<SectionTitle>Network</SectionTitle>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "9.5pt", color: "var(--color-foreground)", fontWeight: 500 }}> Download</span>
<span style={{ fontSize: "9.5pt", color: "#5dd776", fontWeight: 600 }}>{fmtNetBytes(netSpeed.rx)}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: "9.5pt", color: "var(--color-foreground)", fontWeight: 500 }}> Upload</span>
<span style={{ fontSize: "9.5pt", color: "var(--color-blue)", fontWeight: 600 }}>{fmtNetBytes(netSpeed.tx)}</span>
</div>
</div>
</div>
)}
</div>
)}
{/* Service status table (authenticated only) */}
{isAuthed && svcEntries.length > 0 && (
<div style={{ border: "1px solid var(--color-secondary)", borderRadius: 12, overflow: "hidden" }}>
<div style={{ padding: "12px 20px", borderBottom: "1px solid var(--color-secondary)" }}>
<SectionTitle>Service Status</SectionTitle>
</div>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ background: "color-mix(in srgb, var(--color-secondary) 30%, transparent)" }}>
<th style={{ padding: "8px 20px", textAlign: "left", fontSize: "9pt", fontWeight: 600, color: "var(--color-foreground-sec)", textTransform: "uppercase", letterSpacing: "0.05em" }}>Service</th>
<th style={{ padding: "8px 20px", textAlign: "right", fontSize: "9pt", fontWeight: 600, color: "var(--color-foreground-sec)", textTransform: "uppercase", letterSpacing: "0.05em" }}>Status</th>
</tr>
</thead>
<tbody>
{svcEntries.map(([name, status], i) => {
const isRunning = status === "running" || status === "active";
const isFailed = status === "failed";
const color = isFailed ? "#ef4444" : isRunning ? "#5dd776" : "var(--color-foreground-sec)";
return (
<tr key={name} style={{ background: i % 2 !== 0 ? "color-mix(in srgb, var(--color-secondary) 20%, transparent)" : "transparent" }}>
<td style={{ padding: "8px 20px", fontSize: "10pt", fontWeight: 500, color: "var(--color-foreground)" }}>{name}</td>
<td style={{ padding: "8px 20px", textAlign: "right" }}>
<span style={{ fontSize: "9pt", fontWeight: 600, color, textTransform: "capitalize" }}>{status}</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,11 @@
"use client";
import LinksGrid from "../LinksGrid";
export default function LinksPanel() {
return (
<div className="p-4">
<LinksGrid />
</div>
);
}

View file

@ -0,0 +1,44 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { getStats, type Stats, type NetworkInterface } from "../../lib/getStats";
import NetworkCard from "../NetworkCard";
export default function NetworkPanel() {
const [iface, setIface] = useState<string | null>(null);
const [speed, setSpeed] = useState<{ rx: number; tx: number } | null>(null);
const prevRef = useRef<Record<string, NetworkInterface> | null>(null);
const lastRef = useRef<number>(0);
useEffect(() => {
const go = async () => {
try {
const now = Date.now();
const data = await getStats();
const primary = Object.keys(data.network).find(
(k) => !k.startsWith("docker") && !k.startsWith("br-") && data.network[k].rx > 0,
);
setIface(primary ?? null);
if (primary && prevRef.current?.[primary] && lastRef.current > 0) {
const elapsed = (now - lastRef.current) / 1000;
const prev = prevRef.current[primary];
setSpeed({
rx: Math.max(0, (data.network[primary].rx - prev.rx) / elapsed),
tx: Math.max(0, (data.network[primary].tx - prev.tx) / elapsed),
});
}
prevRef.current = data.network;
lastRef.current = now;
} catch {}
};
go();
const id = setInterval(go, 4000);
return () => clearInterval(id);
}, []);
return (
<div className="p-4">
<NetworkCard iface={iface} speed={speed} />
</div>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { getStats, type Stats, type NetworkInterface } from "../../lib/getStats";
import StatsGrid from "../StatsGrid";
export default function OverviewPanel() {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
const go = async () => {
try { setStats(await getStats()); } catch {}
};
go();
const id = setInterval(go, 4000);
return () => clearInterval(id);
}, []);
return (
<div className="p-4 pb-2">
<StatsGrid stats={stats} />
</div>
);
}

View file

@ -0,0 +1,25 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { getPower, type PowerData } from "../../lib/getPower";
import PowerGrid from "../PowerGrid";
export default function PowerPanel({ isAuthed }: { isAuthed: boolean }) {
const [power, setPower] = useState<PowerData | null>(null);
const fetchPower = useCallback(async () => {
try { setPower(await getPower()); } catch {}
}, []);
useEffect(() => {
fetchPower();
const id = setInterval(fetchPower, 3000);
return () => clearInterval(id);
}, [fetchPower]);
return (
<div className="p-4">
<PowerGrid power={power} onRefresh={fetchPower} showControls={isAuthed} />
</div>
);
}

View file

@ -0,0 +1,33 @@
"use client";
import { useState, useEffect } from "react";
import { getStats, type Stats } from "../../lib/getStats";
import ServicesCard from "../ServicesCard";
export default function ServicesPanel({ isAuthed }: { isAuthed: boolean }) {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
if (!isAuthed) return;
const go = async () => {
try { setStats(await getStats()); } catch {}
};
go();
const id = setInterval(go, 4000);
return () => clearInterval(id);
}, [isAuthed]);
if (!isAuthed) {
return (
<div className="p-4 text-sm text-foreground-sec">
Authentication required to view services.
</div>
);
}
return (
<div className="p-4">
<ServicesCard services={stats?.services ?? null} />
</div>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import { useState, useEffect } from "react";
import { getStats, type Stats } from "../../lib/getStats";
import UptimeCard from "../UptimeCard";
export default function UptimePanel() {
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
const go = async () => {
try { setStats(await getStats()); } catch {}
};
go();
const id = setInterval(go, 4000);
return () => clearInterval(id);
}, []);
return (
<div className="p-4">
<UptimeCard uptime={stats?.uptime ?? null} />
</div>
);
}

View file

@ -0,0 +1,281 @@
"use client";
import React, { useState, useCallback, useRef, useEffect, createContext, useContext, memo } from "react";
import { TileNode, LeafNode, ContainerNode, PanelId } from "./types";
import {
splitLeaf, closeLeaf, updatePanelId, patchSizes,
getFirstLeafId, countLeaves, getLeafPanel,
} from "./treeUtils";
import { setFocusedContext, subscribeViewChange } from "@/stores/windowStore";
import WindowPane from "./WindowPane";
// ── Persistence ───────────────────────────────────────────────────────────────
const STORAGE_KEY = "wm-layout-v3";
const DEFAULT_TREE: TileNode = { type: "leaf", id: "root", panelId: "dashboard" };
function persist(t: TileNode) {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(t)); } catch {}
}
// ── Internal context ──────────────────────────────────────────────────────────
interface WMCtx {
focusedId: string | null;
totalPanes: number;
isAuthed: boolean;
onFocus: (paneId: string, panelId: PanelId) => void;
onSplit: (leafId: string, dir: "h" | "v", newFirst: boolean, panelId: PanelId) => void;
onClose: (leafId: string) => void;
onResizeContainer: (containerId: string, sizes: number[]) => void;
}
const WMContext = createContext<WMCtx>(null!);
const useWM = () => useContext(WMContext);
// ── Resize handle ─────────────────────────────────────────────────────────────
interface ResizeHandleProps {
containerId: string;
index: number;
dir: "h" | "v";
sizes: number[];
containerRef: React.RefObject<HTMLDivElement | null>;
}
function ResizeHandle({ containerId, index, dir, sizes, containerRef }: ResizeHandleProps) {
const { onResizeContainer } = useWM();
const isCol = dir === "h";
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const totalPx = isCol ? rect.width : rect.height;
const totalRatio = sizes.reduce((a, b) => a + b, 0);
const startPos = isCol ? e.clientX : e.clientY;
const startSizes = [...sizes];
const onMove = (mv: MouseEvent) => {
const delta = (isCol ? mv.clientX : mv.clientY) - startPos;
const deltaRatio = (delta / totalPx) * totalRatio;
const minRatio = totalRatio * 0.08;
const next = [...startSizes];
next[index] = Math.max(minRatio, startSizes[index] + deltaRatio);
next[index + 1] = Math.max(minRatio, startSizes[index + 1] - deltaRatio);
if (next[index] < minRatio) {
next[index] = minRatio;
next[index + 1] = startSizes[index] + startSizes[index + 1] - minRatio;
} else if (next[index + 1] < minRatio) {
next[index + 1] = minRatio;
next[index] = startSizes[index] + startSizes[index + 1] - minRatio;
}
onResizeContainer(containerId, next);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
document.body.style.cursor = isCol ? "col-resize" : "row-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}, [containerId, index, dir, sizes, containerRef, onResizeContainer, isCol]);
return (
<div
className={[
"group/div shrink-0 flex items-center justify-center z-10",
"hover:bg-blue/5 transition-colors",
isCol ? "w-[6px] cursor-col-resize" : "h-[6px] cursor-row-resize",
].join(" ")}
onMouseDown={handleMouseDown}
>
<div className={[
"rounded-full bg-secondary/40 group-hover/div:bg-blue/50 transition-colors",
isCol ? "w-[2px] h-8" : "h-[2px] w-8",
].join(" ")} />
</div>
);
}
// ── Recursive tree renderer ───────────────────────────────────────────────────
const LeafCard = memo(function LeafCard({ leaf }: { leaf: LeafNode }) {
const { focusedId, totalPanes, isAuthed, onFocus, onSplit, onClose } = useWM();
const isFocused = leaf.id === focusedId;
return (
<div className="p-[2px] flex flex-col w-full h-full min-w-0 min-h-0 box-border">
<div
className={[
"flex-1 min-h-0 rounded-xl overflow-hidden border transition-colors duration-150",
totalPanes > 1 && isFocused
? "border-blue/60 shadow-[0_0_0_1px_rgba(66,140,226,0.2)]"
: "border-secondary/60",
].join(" ")}
onClick={() => onFocus(leaf.id, leaf.panelId)}
>
<WindowPane
node={leaf}
isFocused={isFocused}
canClose={totalPanes > 1}
onSplit={onSplit}
onClose={onClose}
isAuthed={isAuthed}
/>
</div>
</div>
);
});
function RenderTree({ node }: { node: TileNode }) {
const containerRef = useRef<HTMLDivElement>(null);
const { focusedId } = useWM();
if (node.type === "leaf") return <LeafCard leaf={node as LeafNode} />;
const container = node as ContainerNode;
const isCol = container.dir === "h";
return (
<div
ref={containerRef}
className="flex w-full h-full min-w-0 min-h-0"
style={{ flexDirection: isCol ? "row" : "column" }}
>
{container.children.map((child, i) => (
<React.Fragment key={child.id}>
<div
className="min-w-0 min-h-0 flex"
style={{
flex: container.sizes[i] ?? 1,
flexDirection: isCol ? "row" : "column",
}}
>
<RenderTree node={child} />
</div>
{i < container.children.length - 1 && (
<ResizeHandle
containerId={container.id}
index={i}
dir={container.dir}
sizes={container.sizes}
containerRef={containerRef}
/>
)}
</React.Fragment>
))}
</div>
);
}
// ── Root ──────────────────────────────────────────────────────────────────────
export default function WindowManager({ isAuthed }: { isAuthed: boolean }) {
const [tree, setTree] = useState<TileNode>(DEFAULT_TREE);
const [focusedId, setFocusedId] = useState<string | null>(null);
const [ready, setReady] = useState(false);
// Refs for stale-closure-safe callbacks
const treeRef = useRef(tree);
const focusedIdRef = useRef(focusedId);
treeRef.current = tree;
focusedIdRef.current = focusedId;
// Load persisted layout
useEffect(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) setTree(JSON.parse(saved));
} catch {}
setReady(true);
}, []);
// Sync focused context to store whenever tree or focus changes
useEffect(() => {
const id = focusedId ?? getFirstLeafId(tree);
const panelId = getLeafPanel(tree, id);
if (id && panelId) setFocusedContext(id, panelId);
}, [tree, focusedId]);
// Listen for sidebar view-change requests
useEffect(() => {
const unsub = subscribeViewChange((panelId) => {
const id = focusedIdRef.current ?? getFirstLeafId(treeRef.current);
setTree((prev) => {
const next = updatePanelId(prev, id, panelId);
persist(next);
return next;
});
// Update store immediately so sidebar highlight updates
setFocusedContext(id, panelId);
});
return unsub;
}, []);
const onFocus = useCallback((paneId: string, panelId: PanelId) => {
setFocusedId(paneId);
setFocusedContext(paneId, panelId);
}, []);
const onSplit = useCallback((leafId: string, dir: "h" | "v", newFirst: boolean, panelId: PanelId) => {
setTree((prev) => {
const next = splitLeaf(prev, leafId, dir, newFirst, panelId);
persist(next);
return next;
});
}, []);
const onClose = useCallback((leafId: string) => {
setTree((prev) => {
const next = closeLeaf(prev, leafId);
if (!next) return prev;
persist(next);
// If closed pane was focused, move focus to first leaf
if (focusedIdRef.current === leafId) {
const firstId = getFirstLeafId(next);
const panelId = getLeafPanel(next, firstId);
setFocusedId(firstId);
if (panelId) setFocusedContext(firstId, panelId);
}
return next;
});
}, []);
const onResizeContainer = useCallback((containerId: string, sizes: number[]) => {
setTree((prev) => {
const next = patchSizes(prev, containerId, sizes);
persist(next);
return next;
});
}, []);
const total = countLeaves(tree);
const ctx: WMCtx = {
focusedId: focusedId ?? (ready ? getFirstLeafId(tree) : null),
totalPanes: total,
isAuthed,
onFocus,
onSplit,
onClose,
onResizeContainer,
};
if (!ready) return null;
return (
<WMContext.Provider value={ctx}>
<div className="w-full h-full overflow-hidden">
<RenderTree node={tree} />
</div>
</WMContext.Provider>
);
}

View file

@ -0,0 +1,296 @@
"use client";
import React, { useState, useRef, useEffect, lazy, Suspense } from "react";
import { createPortal } from "react-dom";
import { LeafNode, PanelId, PANEL_LABELS, PANEL_SECTIONS } from "./types";
import {
IconX, IconLayoutColumns, IconLayoutRows, IconRefresh,
} from "@tabler/icons-react";
const DashboardPanel = lazy(() => import("../panels/DashboardPanel"));
const AnalyticsPanel = lazy(() => import("../panels/AnalyticsPanel"));
function PanelContent({ panelId, isAuthed }: { panelId: PanelId; isAuthed: boolean }) {
return (
<Suspense fallback={<div className="p-4"><div className="skeleton h-32 rounded-2xl" /></div>}>
{panelId === "dashboard" && <DashboardPanel isAuthed={isAuthed} />}
{panelId === "analytics-line" && <AnalyticsPanel chartType="line" />}
{panelId === "analytics-bar" && <AnalyticsPanel chartType="bar" />}
{panelId === "analytics-candle" && <AnalyticsPanel chartType="candle" />}
</Suspense>
);
}
// ── View picker portal ────────────────────────────────────────────────────────
type SplitDir = "right" | "down";
function ViewPickerPortal({
anchorRef,
mode,
currentView,
onPick,
onClose,
}: {
anchorRef: React.RefObject<HTMLButtonElement | null>;
mode: "change" | SplitDir;
currentView: PanelId;
onPick: (panelId: PanelId) => void;
onClose: () => void;
}) {
const menuRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState({ top: 0, left: 0 });
useEffect(() => {
const rect = anchorRef.current?.getBoundingClientRect();
if (rect) setPos({ top: rect.bottom + 6, left: rect.left });
}, []);
// Clamp to viewport after mount
useEffect(() => {
if (!menuRef.current) return;
const r = menuRef.current.getBoundingClientRect();
const pad = 8;
let { left, top } = pos;
if (r.right > window.innerWidth - pad) left = window.innerWidth - r.width - pad;
if (r.left < pad) left = pad;
if (r.bottom > window.innerHeight - pad) top = window.innerHeight - r.height - pad;
if (left !== pos.left || top !== pos.top) setPos({ top, left });
}, [pos]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!menuRef.current?.contains(e.target as Node) &&
!anchorRef.current?.contains(e.target as Node)) onClose();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [onClose]);
const title = mode === "change" ? "Change View" : mode === "right" ? "Tile Right" : "Tile Down";
// Dashboard entry + sections
const dashboardActive = currentView === "dashboard";
return createPortal(
<div
ref={menuRef}
style={{ position: "fixed", top: pos.top, left: pos.left, zIndex: 9999 }}
className="w-[220px] max-h-[420px] overflow-y-auto bg-primary border border-secondary rounded-xl shadow-2xl py-1"
>
<p className="px-3 pt-1.5 pb-1 text-[10px] font-semibold text-foreground-sec uppercase tracking-wider">
{title}
</p>
{/* Dashboard standalone */}
<button
onClick={() => { onPick("dashboard"); onClose(); }}
className={[
"w-[calc(100%-8px)] mx-[4px] flex items-center gap-2.5 px-2.5 py-[5px] rounded-lg text-left text-xs transition-colors cursor-pointer",
dashboardActive
? "bg-blue/12 border border-blue/30 text-blue font-medium"
: "text-foreground hover:bg-secondary/60 border border-transparent",
].join(" ")}
>
<span className={[
"w-[28px] h-[28px] shrink-0 flex items-center justify-center rounded-[7px] border text-[10px] font-bold",
dashboardActive
? "bg-blue/15 border-blue/35 text-blue"
: "bg-secondary/50 border-secondary text-foreground-sec",
].join(" ")}>D</span>
Dashboard
</button>
{/* Power Analytics section */}
{PANEL_SECTIONS.map((section, si) => (
<div key={section.id}>
<div className="mx-2 my-1 h-px bg-secondary" />
<p className="px-3 pt-1 pb-0.5 text-[9px] font-semibold text-foreground-sec/60 uppercase tracking-wider">
{section.label}
</p>
{section.items.map(({ panelId, label }) => {
const active = panelId === currentView;
return (
<button
key={panelId}
onClick={() => { onPick(panelId); onClose(); }}
className={[
"w-[calc(100%-8px)] mx-[4px] flex items-center gap-2.5 px-2.5 py-[5px] rounded-lg text-left text-xs transition-colors cursor-pointer",
active
? "bg-blue/12 border border-blue/30 text-blue font-medium"
: "text-foreground hover:bg-secondary/60 border border-transparent",
].join(" ")}
>
<span className={[
"w-[28px] h-[28px] shrink-0 flex items-center justify-center rounded-[7px] border text-[10px] font-bold",
active
? "bg-blue/15 border-blue/35 text-blue"
: "bg-secondary/50 border-secondary text-foreground-sec",
].join(" ")}>
{label.charAt(0)}
</span>
{label}
</button>
);
})}
</div>
))}
</div>,
document.body,
);
}
// ── Window controls pill ──────────────────────────────────────────────────────
type MenuMode = "change" | SplitDir;
function WindowControls({
paneId,
currentView,
canClose,
onClose,
onSplit,
onChangeView,
}: {
paneId: string;
currentView: PanelId;
canClose: boolean;
onClose: (id: string) => void;
onSplit: (leafId: string, dir: "h" | "v", newFirst: boolean, panelId: PanelId) => void;
onChangeView: (panelId: PanelId) => void;
}) {
const [menu, setMenu] = useState<MenuMode | null>(null);
const [pillHovered, setPillHovered] = useState(false);
const changeRef = useRef<HTMLButtonElement>(null);
const rightRef = useRef<HTMLButtonElement>(null);
const downRef = useRef<HTMLButtonElement>(null);
const handlePick = (panelId: PanelId) => {
if (menu === "change") onChangeView(panelId);
else if (menu === "right") onSplit(paneId, "h", false, panelId);
else if (menu === "down") onSplit(paneId, "v", false, panelId);
setMenu(null);
};
const anchorRef = menu === "change" ? changeRef : menu === "right" ? rightRef : downRef;
const BTN = "w-[26px] h-[26px] flex items-center justify-center rounded-full transition-colors cursor-pointer text-foreground-sec hover:text-foreground";
return (
<>
<div
className="flex items-center gap-0.5 px-1 py-0.5 rounded-full border transition-colors duration-150"
style={{
borderColor: pillHovered ? "rgba(66,140,226,0.35)" : "var(--color-secondary)",
background: pillHovered ? "color-mix(in srgb, var(--color-blue) 8%, var(--color-primary))" : "var(--color-primary)",
boxShadow: pillHovered ? "none" : "0 1px 4px rgba(0,0,0,0.18)",
transition: "background 150ms, border-color 150ms, box-shadow 150ms",
}}
onMouseEnter={() => setPillHovered(true)}
onMouseLeave={() => setPillHovered(false)}
>
{canClose && (
<button
onClick={(e) => { e.stopPropagation(); onClose(paneId); }}
className={`${BTN} hover:text-red-400 hover:bg-red-500/10`}
title="Close"
>
<IconX size={13} />
</button>
)}
<button
ref={changeRef}
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "change" ? null : "change"); }}
className={`${BTN} ${menu === "change" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
title="Change view"
>
<IconRefresh size={13} />
</button>
<button
ref={rightRef}
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "right" ? null : "right"); }}
className={`${BTN} ${menu === "right" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
title="Tile right"
>
<IconLayoutColumns size={13} />
</button>
<button
ref={downRef}
onClick={(e) => { e.stopPropagation(); setMenu(m => m === "down" ? null : "down"); }}
className={`${BTN} ${menu === "down" ? "text-blue bg-blue/10" : "hover:bg-secondary/60"}`}
title="Tile down"
>
<IconLayoutRows size={13} />
</button>
</div>
{menu && (
<ViewPickerPortal
anchorRef={anchorRef}
mode={menu}
currentView={currentView}
onPick={handlePick}
onClose={() => setMenu(null)}
/>
)}
</>
);
}
// ── WindowPane ────────────────────────────────────────────────────────────────
export default function WindowPane({
node,
isFocused,
canClose,
onSplit,
onClose,
isAuthed,
}: {
node: LeafNode;
isFocused: boolean;
canClose: boolean;
onSplit: (leafId: string, dir: "h" | "v", newFirst: boolean, panelId: PanelId) => void;
onClose: (leafId: string) => void;
isAuthed: boolean;
}) {
const [hovered, setHovered] = useState(false);
const handleChangeView = (panelId: PanelId) => {
import("@/stores/windowStore").then(({ requestViewChange }) => requestViewChange(panelId));
};
return (
<div
className="relative w-full h-full flex flex-col bg-primary"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Title bar */}
<div className="shrink-0 h-[28px] flex items-center justify-between px-3 border-b border-secondary/40">
<span className="text-[11px] font-medium text-foreground-sec truncate select-none">
{PANEL_LABELS[node.panelId]}
</span>
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<PanelContent panelId={node.panelId} isAuthed={isAuthed} />
</div>
{/* Floating controls pill */}
<div
className="absolute top-[4px] right-[8px] z-20 transition-opacity duration-150 pointer-events-none"
style={{ opacity: hovered ? 1 : 0, pointerEvents: hovered ? "auto" : "none" }}
>
<WindowControls
paneId={node.id}
currentView={node.panelId}
canClose={canClose}
onClose={onClose}
onSplit={onSplit}
onChangeView={handleChangeView}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,75 @@
import { TileNode, LeafNode, ContainerNode, PanelId } from "./types";
let _id = 0;
export function gid(): string {
return `wn-${Date.now()}-${++_id}`;
}
// ── Leaf operations ───────────────────────────────────────────────────────────
function mapLeaf(tree: TileNode, leafId: string, fn: (leaf: LeafNode) => TileNode): TileNode {
if (tree.type === "leaf") return tree.id === leafId ? fn(tree) : tree;
return { ...tree, children: tree.children.map((c) => mapLeaf(c, leafId, fn)) };
}
export function splitLeaf(
tree: TileNode,
leafId: string,
dir: "h" | "v",
newFirst: boolean,
newPanelId: PanelId,
): TileNode {
const newLeaf: LeafNode = { type: "leaf", id: gid(), panelId: newPanelId };
return mapLeaf(tree, leafId, (leaf) => ({
type: "container",
id: gid(),
dir,
children: newFirst ? [newLeaf, leaf] : [leaf, newLeaf],
sizes: [1, 1],
}));
}
export function closeLeaf(tree: TileNode, leafId: string): TileNode | null {
if (tree.type === "leaf") return tree.id === leafId ? null : tree;
const children: TileNode[] = [];
const sizes: number[] = [];
tree.children.forEach((c, i) => {
const r = closeLeaf(c, leafId);
if (r !== null) { children.push(r); sizes.push(tree.sizes[i]); }
});
if (children.length === 0) return null;
if (children.length === 1) return children[0]; // collapse single-child container
return { ...tree, children, sizes };
}
export function updatePanelId(tree: TileNode, leafId: string, panelId: PanelId): TileNode {
if (tree.type === "leaf") return tree.id === leafId ? { ...tree, panelId } : tree;
return { ...tree, children: tree.children.map((c) => updatePanelId(c, leafId, panelId)) };
}
export function patchSizes(tree: TileNode, containerId: string, sizes: number[]): TileNode {
if (tree.type === "leaf") return tree;
if (tree.id === containerId) return { ...tree, sizes };
return { ...tree, children: tree.children.map((c) => patchSizes(c, containerId, sizes)) };
}
// ── Query helpers ─────────────────────────────────────────────────────────────
export function getFirstLeafId(tree: TileNode): string {
if (tree.type === "leaf") return tree.id;
return getFirstLeafId(tree.children[0]);
}
export function countLeaves(tree: TileNode): number {
if (tree.type === "leaf") return 1;
return tree.children.reduce((s, c) => s + countLeaves(c), 0);
}
export function getLeafPanel(tree: TileNode, leafId: string): PanelId | null {
if (tree.type === "leaf") return tree.id === leafId ? tree.panelId : null;
for (const c of tree.children) {
const p = getLeafPanel(c, leafId);
if (p) return p;
}
return null;
}

View file

@ -0,0 +1,43 @@
export type PanelId =
| "dashboard"
| "analytics-line"
| "analytics-bar"
| "analytics-candle";
export const PANEL_LABELS: Record<PanelId, string> = {
dashboard: "Dashboard",
"analytics-line": "Power Analytics — Line",
"analytics-bar": "Power Analytics — Bar",
"analytics-candle": "Power Analytics — Candlestick",
};
// ── Sidebar sections ──────────────────────────────────────────────────────────
export interface PanelEntry { panelId: PanelId; label: string; }
export interface PanelSection { id: string; label: string; items: PanelEntry[]; }
export const PANEL_SECTIONS: PanelSection[] = [
{
id: "power-analytics",
label: "Power Analytics",
items: [
{ panelId: "analytics-line", label: "Line Chart" },
{ panelId: "analytics-bar", label: "Bar Chart" },
{ panelId: "analytics-candle", label: "Candlestick" },
],
},
];
export const ALL_PANELS: PanelId[] = Object.keys(PANEL_LABELS) as PanelId[];
// ── N-ary pane tree ───────────────────────────────────────────────────────────
export type LeafNode = { type: "leaf"; id: string; panelId: PanelId };
export type ContainerNode = {
type: "container";
id: string;
dir: "h" | "v";
children: TileNode[];
sizes: number[];
};
export type TileNode = LeafNode | ContainerNode;