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
|
|
@ -1,467 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "./useTheme";
|
||||
import { codingProjects, friends, socials, techProjects } from "../data";
|
||||
import {
|
||||
Contributions,
|
||||
Reveal,
|
||||
Section,
|
||||
SectionHeading,
|
||||
ServiceRow,
|
||||
SocialGlyph,
|
||||
Stat,
|
||||
TerminalCard,
|
||||
type ServiceAccess,
|
||||
} from "./editorial-ui";
|
||||
|
||||
/* --------------------------------------------------------------- content */
|
||||
|
||||
const NAV = [
|
||||
{ href: "#about", label: "About" },
|
||||
{ href: "#services", label: "Services" },
|
||||
{ href: "#projects", label: "Projects" },
|
||||
{ href: "#hardware", label: "Hardware" },
|
||||
{ href: "#friends", label: "Friends" },
|
||||
{ href: "#contact", label: "Contact" },
|
||||
];
|
||||
|
||||
const SERVICES: {
|
||||
name: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
access?: ServiceAccess;
|
||||
checkUrl?: string;
|
||||
disabled?: boolean;
|
||||
}[] = [
|
||||
{ name: "Proxmox", desc: "Server management — private.", url: "https://proxmox.dvorakmortonxd.dev/", access: "private", disabled: true },
|
||||
{ name: "Llama.cpp", desc: "Local AI server on a GPU-passthrough RTX 3070.", url: "https://ai.dvorakmortonxd.dev/", access: "on_request" },
|
||||
{ name: "Jellyfin", desc: "Self-hosted media streaming.", url: "https://jelly.dvorakmortonxd.dev/", access: "on_request" },
|
||||
{ name: "SMP", desc: "My survival Minecraft server.", url: "https://discord.gg/vyvhk7d8bX", checkUrl: "minecraft://mc.dvorakmortonxd.dev:25565", access: "on_request" },
|
||||
{ name: "Navidrome", desc: "My music, streamed from home.", url: "https://navidrome.dvorakmortonxd.dev/", access: "private" },
|
||||
{ name: "GitDXD", desc: "My self-hosted Forgejo instance.", url: "https://git.dvorakmortonxd.dev", access: "public" },
|
||||
];
|
||||
|
||||
const LIFTS = [
|
||||
{ lift: "Squat", weight: "125 kg", date: "5 Jan 2025" },
|
||||
{ lift: "Bench", weight: "111 kg", date: "29 May 2026" },
|
||||
{ lift: "Deadlift", weight: "165 kg", date: "17 Dec 2024" },
|
||||
];
|
||||
|
||||
/** Titles carry their state in parentheses, e.g. "dxdwm (Discontinued)". */
|
||||
function splitStatus(title: string) {
|
||||
const m = title.match(/^(.*?)\s*\(([^)]+)\)\s*$/);
|
||||
return m ? { name: m[1], status: m[2] } : { name: title, status: null };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ page */
|
||||
|
||||
export default function EditorialSite() {
|
||||
const { setTheme } = useTheme();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const stats = [
|
||||
{ value: String(SERVICES.length), label: "self-hosted services", sub: "git, media, music, AI, game server" },
|
||||
{ value: String(codingProjects.length), label: "coding projects", sub: "games, tools, plugins, forks" },
|
||||
{ value: String(techProjects.length), label: "machines collected", sub: "2009 – 2025, mostly e-waste" },
|
||||
{ value: "401 kg", label: "best SBD total", sub: "squat 125 · bench 111 · deadlift 165" },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ------------------------------------------------------------- nav */}
|
||||
<header className="glass-bar fixed inset-x-0 top-0 z-50">
|
||||
<div className="shell flex h-16 items-center gap-6">
|
||||
<a href="#top" className="flex shrink-0 items-baseline gap-2.5" aria-label="Back to top">
|
||||
<span className="display text-[1.05rem] tracking-tight">dvorakmortonxd.dev</span>
|
||||
<span className="label hidden lg:inline">I use gentoo btw</span>
|
||||
</a>
|
||||
|
||||
<nav className="ml-auto hidden items-center gap-7 md:flex" aria-label="Main">
|
||||
{NAV.map((item) => (
|
||||
<a key={item.href} href={item.href} className="text-sm text-muted transition-colors hover:text-ink">
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 md:ml-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme("skeuo")}
|
||||
className="desktop-toggle glass-press-sm px-3 py-1.5 text-sm text-ink"
|
||||
style={{ borderColor: "var(--rule-strong)" }}
|
||||
>
|
||||
Desktop mode
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label={menuOpen ? "Close menu" : "Open menu"}
|
||||
className="glass-press-sm px-3 py-2 text-sm text-ink md:hidden"
|
||||
>
|
||||
{menuOpen ? "Close" : "Menu"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{menuOpen ? (
|
||||
<div className="animate-rise border-t border-[var(--rule-soft)] md:hidden">
|
||||
<nav className="shell py-2" aria-label="Main">
|
||||
{NAV.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="display block border-b border-[var(--rule-soft)] py-4 text-xl text-ink"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<span id="top" />
|
||||
|
||||
<main className="pt-16">
|
||||
{/* ---------------------------------------------------------- hero */}
|
||||
<Section className="pb-16 pt-14 sm:pb-24 sm:pt-20">
|
||||
<div className="grid grid-cols-1 items-start gap-14 lg:grid-cols-[1.05fr_1fr] lg:gap-20">
|
||||
<Reveal>
|
||||
<div className="flex flex-col items-start">
|
||||
<p className="label">September 2026 · Czech Republic · IT student</p>
|
||||
|
||||
<h1 className="display mt-6 text-[2.6rem] leading-[1.06] sm:text-[3.4rem] lg:text-[3.9rem]">
|
||||
I host my own internet, and I write code for <em>fun</em>.
|
||||
</h1>
|
||||
|
||||
<p className="lede measure mt-7">
|
||||
I’m Martin Dvořák — 17 years old, studying network management in Czechia. I self-host
|
||||
everything from Git to music, collect old laptops, build small games, and lift when I’m
|
||||
not doing any of that.
|
||||
</p>
|
||||
|
||||
<div className="mt-9 flex flex-wrap items-center gap-x-8 gap-y-4">
|
||||
<a
|
||||
href="#services"
|
||||
className="bg-[var(--accent)] px-5 py-3 text-sm font-medium text-[#1d2021] transition-colors hover:bg-[#ff9a3d]"
|
||||
>
|
||||
See what I run
|
||||
</a>
|
||||
<a href="#projects" className="link text-sm">
|
||||
Read the projects
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="label mt-9">Self-hosted services · retro hardware · pygame game jams</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={100}>
|
||||
<TerminalCard />
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* ----------------------------------------------------------- stats */}
|
||||
<Section className="pt-0 sm:pt-0">
|
||||
<Reveal>
|
||||
<div className="rule-strong grid grid-cols-1 gap-x-10 gap-y-8 pt-8 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<Stat key={s.label} {...s} />
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* ----------------------------------------------------------- about */}
|
||||
<Section id="about" className="scroll-mt-24">
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
index="01"
|
||||
eyebrow="About"
|
||||
title={<>Who is behind the terminal</>}
|
||||
lead="A student who would rather run the whole stack himself than rent it. The parts of it that are actually interesting:"
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<div className="mt-12 grid grid-cols-1 gap-8 md:grid-cols-2 md:gap-16">
|
||||
<p className="text-body">
|
||||
I’m a sophomore at <a className="link" href="https://sssvt.cz" target="_blank" rel="noopener noreferrer">sssvt.cz</a>
|
||||
, specializing in network management and administration. I wanted to go there since 7th
|
||||
grade, so getting in was a good day. If you’re in middle school and into IT, it’s worth a
|
||||
look.
|
||||
</p>
|
||||
<p className="text-body">
|
||||
I’m a homelabber and self-hosting obsessive — I run my own Git, media, music, AI and game
|
||||
servers. I write code for game jams, tinker with GNU/Linux and musl systems, and rescue old
|
||||
hardware. My laptop collection spans 2009 to 2025; most of it came out of e-waste.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* ------------------------------------------------------------- now */}
|
||||
<Section id="now" className="scroll-mt-24 pt-0 sm:pt-0">
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
index="02"
|
||||
eyebrow="Now"
|
||||
title={<>What I’m doing right now</>}
|
||||
lead="A snapshot, not a feed."
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<div className="mt-12 grid grid-cols-1 gap-8 md:grid-cols-[1fr_1fr] md:gap-16">
|
||||
<p className="text-body">
|
||||
I’m hosting an SMP — message me on Discord if you want in. I’m also cooking most days,
|
||||
bulking through the non-summer seasons.
|
||||
</p>
|
||||
<p className="text-body">
|
||||
Me and my friend <a className="link" href="https://skopec.dev" target="_blank" rel="noopener noreferrer">Pepa</a>
|
||||
{" "}are building an education app so we can study more efficiently. It’s called{" "}
|
||||
<a className="link" href="https://git.dvorakmortonxd.dev/edu/maturita.c" target="_blank" rel="noopener noreferrer">
|
||||
maturita.c
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* -------------------------------------------------------- services */}
|
||||
<Section id="services" className="scroll-mt-24">
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
index="03"
|
||||
eyebrow="Self-hosted"
|
||||
title={<>Services I run from home</>}
|
||||
lead="Live status is checked through this site. Some are private or shared on request — that’s what the label means."
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<div className="mt-12">
|
||||
<ul className="flex flex-col">
|
||||
{SERVICES.map((s) => (
|
||||
<li key={s.name}>
|
||||
<ServiceRow {...s} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* -------------------------------------------------------- projects */}
|
||||
<Section id="projects" className="scroll-mt-24">
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
index="04"
|
||||
eyebrow={`${codingProjects.length} entries`}
|
||||
title={<>Things I’ve built</>}
|
||||
lead="Games, a browser, a window manager, a stack of abandoned servers and one very persistent Discord bot phase. Status in the margin."
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<ol className="mt-12 flex flex-col">
|
||||
{codingProjects.map((p, i) => {
|
||||
const { name, status } = splitStatus(p.title);
|
||||
return (
|
||||
<li key={i} className="rule flex flex-col gap-2 py-5 md:flex-row md:gap-10">
|
||||
<span className="label shrink-0 md:w-44 md:pt-1">
|
||||
<span className="index-num">{String(i + 1).padStart(2, "0")}</span>
|
||||
{status ? <span className="ml-3 text-faint">{status}</span> : null}
|
||||
</span>
|
||||
<div className="measure-wide min-w-0">
|
||||
<a
|
||||
href={p.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="display text-lg transition-colors hover:text-accent"
|
||||
>
|
||||
{name}
|
||||
</a>
|
||||
<p className="mt-2 text-sm leading-relaxed text-body">{p.desc}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* -------------------------------------------------------- hardware */}
|
||||
<Section id="hardware" className="scroll-mt-24">
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
index="05"
|
||||
eyebrow={`${techProjects.length} machines`}
|
||||
title={<>The hardware pile</>}
|
||||
lead="From a 2009 ThinkPad to a 2025 MacBook Air — specs are whatever the machine actually shipped with."
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<div className="mt-12 grid grid-cols-1 gap-x-16 gap-y-6 md:grid-cols-2">
|
||||
{techProjects.map((t) => (
|
||||
<div key={t.title} className="rule-soft pt-4">
|
||||
<h3 className="display text-lg text-ink">{t.title}</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-body">{t.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* ----------------------------------------------------- contributions */}
|
||||
<Section id="gitdxd" className="scroll-mt-24">
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
index="06"
|
||||
eyebrow="GitDXD"
|
||||
title={<>Commits, on my own forge</>}
|
||||
lead={
|
||||
<>
|
||||
I run a Forgejo instance at{" "}
|
||||
<a className="link" href="https://git.dvorakmortonxd.dev" target="_blank" rel="noopener noreferrer">
|
||||
git.dvorakmortonxd.dev
|
||||
</a>
|
||||
. It started as a way to stop renting GitHub; it’s public now.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<div className="mt-12 max-w-4xl">
|
||||
<Contributions />
|
||||
</div>
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* --------------------------------------------------------- friends */}
|
||||
<Section id="friends" className="scroll-mt-24">
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
index="07"
|
||||
eyebrow="Friends"
|
||||
title={<>People worth visiting</>}
|
||||
lead="Sites built by friends — go look at them."
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<ul className="mt-12 flex flex-col">
|
||||
{friends.map((f) => (
|
||||
<li key={f.name} className="rule">
|
||||
<a
|
||||
href={f.realUrl || f.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex flex-wrap items-baseline justify-between gap-x-8 gap-y-1 py-5"
|
||||
>
|
||||
<span className="display text-lg transition-colors group-hover:text-accent">{f.name}</span>
|
||||
<span className="label">{f.desc}</span>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* --------------------------------------------------------- lifting */}
|
||||
<Section id="gym" className="scroll-mt-24">
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
index="08"
|
||||
eyebrow="Strength"
|
||||
title={<>SBD, five days a week</>}
|
||||
lead="A PPL split focused on strength. Current best singles at 73 kg bodyweight:"
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<div className="rule-strong mt-12 grid grid-cols-1 gap-x-10 gap-y-8 pt-8 sm:grid-cols-3">
|
||||
{LIFTS.map((l) => (
|
||||
<Stat key={l.lift} value={l.weight} label={l.lift} sub={l.date} />
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
{/* --------------------------------------------------------- contact */}
|
||||
<Section id="contact" className="scroll-mt-24">
|
||||
<Reveal>
|
||||
<SectionHeading
|
||||
index="09"
|
||||
eyebrow="Contact"
|
||||
title={<>Find me elsewhere</>}
|
||||
lead={
|
||||
<>
|
||||
Fastest reply is Discord or Instagram. For anything longer, mail{" "}
|
||||
<a className="link" href="mailto:dvorak@dvorakmortonxd.dev">
|
||||
dvorak@dvorakmortonxd.dev
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={60}>
|
||||
<div className="rule-strong mt-12 grid grid-cols-2 gap-x-10 gap-y-3 pt-8 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{socials.map((s) => (
|
||||
<a
|
||||
key={s.name}
|
||||
href={s.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 py-1.5 text-sm text-body transition-colors hover:text-ink"
|
||||
>
|
||||
<SocialGlyph name={s.name} icon={s.icon} />
|
||||
<span>{s.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</Section>
|
||||
|
||||
</main>
|
||||
|
||||
{/* ---------------------------------------------------------- footer */}
|
||||
<footer className="shell pb-14 pt-10">
|
||||
<Reveal>
|
||||
<div className="rule-strong pt-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<div className="display text-xl">dvorakmortonxd.dev</div>
|
||||
<p className="measure mt-2 text-sm text-body">
|
||||
Built with Next.js. No trackers here — the same reason I run my own services.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme("skeuo")}
|
||||
className="desktop-toggle glass-press-sm shrink-0 px-3 py-1.5 text-sm text-ink"
|
||||
>
|
||||
Or open the desktop →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rule mt-10 flex flex-col gap-2 pt-5 text-xs text-faint sm:flex-row sm:justify-between">
|
||||
<span>v3.0.0 · © 2025–2026 Martin Dvořák</span>
|
||||
<span>Follow the GPL-3.0 license (or don’t, I don’t mind).</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "./useTheme";
|
||||
import EditorialSite from "./EditorialSite";
|
||||
import WebSite from "./WebSite";
|
||||
import WindowsDesktop from "./WindowsDesktop";
|
||||
|
||||
// The web editorial site is the default and always the landing view (the theme
|
||||
|
|
@ -17,7 +17,7 @@ export default function ThemeRoot() {
|
|||
return (
|
||||
<>
|
||||
<div style={desktop ? { display: "none" } : undefined}>
|
||||
<EditorialSite />
|
||||
<WebSite />
|
||||
</div>
|
||||
{desktop ? <WindowsDesktop /> : null}
|
||||
</>
|
||||
|
|
|
|||
483
src/app/components/WebSite.tsx
Normal file
483
src/app/components/WebSite.tsx
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "./useTheme";
|
||||
import { codingProjects, friends, socials, techProjects } from "../data";
|
||||
import {
|
||||
Bay,
|
||||
ContributionsPanel,
|
||||
Led,
|
||||
Reveal,
|
||||
ServiceUnit,
|
||||
SocialGlyph,
|
||||
type ServiceAccess,
|
||||
} from "./web-ui";
|
||||
|
||||
/* --------------------------------------------------------------- content */
|
||||
|
||||
const NAV = [
|
||||
{ href: "#about", label: "about" },
|
||||
{ href: "#services", label: "rack" },
|
||||
{ href: "#projects", label: "projects" },
|
||||
{ href: "#hardware", label: "inventory" },
|
||||
{ href: "#contact", label: "ports" },
|
||||
];
|
||||
|
||||
const SERVICES: {
|
||||
name: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
access?: ServiceAccess;
|
||||
checkUrl?: string;
|
||||
disabled?: boolean;
|
||||
}[] = [
|
||||
{ name: "proxmox", desc: "server management — private console", url: "https://proxmox.dvorakmortonxd.dev/", access: "private", disabled: true },
|
||||
{ name: "llama.cpp", desc: "local models on a passthrough RTX 3070", url: "https://ai.dvorakmortonxd.dev/", access: "on_request" },
|
||||
{ name: "jellyfin", desc: "media library, streamed from home", url: "https://jelly.dvorakmortonxd.dev/", access: "on_request" },
|
||||
{ name: "minecraft smp", desc: "survival server on mc.dvorakmortonxd.dev", url: "https://discord.gg/vyvhk7d8bX", checkUrl: "minecraft://mc.dvorakmortonxd.dev:25565", access: "on_request" },
|
||||
{ name: "navidrome", desc: "my music, no streaming subscription", url: "https://navidrome.dvorakmortonxd.dev/", access: "private" },
|
||||
{ name: "gitdxd", desc: "self-hosted Forgejo forge", url: "https://git.dvorakmortonxd.dev", access: "public" },
|
||||
];
|
||||
|
||||
const LIFTS = [
|
||||
{ lift: "squat", weight: "125", unit: "kg", date: "05 Jan 2025" },
|
||||
{ lift: "bench", weight: "111", unit: "kg", date: "29 May 2026" },
|
||||
{ lift: "deadlift", weight: "165", unit: "kg", date: "17 Dec 2024" },
|
||||
];
|
||||
|
||||
const NEOFETCH: [string, string][] = [
|
||||
["host", "Proxmox VE · home rack"],
|
||||
["os", "Gentoo Linux (x86_64)"],
|
||||
["cpu", "Xeon E5-2680 v4 · 14c/28t"],
|
||||
["gpu", "RTX 3070 · passthrough"],
|
||||
["memory", "68 GB DDR4 RDIMM"],
|
||||
["storage", "256 GB NVMe · 1 TB · 750 GB"],
|
||||
["services", "6 containers, 0 subscriptions"],
|
||||
["uptime", "17 years"],
|
||||
];
|
||||
|
||||
/** Titles carry their state in parentheses, e.g. "dxdwm (Discontinued)". */
|
||||
function splitStatus(title: string) {
|
||||
const m = title.match(/^(.*?)\s*\(([^)]+)\)\s*$/);
|
||||
return m ? { name: m[1], status: m[2] } : { name: title, status: null };
|
||||
}
|
||||
|
||||
function statusKind(status: string | null): "green" | "amber" | "red" | "off" {
|
||||
if (!status) return "off";
|
||||
const s = status.toLowerCase();
|
||||
if (s.includes("finished") || s.includes("available")) return "green";
|
||||
if (s.includes("wip") || s.includes("unfinished")) return "amber";
|
||||
return "red";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ page */
|
||||
|
||||
export default function WebSite() {
|
||||
const { setTheme } = useTheme();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const stats = [
|
||||
{ value: String(SERVICES.length), label: "self-hosted services" },
|
||||
{ value: String(codingProjects.length), label: "projects on the shelf" },
|
||||
{ value: String(techProjects.length), label: "machines logged" },
|
||||
{ value: "401", label: "kg best SBD total" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-root">
|
||||
{/* ----------------------------------------------------------- rail */}
|
||||
<header className="w-rail w-metal-flat" style={{ borderRadius: 0 }}>
|
||||
<div className="shell flex h-16 items-center gap-5">
|
||||
<a href="#top" className="flex shrink-0 items-center gap-3" aria-label="Back to top">
|
||||
<Led kind="green" pulse />
|
||||
<span className="w-rack__title">dvorakmortonxd.local</span>
|
||||
</a>
|
||||
|
||||
<nav className="ml-auto hidden items-center gap-6 md:flex" aria-label="Main">
|
||||
{NAV.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="w-navlink"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3 md:ml-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Switch to Desktop mode"
|
||||
data-desktop-mode="off"
|
||||
onClick={() => setTheme("skeuo")}
|
||||
className="desktop-toggle flex items-center gap-3"
|
||||
title="Flip to Desktop mode"
|
||||
>
|
||||
<span className="w-plate">desktop</span>
|
||||
<span className="w-switch">
|
||||
<span className="w-switch__knob" />
|
||||
<Led kind="off" className="w-switch__led" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
aria-expanded={menuOpen}
|
||||
aria-label={menuOpen ? "Close menu" : "Open menu"}
|
||||
className="w-btn px-3 py-2"
|
||||
>
|
||||
{menuOpen ? "close" : "menu"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{menuOpen ? (
|
||||
<nav className="shell w-metal-flat mb-3 mt-1 flex flex-col gap-1 p-3 md:hidden" aria-label="Main">
|
||||
{NAV.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="w-unit__name block border-b border-[rgba(255,255,255,0.06)] py-3"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<span id="top" />
|
||||
|
||||
<main className="pt-16">
|
||||
{/* --------------------------------------------------------- hero */}
|
||||
<section className="shell pt-12 pb-14 sm:pt-16">
|
||||
<Reveal>
|
||||
<div className="w-rack w-metal">
|
||||
<div className="w-rack__head">
|
||||
<Led kind="green" pulse />
|
||||
<span className="w-rack__title">dvorakmortonxd.local</span>
|
||||
<span className="w-unit__meta ml-auto hidden sm:block">4U · asus p9x79 · 24/7</span>
|
||||
<span className="w-screw ml-2 hidden sm:block" />
|
||||
<span className="w-screw hidden sm:block" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)]">
|
||||
<div className="w-nameplate min-w-0">
|
||||
<p className="w-kicker">homelab · czech republic · online</p>
|
||||
<h1 className="mt-4 text-[2rem] leading-[1.05] sm:text-[2.6rem] lg:text-[3rem]">
|
||||
My own little internet — one rack, zero subscriptions.
|
||||
</h1>
|
||||
<p className="w-prose mt-5 max-w-[48ch]">
|
||||
I’m Martin Dvořák. Git, media, music, an AI box and a Minecraft server all run on a
|
||||
Proxmox machine in my room — plus the hardware graveyard I keep rescuing from e-waste.
|
||||
</p>
|
||||
<div className="mt-7 flex flex-wrap items-center gap-3">
|
||||
<a href="#services" className="w-btn w-btn--accent">
|
||||
<Led kind="amber" /> see what I run
|
||||
</a>
|
||||
<a href="#projects" className="w-btn">
|
||||
open the drive drawer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-panel min-w-0 p-4">
|
||||
<div className="w-rack__head !border-b-0 !pb-2">
|
||||
<span className="w-rack__title">console — neofetch</span>
|
||||
<Led kind="green" className="ml-auto" />
|
||||
</div>
|
||||
<div className="w-screen-text">
|
||||
{NEOFETCH.map(([k, v]) => (
|
||||
<p key={k} className="flex gap-3">
|
||||
<span className="k w-[8ch] shrink-0">{k}</span>
|
||||
<span className="v min-w-0 truncate">{v}</span>
|
||||
</p>
|
||||
))}
|
||||
<p className="mt-1">
|
||||
<span className="k">$</span> <span className="w-cursor" />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="w-unit w-metal-flat">
|
||||
<Led kind="amber" />
|
||||
<span className="min-w-0">
|
||||
<span className="w-digits block text-[1.5rem] leading-none">{s.value}</span>
|
||||
<span className="w-unit__meta mt-1 block truncate">{s.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
<div className="shell flex flex-col gap-16 pb-20 sm:gap-24">
|
||||
{/* -------------------------------------------------------- about */}
|
||||
<Bay id="about" index="01" label="about" title="Who is behind the rack">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Reveal delay={40}>
|
||||
<article className="w-paper w-card h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-grommet" />
|
||||
<span className="w-kicker !text-[var(--paper-ink-soft)]">manifest.txt</span>
|
||||
</div>
|
||||
<p className="w-prose mt-4">
|
||||
I’m a sophomore at{" "}
|
||||
<a className="w-link" href="https://sssvt.cz" target="_blank" rel="noopener noreferrer">
|
||||
sssvt.cz
|
||||
</a>
|
||||
, studying network management and administration. I wanted to go there since 7th
|
||||
grade, so getting in was a good day.
|
||||
</p>
|
||||
<p className="w-prose mt-3">
|
||||
I’m a homelabber first: my own Git, media, music, AI and game servers, most of it
|
||||
running on hardware other people threw away.
|
||||
</p>
|
||||
</article>
|
||||
</Reveal>
|
||||
<Reveal delay={130}>
|
||||
<article className="w-paper w-card w-note--tilt h-full">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-grommet" />
|
||||
<span className="w-kicker !text-[var(--paper-ink-soft)]">inventory.txt</span>
|
||||
</div>
|
||||
<p className="w-prose mt-4">
|
||||
A laptop collection from 2009 to 2025, mostly e-waste rescues — ThinkPads, MacBooks,
|
||||
a Legion Go, an Xbox with a bad-update exploit and one very dead 2010 unibody.
|
||||
</p>
|
||||
<p className="w-prose mt-3">
|
||||
Away from the rack: PPL training 3–5× a week, Terraria (~1300 h), Minecraft modpacks
|
||||
and the occasional game jam.
|
||||
</p>
|
||||
</article>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Bay>
|
||||
|
||||
{/* ---------------------------------------------------------- now */}
|
||||
<Bay id="now" index="02" label="now" title="On the bench right now">
|
||||
<Reveal delay={40}>
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)]">
|
||||
<article className="w-paper w-note min-w-0">
|
||||
<span className="w-kicker !text-[var(--paper-ink-soft)]">now.txt · updated 09/2026</span>
|
||||
<p className="w-prose mt-4">
|
||||
I’m hosting an SMP — message me on Discord if you want in. I’m also cooking most days,
|
||||
bulking through the non-summer seasons.
|
||||
</p>
|
||||
<p className="w-prose mt-3">
|
||||
Me and my friend{" "}
|
||||
<a className="w-link" href="https://skopec.dev" target="_blank" rel="noopener noreferrer">
|
||||
Pepa
|
||||
</a>{" "}
|
||||
are building an education app so we can study more efficiently. It’s called{" "}
|
||||
<a className="w-link" href="https://git.dvorakmortonxd.dev/edu/maturita.c" target="_blank" rel="noopener noreferrer">
|
||||
maturita.c
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<div className="w-panel min-w-0 p-4">
|
||||
<div className="w-rack__head !border-b-0 !pb-2">
|
||||
<span className="w-rack__title">status</span>
|
||||
<Led kind="green" pulse className="ml-auto" />
|
||||
</div>
|
||||
<div className="w-screen-text">
|
||||
<p><span className="k">smp</span> <span className="v">accepting players</span></p>
|
||||
<p><span className="k">bulk</span> <span className="v">in progress</span></p>
|
||||
<p><span className="k">maturita.c</span> <span className="v">in development</span></p>
|
||||
<p><span className="k">gym</span> <span className="v">PPL · 3–5×/week</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Bay>
|
||||
|
||||
{/* ----------------------------------------------------- services */}
|
||||
<Bay id="services" index="03" label="rack" title="Rack elevation — live status">
|
||||
<Reveal delay={40}>
|
||||
<div className="w-rack w-metal">
|
||||
<div className="w-rack__head">
|
||||
<span className="w-rack__title">services</span>
|
||||
<span className="w-unit__meta ml-auto">checked through this site</span>
|
||||
</div>
|
||||
{SERVICES.map((s) => (
|
||||
<ServiceUnit key={s.name} {...s} />
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</Bay>
|
||||
|
||||
{/* ----------------------------------------------------- projects */}
|
||||
<Bay id="projects" index="04" label="projects" title="The drive drawer">
|
||||
<Reveal delay={40}>
|
||||
<div className="w-drawer w-metal">
|
||||
<div className="w-drawer__handle mx-auto mb-4 w-40" />
|
||||
<div className="max-h-[560px] overflow-y-auto pr-1">
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{codingProjects.map((p, i) => {
|
||||
const { name, status } = splitStatus(p.title);
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={p.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-caddy w-carbon"
|
||||
title={p.desc}
|
||||
>
|
||||
<Led kind={statusKind(status)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="w-caddy__label block truncate">{name}</span>
|
||||
<span className="w-caddy__status block truncate">
|
||||
{status ?? "unlabelled"} · {p.desc}
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Bay>
|
||||
|
||||
{/* ----------------------------------------------------- hardware */}
|
||||
<Bay id="hardware" index="05" label="inventory" title="On the pegboard">
|
||||
<Reveal delay={40}>
|
||||
<div className="w-pegboard w-metal">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{techProjects.map((t) => (
|
||||
<article key={t.title} className="w-paper w-tag">
|
||||
<span className="w-tag__ring" />
|
||||
<h3 className="w-serif text-[1rem]">{t.title}</h3>
|
||||
<p className="w-prose mt-2 text-[0.8rem]">{t.desc}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Bay>
|
||||
|
||||
{/* -------------------------------------------------- contributions */}
|
||||
<Bay id="gitdxd" index="06" label="gitdxd" title="Forge telemetry">
|
||||
<Reveal delay={40}>
|
||||
<ContributionsPanel />
|
||||
</Reveal>
|
||||
</Bay>
|
||||
|
||||
{/* -------------------------------------------------------- friends */}
|
||||
<Bay id="friends" index="07" label="friends" title="Cards on the desk">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{friends.map((f, i) => (
|
||||
<Reveal key={f.name} delay={40 + i * 70}>
|
||||
<a
|
||||
href={f.realUrl || f.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`w-paper w-card block transition-transform hover:-translate-y-1 ${i % 2 ? "w-note--tilt" : ""}`}
|
||||
>
|
||||
<h3 className="w-serif text-[1.1rem]">{f.name}</h3>
|
||||
<p className="mt-1 font-mono text-[0.72rem] text-[var(--paper-ink-soft)]">{f.desc}</p>
|
||||
</a>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Bay>
|
||||
|
||||
{/* ------------------------------------------------------------ gym */}
|
||||
<Bay id="gym" index="08" label="strength" title="On the platform">
|
||||
<Reveal delay={40}>
|
||||
<div className="w-metal p-5">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{LIFTS.map((l) => (
|
||||
<div key={l.lift} className="w-panel p-4 text-center">
|
||||
<div className="w-digits text-[2.2rem] leading-none">
|
||||
{l.weight}
|
||||
<span className="ml-1 text-[1rem] opacity-70">{l.unit}</span>
|
||||
</div>
|
||||
<div className="w-kicker mt-3">{l.lift}</div>
|
||||
<div className="w-unit__meta mt-1">{l.date} · 73 kg BW</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Bay>
|
||||
|
||||
{/* -------------------------------------------------------- contact */}
|
||||
<Bay id="contact" index="09" label="ports" title="Patch panel">
|
||||
<Reveal delay={40}>
|
||||
<div className="w-patch w-metal">
|
||||
<div className="w-rack__head">
|
||||
<span className="w-rack__title">socials</span>
|
||||
<span className="w-unit__meta ml-auto">12 ports</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{socials.map((s) => (
|
||||
<a key={s.name} href={s.url} target="_blank" rel="noopener noreferrer" className="w-port">
|
||||
<span className="w-port__jack" />
|
||||
<span className="text-[var(--faint)]">
|
||||
<SocialGlyph name={s.name} icon={s.icon} />
|
||||
</span>
|
||||
<span className="w-port__label min-w-0 truncate">{s.name}</span>
|
||||
<Led kind="green" className="ml-auto" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="w-unit__meta mt-5">
|
||||
fastest reply is Discord or Instagram · mail{" "}
|
||||
<a className="w-link" href="mailto:dvorak@dvorakmortonxd.dev">
|
||||
dvorak@dvorakmortonxd.dev
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</Bay>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* --------------------------------------------------------- footer */}
|
||||
<footer className="shell pb-14 pt-10">
|
||||
<Reveal>
|
||||
<div className="w-metal-flat flex flex-col gap-5 p-5 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Led kind="green" />
|
||||
<span className="w-rack__title">dvorakmortonxd.local</span>
|
||||
</div>
|
||||
<p className="w-unit__meta mt-2">
|
||||
built with Next.js · textures CC0 via ambientCG ·{" "}
|
||||
<a className="w-link" href="/credits">
|
||||
credits
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme("skeuo")}
|
||||
className="w-btn desktop-toggle shrink-0"
|
||||
title="Flip to Desktop mode"
|
||||
>
|
||||
<Led kind="amber" /> desktop mode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-1 font-mono text-[0.68rem] text-[var(--faint)] sm:flex-row sm:justify-between">
|
||||
<span>v3.1.0 · © 2025–2026 Martin Dvořák</span>
|
||||
<span>GPL-3.0-only — follow it or don’t, I don’t mind</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -22,7 +22,6 @@ export function Reveal({
|
|||
el.classList.add("is-in");
|
||||
return;
|
||||
}
|
||||
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
|
|
@ -56,90 +55,62 @@ export function Reveal({
|
|||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ primitives */
|
||||
/* ------------------------------------------------------------- hardware */
|
||||
|
||||
export function Section({
|
||||
id,
|
||||
children,
|
||||
export function Led({
|
||||
kind = "green",
|
||||
pulse = false,
|
||||
className = "",
|
||||
}: {
|
||||
id?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className={`shell relative py-14 sm:py-20 ${className}`}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionHeading({
|
||||
index,
|
||||
eyebrow,
|
||||
title,
|
||||
lead,
|
||||
}: {
|
||||
index?: string;
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
lead?: ReactNode;
|
||||
kind?: "green" | "amber" | "red" | "blue" | "off";
|
||||
pulse?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<header className="rule pt-5">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:gap-10">
|
||||
<div className="label shrink-0 md:w-44 md:pt-1">
|
||||
{index ? <span className="text-accent">{index}</span> : null}
|
||||
{index && eyebrow ? <span className="px-1.5 text-faint">/</span> : null}
|
||||
{eyebrow}
|
||||
</div>
|
||||
<div className="measure-wide min-w-0">
|
||||
<h2 className="display text-[1.75rem] sm:text-[2.125rem]">{title}</h2>
|
||||
{lead ? <p className="lede mt-4">{lead}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stat({ value, label, sub }: { value: string; label: string; sub?: string }) {
|
||||
return (
|
||||
<div className="rule pt-4">
|
||||
<div className="serif-num text-[2rem] leading-none sm:text-[2.5rem]">{value}</div>
|
||||
<div className="mt-3 text-sm leading-snug text-body">{label}</div>
|
||||
{sub ? <div className="label mt-1.5 block">{sub}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- socials */
|
||||
|
||||
/** Minimal inline LinkedIn mark (the bundled linkedin.svg is a filled badge,
|
||||
* which masks to a plain square). Everything else masks its SVG. */
|
||||
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
|
||||
className={`w-led w-led--${kind}${pulse ? " w-led--pulse" : ""} ${className}`}
|
||||
title={title}
|
||||
aria-hidden="true"
|
||||
className="social-mask size-4"
|
||||
style={{ WebkitMaskImage: `url(${icon})`, maskImage: `url(${icon})` } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 ServiceRow({
|
||||
export function ServiceUnit({
|
||||
name,
|
||||
desc,
|
||||
url,
|
||||
|
|
@ -173,10 +144,9 @@ export function ServiceRow({
|
|||
}, [url, checkUrl, disabled]);
|
||||
|
||||
const effective = disabled ? "up" : status;
|
||||
const dot =
|
||||
effective === "up" ? "var(--positive)" : effective === "down" ? "var(--negative)" : "var(--faint)";
|
||||
const kind = effective === "up" ? "green" : effective === "down" ? "red" : "off";
|
||||
const statusLabel = disabled
|
||||
? "private"
|
||||
? "standby"
|
||||
: effective === "up"
|
||||
? "online"
|
||||
: effective === "down"
|
||||
|
|
@ -185,34 +155,47 @@ export function ServiceRow({
|
|||
const accessLabel =
|
||||
access === "public" ? "public" : access === "private" ? "private" : access === "on_request" ? "on request" : null;
|
||||
|
||||
const body = (
|
||||
const host = (() => {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})();
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="display text-lg">{name}</span>
|
||||
{accessLabel ? <span className="label">{accessLabel}</span> : null}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-body">{desc}</p>
|
||||
</div>
|
||||
<span className="flex shrink-0 items-center gap-2">
|
||||
<span className="size-1.5 rounded-full" style={{ backgroundColor: dot }} />
|
||||
<span className="label">{statusLabel}</span>
|
||||
<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 = "rule-soft flex items-center gap-4 py-4 md:gap-8";
|
||||
const cls = "w-unit w-metal-flat";
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={cls} aria-disabled="true">
|
||||
{body}
|
||||
{inner}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={`${cls} transition-colors hover:bg-[var(--fill)]`}>
|
||||
{body}
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={`${cls} w-unit--link`}>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
|
@ -230,9 +213,7 @@ interface Activity {
|
|||
repo?: { full_name?: string };
|
||||
}
|
||||
|
||||
const LEVEL_BG = ["rgba(235,219,178,0.07)", "#6d7420", "#98971a", "#b8bb26", "#d8e05a"];
|
||||
|
||||
export function Contributions() {
|
||||
export function ContributionsPanel() {
|
||||
const [weeks, setWeeks] = useState<{ date: string; contribs: number; level: number }[][]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
|
|
@ -310,108 +291,99 @@ export function Contributions() {
|
|||
};
|
||||
}, []);
|
||||
|
||||
if (state === "loading") return <p className="label">Loading contributions…</p>;
|
||||
if (state === "error") return <p className="label">Could not load contributions.</p>;
|
||||
// level 4 = brightest LED, level 0 = off
|
||||
const levelOn = (l: number) => (l <= 0 ? "0" : String(5 - l));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="serif-num text-[2rem] leading-none sm:text-[2.5rem]">{total}</div>
|
||||
<div className="label">contributions in the last year</div>
|
||||
<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="ed-heatmap mt-6 pb-2">
|
||||
{weeks.map((w, i) => (
|
||||
<div key={i} className="ed-heatmap-week">
|
||||
{w.map((d) => (
|
||||
<div
|
||||
key={d.date}
|
||||
className="ed-heatmap-cell"
|
||||
style={{ backgroundColor: LEVEL_BG[d.level] }}
|
||||
title={`${d.contribs} on ${d.date}`}
|
||||
/>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<span className="label">less</span>
|
||||
<span className="ed-heatmap-legend">
|
||||
{LEVEL_BG.map((bg, i) => (
|
||||
<span key={i} className="ed-heatmap-cell" style={{ backgroundColor: bg }} />
|
||||
))}
|
||||
</span>
|
||||
<span className="label">more</span>
|
||||
</div>
|
||||
|
||||
{activities.length > 0 ? (
|
||||
<ul className="mt-8 flex flex-col">
|
||||
{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 (
|
||||
<li key={i} className="rule-soft flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1 py-3">
|
||||
<span className="min-w-0 text-sm text-ink">
|
||||
<span className="text-muted">{a.repo?.full_name || "unknown"}</span> — {msg}
|
||||
</span>
|
||||
<span className="label shrink-0">{new Date(a.created).toLocaleDateString()}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- terminal card */
|
||||
/* ------------------------------------------------------------- socials */
|
||||
|
||||
export function TerminalCard() {
|
||||
const rows: [string, string][] = [
|
||||
["OS", "Gentoo Linux (x86_64)"],
|
||||
["WM", "SwayFX"],
|
||||
["Shell", "zsh"],
|
||||
["Editor", "neovim"],
|
||||
["Services", "6 self-hosted"],
|
||||
["Gym", "SBD 401 kg @ 73 kg"],
|
||||
["Uptime", "17 years"],
|
||||
];
|
||||
/** The bundled linkedin.svg is a filled badge, which masks to a plain square. */
|
||||
function LinkedInGlyph({ className = "size-4" }: { className?: string }) {
|
||||
return (
|
||||
<div className="glass-sunken overflow-hidden">
|
||||
<div className="flex items-center gap-2 border-b border-[var(--rule-soft)] px-4 py-2.5">
|
||||
<span className="size-2.5 rounded-full bg-[var(--rule)]" />
|
||||
<span className="size-2.5 rounded-full bg-[var(--rule)]" />
|
||||
<span className="size-2.5 rounded-full bg-[var(--rule)]" />
|
||||
<span className="label ml-2">martin@dvorakmortonxd</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 p-5 font-mono text-[0.78rem] leading-relaxed text-body">
|
||||
<p className="text-ink">
|
||||
<span className="text-accent">$</span> neofetch
|
||||
</p>
|
||||
{rows.map(([k, v]) => (
|
||||
<p key={k} className="flex gap-3">
|
||||
<span className="w-20 shrink-0 text-accent">{k.toLowerCase()}</span>
|
||||
<span>{v}</span>
|
||||
</p>
|
||||
))}
|
||||
<p className="text-ink">
|
||||
<span className="text-accent">$</span> <span className="animate-blink">█</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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