rework the site: gruvbox editorial default with a desktop-mode toggle; drop the terminal layout and phone desktop
This commit is contained in:
parent
976fc362ff
commit
86692637de
19 changed files with 1350 additions and 1664 deletions
453
src/app/components/EditorialSite.tsx
Normal file
453
src/app/components/EditorialSite.tsx
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
"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">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<div className="mt-12">
|
||||
<ul className="flex flex-col">
|
||||
{SERVICES.map((s) => (
|
||||
<li key={s.name}>
|
||||
<ServiceRow {...s} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</Section>
|
||||
|
||||
</main>
|
||||
|
||||
{/* ---------------------------------------------------------- footer */}
|
||||
<footer className="shell pb-14 pt-10">
|
||||
<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>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import { APPS, APP_BY_ID } from "./apps";
|
||||
import Clock from "./Clock";
|
||||
import { useDragOrder } from "./useDragOrder";
|
||||
import { MOBILE_WALLPAPER } from "../data";
|
||||
|
||||
const DOCK_IDS = ["projects", "services", "socials", "contributions"];
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
const id = window.setInterval(callback, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}
|
||||
|
||||
/** Digital iOS-style clock widget: big time, small day/date underneath. */
|
||||
function HomeWidget() {
|
||||
const now = useSyncExternalStore(
|
||||
subscribe,
|
||||
() => {
|
||||
const d = new Date();
|
||||
return [
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
d.toLocaleDateString([], { weekday: "long", day: "numeric", month: "short" }),
|
||||
].join("\n");
|
||||
},
|
||||
() => "\n"
|
||||
);
|
||||
const [time, day] = now.split("\n");
|
||||
|
||||
return (
|
||||
<div className="mobile-widget">
|
||||
<div className="mobile-widget-time" suppressHydrationWarning>
|
||||
{time}
|
||||
</div>
|
||||
<div className="mobile-widget-date" suppressHydrationWarning>
|
||||
{day}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LaunchOrigin {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export default function MobileHome() {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [origin, setOrigin] = useState<LaunchOrigin | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const openApp = openId ? APP_BY_ID[openId] : null;
|
||||
const closeTimer = useRef<number | null>(null);
|
||||
|
||||
const [gridApps, setGridApps] = useState(APPS);
|
||||
const [dockApps, setDockApps] = useState(() => DOCK_IDS.map((id) => ({ id })));
|
||||
const gridDrag = useDragOrder(setGridApps);
|
||||
const dockDrag = useDragOrder(setDockApps);
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
if (closeTimer.current !== null) {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback for when the close animation never reports completion (e.g.
|
||||
// reduced-motion, throttled tabs or a missed animationend). Without this the
|
||||
// full-screen app view can linger and swallow every tap on the home screen.
|
||||
const closeApp = () => {
|
||||
setClosing(true);
|
||||
clearCloseTimer();
|
||||
closeTimer.current = window.setTimeout(() => {
|
||||
setOpenId(null);
|
||||
setClosing(false);
|
||||
closeTimer.current = null;
|
||||
}, 600);
|
||||
};
|
||||
|
||||
useEffect(() => () => clearCloseTimer(), []);
|
||||
|
||||
// Compute a transform-origin so the scaled-down app view sits exactly on top
|
||||
// of the tapped icon, then let it grow to fill the screen (iOS style).
|
||||
const launch = (appId: string, e: MouseEvent<HTMLButtonElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const icon = rect.width > 0 ? rect.width : rect.height;
|
||||
// Guard against a zero/degenerate measured icon (e.g. hidden layout) so the
|
||||
// scale tween never collapses to 0 and the open animation can always run.
|
||||
const scale = icon > 0 ? Math.min(icon / window.innerWidth, 0.95) : 0.2;
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const denom = 1 - scale || 1;
|
||||
setOrigin({
|
||||
x: (cx - (scale * window.innerWidth) / 2) / denom,
|
||||
y: (cy - (scale * window.innerHeight) / 2) / denom,
|
||||
scale,
|
||||
});
|
||||
setClosing(false);
|
||||
setOpenId(appId);
|
||||
};
|
||||
|
||||
const renderIcon = (appId: string, inDock: boolean, drag: ReturnType<typeof useDragOrder>) => {
|
||||
const app = APP_BY_ID[appId];
|
||||
if (!app) return null;
|
||||
return (
|
||||
<button
|
||||
key={app.id}
|
||||
type="button"
|
||||
data-drag-id={app.id}
|
||||
className={
|
||||
"mobile-app" +
|
||||
(inDock ? " in-dock" : "") +
|
||||
(drag.dragId === app.id ? " dragging" : "")
|
||||
}
|
||||
onPointerDown={drag.onPointerDown(app.id)}
|
||||
onClick={(e) => {
|
||||
if (drag.consumeDragged()) return;
|
||||
launch(app.id, e);
|
||||
}}
|
||||
>
|
||||
<span className="mobile-app-icon">
|
||||
<span className="mobile-app-glyph">{app.icon}</span>
|
||||
</span>
|
||||
{!inDock && <span className="mobile-app-label">{app.title}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// The transform origin is exposed as a CSS variable (not an inline
|
||||
// transform-origin) so only the opening keyframe uses the tapped icon as its
|
||||
// pivot. The closing animation keeps a centred origin and always slides down.
|
||||
const viewStyle = origin
|
||||
? ({
|
||||
"--app-origin": `${origin.x}px ${origin.y}px`,
|
||||
"--app-scale": String(origin.scale),
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="mobile-root">
|
||||
<div
|
||||
className="mobile-home"
|
||||
style={{ backgroundImage: `url('${MOBILE_WALLPAPER}')` }}
|
||||
aria-hidden={openApp ? true : undefined}
|
||||
>
|
||||
<div className="mobile-statusbar">
|
||||
<span className="mobile-status-time">
|
||||
<Clock />
|
||||
</span>
|
||||
<span className="mobile-status-icons" aria-hidden="true">
|
||||
<svg width="17" height="11" viewBox="0 0 17 11" fill="currentColor">
|
||||
<rect x="0" y="7" width="3" height="4" rx="1" />
|
||||
<rect x="4.5" y="5" width="3" height="6" rx="1" />
|
||||
<rect x="9" y="2.5" width="3" height="8.5" rx="1" />
|
||||
<rect x="13.5" y="0" width="3" height="11" rx="1" />
|
||||
</svg>
|
||||
<svg width="16" height="11" viewBox="0 0 16 11" fill="currentColor">
|
||||
<path d="M8 2.2c2.3 0 4.4.9 6 2.4l-1.2 1.3A6.9 6.9 0 0 0 8 4.2a6.9 6.9 0 0 0-4.8 1.7L2 4.6A8.5 8.5 0 0 1 8 2.2Z" />
|
||||
<path d="M8 5.6c1.5 0 2.8.6 3.8 1.6l-1.2 1.3A3.7 3.7 0 0 0 8 7.6c-1 0-1.9.4-2.6 1l-1.2-1.4A5.7 5.7 0 0 1 8 5.6Z" />
|
||||
<path d="M8 9a1.4 1.4 0 1 1 0 2.8A1.4 1.4 0 0 1 8 9Z" />
|
||||
</svg>
|
||||
<svg width="25" height="12" viewBox="0 0 25 12" aria-hidden="true">
|
||||
<rect
|
||||
x="0.5"
|
||||
y="0.5"
|
||||
width="21"
|
||||
height="11"
|
||||
rx="3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
opacity="0.5"
|
||||
/>
|
||||
<rect x="2" y="2" width="14" height="8" rx="2" fill="currentColor" />
|
||||
<path d="M23 4v4a2 2 0 0 0 0-4Z" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mobile-grid">
|
||||
<HomeWidget />
|
||||
{gridApps.map((app) => renderIcon(app.id, false, gridDrag))}
|
||||
</div>
|
||||
|
||||
<div className="mobile-dock">
|
||||
{dockApps.map((d) => renderIcon(d.id, true, dockDrag))}
|
||||
</div>
|
||||
|
||||
<div className="mobile-pagedots" aria-hidden="true">
|
||||
<span className="active" />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{openApp && (
|
||||
<div
|
||||
className={"mobile-app-view" + (closing ? " closing" : " opening")}
|
||||
style={viewStyle}
|
||||
onAnimationEnd={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (closing) {
|
||||
clearCloseTimer();
|
||||
setOpenId(null);
|
||||
setClosing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header className="mobile-appbar">
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-back"
|
||||
onClick={closeApp}
|
||||
aria-label="Back"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span className="mobile-appbar-title">{openApp.title}</span>
|
||||
</header>
|
||||
<div className="mobile-app-body app-surface">{openApp.content}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const TRAIN_ASCII = ` ==== ________ ___________
|
||||
_D _| |_______/ \\__I_I_____===__|_________|
|
||||
|(_)--- | H\\________/ | | =|___ ___| _________________ _________________ _________________ _________________ _________________ _________________
|
||||
/ | | H | | | | ||_| |_|| _| \\_____| \\_____| \\_____| \\_____| \\_____| \\_____A
|
||||
| | | H |__--------------------| [___] | =| | | | | | |
|
||||
| ________|___H__/__|_____/[][]~\\_______| | -| | | | | | |
|
||||
|/ | |-----------I_____I [][] [] D |=======|____|________________________|_______________________|_______________________|_______________________|_______________________|_______________________|_
|
||||
__/ =| o |=-~~\\ /~~\\ /~~\\ /~~\\ ____Y___________|__|__________________________|_______________________|_______________________|_______________________|_______________________|_______________________|
|
||||
|/-=|___|= || || || |_____/~\\___/ |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_| |_D__D__D_|
|
||||
\\_/ \\O=====O=====O=====O_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/ \\_/`;
|
||||
|
||||
export default function SlBackground() {
|
||||
const [pos, setPos] = useState(150);
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrameId: number;
|
||||
let lastTime = 0;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!lastTime) lastTime = timestamp;
|
||||
const delta = timestamp - lastTime;
|
||||
|
||||
// Update position every ~30ms to create a retro sliding terminal refresh feel
|
||||
if (delta > 45) {
|
||||
setPos((prev) => {
|
||||
if (prev <= -150) return 150;
|
||||
return prev - 1.5;
|
||||
});
|
||||
lastTime = timestamp;
|
||||
}
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes rainbowShift {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
className="sl-background"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
zIndex: 0,
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
paddingBottom: "5vh",
|
||||
justifyContent: "center",
|
||||
userSelect: "none",
|
||||
backgroundImage: "linear-gradient(rgba(29, 32, 33, 0.94), rgba(29, 32, 33, 0.94)), url('/gruvbox.webp')",
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: "pre",
|
||||
fontFamily: "var(--font-custom-mono), monospace",
|
||||
fontSize: "1rem",
|
||||
transform: "translateX(" + pos + "vw)",
|
||||
willChange: "transform",
|
||||
background: "linear-gradient(90deg, var(--red), var(--accent2), var(--accent), var(--green), var(--aqua), var(--blue), var(--purple))",
|
||||
backgroundSize: "200% auto",
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
animation: "rainbowShift 3s linear infinite",
|
||||
opacity: 0.35,
|
||||
filter: "drop-shadow(0 0 12px rgba(250, 189, 47, 0.5))",
|
||||
}}
|
||||
>
|
||||
{TRAIN_ASCII}
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,603 +0,0 @@
|
|||
import Image from "next/image";
|
||||
import type { CSSProperties } from "react";
|
||||
import SlBackground from "./SlBackground";
|
||||
import JokerLogo from "./JokerLogo";
|
||||
import ServiceButton from "./ServiceButton";
|
||||
import ContributionHeatmap from "./ContributionHeatmap";
|
||||
import { useTheme } from "./useTheme";
|
||||
import { socials, codingProjects, techProjects, friends } from "../data";
|
||||
|
||||
type PanelStyle = CSSProperties & { "--panel-color"?: string };
|
||||
|
||||
const panelStyle = (color: string, style: CSSProperties = {}): PanelStyle => ({
|
||||
...style,
|
||||
"--panel-color": color,
|
||||
});
|
||||
|
||||
export default function TerminalLayout() {
|
||||
const { toggle } = useTheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlBackground />
|
||||
<div className="container">
|
||||
<header className="header">
|
||||
<div className="header-center">
|
||||
<JokerLogo />
|
||||
<h1 className="site-name">dvorakmortonxd.dev</h1>
|
||||
<button type="button" className="header-theme-btn" onClick={toggle}>
|
||||
Desktop
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="hero">
|
||||
<div className="hero-panel" style={panelStyle("var(--accent)")}>
|
||||
<span
|
||||
className="hero-panel-label"
|
||||
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
|
||||
>
|
||||
README.MD
|
||||
</span>
|
||||
<h2 className="hero-title">$ whoami</h2>
|
||||
<p className="hero-subtitle">
|
||||
Welcome my personal website! Here I show off my projects and hardware I own,
|
||||
aswell as my self-hosted services :)<span className="cursor-blink"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="social-section">
|
||||
<div className="tui-panel" style={panelStyle("var(--accent)")}>
|
||||
<span
|
||||
className="tui-panel-label"
|
||||
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
|
||||
>
|
||||
SOCIALS.JS
|
||||
</span>
|
||||
<div className="social-buttons">
|
||||
{socials.map((s) => (
|
||||
<a
|
||||
key={s.name}
|
||||
href={s.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="social-button"
|
||||
>
|
||||
<Image
|
||||
src={s.icon}
|
||||
alt={s.name}
|
||||
width={16}
|
||||
height={16}
|
||||
className="social-icon"
|
||||
/>
|
||||
<span className="social-label">{s.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="mail-contact">
|
||||
mail if u ever wanna contact me:{" "}
|
||||
<a href="mailto:dvorak@dvorakmortonxd.dev">dvorak@dvorakmortonxd.dev</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gym-section">
|
||||
<div className="tui-panel" style={panelStyle("var(--white)")}>
|
||||
<span
|
||||
className="tui-panel-label"
|
||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
||||
>
|
||||
GYM_SBD_AT_73KG_BW.DB
|
||||
</span>
|
||||
<div className="gym-grid">
|
||||
<div className="gym-item">
|
||||
<Image src="/squat.svg" alt="Squat" width={48} height={48} className="gym-icon" />
|
||||
<div className="gym-label">SQUAT</div>
|
||||
<div className="gym-weight">125kg</div>
|
||||
<div className="gym-date">5.1.2025</div>
|
||||
</div>
|
||||
<div className="gym-item">
|
||||
<Image src="/bench.svg" alt="Bench" width={48} height={48} className="gym-icon" />
|
||||
<div className="gym-label">BENCH</div>
|
||||
<div className="gym-weight">111kg</div>
|
||||
<div className="gym-date">29.5.2026</div>
|
||||
</div>
|
||||
<div className="gym-item">
|
||||
<Image
|
||||
src="/deadlift.svg"
|
||||
alt="Deadlift"
|
||||
width={48}
|
||||
height={48}
|
||||
className="gym-icon"
|
||||
/>
|
||||
<div className="gym-label">DEADLIFT</div>
|
||||
<div className="gym-weight">165kg</div>
|
||||
<div className="gym-date">17.12.2024</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="about-section">
|
||||
<div className="tui-panel" style={panelStyle("var(--white)")}>
|
||||
<span
|
||||
className="tui-panel-label"
|
||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
||||
>
|
||||
NOW.TXT
|
||||
</span>
|
||||
<div className="about-content">
|
||||
<p>
|
||||
<span style={{ color: "var(--green)" }}>martin@dvorakmortonxd</span>:
|
||||
<span className="text-blue">~</span>$ cat now.txt
|
||||
</p>
|
||||
<div style={{ color: "#a3a3a3" }}>
|
||||
<p>
|
||||
I'm currently hosting an SMP (dm me on discord). Also I'm pretty
|
||||
much cooking daily, because im bulking through out the non-summer seasons.
|
||||
And me and my pal <a href="https://skopec.dev">Pepa</a> are working on an
|
||||
education app so we can study more efficiently, It's called{" "}
|
||||
<a href="https://git.dvorakmortonxd.dev/edu/maturita.c">maturita.c</a>!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="about-section">
|
||||
<div className="tui-panel" style={panelStyle("var(--accent)")}>
|
||||
<span
|
||||
className="tui-panel-label"
|
||||
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
|
||||
>
|
||||
README.MD
|
||||
</span>
|
||||
<div className="about-content">
|
||||
<p>
|
||||
<span className="text-green">martin@dvorakmortonxd</span>:
|
||||
<span className="text-blue">~</span>$ cat about_me.md
|
||||
</p>
|
||||
<div style={{ color: "#a3a3a3" }}>
|
||||
<p>
|
||||
Hey, I'm Martin Dvořák — a 17(soon to be 18)-year-old IT student based
|
||||
in the Czech Republic, specializing in network management and
|
||||
administration at <a href="https://sssvt.cz">sssvt.cz</a>. I'm a
|
||||
sophomore and I have wanted to go to this school since 7th grade (2022) so
|
||||
I'm happy I got in in 2025, the school is pretty cool so if you are in
|
||||
middle school and interested in IT, make sure to check it out. I'm a
|
||||
homelabber and developer heavily focused on self-hosting{" "}
|
||||
<i>literally everything</i>, writing code (for example games for gamejams),
|
||||
tinkering with GNU/Linux or Musl/Linux systems and retro hardware. I own a
|
||||
small collection of laptops ranging from 2009 to 2014 (and ofcourse my 2025
|
||||
macbook), most of these found in ewaste. In my free time I mostly spend
|
||||
time with my gf {"<"}3, I also spend a bit of time setting up my self
|
||||
hosted stuff (like jellyfin or gitdxd), and also when I'm in my gaming
|
||||
mood I play terraria (around 1300hr playtime btw) and minecraft modpacks
|
||||
with mekanism and/or create. I also go to the gym around 3-5 times a week,
|
||||
running a PPL split focused on strength/powerlifting and especially SBD. I
|
||||
take a big focus on my health and physique.
|
||||
<span className="cursor-blink"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="services-section">
|
||||
<div className="tui-panel" style={panelStyle("var(--accent)")}>
|
||||
<span
|
||||
className="tui-panel-label"
|
||||
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
|
||||
>
|
||||
SERVICES.HTML
|
||||
</span>
|
||||
<div className="services-buttons services-main">
|
||||
<ServiceButton
|
||||
url="https://proxmox.dvorakmortonxd.dev/"
|
||||
className="proxmox"
|
||||
icon="🖥️"
|
||||
name="Proxmox"
|
||||
desc="Server Management"
|
||||
access="private"
|
||||
disabled
|
||||
/>
|
||||
<ServiceButton
|
||||
url="https://ai.dvorakmortonxd.dev/"
|
||||
className="ai"
|
||||
icon="🦾"
|
||||
name="Llama.cpp"
|
||||
desc="Local AI Server"
|
||||
access="on_request"
|
||||
/>
|
||||
<ServiceButton
|
||||
url="https://jelly.dvorakmortonxd.dev/"
|
||||
className="jellyfin"
|
||||
icon="🎬"
|
||||
name="Jellyfin"
|
||||
desc="Local Media Server"
|
||||
access="on_request"
|
||||
/>
|
||||
<ServiceButton
|
||||
url="https://discord.gg/vyvhk7d8bX"
|
||||
checkUrl="minecraft://mc.dvorakmortonxd.dev:25565"
|
||||
className="pterodactyl"
|
||||
icon="🎮"
|
||||
name="SMP"
|
||||
desc="My Survival Minecraft Server"
|
||||
access="on_request"
|
||||
/>
|
||||
<ServiceButton
|
||||
url="https://navidrome.dvorakmortonxd.dev/"
|
||||
className="funkwhale"
|
||||
icon="🎸"
|
||||
name="Navidrome"
|
||||
desc="Music Streaming"
|
||||
access="private"
|
||||
/>
|
||||
<ServiceButton
|
||||
url="https://git.dvorakmortonxd.dev"
|
||||
className="gitdxd"
|
||||
icon="🐙"
|
||||
name="GitDXD"
|
||||
desc="Best Git Hosting OAT"
|
||||
access="public"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gitdxd-section" style={{ marginTop: "20px" }}>
|
||||
<div className="tui-panel" style={panelStyle("var(--white)")}>
|
||||
<span
|
||||
className="tui-panel-label"
|
||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
||||
>
|
||||
GITDXD.GIT
|
||||
</span>
|
||||
<div style={{ padding: "24px 16px 16px" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "2.4rem",
|
||||
fontWeight: "bold",
|
||||
color: "var(--white)",
|
||||
marginBottom: "16px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
GitDXD
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "1.8rem",
|
||||
color: "var(--white)",
|
||||
fontWeight: "500",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
My Self-hosted Git Instance
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "16px",
|
||||
marginBottom: "16px",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(255, 255, 255, 0.4)",
|
||||
boxShadow: "0 0 30px rgba(180, 255, 125, 0.15)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/gitdxd.webp"
|
||||
alt="GitDXD Preview"
|
||||
width={1280}
|
||||
height={720}
|
||||
style={{ width: "100%", height: "auto", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--gray)",
|
||||
marginTop: "12px",
|
||||
lineHeight: "1.6",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
My very own forgejo git instance, originally started as a private git
|
||||
instance for me and my friends to boycott GitHub but I've made it
|
||||
public now so. Join at{" "}
|
||||
<span className="text-purple-500">
|
||||
<a href="https://git.dvorakmortonxd.dev">git.dvorakmortonxd.dev</a>
|
||||
</span>
|
||||
.<span className="cursor-blink"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="commits-section" style={{ marginTop: "20px" }}>
|
||||
<div className="tui-panel" style={panelStyle("var(--accent)")}>
|
||||
<span
|
||||
className="tui-panel-label"
|
||||
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
|
||||
>
|
||||
CONTRIBUTIONS@GITDXD.GIT
|
||||
</span>
|
||||
<div className="commits-content" style={{ padding: "24px 12px 16px" }}>
|
||||
<ContributionHeatmap />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="revival-section" style={{ marginTop: "20px" }}>
|
||||
<div className="tui-panel" style={panelStyle("var(--white)")}>
|
||||
<span
|
||||
className="tui-panel-label"
|
||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
||||
>
|
||||
REVIVAL.INC
|
||||
</span>
|
||||
<div style={{ padding: "24px 16px 16px" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "2.4rem",
|
||||
fontWeight: "bold",
|
||||
color: "var(--white)",
|
||||
marginBottom: "32px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Revival inc.
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "12px",
|
||||
marginBottom: "12px",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(0, 0, 0, 0.4)",
|
||||
boxShadow: "0 0 30px rgba(255, 255, 255, 0.35)",
|
||||
width: "80%",
|
||||
margin: "12px auto",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/revivalinc.webp"
|
||||
alt="revival inc thumbnail"
|
||||
width={768}
|
||||
height={576}
|
||||
style={{ width: "100%", height: "auto", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "1.8rem",
|
||||
color: "var(--white)",
|
||||
fontWeight: "500",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
What is it?
|
||||
</p>
|
||||
<div style={{ color: "var(--gray)", marginTop: "12px", lineHeight: "1.6" }}>
|
||||
<p>
|
||||
It's a game I made for the game jam "Mini Jame Gam #55", the
|
||||
theme was Necromancy and the special item was Milk, the game also includes
|
||||
a tutorial so if you wanna try it do it. Check it out at{" "}
|
||||
<span className="text-purple-500">
|
||||
<a href="https://martin-dv.itch.io/revival-inc">itch.io</a>
|
||||
</span>{" "}
|
||||
or play{" "}
|
||||
<span className="text-purple-500">
|
||||
<a href="https://dvorakmortonxd.dev/revival-inc">here</a>
|
||||
</span>
|
||||
.<span className="cursor-blink"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rustywolf-section" style={{ marginTop: "20px" }}>
|
||||
<div className="tui-panel" style={panelStyle("var(--white)")}>
|
||||
<span
|
||||
className="tui-panel-label"
|
||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
||||
>
|
||||
RUSTYWOLF.RS
|
||||
</span>
|
||||
<div style={{ padding: "24px 16px 16px" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "2.4rem",
|
||||
fontWeight: "bold",
|
||||
color: "var(--white)",
|
||||
marginBottom: "32px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Introducing RustyWolf
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
marginTop: "12px",
|
||||
marginBottom: "12px",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
border: "1px solid rgba(255, 255, 255, 0.4)",
|
||||
boxShadow: "0 0 30px rgba(255, 255, 255, 0.2)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/RustyWolfPreview.webp"
|
||||
alt="RustyWolf Browser Preview"
|
||||
width={1280}
|
||||
height={720}
|
||||
style={{ width: "100%", height: "auto", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "1.8rem",
|
||||
color: "var(--white)",
|
||||
fontWeight: "500",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
What makes it so cool???
|
||||
</p>
|
||||
<div style={{ color: "var(--gray)", marginTop: "12px", lineHeight: "1.6" }}>
|
||||
<p>
|
||||
A lightweight browser made in Rust with WebKitGTK. It's the successor
|
||||
to my previous project Venturesome, aiming to provide a fast and minimalist
|
||||
browsing experience. It's a perfect browser for old machines that
|
||||
struggle with modern browsers. Check it out at{" "}
|
||||
<span style={{ color: "var(--accent2)" }}>
|
||||
<a href="https://git.dvorakmortonxd.dev/dvorakmortonxd/RustyWolf">
|
||||
GitDXD
|
||||
</a>
|
||||
</span>
|
||||
.<span className="cursor-blink"></span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section className="projects">
|
||||
<div className="subsection-wrapper">
|
||||
<div className="subsection-panel" style={panelStyle("var(--white)")}>
|
||||
<span
|
||||
className="subsection-panel-label"
|
||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
||||
>
|
||||
TECH_LIST.JSON
|
||||
</span>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "2rem",
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
margin: "4px 0 20px",
|
||||
color: "var(--white)",
|
||||
}}
|
||||
>
|
||||
All my hardware equipment :)
|
||||
</h2>
|
||||
<div className="project-grid">
|
||||
{techProjects.map((project) => (
|
||||
<div key={project.title} className="project-card">
|
||||
<div
|
||||
className="project-image"
|
||||
style={{ backgroundImage: `url(${project.image})` }}
|
||||
/>
|
||||
<h4 className="project-title">{project.title}</h4>
|
||||
<p className="project-desc">{project.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="subsection-wrapper">
|
||||
<div className="subsection-panel coding-panel" style={panelStyle("var(--accent)")}>
|
||||
<span
|
||||
className="subsection-panel-label"
|
||||
style={{ color: "var(--accent)", borderColor: "var(--accent)" }}
|
||||
>
|
||||
PROJECTS.DOCX
|
||||
</span>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "2rem",
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
margin: "4px 0 20px",
|
||||
color: "var(--accent)",
|
||||
}}
|
||||
>
|
||||
All the stuff I've worked on :))
|
||||
</h2>
|
||||
<div className="project-grid">
|
||||
{codingProjects.map((project, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={project.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="project-card"
|
||||
>
|
||||
<div
|
||||
className="project-image"
|
||||
style={{ backgroundImage: `url(${project.img})` }}
|
||||
/>
|
||||
<h4 className="project-title">{project.title}</h4>
|
||||
<p className="project-desc">{project.desc}</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="subsection-wrapper">
|
||||
<div
|
||||
className="subsection-panel friends-panel"
|
||||
style={panelStyle("var(--white)", { backgroundColor: "rgba(255, 255, 255, 0.02)" })}
|
||||
>
|
||||
<span
|
||||
className="subsection-panel-label"
|
||||
style={{ color: "var(--white)", borderColor: "var(--white)" }}
|
||||
>
|
||||
FRIENDS.TSX
|
||||
</span>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "2rem",
|
||||
fontWeight: "bold",
|
||||
textAlign: "center",
|
||||
margin: "4px 0 20px",
|
||||
color: "var(--white)",
|
||||
}}
|
||||
>
|
||||
Friend's websites and projects
|
||||
</h2>
|
||||
<div className="services-buttons" style={{ gap: "1rem" }}>
|
||||
{friends.map((friend, idx) => (
|
||||
<ServiceButton
|
||||
key={idx}
|
||||
url={friend.realUrl || friend.url}
|
||||
className="friend"
|
||||
icon={
|
||||
<Image
|
||||
src={friend.img}
|
||||
alt={friend.name}
|
||||
width={68}
|
||||
height={68}
|
||||
className="friend-icon"
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
filter:
|
||||
"brightness(0) invert(65%) hue-rotate(70deg) saturate(100%)",
|
||||
}}
|
||||
/>
|
||||
}
|
||||
name={friend.name}
|
||||
desc={friend.desc}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="footer">
|
||||
<span className="footer-label">v2.1.0</span>
|
||||
© 2025-2026 Martin Dvořák. All rights reserved (not really I dont care, just follow
|
||||
the GPL3.0 License)
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,32 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "./useTheme";
|
||||
import TerminalLayout from "./TerminalLayout";
|
||||
import EditorialSite from "./EditorialSite";
|
||||
import WindowsDesktop from "./WindowsDesktop";
|
||||
import MobileHome from "./MobileHome";
|
||||
|
||||
// The gruvbox editorial site is the default. Phones can never reach the KDE
|
||||
// desktop: ThemeScript forces gruvbox there (ignoring a stored "skeuo") and CSS
|
||||
// hides the Desktop-mode buttons, so `theme === "skeuo"` is desktop-only.
|
||||
//
|
||||
// The editorial page stays mounted (hidden) while the desktop is up, so its
|
||||
// scroll position survives a round-trip. The desktop mounts on demand.
|
||||
export default function ThemeRoot() {
|
||||
const { theme } = useTheme();
|
||||
const terminal = theme === "terminal";
|
||||
const desktop = theme === "skeuo";
|
||||
|
||||
// The desktop is kept mounted (just hidden) while the Gruvbox/terminal theme
|
||||
// is active. That way the Welcome window is only created on a fresh page
|
||||
// load and isn't reopened by toggling themes.
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="skeuo-desktop-root"
|
||||
style={terminal ? { display: "none" } : undefined}
|
||||
>
|
||||
<WindowsDesktop />
|
||||
<div style={desktop ? { display: "none" } : undefined}>
|
||||
<EditorialSite />
|
||||
</div>
|
||||
<div
|
||||
className="skeuo-mobile-root"
|
||||
style={terminal ? { display: "none" } : undefined}
|
||||
>
|
||||
<MobileHome />
|
||||
</div>
|
||||
{terminal && <TerminalLayout />}
|
||||
{desktop ? <WindowsDesktop /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,10 @@ import { useServerInsertedHTML } from "next/navigation";
|
|||
// without a flash. It is injected with useServerInsertedHTML so the <script>
|
||||
// lives in the SSR HTML stream instead of inside the React tree — React 19
|
||||
// logs an error for script tags rendered by components on the client.
|
||||
const INIT_SCRIPT = `(function(){try{var t=localStorage.getItem('theme');if(t==='terminal'||t==='skeuo'){document.documentElement.setAttribute('data-theme',t);}var ua=navigator.userAgent||'';var phone=/(iPhone|iPod|Android.+Mobile|Windows Phone|IEMobile|BlackBerry|Opera Mini|Mobile)/i.test(ua);document.documentElement.setAttribute('data-device',phone?'phone':'desktop');}catch(e){}})();`;
|
||||
//
|
||||
// Phones always get gruvbox: the desktop mode is mouse-oriented, so we ignore a
|
||||
// stored "skeuo" there and the CSS also hides the Desktop-mode button.
|
||||
const INIT_SCRIPT = `(function(){try{var t=localStorage.getItem('theme');var ua=navigator.userAgent||'';var phone=/(iPhone|iPod|Android.+Mobile|Windows Phone|IEMobile|BlackBerry|Opera Mini|Mobile)/i.test(ua);document.documentElement.setAttribute('data-device',phone?'phone':'desktop');if(phone){t='gruvbox';}if(t==='gruvbox'||t==='skeuo'){document.documentElement.setAttribute('data-theme',t);}}catch(e){}})();`;
|
||||
|
||||
export default function ThemeScript() {
|
||||
// useServerInsertedHTML can fire several times during a single render pass;
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ function WelcomeApp() {
|
|||
<button
|
||||
type="button"
|
||||
className="app-inline-link"
|
||||
onClick={() => setTheme("terminal")}
|
||||
onClick={() => setTheme("gruvbox")}
|
||||
>
|
||||
click here
|
||||
</button>
|
||||
|
|
|
|||
405
src/app/components/editorial-ui.tsx
Normal file
405
src/app/components/editorial-ui.tsx
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
|
||||
/* --------------------------------------------------------------- reveal */
|
||||
|
||||
export function Reveal({
|
||||
children,
|
||||
delay = 0,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
el.classList.add("is-in");
|
||||
return;
|
||||
}
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
el.classList.add("is-in");
|
||||
io.disconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -10% 0px", threshold: 0.05 }
|
||||
);
|
||||
io.observe(el);
|
||||
return () => io.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`reveal ${className}`} style={{ "--delay": `${delay}ms` } as CSSProperties}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ primitives */
|
||||
|
||||
export function Section({
|
||||
id,
|
||||
children,
|
||||
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;
|
||||
}) {
|
||||
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
|
||||
aria-hidden="true"
|
||||
className="social-mask size-4"
|
||||
style={{ WebkitMaskImage: `url(${icon})`, maskImage: `url(${icon})` } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- services */
|
||||
|
||||
export type ServiceAccess = "public" | "private" | "on_request";
|
||||
|
||||
export function ServiceRow({
|
||||
name,
|
||||
desc,
|
||||
url,
|
||||
access,
|
||||
checkUrl,
|
||||
disabled,
|
||||
}: {
|
||||
name: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
access?: ServiceAccess;
|
||||
checkUrl?: string;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const [status, setStatus] = useState<"pending" | "up" | "down">("pending");
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
let alive = true;
|
||||
fetch(`/api/ping?url=${encodeURIComponent(checkUrl || url)}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
if (alive) setStatus(d.status === "up" ? "up" : "down");
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setStatus("down");
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [url, checkUrl, disabled]);
|
||||
|
||||
const effective = disabled ? "up" : status;
|
||||
const dot =
|
||||
effective === "up" ? "var(--positive)" : effective === "down" ? "var(--negative)" : "var(--faint)";
|
||||
const statusLabel = disabled
|
||||
? "private"
|
||||
: effective === "up"
|
||||
? "online"
|
||||
: effective === "down"
|
||||
? "offline"
|
||||
: "checking";
|
||||
const accessLabel =
|
||||
access === "public" ? "public" : access === "private" ? "private" : access === "on_request" ? "on request" : null;
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
const cls = "rule-soft flex items-center gap-4 py-4 md:gap-8";
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className={cls} aria-disabled="true">
|
||||
{body}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={`${cls} transition-colors hover:bg-[var(--fill)]`}>
|
||||
{body}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- contributions */
|
||||
|
||||
interface HeatmapPoint {
|
||||
timestamp: number;
|
||||
contributions: number;
|
||||
}
|
||||
interface Activity {
|
||||
op_type: string;
|
||||
content?: string | null;
|
||||
created: string;
|
||||
repo?: { full_name?: string };
|
||||
}
|
||||
|
||||
const LEVEL_BG = ["rgba(235,219,178,0.07)", "#6d7420", "#98971a", "#b8bb26", "#d8e05a"];
|
||||
|
||||
export function Contributions() {
|
||||
const [weeks, setWeeks] = useState<{ date: string; contribs: number; level: number }[][]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/heatmap");
|
||||
if (!res.ok) throw new Error("bad status");
|
||||
const data: HeatmapPoint[] = await res.json();
|
||||
const map = new Map<string, number>();
|
||||
let sum = 0;
|
||||
let max = 0;
|
||||
for (const item of data) {
|
||||
const d = new Date(item.timestamp * 1000);
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const v = (map.get(key) ?? 0) + item.contributions;
|
||||
map.set(key, v);
|
||||
if (v > max) max = v;
|
||||
sum += item.contributions;
|
||||
}
|
||||
|
||||
const end = new Date();
|
||||
end.setHours(0, 0, 0, 0);
|
||||
const start = new Date(end);
|
||||
start.setFullYear(start.getFullYear() - 1);
|
||||
start.setDate(start.getDate() - start.getDay());
|
||||
|
||||
const out: { date: string; contribs: number; level: number }[][] = [];
|
||||
let week: { date: string; contribs: number; level: number }[] = [];
|
||||
for (const d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
const contribs = map.get(key) ?? 0;
|
||||
let level = 0;
|
||||
if (contribs > 0) {
|
||||
level = 1;
|
||||
if (contribs > max * 0.25) level = 2;
|
||||
if (contribs > max * 0.5) level = 3;
|
||||
if (contribs > max * 0.75) level = 4;
|
||||
}
|
||||
week.push({ date: key, contribs, level });
|
||||
if (week.length === 7) {
|
||||
out.push(week);
|
||||
week = [];
|
||||
}
|
||||
}
|
||||
if (week.length) out.push(week);
|
||||
|
||||
if (!alive) return;
|
||||
setWeeks(out);
|
||||
setTotal(sum);
|
||||
setState("ready");
|
||||
} catch {
|
||||
if (alive) setState("error");
|
||||
}
|
||||
})();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/activities");
|
||||
if (!res.ok) return;
|
||||
const data: Activity[] = await res.json();
|
||||
if (alive) {
|
||||
setActivities(data.filter((d) => d.op_type === "commit_repo" || d.op_type === "push_repo").slice(0, 3));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (state === "loading") return <p className="label">Loading contributions…</p>;
|
||||
if (state === "error") return <p className="label">Could not load contributions.</p>;
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
))}
|
||||
</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}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- terminal card */
|
||||
|
||||
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"],
|
||||
];
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type Theme = "skeuo" | "terminal";
|
||||
// "gruvbox" is the editorial site (the default). "skeuo" is the KDE desktop,
|
||||
// reachable from the editorial page's Desktop-mode button on non-phones.
|
||||
export type Theme = "gruvbox" | "skeuo";
|
||||
|
||||
const EVENT = "theme-change";
|
||||
|
||||
|
|
@ -16,13 +18,11 @@ function subscribe(callback: () => void) {
|
|||
}
|
||||
|
||||
function getSnapshot(): Theme {
|
||||
return document.documentElement.getAttribute("data-theme") === "terminal"
|
||||
? "terminal"
|
||||
: "skeuo";
|
||||
return document.documentElement.getAttribute("data-theme") === "skeuo" ? "skeuo" : "gruvbox";
|
||||
}
|
||||
|
||||
function getServerSnapshot(): Theme {
|
||||
return "skeuo";
|
||||
return "gruvbox";
|
||||
}
|
||||
|
||||
export function setTheme(theme: Theme) {
|
||||
|
|
@ -40,6 +40,7 @@ export function useTheme() {
|
|||
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
return {
|
||||
theme,
|
||||
toggle: () => setTheme(theme === "skeuo" ? "terminal" : "skeuo"),
|
||||
setTheme,
|
||||
toggle: () => setTheme(theme === "skeuo" ? "gruvbox" : "skeuo"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue