Initial ui
This commit is contained in:
parent
526822335e
commit
4bfb87448f
18 changed files with 702 additions and 92 deletions
24
app/components/Hero.tsx
Normal file
24
app/components/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
app/components/LinkCard.tsx
Normal file
27
app/components/LinkCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/components/LinksGrid.tsx
Normal file
19
app/components/LinksGrid.tsx
Normal 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 & 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
35
app/components/NavBar.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
47
app/components/NetworkCard.tsx
Normal file
47
app/components/NetworkCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
app/components/ServicePill.tsx
Normal file
31
app/components/ServicePill.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
app/components/ServicesCard.tsx
Normal file
28
app/components/ServicesCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
app/components/StatCard.tsx
Normal file
39
app/components/StatCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
app/components/StatsGrid.tsx
Normal file
48
app/components/StatsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
app/components/UptimeCard.tsx
Normal file
45
app/components/UptimeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue