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
60
README.md
60
README.md
|
|
@ -2,16 +2,18 @@
|
|||
|
||||
> The source code for my personal website, **[dvorakmortonxd.dev](https://dvorakmortonxd.dev)**.
|
||||
|
||||
It's an **editorial-style personal site** in a **gruvbox** palette, with the whole KDE
|
||||
desktop simulation kept behind a "Desktop mode" button. Two modes, one content set:
|
||||
It's a **skeuomorphic personal site**: a dark homelab desk built from anodised aluminium, OLED
|
||||
glass, carbon fibre and paper, with the whole KDE desktop simulation kept behind a physical
|
||||
power switch. Two modes, one content set:
|
||||
|
||||
- **Web** (default) — the editorial site: a technical-print layout painted in the gruvbox
|
||||
palette, with serif headlines, mono labels in the margin, hairline rules and a data strip. No
|
||||
images at all; the only graphics are the social icons, used as monochrome CSS masks.
|
||||
- **Web** (default) — the homelab desk. A 4U rack carries the headline on an engraved nameplate
|
||||
and a live console readout; services are rack units with status LEDs, the 18 coding projects
|
||||
are labelled SSD caddies in a drive drawer, hardware hangs on a pegboard, the contribution
|
||||
heatmap is an LED matrix and the socials are a patch panel. Textures are CC0 — see
|
||||
[`/credits`](http://localhost:3000/credits).
|
||||
- **Desktop mode** — the KDE Plasma / Windows-style **desktop simulation**: draggable
|
||||
snap-to-grid icons, resizable/floating windows, a Start menu, taskbar, live clock and a
|
||||
working in-browser terminal (Alacritty). Reachable from the "Desktop mode" button and hidden
|
||||
on phones.
|
||||
working in-browser terminal (Alacritty). Reached from the power switch and hidden on phones.
|
||||
|
||||
Both modes surface the same personal content: socials, self-hosted services with **live
|
||||
up/down health checks**, a hardware collection, coding projects, a **GitDXD contribution
|
||||
|
|
@ -19,13 +21,13 @@ heatmap**, gym SBD and links to friends' sites.
|
|||
|
||||
## ✨ Features
|
||||
|
||||
- **Web theme is the landing view** — the site always opens on the editorial page; the theme is
|
||||
deliberately not persisted, so a reload never drops you into the desktop. A technical-print
|
||||
layout with serif headlines, mono labels in the margin, hairline rules and a data strip. Text
|
||||
and type only — the sole graphics are the social icons, rendered as monochrome CSS masks.
|
||||
- **Desktop mode (desktop-only)** — a "Desktop mode" button swaps to the KDE-style desktop for
|
||||
the session. Phones never get it: the buttons are hidden, so the old iOS-style phone home
|
||||
screen is gone.
|
||||
- **Web theme is the landing view** — the site always opens on the desk; the theme is
|
||||
deliberately not persisted, so a reload never drops you into the desktop.
|
||||
- **Skeuomorphic hardware** — rack, LEDs, rocker switch, screws, vents and panels are CSS
|
||||
materials over five CC0 textures; every section is a real-world object rather than a card.
|
||||
- **Desktop mode (desktop-only)** — a physical power switch in the top rail (and in the footer)
|
||||
flips to the KDE-style desktop for the session. Phones never get it: the switch is hidden, so
|
||||
the old iOS-style phone home screen is gone.
|
||||
- **Draggable desktop icons** — free placement that snaps to a measured grid, one icon per
|
||||
cell, with a follow-the-cursor drag ghost. Icons shrink on cramped viewports so they never
|
||||
collide.
|
||||
|
|
@ -61,15 +63,16 @@ heatmap**, gym SBD and links to friends' sites.
|
|||
|
||||
- **Framework:** Next.js 16 (App Router, Turbopack)
|
||||
- **Language:** TypeScript, React 19 (client components everywhere interactivity lives)
|
||||
- **Styling:** `src/app/globals.css` is the web editorial design system — the gruvbox palette as
|
||||
CSS custom properties, plus Tailwind v4 `@theme`/`@utility`, base and scroll reveals.
|
||||
`editorial.css` holds page helpers; `page.css` keeps the shared component styles the desktop
|
||||
apps still use, and `skeuo.css` is the KDE desktop. The editorial page is built from Tailwind
|
||||
utilities.
|
||||
- **Fonts:** Newsreader (display) + IBM Plex Sans/Mono (body/labels) via `next/font/google`;
|
||||
the desktop keeps its system sans stack.
|
||||
- **Images:** `next/image` — but the editorial page uses none (social SVGs as monochrome masks only).
|
||||
- **Version:** `3.0.0` (see `package.json`)
|
||||
- **Styling:** `src/app/globals.css` holds the palette tokens, base styles, the Tailwind v4
|
||||
`@theme`/`@utility` classes and the scroll reveals. `src/app/web.css` is the skeuomorphic
|
||||
material system (metal, OLED glass, paper, carbon, rubber, plastic, LEDs, screws, vents,
|
||||
switches) and the object layouts. `page.css` keeps the shared component styles the desktop
|
||||
apps still use, and `skeuo.css` is the KDE desktop.
|
||||
- **Fonts:** Newsreader (headlines, paper) + IBM Plex Sans/Mono (body, machine labels) via
|
||||
`next/font/google`; the desktop keeps its system sans stack.
|
||||
- **Textures:** five CC0 materials from ambientCG, downscaled to 512 px in `public/textures`.
|
||||
Attribution is on `/credits`.
|
||||
- **Version:** `3.1.0` (see `package.json`)
|
||||
|
||||
## 🚀 Running Locally
|
||||
|
||||
|
|
@ -171,11 +174,12 @@ they contend for `:3000` and `.next/dev/lock`.
|
|||
src/app/
|
||||
page.tsx → renders <ThemeRoot/>, imports the theme stylesheets
|
||||
layout.tsx → html shell + metadata; loads the fonts; injects ThemeScript
|
||||
globals.css → web editorial design system (gruvbox palette; tokens, @utility classes, base)
|
||||
editorial.css → editorial page helpers (social masks, heatmap)
|
||||
globals.css → palette tokens, base, Tailwind @theme/@utility, scroll reveals
|
||||
web.css → skeuomorphic material system + object layouts
|
||||
page.css → shared component styles still used by the desktop apps
|
||||
skeuo.css → KDE desktop styles
|
||||
data.ts → socials, codingProjects, techProjects, friends, wallpapers
|
||||
credits/page.tsx → credit page for the textures and fonts
|
||||
revival-inc/page.tsx → embeds the bundled game at /public/revival-inc/index.html
|
||||
api/
|
||||
heatmap/route.ts → contribution heatmap proxy
|
||||
|
|
@ -183,11 +187,11 @@ src/app/
|
|||
frame/route.ts → header-stripping proxy for unframeable friend sites
|
||||
ping/route.ts → service health check (HTTP + Minecraft)
|
||||
components/
|
||||
ThemeRoot.tsx → web editorial vs. Desktop mode
|
||||
ThemeRoot.tsx → web desk vs. Desktop mode
|
||||
ThemeScript.tsx → pre-paint script: device detection
|
||||
useTheme.ts → theme state via useSyncExternalStore
|
||||
EditorialSite.tsx → the web editorial page (nav, hero, sections)
|
||||
editorial-ui.tsx → editorial primitives: Section, ServiceRow, Contributions, …
|
||||
WebSite.tsx → the desk: rack hero, drawer, pegboard, patch panel
|
||||
web-ui.tsx → rack units, LED dashboard, social masks, Reveal
|
||||
WindowsDesktop.tsx → desktop shell: icons, windows, taskbar, Start menu
|
||||
WinWindow.tsx → one draggable/resizable window
|
||||
apps.tsx → APPS registry, window contents, in-app terminal
|
||||
|
|
|
|||
67
agents.md
67
agents.md
|
|
@ -34,17 +34,19 @@ When in doubt, prefer updating. A stale README **or** agents.md is treated as a
|
|||
## What this project is
|
||||
|
||||
Personal portfolio site for **dvorakmortonxd.dev**. The default view is a
|
||||
**web editorial page** (a technical-print layout, no images); a "Desktop mode"
|
||||
button swaps to a **KDE Plasma desktop simulation**. Phones only ever get the
|
||||
editorial page.
|
||||
**skeuomorphic homelab desk** (a rack, LEDs, paper, anodised metal); a physical
|
||||
power switch flips to a **KDE Plasma desktop simulation**. Phones only ever get
|
||||
the desk.
|
||||
|
||||
- Framework: **Next.js 16** (App Router, Turbopack), **React 19**, TypeScript.
|
||||
- Styling: **Tailwind v4** for the editorial page — `globals.css` is the design
|
||||
system (CSS custom properties, `@theme` tokens, `@utility` classes) — plus plain
|
||||
CSS for the desktop (`skeuo.css`) and the shared component styles the desktop
|
||||
apps still use (`page.css`).
|
||||
- Styling: **Tailwind v4** for layout plus **`web.css`**, the skeuomorphic
|
||||
material system (metal, OLED glass, paper, carbon, rubber, plastic, LEDs,
|
||||
screws, vents, switches) over five CC0 textures in `public/textures`
|
||||
(credited on `/credits`). `globals.css` holds the tokens, base styles and the
|
||||
reveals; `skeuo.css` and `page.css` are the desktop and the shared
|
||||
desktop-app styles.
|
||||
- Path alias: `@/*` → `src/*`.
|
||||
- Version: `3.0.0` (see `package.json`).
|
||||
- Version: `3.1.0` (see `package.json`).
|
||||
|
||||
### Scripts
|
||||
```bash
|
||||
|
|
@ -77,11 +79,12 @@ token.** Do not print it, copy it into new files, or push it anywhere else.
|
|||
src/app/
|
||||
page.tsx → renders <ThemeRoot/>, imports the theme stylesheets
|
||||
layout.tsx → html shell, metadata, next/font, ThemeScript
|
||||
globals.css → web editorial design system (gruvbox palette; tokens, @utility, base, reveals)
|
||||
editorial.css → editorial page helpers (social masks, heatmap)
|
||||
globals.css → palette tokens, base, @theme/@utility, scroll reveals
|
||||
web.css → skeuomorphic material system + object layouts
|
||||
page.css → shared component styles used by the desktop apps
|
||||
skeuo.css → KDE Plasma desktop styles
|
||||
data.ts → socials, codingProjects, techProjects, friends, wallpapers
|
||||
credits/page.tsx → credits for the CC0 textures and fonts
|
||||
revival-inc/page.tsx → fullscreen iframe embedding /public/revival-inc/index.html
|
||||
api/
|
||||
heatmap/route.ts → proxy for git.dvorakmortonxd.dev contribution heatmap
|
||||
|
|
@ -89,9 +92,9 @@ src/app/
|
|||
ping/route.ts → service health check (HTTP + minecraft:// via mcstatus.io); host allowlist
|
||||
frame/route.ts → header-stripping proxy so an unframeable friend site loads in a window; host allowlist
|
||||
components/
|
||||
ThemeRoot.tsx → editorial page by default, desktop when theme === "skeuo"
|
||||
EditorialSite.tsx → the web editorial page (nav, hero, sections)
|
||||
editorial-ui.tsx → editorial primitives: Reveal, Section, ServiceRow, Contributions, …
|
||||
ThemeRoot.tsx → the web desk by default, desktop when theme === "skeuo"
|
||||
WebSite.tsx → the desk: rack hero, drive drawer, pegboard, patch panel
|
||||
web-ui.tsx → rack units, LED dashboard, social masks, Reveal
|
||||
WindowsDesktop.tsx → "skeuo" theme, desktop: icons, windows, taskbar
|
||||
WinWindow.tsx → a single draggable/resizable window
|
||||
apps.tsx → APPS registry + the content of each app + in-app terminal
|
||||
|
|
@ -115,17 +118,19 @@ scripts/
|
|||
2. `useTheme()` (in `useTheme.ts`) reads `data-theme` via `useSyncExternalStore`;
|
||||
`setTheme` sets the attribute and dispatches `theme-change`. Nothing is
|
||||
persisted, so a reload returns to `web`.
|
||||
3. `ThemeRoot` renders `EditorialSite` unless `theme === "skeuo"`, in which case it
|
||||
mounts `WindowsDesktop` instead. The editorial page stays mounted (hidden) while
|
||||
the desktop is up so its scroll position survives; the desktop mounts on demand
|
||||
3. `ThemeRoot` renders `WebSite` unless `theme === "skeuo"`, in which case it
|
||||
mounts `WindowsDesktop` instead. The desk stays mounted (hidden) while the
|
||||
desktop is up so its scroll position survives; the desktop mounts on demand
|
||||
(phones can never reach `skeuo`).
|
||||
|
||||
Modes: `web` = the editorial site (default/landing), `skeuo` = KDE Plasma desktop.
|
||||
Modes: `web` = the skeuomorphic homelab desk (default/landing), `skeuo` = KDE
|
||||
Plasma desktop.
|
||||
|
||||
The editorial page is **asset-free** apart from the social SVGs, which are used as
|
||||
monochrome CSS masks (`.social-mask` in `editorial.css`). `linkedin.svg` is a filled
|
||||
badge that masks to a plain square, so `editorial-ui.tsx` renders an inline mark for
|
||||
it instead. The desktop still uses the image assets.
|
||||
The desk uses **five CC0 textures** from ambientCG (`public/textures`: metal,
|
||||
paper, carbon, rubber, plastic), downscaled to 512 px and credited on
|
||||
`/credits`. The social SVGs are used as monochrome CSS masks (`.social-mask`);
|
||||
`linkedin.svg` is a filled badge that masks to a plain square, so `web-ui.tsx`
|
||||
renders an inline mark for it instead. The desktop still uses the image assets.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -225,14 +230,18 @@ not free placement. Don't confuse the two.
|
|||
- Components are client components (`"use client"` at the top) — they use
|
||||
pointer events and window APIs freely.
|
||||
- CSS classes are prefixed by area: `win-` (desktop), `app-` / `kickoff` etc.
|
||||
(desktop windows), plus the editorial design-system classes (`shell`, `display`,
|
||||
`label`, `rule*`, `glass*`). Grep by prefix to find styles.
|
||||
- The editorial page reveals blocks with `.reveal` (see `Reveal` in
|
||||
`editorial-ui.tsx`): hidden by default, `.is-in` shows. On load the first
|
||||
screenful cascades top-to-bottom (90ms per block, in DOM order); the rest
|
||||
reveal when they scroll in. **Wrap every content block** — anything left
|
||||
unwrapped paints before the top and breaks the top-to-bottom order. The
|
||||
`<noscript>` rule in `layout.tsx` keeps everything visible without JS.
|
||||
(desktop windows), and `w-` (the web desk's materials and objects, in
|
||||
`web.css`). **Never name a `w-*` class after a Tailwind utility** — `.w-screen`
|
||||
collided with Tailwind's `w-screen` (`width: 100vw`) and silently stretched
|
||||
every panel to the viewport. Also remember `web.css` is unlayered, so its
|
||||
properties beat Tailwind utilities on the same element; use a dedicated class
|
||||
(e.g. `.w-navlink`) or `!` when a utility must win.
|
||||
- The desk reveals blocks with `.reveal` (see `Reveal` in `web-ui.tsx`): hidden by
|
||||
default, `.is-in` shows. On load the first screenful cascades top-to-bottom
|
||||
(90ms per block, in DOM order); the rest reveal when they scroll in. **Wrap
|
||||
every content block** — anything left unwrapped paints before the top and breaks
|
||||
the top-to-bottom order. The `<noscript>` rule in `layout.tsx` keeps everything
|
||||
visible without JS.
|
||||
- Several places deliberately use **imperative DOM work** (e.g. the drag ghost).
|
||||
This is intentional for performance; prefer matching the existing pattern over
|
||||
"Reactifying" it.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "dvorakmortonxd",
|
||||
"version": "3.0.0",
|
||||
"version": "3.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/dev-server.mjs",
|
||||
|
|
|
|||
BIN
public/textures/carbon.jpg
Normal file
BIN
public/textures/carbon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
public/textures/metal.jpg
Normal file
BIN
public/textures/metal.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
public/textures/paper.jpg
Normal file
BIN
public/textures/paper.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
public/textures/plastic.jpg
Normal file
BIN
public/textures/plastic.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.4 KiB |
BIN
public/textures/rubber.jpg
Normal file
BIN
public/textures/rubber.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
|
|
@ -9,7 +9,7 @@ import { NextResponse } from 'next/server';
|
|||
// Host matching is exact or by subdomain (e.g. anything under
|
||||
// dvorakmortonxd.dev). Keep this in sync with the ServiceButton `url`/`checkUrl`
|
||||
// values in components/apps.tsx and the services list in
|
||||
// components/EditorialSite.tsx, and with the friend list in data.ts.
|
||||
// components/WebSite.tsx, and with the friend list in data.ts.
|
||||
const ALLOWED_HOSTS = [
|
||||
'dvorakmortonxd.dev', // ai / jelly / navidrome / git / proxmox / mc (SMP)
|
||||
'skopec.dev',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
138
src/app/credits/page.tsx
Normal file
138
src/app/credits/page.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import "../web.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "credits · dvorakmortonxd.dev",
|
||||
description: "Textures, type and other borrowed work used on dvorakmortonxd.dev.",
|
||||
};
|
||||
|
||||
const TEXTURES = [
|
||||
{ file: "metal.jpg", asset: "Metal 051 C", id: "Metal051C" },
|
||||
{ file: "paper.jpg", asset: "Paper 003", id: "Paper003" },
|
||||
{ file: "carbon.jpg", asset: "Fabric 004", id: "Fabric004" },
|
||||
{ file: "rubber.jpg", asset: "Rubber 004", id: "Rubber004" },
|
||||
{ file: "plastic.jpg", asset: "Plastic 012 A", id: "Plastic012A" },
|
||||
];
|
||||
|
||||
const FONTS = [
|
||||
{
|
||||
name: "Newsreader",
|
||||
by: "Production Type",
|
||||
use: "headlines, paper copy",
|
||||
href: "https://fonts.google.com/specimen/Newsreader",
|
||||
},
|
||||
{
|
||||
name: "IBM Plex Sans",
|
||||
by: "IBM",
|
||||
use: "body text",
|
||||
href: "https://fonts.google.com/specimen/IBM+Plex+Sans",
|
||||
},
|
||||
{
|
||||
name: "IBM Plex Mono",
|
||||
by: "IBM",
|
||||
use: "labels, readouts, LEDs",
|
||||
href: "https://fonts.google.com/specimen/IBM+Plex+Mono",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Credits() {
|
||||
return (
|
||||
<div className="w-root">
|
||||
<main className="shell pb-20 pt-24">
|
||||
<p className="w-kicker">credits</p>
|
||||
<h1 className="w-serif mt-3 text-[2rem] sm:text-[2.6rem]">Everything borrowed, credited.</h1>
|
||||
<p className="w-prose mt-5 max-w-[62ch]">
|
||||
The web theme is built out of a few CC0 textures and open-licensed typefaces. Nothing here is
|
||||
scraped or unlicensed — everything below is free to reuse, and the authors are named even where
|
||||
the licence does not require it.
|
||||
</p>
|
||||
|
||||
<section className="mt-12">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span className="w-plate">textures</span>
|
||||
<h2 className="w-serif text-[1.3rem]">ambientCG — CC0 1.0 (public domain)</h2>
|
||||
</div>
|
||||
<p className="w-prose mt-4 max-w-[62ch]">
|
||||
All five materials come from{" "}
|
||||
<a className="w-link" href="https://ambientcg.com" target="_blank" rel="noopener noreferrer">
|
||||
ambientCG
|
||||
</a>{" "}
|
||||
(Struffel Productions), released under the{" "}
|
||||
<a
|
||||
className="w-link"
|
||||
href="https://creativecommons.org/publicdomain/zero/1.0/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
CC0 1.0
|
||||
</a>{" "}
|
||||
public-domain dedication. I downloaded the 1K JPG colour maps, downscaled them to 512 px and
|
||||
tint them in CSS.
|
||||
</p>
|
||||
|
||||
<ul className="mt-6 grid gap-3 sm:grid-cols-2">
|
||||
{TEXTURES.map((t) => (
|
||||
<li key={t.id} className="w-paper w-card">
|
||||
<div className="w-serif text-[1.02rem]">{t.asset}</div>
|
||||
<div className="mt-1 font-mono text-[0.7rem] leading-relaxed text-[var(--paper-ink-soft)]">
|
||||
source:{" "}
|
||||
<a
|
||||
className="w-link"
|
||||
href={`https://ambientcg.com/view?id=${t.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
ambientCG / {t.id}
|
||||
</a>
|
||||
<br />
|
||||
licence: CC0 1.0 · used as <code>{`/textures/${t.file}`}</code>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mt-14">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span className="w-plate">type</span>
|
||||
<h2 className="w-serif text-[1.3rem]">Google Fonts — SIL Open Font License 1.1</h2>
|
||||
</div>
|
||||
<ul className="mt-6 grid gap-3 sm:grid-cols-3">
|
||||
{FONTS.map((f) => (
|
||||
<li key={f.name} className="w-paper w-card">
|
||||
<div className="w-serif text-[1.02rem]">{f.name}</div>
|
||||
<div className="mt-1 font-mono text-[0.7rem] leading-relaxed text-[var(--paper-ink-soft)]">
|
||||
by {f.by} · {f.use}
|
||||
<br />
|
||||
<a className="w-link" href={f.href} target="_blank" rel="noopener noreferrer">
|
||||
fonts.google.com
|
||||
</a>{" "}
|
||||
· OFL 1.1
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mt-14">
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<span className="w-plate">notes</span>
|
||||
<h2 className="w-serif text-[1.3rem]">Other marks</h2>
|
||||
</div>
|
||||
<p className="w-prose mt-4 max-w-[62ch]">
|
||||
The social buttons use brand marks that remain the property of their owners and are used only
|
||||
to link to those accounts. The project and hardware text is my own. The desktop mode (KDE
|
||||
sim) uses separate assets credited inside it.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="mt-14">
|
||||
<Link href="/" className="w-link font-mono text-[0.8rem]">
|
||||
← back to the rack
|
||||
</Link>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
/* ============================================================
|
||||
Editorial page — small page-scoped helpers.
|
||||
Layout is Tailwind utilities; only the pieces that can't be
|
||||
expressed that way live here (social-icon masks, heatmap).
|
||||
============================================================ */
|
||||
|
||||
/* Monochrome social icon: the SVG is used as an alpha mask and
|
||||
painted with currentColor, so it follows the theme. */
|
||||
.social-mask {
|
||||
display: inline-block;
|
||||
background-color: currentColor;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
mask-size: contain;
|
||||
}
|
||||
|
||||
/* GitDXD contribution heatmap: 53 columns of 7 cells. */
|
||||
.ed-heatmap {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.ed-heatmap-week {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.ed-heatmap-cell {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--fill-strong);
|
||||
}
|
||||
.ed-heatmap-legend {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.ed-heatmap-legend span.ed-heatmap-cell {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.ed-heatmap-cell {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
}
|
||||
|
|
@ -70,18 +70,6 @@ html[data-theme="web"] {
|
|||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
html[data-theme="web"] body {
|
||||
background-color: var(--bg);
|
||||
background-image: radial-gradient(120% 60% at 50% -10%, rgba(254, 128, 25, 0.05), transparent 70%);
|
||||
background-repeat: no-repeat;
|
||||
color: var(--body);
|
||||
font-family: var(--font-plex), ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
html[data-theme="web"] ::selection {
|
||||
background: rgba(254, 128, 25, 0.28);
|
||||
color: var(--ink);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Metadata } from 'next';
|
||||
import './page.css';
|
||||
import './skeuo.css';
|
||||
import './editorial.css';
|
||||
import './web.css';
|
||||
import ThemeRoot from './components/ThemeRoot';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
|
|||
634
src/app/web.css
Normal file
634
src/app/web.css
Normal file
|
|
@ -0,0 +1,634 @@
|
|||
/* ============================================================
|
||||
WEB THEME — a skeuomorphic homelab desk.
|
||||
------------------------------------------------------------
|
||||
Modern dark hardware: anodised aluminium, OLED glass, carbon
|
||||
fibre, black rubber and matte plastic, with paper objects
|
||||
resting on it. Textures are CC0 (see /credits). Everything is
|
||||
scoped under `.w-root` so the KDE desktop is never affected.
|
||||
============================================================ */
|
||||
|
||||
html[data-theme="web"] {
|
||||
color-scheme: dark;
|
||||
|
||||
/* room + chassis */
|
||||
--room: #0f1113;
|
||||
--room-2: #15181a;
|
||||
--chassis: #24282b;
|
||||
--chassis-2: #1a1e20;
|
||||
--chassis-edge: #0a0c0d;
|
||||
--screen: #0b0e0f;
|
||||
|
||||
/* ink */
|
||||
--ink: #fbf1c7;
|
||||
--body: #d5c4a1;
|
||||
--muted: #a89984;
|
||||
--faint: #928374;
|
||||
--paper-ink: #2a2521;
|
||||
--paper-ink-soft: #5c544b;
|
||||
|
||||
/* signals */
|
||||
--accent: #fe8019;
|
||||
--accent-2: #fabd2f;
|
||||
--led-green: #b8bb26;
|
||||
--led-amber: #fabd2f;
|
||||
--led-red: #fb4934;
|
||||
--led-blue: #83a598;
|
||||
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
html[data-theme="web"] body {
|
||||
background:
|
||||
radial-gradient(120% 70% at 50% -15%, #191d20 0%, transparent 55%),
|
||||
linear-gradient(var(--room-2), var(--room));
|
||||
background-attachment: fixed;
|
||||
color: var(--body);
|
||||
font-family: var(--font-plex), ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.65;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- materials */
|
||||
|
||||
.w-metal {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(0, 0, 0, 0.16) 40%, rgba(0, 0, 0, 0.34)),
|
||||
linear-gradient(rgba(32, 36, 39, 0.8), rgba(18, 21, 23, 0.86)),
|
||||
url("/textures/metal.jpg") center / 420px;
|
||||
border: 1px solid var(--chassis-edge);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.07),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.55),
|
||||
0 18px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.w-metal-flat {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(0, 0, 0, 0.22)),
|
||||
linear-gradient(rgba(30, 34, 37, 0.86), rgba(20, 23, 25, 0.9)),
|
||||
url("/textures/metal.jpg") center / 380px;
|
||||
border: 1px solid #0b0d0e;
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.w-carbon {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(0, 0, 0, 0.4)),
|
||||
linear-gradient(rgba(26, 29, 31, 0.66), rgba(14, 16, 18, 0.78)),
|
||||
url("/textures/carbon.jpg") center / 150px, #14171a;
|
||||
border: 1px solid #0a0c0d;
|
||||
border-radius: 7px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
||||
0 6px 14px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.w-rubber {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(0, 0, 0, 0.35)),
|
||||
linear-gradient(rgba(22, 25, 27, 0.66), rgba(12, 14, 15, 0.76)),
|
||||
url("/textures/rubber.jpg") center / 280px;
|
||||
border: 1px solid #090b0c;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.w-plastic {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(0, 0, 0, 0.3)),
|
||||
linear-gradient(rgba(34, 38, 41, 0.88), rgba(20, 23, 25, 0.92)),
|
||||
url("/textures/plastic.jpg") center / 240px;
|
||||
border: 1px solid #0a0c0d;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.w-paper {
|
||||
background:
|
||||
linear-gradient(rgba(253, 250, 243, 0.88), rgba(243, 236, 223, 0.9)),
|
||||
url("/textures/paper.jpg") center / 340px;
|
||||
color: var(--paper-ink);
|
||||
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 1px 0 rgba(255, 255, 255, 0.6) inset,
|
||||
0 10px 22px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
|
||||
.w-panel {
|
||||
position: relative;
|
||||
background:
|
||||
radial-gradient(130% 90% at 50% -10%, rgba(254, 128, 25, 0.07), transparent 62%),
|
||||
linear-gradient(var(--screen), #070a0b);
|
||||
border: 1px solid #000;
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.045),
|
||||
inset 0 14px 34px rgba(0, 0, 0, 0.75),
|
||||
0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* faint scanlines so the glass reads as a panel, not a flat fill */
|
||||
.w-panel::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: repeating-linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.022) 0 1px,
|
||||
transparent 1px 3px
|
||||
);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- details */
|
||||
|
||||
/* NOTE: no class in here may collide with a Tailwind utility name. `.w-screen`
|
||||
was a real bug: Tailwind's `w-screen` sets width:100vw and beat this file, so
|
||||
every "screen" panel was forced to full viewport width. Keep the `w-` names
|
||||
away from Tailwind's width namespace (`w-full`, `w-screen`, `w-min`, ...). */
|
||||
|
||||
.w-screw {
|
||||
position: relative;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 30%, #767c7a, #2b2f30 68%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(0, 0, 0, 0.65),
|
||||
0 1px 1px rgba(0, 0, 0, 0.5);
|
||||
flex: none;
|
||||
}
|
||||
.w-screw::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
background: linear-gradient(90deg, transparent 38%, #101314 38%, #101314 62%, transparent 62%);
|
||||
transform: rotate(38deg);
|
||||
}
|
||||
|
||||
.w-vent {
|
||||
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.85) 1.1px, transparent 1.3px);
|
||||
background-size: 8px 8px;
|
||||
background-position: 0 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.w-led {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
box-shadow:
|
||||
0 0 7px currentColor,
|
||||
inset 0 0 2px rgba(255, 255, 255, 0.7);
|
||||
flex: none;
|
||||
}
|
||||
.w-led--green { color: var(--led-green); }
|
||||
.w-led--amber { color: var(--led-amber); }
|
||||
.w-led--red { color: var(--led-red); }
|
||||
.w-led--blue { color: var(--led-blue); }
|
||||
.w-led--off {
|
||||
color: #3a3f41;
|
||||
box-shadow: inset 0 0 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.w-led--pulse {
|
||||
animation: w-led-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes w-led-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
/* etched / engraved label plate */
|
||||
.w-plate {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.28rem 0.6rem;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(180deg, #2a2e31, #1b1f21);
|
||||
border: 1px solid #0b0d0e;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.07),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.5);
|
||||
color: var(--faint);
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
/* push button */
|
||||
.w-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.95rem;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #3c4145, #24282b);
|
||||
border: 1px solid #0b0d0e;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.02em;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.14),
|
||||
0 3px 0 #0a0c0d,
|
||||
0 9px 18px rgba(0, 0, 0, 0.45);
|
||||
transition: filter 140ms ease-out, transform 90ms ease-out, box-shadow 90ms ease-out;
|
||||
}
|
||||
.w-btn:hover { filter: brightness(1.12); }
|
||||
.w-btn:active {
|
||||
transform: translateY(3px);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
||||
0 0 0 #0a0c0d,
|
||||
0 4px 10px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.w-btn--accent {
|
||||
background: linear-gradient(180deg, #ff9a3d, #d65d0e);
|
||||
border-color: #6d2f06;
|
||||
color: #22160a;
|
||||
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.w-btn--accent:hover { filter: brightness(1.06); }
|
||||
|
||||
/* rocker power switch */
|
||||
.w-switch {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(180deg, #0d0f10, #191d1f);
|
||||
border: 1px solid #000;
|
||||
box-shadow: inset 0 3px 7px rgba(0, 0, 0, 0.85);
|
||||
flex: none;
|
||||
}
|
||||
.w-switch__knob {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 24px;
|
||||
height: 22px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(180deg, #565d61, #2b3033);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22),
|
||||
0 2px 4px rgba(0, 0, 0, 0.6);
|
||||
transition: transform 180ms var(--ease);
|
||||
}
|
||||
.w-switch__knob::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 6px 5px;
|
||||
border-radius: 2px;
|
||||
background: repeating-linear-gradient(180deg, rgba(255, 255, 255, 0.18) 0 1px, transparent 1px 4px);
|
||||
}
|
||||
[data-desktop-mode="on"] .w-switch__knob { transform: translateX(26px); }
|
||||
.w-switch__led {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
[data-desktop-mode="on"] .w-switch__led { color: var(--led-green); }
|
||||
|
||||
/* ------------------------------------------------------------ objects */
|
||||
|
||||
/* top chassis rail */
|
||||
.w-rail {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
z-index: 50;
|
||||
border-bottom: 1px solid #050607;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
/* the hero rack */
|
||||
.w-rack {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
.w-rack__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 4px 6px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
.w-rack__title {
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
.w-rack__ears {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* a 1U unit */
|
||||
.w-unit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
min-height: 58px;
|
||||
}
|
||||
.w-unit__leds {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: none;
|
||||
}
|
||||
.w-unit__name {
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.9rem;
|
||||
color: var(--ink);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.w-unit__meta {
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
color: var(--faint);
|
||||
}
|
||||
.w-unit--link {
|
||||
transition: filter 150ms ease-out, transform 150ms ease-out;
|
||||
}
|
||||
.w-unit--link:hover {
|
||||
filter: brightness(1.14);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* engraved brass-style nameplate */
|
||||
.w-nameplate {
|
||||
padding: 1.1rem 1.3rem;
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(0, 0, 0, 0.22)),
|
||||
linear-gradient(rgba(38, 42, 45, 0.9), rgba(23, 26, 28, 0.94)),
|
||||
url("/textures/metal.jpg") center / 460px;
|
||||
border: 1px solid #0a0c0d;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.09),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.w-nameplate h1 {
|
||||
font-family: var(--font-news), Georgia, serif;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.06;
|
||||
color: var(--ink);
|
||||
text-shadow:
|
||||
0 1px 0 rgba(0, 0, 0, 0.85),
|
||||
0 -1px 0 rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* paper objects */
|
||||
.w-note {
|
||||
padding: 1.1rem 1.2rem;
|
||||
transform: rotate(-0.6deg);
|
||||
}
|
||||
.w-note--tilt { transform: rotate(0.7deg); }
|
||||
.w-card {
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
.w-grommet {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 40% 35%, #6d7371, #26292a 70%);
|
||||
box-shadow: inset 0 0 0 2px rgba(0, 0, 0, 0.45), 0 1px 1px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* SSD caddy */
|
||||
.w-caddy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
transition: transform 150ms var(--ease), filter 150ms ease-out;
|
||||
}
|
||||
.w-caddy:hover { transform: translateY(-2px); filter: brightness(1.15); }
|
||||
.w-caddy__label {
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.82rem;
|
||||
color: var(--ink);
|
||||
}
|
||||
.w-caddy__status {
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* drawer */
|
||||
.w-drawer {
|
||||
padding: 12px;
|
||||
}
|
||||
.w-drawer__handle {
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #3a3f43, #22262a);
|
||||
border: 1px solid #0a0c0d;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12), 0 2px 5px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* pegboard + hanging tag */
|
||||
.w-pegboard {
|
||||
padding: 14px;
|
||||
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.7) 1.4px, transparent 1.6px);
|
||||
background-size: 22px 22px;
|
||||
}
|
||||
.w-tag {
|
||||
padding: 0.9rem 1rem 0.9rem 2.6rem;
|
||||
position: relative;
|
||||
}
|
||||
.w-tag__ring {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at 40% 35%, #6d7371, #26292a 70%);
|
||||
box-shadow:
|
||||
inset 0 0 0 2px rgba(0, 0, 0, 0.45),
|
||||
0 1px 1px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* LED dashboard */
|
||||
.w-dashboard {
|
||||
padding: 14px;
|
||||
}
|
||||
.w-ledgrid {
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.w-ledgrid__col { display: flex; flex-direction: column; gap: 3px; }
|
||||
.w-ledgrid__cell {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
background: #23272a;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.w-ledgrid__cell[data-on="1"] { background: var(--led-green); box-shadow: 0 0 6px rgba(184, 187, 38, 0.65); }
|
||||
.w-ledgrid__cell[data-on="2"] { background: #9ea22a; box-shadow: 0 0 6px rgba(158, 162, 42, 0.6); }
|
||||
.w-ledgrid__cell[data-on="3"] { background: #6f7420; }
|
||||
.w-ledgrid__cell[data-on="4"] { background: #4a4e16; }
|
||||
|
||||
/* scoreboard digits */
|
||||
.w-digits {
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-weight: 500;
|
||||
color: var(--led-amber);
|
||||
text-shadow: 0 0 12px rgba(250, 189, 47, 0.45);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* patch panel */
|
||||
.w-patch {
|
||||
padding: 14px;
|
||||
}
|
||||
.w-port {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 11px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(180deg, #2c3134, #1c2022);
|
||||
border: 1px solid #0a0c0d;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), inset 0 -2px 0 rgba(0, 0, 0, 0.45);
|
||||
transition: filter 140ms ease-out, transform 140ms ease-out;
|
||||
}
|
||||
.w-port:hover { filter: brightness(1.16); transform: translateY(-1px); }
|
||||
.w-port__jack {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(180deg, #0b0d0e, #1a1e20);
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.9);
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
.w-port__jack::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
right: 3px;
|
||||
top: 5px;
|
||||
height: 3px;
|
||||
border-radius: 1px;
|
||||
background: #3f4548;
|
||||
}
|
||||
.w-port__label {
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.72rem;
|
||||
color: var(--body);
|
||||
}
|
||||
|
||||
/* nav link — its own class because these rules are unlayered and would beat
|
||||
Tailwind's hover utilities on the same element */
|
||||
.w-navlink {
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
color: var(--faint);
|
||||
transition: color 140ms ease-out;
|
||||
}
|
||||
.w-navlink:hover { color: var(--ink); }
|
||||
|
||||
/* monochrome social icon: the SVG is an alpha mask painted with currentColor */
|
||||
.social-mask {
|
||||
display: inline-block;
|
||||
background-color: currentColor;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-position: center;
|
||||
-webkit-mask-size: contain;
|
||||
mask-size: contain;
|
||||
}
|
||||
|
||||
/* screen text */
|
||||
.w-screen-text {
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.7;
|
||||
color: var(--body);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.w-screen-text .k { color: var(--accent); }
|
||||
.w-screen-text .v { color: var(--ink); }
|
||||
|
||||
.w-cursor {
|
||||
display: inline-block;
|
||||
width: 0.55em;
|
||||
height: 1em;
|
||||
background: var(--accent);
|
||||
vertical-align: -0.15em;
|
||||
animation: w-blink 1.05s step-end infinite;
|
||||
}
|
||||
@keyframes w-blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* body copy on paper / machine */
|
||||
.w-serif {
|
||||
font-family: var(--font-news), Georgia, serif;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.w-paper .w-serif { color: var(--paper-ink); }
|
||||
.w-prose {
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.w-paper .w-prose { color: var(--paper-ink-soft); }
|
||||
.w-kicker {
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
.w-link {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-thickness: 1px;
|
||||
text-decoration-color: rgba(254, 128, 25, 0.45);
|
||||
}
|
||||
.w-link:hover { text-decoration-color: var(--accent); }
|
||||
.w-paper .w-link { color: #b1560c; text-decoration-color: rgba(177, 86, 12, 0.45); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.w-led--pulse,
|
||||
.w-cursor { animation: none !important; }
|
||||
.w-btn,
|
||||
.w-btn:hover,
|
||||
.w-btn:active,
|
||||
.w-unit--link,
|
||||
.w-caddy,
|
||||
.w-port,
|
||||
.w-switch__knob { transition: none !important; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue