Redesign
This commit is contained in:
parent
3f178f8795
commit
c6e6c5ca48
20 changed files with 664 additions and 277 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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..."}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 & Apps
|
||||
</h2>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
299
app/components/SideNav.tsx
Normal 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;
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
185
app/globals.css
185
app/globals.css
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
25
app/page.tsx
25
app/page.tsx
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue