Initial ui

This commit is contained in:
Jack Mechem 2026-03-25 23:33:09 -07:00
parent 526822335e
commit 4bfb87448f
18 changed files with 702 additions and 92 deletions

24
app/components/Hero.tsx Normal file
View file

@ -0,0 +1,24 @@
interface HeroProps {
lastUpdated: string | null;
}
export default function Hero({ lastUpdated }: HeroProps) {
return (
<div className="mb-11 animate-fade-up">
<p className="text-xs font-medium tracking-widest uppercase text-blue-500 mb-3">
dell-xps-nixos-serv
</p>
<h1
className="text-4xl md:text-5xl font-normal leading-tight tracking-tight text-gray-900 mb-2"
style={{ fontFamily: "'Playfair Display', serif" }}
>
Home server
</h1>
<p className="text-sm text-gray-400 font-light">
{lastUpdated
? `Last updated ${new Date(lastUpdated).toLocaleTimeString()}`
: "Fetching system stats..."}
</p>
</div>
);
}

View file

@ -0,0 +1,27 @@
import { type AppLink } from "../lib/links";
interface LinkCardProps {
link: AppLink;
delay?: number;
}
export default function LinkCard({ link, delay = 0 }: LinkCardProps) {
return (
<a
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"
style={{ animationDelay: `${delay}ms` }}
>
<div className="w-11 h-11 bg-blue-50 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">
Open app
</span>
</a>
);
}

View file

@ -0,0 +1,19 @@
import { LINKS } from "../lib/links";
import LinkCard from "./LinkCard";
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">
Services &amp; Apps
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3.5">
{LINKS.map((link, i) => (
<LinkCard key={link.name} link={link} delay={i * 60} />
))}
</div>
</div>
);
}

35
app/components/NavBar.tsx Normal file
View file

@ -0,0 +1,35 @@
interface NavBarProps {
online: boolean;
}
export default function NavBar({ online }: NavBarProps) {
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-3">
<span
className="text-lg font-medium tracking-tight text-gray-900"
style={{ fontFamily: "'Playfair Display', serif" }}
>
Jack&apos;s Servers
</span>
</div>
<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>
</nav>
);
}

View file

@ -0,0 +1,47 @@
import { formatBytes } from "../lib/utils";
interface NetworkCardProps {
iface: string | null;
speed: { rx: number; tx: number } | null;
delay?: number;
}
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"
style={{ animationDelay: `${delay}ms` }}
>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400 mb-4">
Network
</p>
{iface && speed ? (
<>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400 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">
{formatBytes(speed.rx)}/s
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400">
Download
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-lg font-medium text-violet-500">
{formatBytes(speed.tx)}/s
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400">
Upload
</span>
</div>
</div>
</>
) : (
<div className="skeleton h-11" />
)}
</div>
);
}

View file

@ -0,0 +1,31 @@
interface ServicePillProps {
name: string;
status: string;
}
export default function ServicePill({ name, status }: ServicePillProps) {
const active = status === "active";
return (
<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"
}`}
>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${
active ? "bg-green-500" : "bg-gray-300"
}`}
/>
<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"
}`}
>
{status}
</span>
</div>
);
}

View file

@ -0,0 +1,28 @@
import ServicePill from "./ServicePill";
interface ServicesCardProps {
services: Record<string, string> | null;
delay?: number;
}
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"
style={{ animationDelay: `${delay}ms` }}
>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400 mb-4">
Services
</p>
<div className="flex flex-col gap-2">
{services
? Object.entries(services).map(([name, status]) => (
<ServicePill key={name} name={name} status={status} />
))
: [1, 2, 3, 4].map((i) => (
<div key={i} className="skeleton h-10" />
))}
</div>
</div>
);
}

View file

@ -0,0 +1,39 @@
import { statColor } from "../lib/utils";
interface StatCardProps {
label: string;
value: string;
sub?: string;
percent?: number;
delay?: number;
}
export default function StatCard({ label, value, sub, percent, delay = 0 }: StatCardProps) {
const pct = percent ?? 0;
const color = statColor(pct);
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"
style={{ animationDelay: `${delay}ms` }}
>
<span className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400">
{label}
</span>
<span className="text-3xl font-medium tracking-tight text-gray-900 leading-none mt-1">
{value}
</span>
{sub && (
<span className="text-[0.7rem] text-gray-400 mt-0.5 truncate">{sub}</span>
)}
{percent !== undefined && (
<div className="h-[3px] bg-gray-100 rounded-full mt-3 overflow-hidden">
<div
className="h-full rounded-full transition-all duration-700"
style={{ width: `${pct}%`, background: color }}
/>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,48 @@
import { type Stats } from "../lib/getStats";
import StatCard from "./StatCard";
interface StatsGridProps {
stats: Stats | null;
}
export default function StatsGrid({ stats }: StatsGridProps) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3.5 mb-11">
<StatCard
label="CPU"
value={stats ? `${stats.cpu.percent}%` : "—"}
sub={stats?.cpu.model.replace(/\(R\)/g, "").replace(/\(TM\)/g, "").trim()}
percent={stats?.cpu.percent}
delay={0}
/>
<StatCard
label="Memory"
value={stats ? `${stats.memory.percent}%` : "—"}
sub={stats ? `${stats.memory.used} MB / ${stats.memory.total} MB` : ""}
percent={stats?.memory.percent}
delay={60}
/>
<StatCard
label="Disk"
value={stats ? `${stats.disk.percent}%` : "—"}
sub={stats ? `${stats.disk.used} GB / ${stats.disk.total} GB` : ""}
percent={stats?.disk.percent}
delay={120}
/>
<StatCard
label="Temperature"
value={stats?.temperature != null ? `${stats.temperature}°C` : "—"}
sub={
stats?.temperature != null
? stats.temperature > 80
? "Running hot"
: stats.temperature > 60
? "Warm"
: "Cool"
: ""
}
delay={180}
/>
</div>
);
}

View file

@ -0,0 +1,45 @@
import { pad } from "../lib/utils";
import { type Uptime } from "../lib/getStats";
interface UptimeCardProps {
uptime: Uptime | null;
delay?: number;
}
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"
style={{ animationDelay: `${delay}ms` }}
>
<p className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400 mb-4">
Uptime
</p>
{uptime ? (
<div className="flex">
{[
{ val: uptime.days, unit: "days" },
{ val: uptime.hours, unit: "hrs" },
{ val: uptime.minutes, unit: "min" },
].map(({ val, unit }, i) => (
<div
key={unit}
className={`flex flex-col items-center flex-1 ${
i < 2 ? "border-r border-gray-200" : ""
}`}
>
<span className="text-3xl font-medium tracking-tight text-gray-900 leading-none">
{pad(val)}
</span>
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400 mt-1.5">
{unit}
</span>
</div>
))}
</div>
) : (
<div className="skeleton h-12" />
)}
</div>
);
}