rebuild the web theme as a skeuomorphic homelab desk (rack hero, drive drawer, LED dashboard, patch panel; CC0 textures + /credits)
This commit is contained in:
parent
40824dbcb1
commit
dd6aeb5486
18 changed files with 1477 additions and 768 deletions
389
src/app/components/web-ui.tsx
Normal file
389
src/app/components/web-ui.tsx
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
|
||||
/* --------------------------------------------------------------- reveal */
|
||||
|
||||
export function Reveal({
|
||||
children,
|
||||
delay = 0,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
el.classList.add("is-in");
|
||||
return;
|
||||
}
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
el.classList.add("is-in");
|
||||
io.disconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -12% 0px", threshold: 0.08 }
|
||||
);
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const onScreen = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
if (onScreen) {
|
||||
// First screenful: stagger in document order so it fills top to bottom.
|
||||
const all = Array.from(document.querySelectorAll<HTMLElement>(".reveal"));
|
||||
const order = Math.min(Math.max(0, all.indexOf(el)), 8);
|
||||
el.style.setProperty("--delay", `${delay + order * 90}ms`);
|
||||
requestAnimationFrame(() => el.classList.add("is-in"));
|
||||
} else {
|
||||
io.observe(el);
|
||||
}
|
||||
return () => io.disconnect();
|
||||
}, [delay]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`reveal ${className}`} style={{ "--delay": `${delay}ms` } as CSSProperties}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- hardware */
|
||||
|
||||
export function Led({
|
||||
kind = "green",
|
||||
pulse = false,
|
||||
className = "",
|
||||
title,
|
||||
}: {
|
||||
kind?: "green" | "amber" | "red" | "blue" | "off";
|
||||
pulse?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`w-led w-led--${kind}${pulse ? " w-led--pulse" : ""} ${className}`}
|
||||
title={title}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Section header, mounted on the chassis. */
|
||||
export function Bay({
|
||||
id,
|
||||
index,
|
||||
label,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
index: string;
|
||||
label: string;
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className="scroll-mt-28">
|
||||
<Reveal>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
|
||||
<span className="w-plate">
|
||||
{index} <span className="opacity-50">/</span> {label}
|
||||
</span>
|
||||
<h2 className="w-serif text-[1.6rem] sm:text-[2rem]">{title}</h2>
|
||||
</div>
|
||||
</Reveal>
|
||||
<div className="mt-8">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- services */
|
||||
|
||||
export type ServiceAccess = "public" | "private" | "on_request";
|
||||
|
||||
export function ServiceUnit({
|
||||
name,
|
||||
desc,
|
||||
url,
|
||||
access,
|
||||
checkUrl,
|
||||
disabled,
|
||||
}: {
|
||||
name: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
access?: ServiceAccess;
|
||||
checkUrl?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [status, setStatus] = useState<"pending" | "up" | "down">("pending");
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
let alive = true;
|
||||
fetch(`/api/ping?url=${encodeURIComponent(checkUrl || url)}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (alive) setStatus(d.status === "up" ? "up" : "down");
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setStatus("down");
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [url, checkUrl, disabled]);
|
||||
|
||||
const effective = disabled ? "up" : status;
|
||||
const kind = effective === "up" ? "green" : effective === "down" ? "red" : "off";
|
||||
const statusLabel = disabled
|
||||
? "standby"
|
||||
: effective === "up"
|
||||
? "online"
|
||||
: effective === "down"
|
||||
? "offline"
|
||||
: "checking";
|
||||
const accessLabel =
|
||||
access === "public" ? "public" : access === "private" ? "private" : access === "on_request" ? "on request" : null;
|
||||
|
||||
const host = (() => {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})();
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<span className="w-unit__leds">
|
||||
<Led kind={kind} pulse={effective === "up"} />
|
||||
<Led kind="amber" pulse={effective === "up"} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="w-unit__name block truncate">{name}</span>
|
||||
<span className="w-unit__meta block truncate">
|
||||
{host} {accessLabel ? `· ${accessLabel}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden text-right sm:block">
|
||||
<span className="w-unit__meta block max-w-[26ch] truncate">{desc}</span>
|
||||
</span>
|
||||
<span className="w-plate shrink-0">
|
||||
<Led kind={kind} /> {statusLabel}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
const cls = "w-unit w-metal-flat";
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={cls} aria-disabled="true">
|
||||
{inner}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={`${cls} w-unit--link`}>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- contributions */
|
||||
|
||||
interface HeatmapPoint {
|
||||
timestamp: number;
|
||||
contributions: number;
|
||||
}
|
||||
interface Activity {
|
||||
op_type: string;
|
||||
content?: string | null;
|
||||
created: string;
|
||||
repo?: { full_name?: string };
|
||||
}
|
||||
|
||||
export function ContributionsPanel() {
|
||||
const [weeks, setWeeks] = useState<{ date: string; contribs: number; level: number }[][]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/heatmap");
|
||||
if (!res.ok) throw new Error("bad status");
|
||||
const data: HeatmapPoint[] = await res.json();
|
||||
const map = new Map<string, number>();
|
||||
let sum = 0;
|
||||
let max = 0;
|
||||
for (const item of data) {
|
||||
const d = new Date(item.timestamp * 1000);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const v = (map.get(key) ?? 0) + item.contributions;
|
||||
map.set(key, v);
|
||||
if (v > max) max = v;
|
||||
sum += item.contributions;
|
||||
}
|
||||
|
||||
const end = new Date();
|
||||
end.setHours(0, 0, 0, 0);
|
||||
const start = new Date(end);
|
||||
start.setFullYear(start.getFullYear() - 1);
|
||||
start.setDate(start.getDate() - start.getDay());
|
||||
|
||||
const out: { date: string; contribs: number; level: number }[][] = [];
|
||||
let week: { date: string; contribs: number; level: number }[] = [];
|
||||
for (const d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const contribs = map.get(key) ?? 0;
|
||||
let level = 0;
|
||||
if (contribs > 0) {
|
||||
level = 1;
|
||||
if (contribs > max * 0.25) level = 2;
|
||||
if (contribs > max * 0.5) level = 3;
|
||||
if (contribs > max * 0.75) level = 4;
|
||||
}
|
||||
week.push({ date: key, contribs, level });
|
||||
if (week.length === 7) {
|
||||
out.push(week);
|
||||
week = [];
|
||||
}
|
||||
}
|
||||
if (week.length) out.push(week);
|
||||
|
||||
if (!alive) return;
|
||||
setWeeks(out);
|
||||
setTotal(sum);
|
||||
setState("ready");
|
||||
} catch {
|
||||
if (alive) setState("error");
|
||||
}
|
||||
})();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/activities");
|
||||
if (!res.ok) return;
|
||||
const data: Activity[] = await res.json();
|
||||
if (alive) {
|
||||
setActivities(data.filter((d) => d.op_type === "commit_repo" || d.op_type === "push_repo").slice(0, 3));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// level 4 = brightest LED, level 0 = off
|
||||
const levelOn = (l: number) => (l <= 0 ? "0" : String(5 - l));
|
||||
|
||||
return (
|
||||
<div className="w-dashboard w-metal">
|
||||
<div className="w-rack__head">
|
||||
<span className="w-rack__title">gitdxd.activity</span>
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
<Led kind={state === "error" ? "red" : "green"} pulse={state === "ready"} />
|
||||
<span className="w-unit__meta">{state === "error" ? "link down" : "streaming"}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="w-digits text-[2rem] leading-none sm:text-[2.5rem]">{total}</div>
|
||||
<div className="w-kicker">contributions / last year</div>
|
||||
</div>
|
||||
|
||||
{state === "loading" ? (
|
||||
<p className="w-screen-text mt-5 opacity-70">reading matrix…</p>
|
||||
) : state === "error" ? (
|
||||
<p className="w-screen-text mt-5 opacity-70">could not reach the forge.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-ledgrid mt-5">
|
||||
{weeks.map((w, i) => (
|
||||
<div key={i} className="w-ledgrid__col">
|
||||
{w.map((d) => (
|
||||
<div key={d.date} className="w-ledgrid__cell" data-on={levelOn(d.level)} title={`${d.contribs} on ${d.date}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<span className="w-unit__meta">less</span>
|
||||
{[4, 3, 2, 1, 0].map((l) => (
|
||||
<span key={l} className="w-ledgrid__cell" data-on={levelOn(l)} />
|
||||
))}
|
||||
<span className="w-unit__meta">more</span>
|
||||
</div>
|
||||
|
||||
{activities.length > 0 ? (
|
||||
<div className="w-panel mt-5 p-3">
|
||||
<div className="w-screen-text">
|
||||
{activities.map((a, i) => {
|
||||
let msg = "commit";
|
||||
try {
|
||||
if (a.content) {
|
||||
const parsed = JSON.parse(a.content) as {
|
||||
Commits?: { Message?: string }[];
|
||||
HeadCommit?: { Message?: string };
|
||||
};
|
||||
msg =
|
||||
parsed.Commits?.[0]?.Message?.split("\n")[0] ??
|
||||
parsed.HeadCommit?.Message?.split("\n")[0] ??
|
||||
msg;
|
||||
}
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
return (
|
||||
<p key={i} className="truncate">
|
||||
<span className="k">$</span> {a.repo?.full_name || "unknown"} — {msg}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- socials */
|
||||
|
||||
/** The bundled linkedin.svg is a filled badge, which masks to a plain square. */
|
||||
function LinkedInGlyph({ className = "size-4" }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
|
||||
<path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.77-1.95 4.03 0 4.78 2.5 4.78 5.75V21h-4v-5.6c0-1.34-.03-3.06-1.95-3.06-1.95 0-2.25 1.45-2.25 2.96V21H9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SocialGlyph({ name, icon }: { name: string; icon: string }) {
|
||||
if (name === "LinkedIn") return <LinkedInGlyph />;
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="social-mask size-4"
|
||||
style={{ WebkitMaskImage: `url(${icon})`, maskImage: `url(${icon})` } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue