This commit is contained in:
Jack Mechem 2026-05-21 21:13:06 -07:00
parent 3f178f8795
commit c6e6c5ca48
Signed by: jackmechem
SSH key fingerprint: SHA256:GjIjMAC33pzYOe+hWcX5uvgnPrVFAXSrquvt84AOJbU
20 changed files with 664 additions and 277 deletions

View file

@ -50,7 +50,6 @@ export default function AuthPage() {
setStatus("checking");
try {
// Step 1: verify password → get WebAuthn challenge
const loginRes = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -65,7 +64,6 @@ export default function AuthPage() {
const { session_id, challenge } = await loginRes.json();
// Step 2: prompt YubiKey tap
setStatus("waiting_yubikey");
const opts = challenge.publicKey;
opts.challenge = b64uToBuf(opts.challenge);
@ -94,7 +92,6 @@ export default function AuthPage() {
return;
}
// Step 3: verify assertion → set cookie
setStatus("verifying");
const assertion = cred.response as AuthenticatorAssertionResponse;
@ -140,23 +137,21 @@ export default function AuthPage() {
const busy = status !== "idle";
return (
<main className="h-full bg-gray-100 flex items-center justify-center">
<div className="bg-white border border-gray-300 rounded-2xl md:p-12 p-8 w-full m-[10px] md:max-w-md shadow-sm">
{/* Header */}
<main className="h-full bg-primary flex items-center justify-center">
<div className="bg-primary border border-secondary rounded-2xl md:p-12 p-8 w-full m-[10px] md:max-w-md">
<div className="mb-8">
<p className="text-[11px] tracking-widest text-blue-500 uppercase mb-2">
<p className="text-[11px] tracking-widest text-blue uppercase mb-2">
dell-xps-nixos-serv
</p>
<h1 className="text-[28px] font-normal text-gray-900 tracking-tight mb-1.5">
<h1 className="text-[28px] font-normal text-foreground tracking-tight mb-1.5" style={{ lineHeight: "normal" }}>
Login
</h1>
<p className="text-sm text-gray-400">Enter system credentials</p>
<p className="text-sm text-foreground-sec" style={{ fontSize: "14px", lineHeight: "normal" }}>Enter system credentials</p>
</div>
<form onSubmit={handleLogin}>
{/* Username */}
<div className="mb-4">
<label className="block text-[11px] tracking-wider text-gray-400 uppercase mb-1.5">
<label className="block text-[11px] tracking-wider text-foreground-sec uppercase mb-1.5">
Username
</label>
<input
@ -166,13 +161,12 @@ export default function AuthPage() {
required
disabled={busy}
autoComplete="username"
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors"
className="w-full px-3.5 py-2.5 border border-secondary rounded-xl text-sm text-foreground bg-secondary/50 outline-none focus:border-blue/50 transition-colors"
/>
</div>
{/* Password */}
<div className="mb-6">
<label className="block text-[11px] tracking-wider text-gray-400 uppercase mb-1.5">
<label className="block text-[11px] tracking-wider text-foreground-sec uppercase mb-1.5">
Password
</label>
<input
@ -182,30 +176,27 @@ export default function AuthPage() {
required
disabled={busy}
autoComplete="current-password"
className="w-full px-3.5 py-2.5 border border-gray-200 rounded-xl text-sm text-gray-900 bg-gray-50 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-50 transition-colors"
className="w-full px-3.5 py-2.5 border border-secondary rounded-xl text-sm text-foreground bg-secondary/50 outline-none focus:border-blue/50 transition-colors"
/>
</div>
{/* YubiKey cue */}
{status === "waiting_yubikey" && (
<p className="text-sm text-blue-500 mb-4 text-center animate-pulse">
<p className="text-sm text-blue mb-4 text-center animate-pulse" style={{ fontSize: "14px" }}>
Touch your YubiKey
</p>
)}
{/* Error */}
{error && (
<p className="text-[13px] text-red-400 mb-4">{error}</p>
)}
{/* Submit */}
<button
type="submit"
disabled={busy}
className={`w-full py-2.5 rounded-xl text-md border border-blue-600 shadow-sm text-white font-[600] tracking-wide transition-colors ${
className={`w-full py-2.5 rounded-xl text-md border border-blue/30 text-white font-[600] tracking-wide transition-colors ${
busy
? "bg-blue-200 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-400 cursor-pointer"
? "bg-blue/40 cursor-not-allowed"
: "bg-blue hover:bg-blue/80 cursor-pointer"
}`}
>
{status === "idle" && "Sign in"}

View file

@ -32,12 +32,12 @@ function tryPretty(text: string): string {
function methodBg(method: string): string {
switch (method) {
case "GET": return "#2563eb";
case "POST": return "#16a34a";
case "PUT": return "#d97706";
case "GET": return "#428ce2";
case "POST": return "#5dd776";
case "PUT": return "#f59e0b";
case "PATCH": return "#7c3aed";
case "DELETE": return "#dc2626";
default: return "#6b7280";
case "DELETE": return "#ef4444";
default: return "#7b899a";
}
}
@ -124,8 +124,8 @@ export default function DevConsole({
zIndex: 50,
display: "flex",
flexDirection: "column",
background: "#ffffff",
borderLeft: "1px solid #e5e7eb",
background: "var(--color-primary)",
borderLeft: "1px solid var(--color-secondary)",
boxShadow: "-4px 0 24px rgba(0,0,0,0.06)",
transform: open ? "translateX(0)" : "translateX(100%)",
transition: "transform 280ms cubic-bezier(0.4,0,0.2,1)",
@ -158,17 +158,17 @@ export default function DevConsole({
alignItems: "center",
gap: "8px",
padding: "10px 14px",
borderBottom: "1px solid #e5e7eb",
borderBottom: "1px solid var(--color-secondary)",
flexShrink: 0,
background: "#ffffff",
background: "var(--color-primary)",
}}>
<LuTerminal size={13} style={{ color: "#9ca3af" }} />
<LuTerminal size={13} style={{ color: "var(--color-foreground-sec)" }} />
<span style={{
fontSize: "0.65rem",
fontWeight: 700,
letterSpacing: "0.1em",
textTransform: "uppercase",
color: "#9ca3af",
color: "var(--color-foreground-sec)",
}}>
Dev Console
</span>
@ -185,8 +185,8 @@ export default function DevConsole({
borderRadius: "6px",
border: "none",
cursor: "pointer",
background: activeTab === tab ? "#f3f4f6" : "transparent",
color: activeTab === tab ? "#111827" : "#9ca3af",
background: activeTab === tab ? "var(--color-secondary)" : "transparent",
color: activeTab === tab ? "var(--color-foreground)" : "var(--color-foreground-sec)",
textTransform: "capitalize",
}}
>
@ -200,7 +200,7 @@ export default function DevConsole({
background: "transparent",
border: "none",
cursor: "pointer",
color: "#9ca3af",
color: "var(--color-foreground-sec)",
display: "flex",
alignItems: "center",
padding: "2px",
@ -213,9 +213,9 @@ export default function DevConsole({
{/* Logs tab */}
{activeTab === "logs" && (
<div style={{ flex: 1, overflowY: "auto", fontFamily: "ui-monospace, 'Cascadia Code', monospace" }}>
<div style={{ flex: 1, overflowY: "auto", fontFamily: "inherit" }}>
{logs.length === 0 ? (
<div style={{ padding: "32px 16px", textAlign: "center", color: "#d1d5db", fontSize: "0.75rem" }}>
<div style={{ padding: "32px 16px", textAlign: "center", color: "var(--color-foreground-sec)", fontSize: "0.75rem" }}>
No requests captured yet
</div>
) : (
@ -234,20 +234,20 @@ export default function DevConsole({
{/* Request tab */}
{activeTab === "request" && (
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
<div style={{ padding: "12px", borderBottom: "1px solid #e5e7eb", flexShrink: 0 }}>
<div style={{ padding: "12px", borderBottom: "1px solid var(--color-secondary)", flexShrink: 0 }}>
<div style={{ display: "flex", gap: "6px", marginBottom: "8px" }}>
<select
value={reqMethod}
onChange={(e) => setReqMethod(e.target.value)}
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
background: "var(--color-secondary)",
border: "1px solid var(--color-secondary)",
borderRadius: "8px",
color: "#111827",
color: "var(--color-foreground)",
fontSize: "0.68rem",
padding: "5px 6px",
cursor: "pointer",
fontFamily: "ui-monospace, monospace",
fontFamily: "inherit",
outline: "none",
}}
>
@ -262,13 +262,13 @@ export default function DevConsole({
placeholder="/api/power"
style={{
flex: 1,
background: "#f9fafb",
border: "1px solid #e5e7eb",
background: "var(--color-secondary)",
border: "1px solid var(--color-secondary)",
borderRadius: "8px",
color: "#111827",
color: "var(--color-foreground)",
fontSize: "0.68rem",
padding: "5px 8px",
fontFamily: "ui-monospace, monospace",
fontFamily: "inherit",
outline: "none",
minWidth: 0,
}}
@ -277,7 +277,7 @@ export default function DevConsole({
onClick={sendRequest}
disabled={reqLoading}
style={{
background: "#2563eb",
background: "#428ce2",
border: "none",
borderRadius: "8px",
color: "white",
@ -305,13 +305,13 @@ export default function DevConsole({
rows={4}
style={{
width: "100%",
background: "#f9fafb",
border: "1px solid #e5e7eb",
background: "var(--color-secondary)",
border: "1px solid var(--color-secondary)",
borderRadius: "8px",
color: "#111827",
color: "var(--color-foreground)",
fontSize: "0.68rem",
padding: "6px 8px",
fontFamily: "ui-monospace, monospace",
fontFamily: "inherit",
resize: "vertical",
outline: "none",
boxSizing: "border-box",
@ -320,11 +320,11 @@ export default function DevConsole({
/>
)}
<div style={{ fontSize: "0.6rem", color: "#d1d5db" }}>
<div style={{ fontSize: "0.6rem", color: "var(--color-foreground-sec)" }}>
Bearer token attached via cookie ·{" "}
<span style={{ color: "#9ca3af" }}>use</span>{" "}
<code style={{ color: "#6b7280" }}>http://localhost:3001/path</code>{" "}
<span style={{ color: "#9ca3af" }}>to bypass Next.js proxy</span>
<span style={{ color: "var(--color-foreground-sec)" }}>use</span>{" "}
<code style={{ color: "var(--color-blue, #428ce2)" }}>http://localhost:3001/path</code>{" "}
<span style={{ color: "var(--color-foreground-sec)" }}>to bypass Next.js proxy</span>
</div>
</div>
@ -332,11 +332,11 @@ export default function DevConsole({
flex: 1,
overflow: "auto",
padding: "12px",
fontFamily: "ui-monospace, 'Cascadia Code', monospace",
background: "#f9fafb",
fontFamily: "inherit",
background: "var(--color-secondary)",
}}>
{reqLoading && (
<div style={{ fontSize: "0.7rem", color: "#9ca3af" }}>Sending</div>
<div style={{ fontSize: "0.7rem", color: "var(--color-foreground-sec)" }}>Sending</div>
)}
{!reqLoading && reqResponse && (
<>
@ -344,13 +344,13 @@ export default function DevConsole({
fontSize: "0.65rem",
fontWeight: 600,
marginBottom: "8px",
color: reqResponse.status >= 200 && reqResponse.status < 300 ? "#16a34a" : "#dc2626",
color: reqResponse.status >= 200 && reqResponse.status < 300 ? "#5dd776" : "#ef4444",
}}>
HTTP {reqResponse.status}
</div>
<pre style={{
fontSize: "0.65rem",
color: "#6b7280",
color: "var(--color-foreground-sec)",
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
@ -361,7 +361,7 @@ export default function DevConsole({
</>
)}
{!reqLoading && !reqResponse && (
<div style={{ fontSize: "0.7rem", color: "#d1d5db" }}>
<div style={{ fontSize: "0.7rem", color: "var(--color-foreground-sec)" }}>
Response will appear here
</div>
)}
@ -384,13 +384,13 @@ function LogRow({
const [hovered, setHovered] = useState(false);
const statusColor =
entry.status === null
? "#9ca3af"
? "var(--color-foreground-sec)"
: entry.status >= 200 && entry.status < 300
? "#16a34a"
: "#dc2626";
? "#5dd776"
: "#ef4444";
return (
<div style={{ borderBottom: "1px solid #f3f4f6" }}>
<div style={{ borderBottom: "1px solid var(--color-secondary)" }}>
<div
onClick={onToggle}
onMouseEnter={() => setHovered(true)}
@ -401,7 +401,7 @@ function LogRow({
gap: "8px",
padding: "6px 12px",
cursor: "pointer",
background: hovered ? "#f9fafb" : "transparent",
background: hovered ? "var(--color-secondary)" : "transparent",
}}
>
<span style={{
@ -430,7 +430,7 @@ function LogRow({
</span>
<span style={{
fontSize: "0.68rem",
color: "#6b7280",
color: "var(--color-foreground-sec)",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
@ -438,23 +438,23 @@ function LogRow({
}}>
{entry.path}
</span>
<span style={{ fontSize: "0.6rem", color: "#9ca3af", flexShrink: 0 }}>
<span style={{ fontSize: "0.6rem", color: "var(--color-foreground-sec)", flexShrink: 0 }}>
{entry.duration !== null ? `${entry.duration}ms` : ""}
</span>
<span style={{ fontSize: "0.6rem", color: "#d1d5db", flexShrink: 0 }}>
<span style={{ fontSize: "0.6rem", color: "var(--color-foreground-sec)", flexShrink: 0, opacity: 0.6 }}>
{entry.timestamp}
</span>
</div>
{expanded && (
<div style={{
background: "#f9fafb",
background: "var(--color-secondary)",
padding: "8px 12px 10px",
borderTop: "1px solid #e5e7eb",
borderTop: "1px solid var(--color-secondary)",
}}>
<div style={{
fontSize: "0.6rem",
color: "#9ca3af",
color: "var(--color-foreground-sec)",
marginBottom: "6px",
wordBreak: "break-all",
}}>
@ -463,7 +463,7 @@ function LogRow({
{entry.response !== null && (
<pre style={{
fontSize: "0.63rem",
color: "#6b7280",
color: "var(--color-foreground-sec)",
margin: 0,
overflow: "auto",
maxHeight: "220px",

View file

@ -34,7 +34,7 @@ export default function Hero({ lastUpdated }: HeroProps) {
<ControlPanel onClose={() => setMenuOpen(false)} />,
document.body,
)}
<p className="text-xs font-medium tracking-widest uppercase text-blue-500 mb-3">
<p className="text-xs font-medium tracking-widest uppercase text-blue mb-3">
dell-xps-nixos-serv
</p>
<div className="flex gap-[10px] items-center w-full">
@ -57,28 +57,27 @@ export default function Hero({ lastUpdated }: HeroProps) {
/>
</svg>
<h1
className="text-4xl md:text-5xl font-normal leading-tight tracking-tight text-gray-900 mb-2"
style={{ fontFamily: "'Playfair Display', serif" }}
className="text-4xl md:text-5xl font-normal leading-tight tracking-tight text-foreground mb-2"
>
Home server
</h1>
{authed ? (
<div
onClick={() => setMenuOpen(true)}
className="align-self-end ml-auto px-[20px] py-[20px] rounded-2xl hover:bg-gray-200 cursor-pointer duration-[200ms] text-gray-600 hover:shadow-sm font-[600]"
className="align-self-end ml-auto px-[20px] py-[20px] rounded-2xl hover:bg-secondary cursor-pointer duration-[200ms] text-foreground-sec font-[600]"
>
<LuSettings2 />
</div>
) : (
<div
onClick={() => router.push("/auth")}
className="align-self-end ml-auto px-[18px] py-[5px] rounded-xl hover:bg-blue-500 shadow-sm bg-white border-blue-300 hover:border-blue-400 border cursor-pointer duration-[200ms] text-blue-400 hover:shadow-sm hover:text-blue-100 text-[11pt] font-[600]"
className="align-self-end ml-auto px-[18px] py-[5px] rounded-xl hover:bg-blue shadow-sm bg-primary border-blue/30 hover:border-blue border cursor-pointer duration-[200ms] text-blue hover:text-primary text-[11pt] font-[600]"
>
Authenticate
</div>
)}
</div>
<p className="text-sm text-gray-400 font-light">
<p className="text-sm text-foreground-sec font-light">
{lastUpdated
? `Last updated ${new Date(lastUpdated).toLocaleTimeString()}`
: "Fetching system stats..."}

View file

@ -11,15 +11,15 @@ export default function LinkCard({ link, delay = 0 }: LinkCardProps) {
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm flex flex-col gap-2 hover:shadow-md hover:-translate-y-1 hover:border-blue-200 transition-all duration-200 animate-fade-up no-underline text-inherit"
className="bg-primary border border-secondary rounded-2xl p-6 flex flex-col gap-2 hover:-translate-y-1 hover:border-blue/30 transition-all duration-200 animate-fade-up no-underline text-inherit"
style={{ animationDelay: `${delay}ms` }}
>
<div className="w-11 h-11 bg-blue-50 rounded-xl flex items-center justify-center text-2xl mb-1">
<div className="w-11 h-11 bg-blue/10 rounded-xl flex items-center justify-center text-2xl mb-1">
{link.icon}
</div>
<span className="text-base font-medium text-gray-900">{link.name}</span>
<span className="text-sm text-gray-400 font-light">{link.description}</span>
<span className="mt-auto pt-2 text-sm font-medium text-blue-500">
<span className="text-base font-medium text-foreground">{link.name}</span>
<span className="text-sm text-foreground-sec font-light">{link.description}</span>
<span className="mt-auto pt-2 text-sm font-medium text-blue">
Open app
</span>
</a>

View file

@ -5,7 +5,7 @@ export default function LinksGrid() {
return (
<div>
<div className="flex items-baseline justify-between mb-5">
<h2 className="text-lg font-medium tracking-tight text-gray-900">
<h2 className="text-lg font-medium tracking-tight text-foreground" style={{ lineHeight: "normal", marginTop: 0 }}>
Services &amp; Apps
</h2>
</div>

View file

@ -1,72 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { LuCode } from "react-icons/lu";
interface NavBarProps {
online: boolean;
devConsoleOpen: boolean;
onToggleDevConsole: () => void;
}
export default function NavBar({ online, devConsoleOpen, onToggleDevConsole }: NavBarProps) {
const router = useRouter();
const [auth, setAuth] = useState(false);
useEffect(() => {
fetch("/api/auth/check")
.then((r) => setAuth(r.ok))
.catch(() => setAuth(false));
}, []);
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/auth");
}
return (
<nav className="flex items-center justify-between pt-7 pb-6 mb-13 border-b border-gray-200">
<div className="flex items-center gap-2">
{auth && (
<button
onClick={handleLogout}
className="flex items-center gap-2 text-xs text-red-700 bg-red-50 border border-red-200 px-3 py-1.5 rounded-full cursor-pointer"
>
Log out
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onToggleDevConsole}
className={`flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full border cursor-pointer transition-colors ${
devConsoleOpen
? "text-blue-700 bg-blue-50 border-blue-200"
: "text-gray-500 bg-gray-50 border-gray-200 hover:border-gray-300 hover:text-gray-700"
}`}
>
<LuCode size={12} />
Dev
</button>
<div
className={`flex items-center gap-2 text-xs font-medium px-3 py-1.5 rounded-full border ${
online
? "text-green-700 bg-green-50 border-green-200"
: "text-gray-500 bg-gray-50 border-gray-200"
}`}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{
background: online ? "#22c55e" : "#d1d5db",
animation: online ? "pulse-dot 2s infinite" : "none",
}}
/>
{online ? "Online" : "Connecting..."}
</div>
</div>
</nav>
);
}

View file

@ -9,31 +9,31 @@ interface NetworkCardProps {
export default function NetworkCard({ iface, speed, delay = 0 }: NetworkCardProps) {
return (
<div
className="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm animate-fade-up"
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-gray-400 mb-4">
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec mb-4">
Network
</p>
{iface && speed ? (
<>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400 mb-3">
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec mb-3">
{iface}
</p>
<div className="flex gap-6">
<div className="flex flex-col gap-0.5">
<span className="text-lg font-medium text-blue-500">
<span className="text-lg font-medium text-blue">
{formatBytes(speed.rx)}/s
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400">
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
Download
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-lg font-medium text-violet-500">
<span className="text-lg font-medium text-blue/70">
{formatBytes(speed.tx)}/s
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400">
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
Upload
</span>
</div>

View file

@ -13,7 +13,7 @@ interface PowerCardProps {
function powerColor(watts: number): string {
if (watts > 400) return "#ef4444";
if (watts > 200) return "#f59e0b";
return "#3b82f6";
return "#428ce2";
}
export default function PowerCard({ device, label, delay = 0, toggling = false, onToggle }: PowerCardProps) {
@ -23,23 +23,23 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
return (
<div
className="bg-white border border-gray-200 rounded-2xl p-5 flex flex-col shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 animate-fade-up"
className="bg-primary border border-secondary rounded-2xl p-5 flex flex-col hover:-translate-y-0.5 transition-all duration-200 animate-fade-up"
style={{ animationDelay: `${delay}ms` }}
>
<div className="flex items-center justify-between mb-3">
<span className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400">
<span className="text-[0.68rem] font-medium tracking-widest uppercase 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 ${
device.on ? "text-emerald-500" : "text-gray-400"
device.on ? "text-green" : "text-foreground-sec"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
device.on ? "bg-emerald-400" : "bg-gray-300"
device.on ? "bg-green" : "bg-foreground-sec/40"
}`}
/>
{device.on ? "On" : "Off"}
@ -50,8 +50,8 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
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 ${
device.on
? "border-red-200 text-red-400 hover:bg-red-50"
: "border-emerald-200 text-emerald-500 hover:bg-emerald-50"
? "border-red-500/20 text-red-400 hover:bg-red-500/10"
: "border-green/20 text-green hover:bg-green/10"
}`}
>
{toggling ? "···" : device.on ? "Turn off" : "Turn on"}
@ -64,16 +64,16 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
{device ? (
<>
<div className="flex items-baseline gap-1.5 mt-0.5">
<span className="text-3xl font-medium tracking-tight text-gray-900 leading-none">
<span className="text-3xl font-medium tracking-tight text-foreground leading-none">
{device.current_power_w.toFixed(1)}
</span>
<span className="text-base text-gray-400 font-medium">W</span>
<span className="text-base text-foreground-sec font-medium">W</span>
</div>
<span className="text-[0.7rem] text-gray-400 mt-1 truncate">
<span className="text-[0.7rem] text-foreground-sec mt-1 truncate">
{device.alias} · {device.model}
</span>
<div className="h-[3px] bg-gray-100 rounded-full mt-4 overflow-hidden">
<div className="h-[3px] bg-secondary rounded-full mt-4 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-700"
style={{
@ -83,30 +83,30 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
/>
</div>
<div className="grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-gray-100">
<div className="grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-secondary">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-gray-900">
<span className="text-sm font-medium text-foreground">
{(device.today_energy_wh / 1000).toFixed(3)}
<span className="text-gray-400 text-xs ml-0.5">kWh</span>
<span className="text-foreground-sec text-xs ml-0.5">kWh</span>
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400">
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
Today
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-gray-900">
<span className="text-sm font-medium text-foreground">
{(device.month_energy_wh / 1000).toFixed(2)}
<span className="text-gray-400 text-xs ml-0.5">kWh</span>
<span className="text-foreground-sec text-xs ml-0.5">kWh</span>
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400">
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
Month
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-gray-900">
<span className="text-sm font-medium text-foreground">
{runtimeHours}h {runtimeMins}m
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400">
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
Runtime
</span>
</div>

View file

@ -9,19 +9,19 @@ export default function ServicePill({ name, status }: ServicePillProps) {
<div
className={`flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border text-sm ${
active
? "bg-green-50 border-green-200"
: "bg-gray-50 border-gray-200 text-gray-400"
? "bg-green/10 border-green/20"
: "bg-secondary/30 border-secondary text-foreground-sec"
}`}
>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
active ? "bg-green-500" : "bg-gray-300"
active ? "bg-green" : "bg-foreground-sec/40"
}`}
/>
<span className="flex-1 font-normal">{name}</span>
<span
className={`text-[0.65rem] uppercase tracking-widest font-medium ${
active ? "text-green-600" : "text-gray-400"
active ? "text-green" : "text-foreground-sec"
}`}
>
{status}

View file

@ -8,10 +8,10 @@ interface ServicesCardProps {
export default function ServicesCard({ services, delay = 0 }: ServicesCardProps) {
return (
<div
className="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm animate-fade-up"
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-gray-400 mb-4">
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec mb-4">
Services
</p>
<div className="flex flex-col gap-2">

299
app/components/SideNav.tsx Normal file
View file

@ -0,0 +1,299 @@
"use client";
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,
} from "@tabler/icons-react";
import { useSetTheme } from "@/stores/useThemeStore";
const LINKS = [
{ href: "/", label: "Dashboard", icon: IconHome2 },
{ href: "/auth", label: "Auth", icon: IconKey },
];
const COLLAPSED_W = 52;
interface SideNavProps {
online: boolean;
devConsoleOpen: boolean;
onToggleDevConsole: () => void;
}
const SideNav = ({ online, devConsoleOpen, onToggleDevConsole }: SideNavProps) => {
const pathname = usePathname();
const router = useRouter();
const setTheme = useSetTheme();
const [collapsed, setCollapsed] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(168);
const [menuOpen, setMenuOpen] = useState(false);
const [auth, setAuth] = useState(false);
const isDragging = useRef(false);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMenuOpen(false);
}, [pathname]);
useEffect(() => {
fetch("/api/auth/check")
.then((r) => setAuth(r.ok))
.catch(() => setAuth(false));
}, []);
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)));
};
const onUp = () => { isDragging.current = false; };
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
return () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
}, []);
async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/auth");
}
return (
<>
{/* Desktop sidebar + drag handle */}
<div ref={wrapperRef} className="hidden lg:flex flex-row shrink-0 select-none">
<div
style={{ width: collapsed ? COLLAPSED_W : sidebarWidth }}
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"}>
<Link href="/">
<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));
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>
);
})}
</nav>
{/* 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",
}}
/>
{!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>
{/* 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")
}
>
<IconLogout size={16} className="shrink-0" />
{!collapsed && "Log out"}
</button>
</div>
)}
{/* 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")
}
>
<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>
</>
)}
</button>
</div>
{/* Divider + collapse toggle */}
<div className="mx-[8px] my-[8px] border-t border-secondary shrink-0" />
<div className="px-[8px] shrink-0">
<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")
}
>
{collapsed
? <IconChevronsRight size={16} className="shrink-0" />
: <IconChevronsLeft size={16} className="shrink-0" />}
{!collapsed && "Collapse"}
</button>
</div>
</div>
{/* 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 className="w-[3px] h-[40px] rounded-full bg-blue/20 transition-colors" />
</div>
)}
</div>
{/* Mobile header */}
<div className="lg:hidden fixed top-0 left-0 right-0 z-[998] h-[52px] bg-primary border-b border-secondary flex items-center px-[16px]">
<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"
>
{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="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"
>
<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"
>
<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>
<span className="hidden dark-theme:block">Light mode</span>
</button>
</div>
</div>
)}
</>
);
};
export default SideNav;

View file

@ -14,20 +14,20 @@ export default function StatCard({ label, value, sub, percent, delay = 0 }: Stat
return (
<div
className="bg-white border border-gray-200 rounded-2xl p-5 flex flex-col gap-1 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 animate-fade-up"
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-gray-400">
<span className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec">
{label}
</span>
<span className="text-3xl font-medium tracking-tight text-gray-900 leading-none mt-1">
<span className="text-3xl font-medium tracking-tight text-foreground leading-none mt-1">
{value}
</span>
{sub && (
<span className="text-[0.7rem] text-gray-400 mt-0.5 truncate">{sub}</span>
<span className="text-[0.7rem] text-foreground-sec mt-0.5 truncate">{sub}</span>
)}
{percent !== undefined && (
<div className="h-[3px] bg-gray-100 rounded-full mt-3 overflow-hidden">
<div className="h-[3px] bg-secondary rounded-full mt-3 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-700"
style={{ width: `${pct}%`, background: color }}

View file

@ -9,10 +9,10 @@ interface UptimeCardProps {
export default function UptimeCard({ uptime, delay = 0 }: UptimeCardProps) {
return (
<div
className="bg-white border border-gray-200 rounded-2xl p-6 shadow-sm animate-fade-up"
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-gray-400 mb-4">
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-foreground-sec mb-4">
Uptime
</p>
{uptime ? (
@ -25,13 +25,13 @@ export default function UptimeCard({ uptime, delay = 0 }: UptimeCardProps) {
<div
key={unit}
className={`flex flex-col items-center flex-1 ${
i < 2 ? "border-r border-gray-200" : ""
i < 2 ? "border-r border-secondary" : ""
}`}
>
<span className="text-3xl font-medium tracking-tight text-gray-900 leading-none">
<span className="text-3xl font-medium tracking-tight text-foreground leading-none">
{pad(val)}
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400 mt-1.5">
<span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec mt-1.5">
{unit}
</span>
</div>

View file

@ -1,68 +1,153 @@
@import "tailwindcss";
@custom-variant dark-theme (&:where(.dark-theme, .dark-theme *));
:root {
--background: #f9fafb;
--foreground: #111827;
--font-dm-sans: "DM Sans", sans-serif;
--font-playfair: "Playfair Display", serif;
@theme {
--color-primary: #f0f5fc;
--color-secondary: #d9dfe5;
--color-blue: #428ce2;
--color-green: #5dd776;
--color-foreground: #364f6b;
--color-foreground-sec: #7b899a;
--shadow-secondary-center: 0px 0px 10px 9px var(--color-secondary);
--shadow-bluexlrr-sm: 3px 3px 0px var(--color-blue);
--animate-slideInDown: slideInDown 0.1s ease-in-out 1;
--animate-page: page 1s ease-in-out 1;
--animate-fade-up: fadeUp 0.45s ease both;
@keyframes slideInDown {
0% {
transform: translateY(-10px);
opacity: 0;
}
100% {
transform: translateY(0px);
opacity: 1;
}
}
@keyframes page {
0% {
transform: translateY(-20px);
opacity: 0;
}
100% {
transform: translateY(0px);
opacity: 1;
}
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
}
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
/* Link Tailwind to the Next.js Font Variables */
--font-sans: var(--font-dm-sans), ui-sans-serif, system-ui;
--font-serif: var(--font-playfair), ui-serif, Georgia;
.dark-theme {
--color-primary: #20232c;
--color-secondary: #232a32;
--color-foreground: #a1aebd;
--color-foreground-sec: #7a9ab2;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
@layer base {
* {
-webkit-tap-highlight-color: transparent;
-webkit-overflow-scrolling: touch;
}
/* --- Animations --- */
html,
body {
height: 100%;
}
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
h1 {
font-size: 26px;
font-weight: 500;
}
@keyframes shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
h2 {
font-size: 23px;
font-weight: 600;
line-height: 250%;
margin-top: 10px;
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.25;
}
h3 {
font-size: 19px;
font-weight: 500;
}
h4 {
letter-spacing: 4px;
font-weight: 600;
}
p {
font-size: 16px;
font-weight: 500;
line-height: 160%;
}
@media (min-width: 1000px) {
h1 {
font-size: 36px;
font-weight: 500;
}
h3 {
font-size: 22px;
line-height: 40px;
font-weight: 500;
}
p {
font-size: 18px;
font-weight: 500;
line-height: 160%;
}
}
}
.animate-fade-up {
animation: fadeUp 0.45s ease both;
animation: fadeUp 0.45s ease both;
}
.skeleton {
background: linear-gradient(90deg, #f3f4f6 25%, #e9eaec 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
border-radius: 8px;
background: linear-gradient(
90deg,
var(--color-secondary) 25%,
var(--color-primary) 50%,
var(--color-secondary) 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
border-radius: 8px;
}

View file

@ -1,18 +1,23 @@
import type { Metadata } from "next";
import { DM_Sans, Playfair_Display } from "next/font/google"; // Import your specific fonts
import type { Metadata, Viewport } from "next";
import { JetBrains_Mono } from "next/font/google";
import "./globals.css";
const dmSans = DM_Sans({
variable: "--font-dm-sans",
const jetbrains = JetBrains_Mono({
subsets: ["latin"],
display: "swap",
variable: "--font-jetbrains",
});
const playfair = Playfair_Display({
variable: "--font-playfair",
subsets: ["latin"],
display: "swap",
});
const noFlashScript = `(function(){try{var raw=localStorage.getItem("theme");var v="light";if(raw==="dark"||raw==="light"){v=raw}else{try{v=JSON.parse(raw).state.theme}catch(e){}}if(v==="dark"){document.documentElement.classList.add("dark-theme")}}catch(e){}})();`;
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f0f5fc" },
{ media: "(prefers-color-scheme: dark)", color: "#20232c" },
],
};
export const metadata: Metadata = {
title: "Server Dashboard",
@ -27,11 +32,15 @@ export default function RootLayout({
return (
<html
lang="en"
className={`${dmSans.variable} ${playfair.variable} h-full antialiased`}
className={jetbrains.variable}
suppressHydrationWarning
>
<body className="min-h-full h-full flex flex-col">
<head>
<script dangerouslySetInnerHTML={{ __html: noFlashScript }} />
</head>
<body className={jetbrains.className + " bg-[#ffffff] dark-theme:bg-[#0F1318] overflow-hidden"}>
{children}
<span className="fixed bottom-3 right-4 text-[10px] text-gray-300 select-none pointer-events-none">
<span className="fixed bottom-3 right-4 text-[10px] text-foreground-sec/40 select-none pointer-events-none">
v0.1.0
</span>
</body>

View file

@ -3,7 +3,7 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { getStats, type Stats, type NetworkInterface } from "./lib/getStats";
import NavBar from "./components/NavBar";
import SideNav from "./components/SideNav";
import Hero from "./components/Hero";
import StatsGrid from "./components/StatsGrid";
import ServicesCard from "./components/ServicesCard";
@ -35,7 +35,6 @@ export default function Home() {
const lastFetchRef = useRef<number>(0);
const logIdRef = useRef(0);
// Mobile detection
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768);
check();
@ -43,7 +42,6 @@ export default function Home() {
return () => window.removeEventListener("resize", check);
}, []);
// Fetch interceptor — captures all /api/* requests
useEffect(() => {
const original = window.fetch;
@ -168,23 +166,24 @@ export default function Home() {
const primarySpeed = primaryIface ? netSpeed[primaryIface] || null : null;
return (
<>
<div className="w-full h-full bg-primary text-foreground overflow-hidden flex flex-row">
<SideNav
online={!!stats}
devConsoleOpen={panelOpen}
onToggleDevConsole={() => setPanelOpen((o) => !o)}
/>
<div
className="flex-1 overflow-y-auto pt-[52px] lg:pt-0 lg:m-[10px_10px_10px_0px] lg:rounded-2xl lg:border lg:border-blue/20 min-w-0"
style={{
paddingRight: panelOpen && !isMobile ? panelWidth : 0,
transition: "padding-right 280ms cubic-bezier(0.4,0,0.2,1)",
}}
>
<div className="max-w-5xl mx-auto px-6 pb-20">
<NavBar
online={!!stats}
devConsoleOpen={panelOpen}
onToggleDevConsole={() => setPanelOpen((o) => !o)}
/>
<div className="max-w-5xl mx-auto px-6 pb-20 pt-8">
<Hero lastUpdated={stats?.timestamp ?? null} />
<div className="flex items-baseline justify-between mb-5">
<h2 className="text-lg font-medium tracking-tight text-gray-900">
<h2 className="text-lg font-medium tracking-tight text-foreground" style={{ lineHeight: "normal", marginTop: 0 }}>
System Stats
</h2>
</div>
@ -204,7 +203,7 @@ export default function Home() {
</div>
<div className="flex items-baseline justify-between mb-5">
<h2 className="text-lg font-medium tracking-tight text-gray-900">
<h2 className="text-lg font-medium tracking-tight text-foreground" style={{ lineHeight: "normal", marginTop: 0 }}>
Power Consumption
</h2>
</div>
@ -223,6 +222,6 @@ export default function Home() {
onWidthChange={setPanelWidth}
logs={logs}
/>
</>
</div>
);
}