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"); setStatus("checking");
try { try {
// Step 1: verify password → get WebAuthn challenge
const loginRes = await fetch("/api/auth/login", { const loginRes = await fetch("/api/auth/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -65,7 +64,6 @@ export default function AuthPage() {
const { session_id, challenge } = await loginRes.json(); const { session_id, challenge } = await loginRes.json();
// Step 2: prompt YubiKey tap
setStatus("waiting_yubikey"); setStatus("waiting_yubikey");
const opts = challenge.publicKey; const opts = challenge.publicKey;
opts.challenge = b64uToBuf(opts.challenge); opts.challenge = b64uToBuf(opts.challenge);
@ -94,7 +92,6 @@ export default function AuthPage() {
return; return;
} }
// Step 3: verify assertion → set cookie
setStatus("verifying"); setStatus("verifying");
const assertion = cred.response as AuthenticatorAssertionResponse; const assertion = cred.response as AuthenticatorAssertionResponse;
@ -140,23 +137,21 @@ export default function AuthPage() {
const busy = status !== "idle"; const busy = status !== "idle";
return ( return (
<main className="h-full bg-gray-100 flex items-center justify-center"> <main className="h-full bg-primary 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"> <div className="bg-primary border border-secondary rounded-2xl md:p-12 p-8 w-full m-[10px] md:max-w-md">
{/* Header */}
<div className="mb-8"> <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 dell-xps-nixos-serv
</p> </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 Login
</h1> </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> </div>
<form onSubmit={handleLogin}> <form onSubmit={handleLogin}>
{/* Username */}
<div className="mb-4"> <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 Username
</label> </label>
<input <input
@ -166,13 +161,12 @@ export default function AuthPage() {
required required
disabled={busy} disabled={busy}
autoComplete="username" 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> </div>
{/* Password */}
<div className="mb-6"> <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 Password
</label> </label>
<input <input
@ -182,30 +176,27 @@ export default function AuthPage() {
required required
disabled={busy} disabled={busy}
autoComplete="current-password" 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> </div>
{/* YubiKey cue */}
{status === "waiting_yubikey" && ( {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 Touch your YubiKey
</p> </p>
)} )}
{/* Error */}
{error && ( {error && (
<p className="text-[13px] text-red-400 mb-4">{error}</p> <p className="text-[13px] text-red-400 mb-4">{error}</p>
)} )}
{/* Submit */}
<button <button
type="submit" type="submit"
disabled={busy} 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 busy
? "bg-blue-200 cursor-not-allowed" ? "bg-blue/40 cursor-not-allowed"
: "bg-blue-500 hover:bg-blue-400 cursor-pointer" : "bg-blue hover:bg-blue/80 cursor-pointer"
}`} }`}
> >
{status === "idle" && "Sign in"} {status === "idle" && "Sign in"}

View file

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

View file

@ -34,7 +34,7 @@ export default function Hero({ lastUpdated }: HeroProps) {
<ControlPanel onClose={() => setMenuOpen(false)} />, <ControlPanel onClose={() => setMenuOpen(false)} />,
document.body, 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 dell-xps-nixos-serv
</p> </p>
<div className="flex gap-[10px] items-center w-full"> <div className="flex gap-[10px] items-center w-full">
@ -57,28 +57,27 @@ export default function Hero({ lastUpdated }: HeroProps) {
/> />
</svg> </svg>
<h1 <h1
className="text-4xl md:text-5xl font-normal leading-tight tracking-tight text-gray-900 mb-2" className="text-4xl md:text-5xl font-normal leading-tight tracking-tight text-foreground mb-2"
style={{ fontFamily: "'Playfair Display', serif" }}
> >
Home server Home server
</h1> </h1>
{authed ? ( {authed ? (
<div <div
onClick={() => setMenuOpen(true)} 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 /> <LuSettings2 />
</div> </div>
) : ( ) : (
<div <div
onClick={() => router.push("/auth")} 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 Authenticate
</div> </div>
)} )}
</div> </div>
<p className="text-sm text-gray-400 font-light"> <p className="text-sm text-foreground-sec font-light">
{lastUpdated {lastUpdated
? `Last updated ${new Date(lastUpdated).toLocaleTimeString()}` ? `Last updated ${new Date(lastUpdated).toLocaleTimeString()}`
: "Fetching system stats..."} : "Fetching system stats..."}

View file

@ -11,15 +11,15 @@ export default function LinkCard({ link, delay = 0 }: LinkCardProps) {
href={link.href} href={link.href}
target="_blank" target="_blank"
rel="noopener noreferrer" 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` }} 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} {link.icon}
</div> </div>
<span className="text-base font-medium text-gray-900">{link.name}</span> <span className="text-base font-medium text-foreground">{link.name}</span>
<span className="text-sm text-gray-400 font-light">{link.description}</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-500"> <span className="mt-auto pt-2 text-sm font-medium text-blue">
Open app Open app
</span> </span>
</a> </a>

View file

@ -5,7 +5,7 @@ export default function LinksGrid() {
return ( return (
<div> <div>
<div className="flex items-baseline justify-between mb-5"> <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 Services &amp; Apps
</h2> </h2>
</div> </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) { export default function NetworkCard({ iface, speed, delay = 0 }: NetworkCardProps) {
return ( return (
<div <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` }} 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 Network
</p> </p>
{iface && speed ? ( {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} {iface}
</p> </p>
<div className="flex gap-6"> <div className="flex gap-6">
<div className="flex flex-col gap-0.5"> <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 {formatBytes(speed.rx)}/s
</span> </span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400"> <span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
Download Download
</span> </span>
</div> </div>
<div className="flex flex-col gap-0.5"> <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 {formatBytes(speed.tx)}/s
</span> </span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400"> <span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
Upload Upload
</span> </span>
</div> </div>

View file

@ -13,7 +13,7 @@ interface PowerCardProps {
function powerColor(watts: number): string { function powerColor(watts: number): string {
if (watts > 400) return "#ef4444"; if (watts > 400) return "#ef4444";
if (watts > 200) return "#f59e0b"; if (watts > 200) return "#f59e0b";
return "#3b82f6"; return "#428ce2";
} }
export default function PowerCard({ device, label, delay = 0, toggling = false, onToggle }: PowerCardProps) { 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 ( return (
<div <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` }} style={{ animationDelay: `${delay}ms` }}
> >
<div className="flex items-center justify-between mb-3"> <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} {label}
</span> </span>
{device ? ( {device ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <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.62rem] font-medium uppercase tracking-widest ${
device.on ? "text-emerald-500" : "text-gray-400" device.on ? "text-green" : "text-foreground-sec"
}`} }`}
> >
<span <span
className={`w-1.5 h-1.5 rounded-full ${ 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"} {device.on ? "On" : "Off"}
@ -50,8 +50,8 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
disabled={toggling} 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.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 device.on
? "border-red-200 text-red-400 hover:bg-red-50" ? "border-red-500/20 text-red-400 hover:bg-red-500/10"
: "border-emerald-200 text-emerald-500 hover:bg-emerald-50" : "border-green/20 text-green hover:bg-green/10"
}`} }`}
> >
{toggling ? "···" : device.on ? "Turn off" : "Turn on"} {toggling ? "···" : device.on ? "Turn off" : "Turn on"}
@ -64,16 +64,16 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
{device ? ( {device ? (
<> <>
<div className="flex items-baseline gap-1.5 mt-0.5"> <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)} {device.current_power_w.toFixed(1)}
</span> </span>
<span className="text-base text-gray-400 font-medium">W</span> <span className="text-base text-foreground-sec font-medium">W</span>
</div> </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} {device.alias} · {device.model}
</span> </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 <div
className="h-full rounded-full transition-all duration-700" className="h-full rounded-full transition-all duration-700"
style={{ style={{
@ -83,30 +83,30 @@ export default function PowerCard({ device, label, delay = 0, toggling = false,
/> />
</div> </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"> <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)} {(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>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400"> <span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
Today Today
</span> </span>
</div> </div>
<div className="flex flex-col gap-0.5"> <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)} {(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>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400"> <span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
Month Month
</span> </span>
</div> </div>
<div className="flex flex-col gap-0.5"> <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 {runtimeHours}h {runtimeMins}m
</span> </span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400"> <span className="text-[0.62rem] uppercase tracking-widest text-foreground-sec">
Runtime Runtime
</span> </span>
</div> </div>

View file

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

View file

@ -8,10 +8,10 @@ interface ServicesCardProps {
export default function ServicesCard({ services, delay = 0 }: ServicesCardProps) { export default function ServicesCard({ services, delay = 0 }: ServicesCardProps) {
return ( return (
<div <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` }} 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 Services
</p> </p>
<div className="flex flex-col gap-2"> <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 ( return (
<div <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` }} 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} {label}
</span> </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} {value}
</span> </span>
{sub && ( {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 && ( {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 <div
className="h-full rounded-full transition-all duration-700" className="h-full rounded-full transition-all duration-700"
style={{ width: `${pct}%`, background: color }} style={{ width: `${pct}%`, background: color }}

View file

@ -9,10 +9,10 @@ interface UptimeCardProps {
export default function UptimeCard({ uptime, delay = 0 }: UptimeCardProps) { export default function UptimeCard({ uptime, delay = 0 }: UptimeCardProps) {
return ( return (
<div <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` }} 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 Uptime
</p> </p>
{uptime ? ( {uptime ? (
@ -25,13 +25,13 @@ export default function UptimeCard({ uptime, delay = 0 }: UptimeCardProps) {
<div <div
key={unit} key={unit}
className={`flex flex-col items-center flex-1 ${ 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)} {pad(val)}
</span> </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} {unit}
</span> </span>
</div> </div>

View file

@ -1,68 +1,153 @@
@import "tailwindcss"; @import "tailwindcss";
@custom-variant dark-theme (&:where(.dark-theme, .dark-theme *));
:root { @theme {
--background: #f9fafb; --color-primary: #f0f5fc;
--foreground: #111827; --color-secondary: #d9dfe5;
--font-dm-sans: "DM Sans", sans-serif; --color-blue: #428ce2;
--font-playfair: "Playfair Display", serif; --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 { .dark-theme {
--color-background: var(--background); --color-primary: #20232c;
--color-foreground: var(--foreground); --color-secondary: #232a32;
--color-foreground: #a1aebd;
/* Link Tailwind to the Next.js Font Variables */ --color-foreground-sec: #7a9ab2;
--font-sans: var(--font-dm-sans), ui-sans-serif, system-ui;
--font-serif: var(--font-playfair), ui-serif, Georgia;
} }
body { @layer base {
background: var(--background); * {
color: var(--foreground); -webkit-tap-highlight-color: transparent;
font-family: var(--font-sans); -webkit-overflow-scrolling: touch;
-webkit-font-smoothing: antialiased; }
}
/* --- Animations --- */ html,
body {
height: 100%;
}
@keyframes fadeUp { h1 {
from { font-size: 26px;
opacity: 0; font-weight: 500;
transform: translateY(10px); }
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer { h2 {
from { font-size: 23px;
background-position: 200% 0; font-weight: 600;
} line-height: 250%;
to { margin-top: 10px;
background-position: -200% 0; }
}
}
@keyframes pulse-dot { h3 {
0%, font-size: 19px;
100% { font-weight: 500;
opacity: 1; }
}
50% { h4 {
opacity: 0.25; 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 { .animate-fade-up {
animation: fadeUp 0.45s ease both; animation: fadeUp 0.45s ease both;
} }
.skeleton { .skeleton {
background: linear-gradient(90deg, #f3f4f6 25%, #e9eaec 50%, #f3f4f6 75%); background: linear-gradient(
background-size: 200% 100%; 90deg,
animation: shimmer 1.4s infinite; var(--color-secondary) 25%,
border-radius: 8px; 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 type { Metadata, Viewport } from "next";
import { DM_Sans, Playfair_Display } from "next/font/google"; // Import your specific fonts import { JetBrains_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
const dmSans = DM_Sans({ const jetbrains = JetBrains_Mono({
variable: "--font-dm-sans",
subsets: ["latin"], subsets: ["latin"],
display: "swap", display: "swap",
variable: "--font-jetbrains",
}); });
const playfair = Playfair_Display({ 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){}})();`;
variable: "--font-playfair",
subsets: ["latin"], export const viewport: Viewport = {
display: "swap", width: "device-width",
}); initialScale: 1,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f0f5fc" },
{ media: "(prefers-color-scheme: dark)", color: "#20232c" },
],
};
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Server Dashboard", title: "Server Dashboard",
@ -27,11 +32,15 @@ export default function RootLayout({
return ( return (
<html <html
lang="en" 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} {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 v0.1.0
</span> </span>
</body> </body>

View file

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

27
package-lock.json generated
View file

@ -8,6 +8,7 @@
"name": "server-dash", "name": "server-dash",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tabler/icons-react": "^3.44.0",
"next": "16.2.1", "next": "16.2.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
@ -1243,6 +1244,32 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/@tabler/icons": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.44.0.tgz",
"integrity": "sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
}
},
"node_modules/@tabler/icons-react": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.44.0.tgz",
"integrity": "sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==",
"license": "MIT",
"dependencies": {
"@tabler/icons": "3.44.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/codecalm"
},
"peerDependencies": {
"react": ">= 16"
}
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",

View file

@ -7,9 +7,10 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"deploy": "npm run build && sudo rm -rf /var/lib/server-dash/build && sudo mkdir -p /var/lib/server-dash/build/.next && sudo cp -r .next/standalone/. /var/lib/server-dash/build/ && sudo cp -r .next/static /var/lib/server-dash/build/.next/static && sudo cp -r public /var/lib/server-dash/build/public && sudo cp .env /var/lib/server-dash/.env && sudo chown -R server-dash:server-dash /var/lib/server-dash && sudo systemctl restart server-dash" "deploy": "npm run build && sudo rm -rf /var/lib/server-dash/build && sudo mkdir -p /var/lib/server-dash/build/.next && sudo cp -r .next/standalone/. /var/lib/server-dash/build/ && sudo cp -r .next/static /var/lib/server-dash/build/.next/static && sudo cp -r public /var/lib/server-dash/build/public && sudo cp .env /var/lib/server-dash/.env && sudo chown -R server-dash:server-dash /var/lib/server-dash && sudo systemctl restart server-dash"
}, },
"dependencies": { "dependencies": {
"@tabler/icons-react": "^3.44.0",
"next": "16.2.1", "next": "16.2.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",

6
public/logo.svg Normal file
View file

@ -0,0 +1,6 @@
<svg width="140" height="140" viewBox="0 0 140 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M72.1388 105.056C64.7788 105.056 58.9228 103.072 54.5708 99.104C50.2828 95.072 48.1388 89.664 48.1388 82.88H62.5388C62.5388 85.824 63.4028 88.192 65.1308 89.984C66.8588 91.712 69.1948 92.576 72.1388 92.576C75.0828 92.576 77.4188 91.744 79.1468 90.08C80.8748 88.352 81.7388 86.016 81.7388 83.072V47.36H65.8988V33.92H96.1388V83.072C96.1388 89.856 93.9948 95.232 89.7068 99.2C85.4188 103.104 79.5628 105.056 72.1388 105.056Z" fill="#428CE2"/>
<path d="M12 10V130" stroke="#428CE2" stroke-width="15" stroke-linecap="round"/>
<path d="M129 70C129 82.4932 124.668 94.5999 116.743 104.257C108.817 113.915 97.788 120.525 85.5349 122.962C73.2817 125.4 60.5626 123.513 49.5446 117.624C38.5266 111.735 29.8914 102.207 25.1105 90.6649C20.3296 79.1227 19.6986 66.2799 23.3252 54.3246C26.9518 42.3694 34.6115 32.0415 44.9992 25.1006C55.3869 18.1598 67.8599 15.0355 80.2929 16.26C92.7259 17.4846 104.35 22.9822 113.184 31.8162" stroke="#428CE2" stroke-width="15" stroke-linecap="round"/>
<path d="M129 70V130" stroke="#428CE2" stroke-width="15" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

43
stores/useThemeStore.ts Normal file
View file

@ -0,0 +1,43 @@
"use client";
import { useSyncExternalStore } from "react";
const STORAGE_KEY = "theme";
function getTheme(): "light" | "dark" {
if (typeof document === "undefined") return "light";
return document.documentElement.classList.contains("dark-theme") ? "dark" : "light";
}
function getThemeSnapshot(): "light" | "dark" {
return getTheme();
}
function getServerSnapshot(): "light" | "dark" {
return "light";
}
const listeners = new Set<() => void>();
function subscribe(cb: () => void) {
listeners.add(cb);
return () => listeners.delete(cb);
}
function notifyListeners() {
listeners.forEach((cb) => cb());
}
export function useTheme(): "light" | "dark" {
return useSyncExternalStore(subscribe, getThemeSnapshot, getServerSnapshot);
}
export function useSetTheme() {
return function setTheme() {
const isDark = document.documentElement.classList.toggle("dark-theme");
try {
localStorage.setItem(STORAGE_KEY, isDark ? "dark" : "light");
} catch {}
notifyListeners();
};
}