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

@ -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>