revert to the state before the editorial rework (8669263): restore the terminal layout, phone home screen and pre-rework assets
This undoes 8669263..1880a5b as a single revert commit; the reverted work
remains reachable in history at 1880a5b.
This commit is contained in:
parent
1880a5b0db
commit
b2244da743
24 changed files with 1693 additions and 3108 deletions
|
|
@ -8,8 +8,8 @@ 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/WebSite.tsx, and with the friend list in data.ts.
|
||||
// values in components/apps.tsx and components/TerminalLayout.tsx, and with the
|
||||
// friend list in data.ts.
|
||||
const ALLOWED_HOSTS = [
|
||||
'dvorakmortonxd.dev', // ai / jelly / navidrome / git / proxmox / mc (SMP)
|
||||
'skopec.dev',
|
||||
|
|
|
|||
232
src/app/components/MobileHome.tsx
Normal file
232
src/app/components/MobileHome.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
106
src/app/components/SlBackground.tsx
Normal file
106
src/app/components/SlBackground.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
603
src/app/components/TerminalLayout.tsx
Normal file
603
src/app/components/TerminalLayout.tsx
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
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,25 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "./useTheme";
|
||||
import WebSite from "./WebSite";
|
||||
import TerminalLayout from "./TerminalLayout";
|
||||
import WindowsDesktop from "./WindowsDesktop";
|
||||
import MobileHome from "./MobileHome";
|
||||
|
||||
// The web editorial site is the default and always the landing view (the theme
|
||||
// is not persisted). Phones can never reach the KDE desktop: the Desktop-mode
|
||||
// buttons are hidden there, so `theme === "skeuo"` only happens on desktop.
|
||||
//
|
||||
// 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 desktop = theme === "skeuo";
|
||||
const terminal = theme === "terminal";
|
||||
|
||||
// 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 style={desktop ? { display: "none" } : undefined}>
|
||||
<WebSite />
|
||||
<div
|
||||
className="skeuo-desktop-root"
|
||||
style={terminal ? { display: "none" } : undefined}
|
||||
>
|
||||
<WindowsDesktop />
|
||||
</div>
|
||||
{desktop ? <WindowsDesktop /> : null}
|
||||
<div
|
||||
className="skeuo-mobile-root"
|
||||
style={terminal ? { display: "none" } : undefined}
|
||||
>
|
||||
<MobileHome />
|
||||
</div>
|
||||
{terminal && <TerminalLayout />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
import { useRef } from "react";
|
||||
import { useServerInsertedHTML } from "next/navigation";
|
||||
|
||||
// Runs before paint to set the phone/desktop split. The theme itself is not
|
||||
// read from storage — the web theme is always the landing view — so this only
|
||||
// tags the device, which CSS uses to hide the Desktop-mode button on phones.
|
||||
const INIT_SCRIPT = `(function(){try{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){}})();`;
|
||||
// Runs before paint so the saved theme and the phone/desktop split are applied
|
||||
// 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){}})();`;
|
||||
|
||||
export default function ThemeScript() {
|
||||
// useServerInsertedHTML can fire several times during a single render pass;
|
||||
|
|
|
|||
|
|
@ -1,569 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { useTheme } from "./useTheme";
|
||||
import { codingProjects, friends, socials, techProjects } from "../data";
|
||||
import {
|
||||
Keyboard,
|
||||
Led,
|
||||
MiniRack,
|
||||
Monitor,
|
||||
Plaque,
|
||||
Report,
|
||||
Reveal,
|
||||
SectionHead,
|
||||
ServiceUnit,
|
||||
SocialGlyph,
|
||||
type ServiceAccess,
|
||||
} from "./web-ui";
|
||||
|
||||
/* --------------------------------------------------------------- content */
|
||||
|
||||
const NAV = [
|
||||
{ href: "#about", label: "about" },
|
||||
{ href: "#services", label: "services" },
|
||||
{ href: "#projects", label: "projects" },
|
||||
{ href: "#hardware", label: "machines" },
|
||||
{ href: "#contact", label: "contact" },
|
||||
];
|
||||
|
||||
/** One hue per item, so every list reads as separate pieces of hardware. */
|
||||
const TONES = [
|
||||
"var(--orange)",
|
||||
"var(--teal)",
|
||||
"var(--blue)",
|
||||
"var(--lilac)",
|
||||
"var(--mint)",
|
||||
"var(--coral)",
|
||||
"var(--yellow)",
|
||||
];
|
||||
|
||||
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", date: "05 Jan 2025" },
|
||||
{ lift: "bench", weight: "111", date: "29 May 2026" },
|
||||
{ lift: "deadlift", weight: "165", 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 };
|
||||
}
|
||||
|
||||
function statusKind(status: string | null): "on" | "amber" | "off" {
|
||||
if (!status) return "off";
|
||||
const s = status.toLowerCase();
|
||||
if (s.includes("finished") || s.includes("available")) return "on";
|
||||
if (s.includes("wip") || s.includes("unfinished")) return "amber";
|
||||
return "off";
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ page */
|
||||
|
||||
export default function WebSite() {
|
||||
const { setTheme } = useTheme();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="w-page">
|
||||
{/* -------------------------------------------------------------- rail */}
|
||||
<header className="w-rail">
|
||||
<div className="mx-auto flex h-16 w-full max-w-[1120px] items-center gap-5 px-5 sm:px-8">
|
||||
<a href="#top" className="flex shrink-0 items-center gap-2.5" aria-label="Back to top">
|
||||
<Led kind="on" pulse slow />
|
||||
<span className="w-wordmark">dvorakmortonxd</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"
|
||||
onClick={() => setTheme("skeuo")}
|
||||
className="desktop-toggle flex items-center gap-3"
|
||||
title="Flip to Desktop mode"
|
||||
>
|
||||
<span className="w-mono hidden text-[0.6rem] tracking-[0.2em] text-[var(--ink-soft)] sm:inline">
|
||||
DESKTOP
|
||||
</span>
|
||||
<span className="w-rocker">
|
||||
<span className="w-rocker__knob" />
|
||||
<Led kind="off" className="w-rocker__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-keybtn"
|
||||
>
|
||||
{menuOpen ? "close" : "menu"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{menuOpen ? (
|
||||
<nav
|
||||
className="w-plastic mx-auto mb-3 flex w-[calc(100%-2.5rem)] max-w-[1120px] 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-display border-b-2 border-dashed border-[var(--plastic-edge)] py-3 text-[0.9rem] text-[var(--plastic-ink)] last:border-b-0"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<span id="top" />
|
||||
|
||||
<main className="pt-24 sm:pt-28">
|
||||
{/* ------------------------------------------------------------ hero */}
|
||||
<section className="mx-auto w-full max-w-[1120px] px-5 sm:px-8">
|
||||
<div className="grid gap-10 lg:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)] lg:items-end lg:gap-8">
|
||||
<Reveal>
|
||||
<Monitor>
|
||||
<p className="w-tft">
|
||||
<span className="a">martin@dvorakmortonxd</span>:~$ whoami
|
||||
<span className="w-caret ml-2" />
|
||||
</p>
|
||||
<h1 className="w-display mt-5 text-[clamp(1.8rem,4.1vw,2.75rem)] text-[var(--glass-ink)]">
|
||||
My own little internet — one rack, zero subscriptions.
|
||||
</h1>
|
||||
<p className="w-prose mt-5 max-w-[54ch] text-[var(--glass-dim)]">
|
||||
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>
|
||||
<p className="mt-7 flex flex-wrap gap-3">
|
||||
<a href="#services" className="w-cmd">
|
||||
$ open rack --services
|
||||
</a>
|
||||
<a href="#projects" className="w-cmd w-cmd--quiet">
|
||||
$ open drawer --projects
|
||||
</a>
|
||||
</p>
|
||||
</Monitor>
|
||||
|
||||
<div className="-mt-1 px-1 pt-7 sm:px-8">
|
||||
<Keyboard />
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={120}>
|
||||
<div className="flex flex-col gap-6">
|
||||
<MiniRack />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Plaque value={String(SERVICES.length)} label="services" tone="var(--orange)" />
|
||||
<Plaque value={String(codingProjects.length)} label="projects" tone="var(--teal)" />
|
||||
<Plaque value={String(techProjects.length)} label="machines" tone="var(--blue)" />
|
||||
<Plaque value="401" label="kg best SBD" tone="var(--coral)" />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-[1120px] flex-col gap-24 px-5 pt-28 pb-24 sm:gap-28 sm:px-8">
|
||||
{/* --------------------------------------------------------- about */}
|
||||
<section id="about" className="scroll-mt-28">
|
||||
<Reveal>
|
||||
<SectionHead index="01" label="about" title="Who is behind the rack" />
|
||||
</Reveal>
|
||||
<div className="grid gap-7 lg:grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)] lg:items-start">
|
||||
<Reveal delay={60}>
|
||||
<article className="w-paper w-sheet">
|
||||
<div className="w-sheet__head">
|
||||
<span>manifest.txt</span>
|
||||
<span className="ml-auto">printed on the desk</span>
|
||||
</div>
|
||||
<div className="mt-5 grid gap-x-9 gap-y-4 sm:grid-cols-2">
|
||||
<p className="w-prose">
|
||||
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">
|
||||
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>
|
||||
<p className="w-prose">
|
||||
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">
|
||||
Away from the rack: PPL training 3–5× a week, Terraria (~1300 h), Minecraft modpacks
|
||||
and the occasional game jam.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={150}>
|
||||
<aside className="w-paper w-cardindex lg:mt-14" style={{ "--tone": "var(--teal)" } as CSSProperties}>
|
||||
<p className="w-kicker">index card</p>
|
||||
<p className="w-serif mt-3 text-[1.2rem] text-[var(--ink)]">
|
||||
“If it has a fan, I’ll find a use for it.”
|
||||
</p>
|
||||
<div className="w-rule my-4" />
|
||||
<ul className="w-mono space-y-2 text-[0.68rem] text-[var(--ink-soft)]">
|
||||
<li>school · sssvt.cz</li>
|
||||
<li>gym · PPL 3–5×/week</li>
|
||||
<li>games · Terraria, Minecraft</li>
|
||||
<li>currently · bulking season</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ----------------------------------------------------------- now */}
|
||||
<section id="now" className="scroll-mt-28">
|
||||
<Reveal>
|
||||
<SectionHead index="02" label="now" title="On the bench right now" />
|
||||
</Reveal>
|
||||
<div className="grid gap-7 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] lg:items-start">
|
||||
<Reveal delay={60}>
|
||||
<div className="w-note">
|
||||
<span className="w-pin" aria-hidden="true" />
|
||||
<p className="w-kicker !text-[#8a7420]">now.txt · updated 09/2026</p>
|
||||
<p className="w-prose mt-4 text-[#5c4a1f]">
|
||||
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 text-[#5c4a1f]">
|
||||
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>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={140}>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="w-paper w-cardindex w-cardindex--alt" style={{ "--tone": "var(--coral)" } as CSSProperties}>
|
||||
<p className="w-kicker">bench status</p>
|
||||
<ul className="w-display mt-4 space-y-2.5 text-[0.82rem] text-[var(--ink)]">
|
||||
<li className="flex items-center gap-2.5">
|
||||
<Led kind="on" /> smp · accepting players
|
||||
</li>
|
||||
<li className="flex items-center gap-2.5">
|
||||
<Led kind="amber" pulse /> maturita.c · in development
|
||||
</li>
|
||||
<li className="flex items-center gap-2.5">
|
||||
<Led kind="on" /> gym · PPL 3–5×/week
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-end gap-6">
|
||||
<i className="w-pen mb-3 w-40" aria-hidden="true" />
|
||||
<span className="w-mug mr-6" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------ services */}
|
||||
<section id="services" className="scroll-mt-28">
|
||||
<Reveal>
|
||||
<SectionHead index="03" label="rack" title="Six machines, one shelf" />
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<div className="w-chassis w-rack">
|
||||
<div className="w-rack__top">
|
||||
<Led kind="on" pulse slow />
|
||||
<span className="w-display text-[0.7rem] tracking-[0.18em] text-[var(--chassis-ink)]">
|
||||
RACK 01 · SERVICES
|
||||
</span>
|
||||
<span className="w-mono ml-auto hidden text-[0.62rem] tracking-[0.12em] text-[var(--chassis-faint)] sm:inline">
|
||||
health checked from this page
|
||||
</span>
|
||||
<span className="w-rack__vent w-vents" />
|
||||
</div>
|
||||
<div className="w-rack__grid">
|
||||
<span className="w-rails" />
|
||||
<div>
|
||||
{SERVICES.map((s, i) => (
|
||||
<ServiceUnit key={s.name} tone={TONES[i % TONES.length]} {...s} />
|
||||
))}
|
||||
</div>
|
||||
<span className="w-rails" />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------ projects */}
|
||||
<section id="projects" className="scroll-mt-28">
|
||||
<Reveal>
|
||||
<SectionHead index="04" label="drawer" title="Eighteen drives, all labelled" />
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<div className="w-chassis w-drawer">
|
||||
<div className="w-well w-drawer__inner w-scroll">
|
||||
<div className="grid gap-3 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-drive"
|
||||
style={
|
||||
{
|
||||
"--tilt": `${i % 2 ? 0.6 : -0.7}deg`,
|
||||
"--tone": TONES[i % TONES.length],
|
||||
} as CSSProperties
|
||||
}
|
||||
title={p.desc}
|
||||
>
|
||||
<span className="w-drive__port" aria-hidden="true" />
|
||||
<span className="w-drive__sticker">
|
||||
<span className="w-drive__name">{name}</span>
|
||||
<span className="w-drive__meta">
|
||||
{status ?? "unlabelled"} · {p.desc}
|
||||
</span>
|
||||
<span className="w-barcode w-drive__bar" aria-hidden="true" />
|
||||
</span>
|
||||
<Led kind={statusKind(status)} className="w-drive__led" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-chassis-2 w-drawer__front">
|
||||
<i className="w-screw" aria-hidden="true" />
|
||||
<span className="w-drawer__handle" aria-hidden="true" />
|
||||
<i className="w-screw" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------ hardware */}
|
||||
<section id="hardware" className="scroll-mt-28">
|
||||
<Reveal>
|
||||
<SectionHead index="05" label="machines" title="Pinned to the cork board" />
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<div className="w-pegboard">
|
||||
<div className="w-pegboard__face">
|
||||
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{techProjects.map((t, i) => (
|
||||
<article
|
||||
key={t.title}
|
||||
className="w-paper w-hangtag"
|
||||
style={{ "--tone": TONES[i % TONES.length] } as CSSProperties}
|
||||
>
|
||||
<span className="w-pin" aria-hidden="true" />
|
||||
<h3 className="w-display text-[1.05rem] text-[var(--ink)]">{t.title}</h3>
|
||||
<span className="w-hangtag__rule" aria-hidden="true" />
|
||||
<p className="w-prose text-[0.84rem] text-[var(--ink-soft)]">{t.desc}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* -------------------------------------------------- activity feed */}
|
||||
<section id="gitdxd" className="scroll-mt-28">
|
||||
<Reveal>
|
||||
<SectionHead index="06" label="gitdxd" title="The receipt for this year's commits" />
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<Report />
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------- friends */}
|
||||
<section id="friends" className="scroll-mt-28">
|
||||
<Reveal>
|
||||
<SectionHead index="07" label="friends" title="Cards on the desk" />
|
||||
</Reveal>
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{friends.map((f, i) => (
|
||||
<Reveal key={f.name} delay={60 + i * 70}>
|
||||
<a
|
||||
href={f.realUrl || f.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-bizcard"
|
||||
style={
|
||||
{
|
||||
"--tilt": `${i === 1 ? 0.9 : i === 2 ? -1 : -1.4}deg`,
|
||||
"--tone": TONES[(i + 1) % TONES.length],
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<p className="w-serif text-[1.2rem] text-[var(--ink)]">{f.name}</p>
|
||||
<p className="w-mono mt-1 text-[0.68rem] text-[var(--ink-soft)]">{f.desc}</p>
|
||||
</a>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ----------------------------------------------------------- gym */}
|
||||
<section id="gym" className="scroll-mt-28">
|
||||
<Reveal>
|
||||
<SectionHead index="08" label="strength" title="Numbers on the scoreboard" />
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<div className="w-chassis w-score">
|
||||
<div className="w-score__head">
|
||||
<Led kind="on" pulse slow />
|
||||
<span>TRAINING LOG · 73 KG BODYWEIGHT</span>
|
||||
<span className="ml-auto hidden sm:inline">3 LIFTS</span>
|
||||
</div>
|
||||
<div className="w-score__tiles">
|
||||
{LIFTS.map((l, i) => (
|
||||
<div
|
||||
key={l.lift}
|
||||
className="w-score__tile"
|
||||
style={{ "--tone": TONES[(i + 2) % TONES.length] } as CSSProperties}
|
||||
>
|
||||
<span className="w-score__num">
|
||||
{l.weight}
|
||||
<small>kg</small>
|
||||
</span>
|
||||
<span className="w-score__lift">{l.lift}</span>
|
||||
<span className="w-score__date">{l.date}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="w-score__foot">BEST SBD TOTAL · 401 KG · 165 + 111 + 125</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
|
||||
{/* ------------------------------------------------------- contact */}
|
||||
<section id="contact" className="scroll-mt-28">
|
||||
<Reveal>
|
||||
<SectionHead index="09" label="patch panel" title="Twelve ports, all live" />
|
||||
</Reveal>
|
||||
<Reveal delay={60}>
|
||||
<div className="w-chassis w-patch">
|
||||
<div className="w-chassis-2 w-patch__face">
|
||||
<div className="w-patch__head">
|
||||
<i className="w-screw" aria-hidden="true" />
|
||||
<span>PATCH 01 · SOCIALS</span>
|
||||
<i className="w-screw ml-auto" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{socials.map((s, i) => (
|
||||
<a
|
||||
key={s.name}
|
||||
href={s.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-port"
|
||||
style={{ "--tone": TONES[i % TONES.length] } as CSSProperties}
|
||||
>
|
||||
<span className="w-port__jack" aria-hidden="true">
|
||||
<span className="w-port__slot" />
|
||||
</span>
|
||||
<span className="w-port__icon">
|
||||
<SocialGlyph name={s.name} icon={s.icon} />
|
||||
</span>
|
||||
<span className="w-port__label">{s.name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<p className="w-mono mt-5 text-[0.66rem] text-[var(--chassis-faint)]">
|
||||
fastest reply is Discord or Instagram · mail{" "}
|
||||
<a className="w-link" href="mailto:dvorak@dvorakmortonxd.dev">
|
||||
dvorak@dvorakmortonxd.dev
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* ------------------------------------------------------------ footer */}
|
||||
<footer className="mx-auto w-full max-w-[1120px] px-5 pb-16 sm:px-8">
|
||||
<Reveal>
|
||||
<div className="w-plastic relative flex flex-col gap-5 px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="w-display text-[0.8rem] tracking-[0.16em] text-[var(--plastic-ink)]">
|
||||
DVORAKMORTONXD.LOCAL
|
||||
</p>
|
||||
<p className="w-mono mt-1.5 text-[0.64rem] text-[var(--ink-soft)]">
|
||||
v4.0.0 · GPL-3.0-only · built with Next.js · no textures, all CSS ·{" "}
|
||||
<a className="w-link" href="/credits">
|
||||
credits
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme("skeuo")}
|
||||
className="w-keybtn w-btn-accent desktop-toggle shrink-0 self-start sm:self-auto"
|
||||
title="Flip to Desktop mode"
|
||||
>
|
||||
<Led kind="on" /> desktop mode
|
||||
</button>
|
||||
<i className="w-screw w-corner-tr" aria-hidden="true" />
|
||||
<i className="w-screw w-corner-bl" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="w-mono mt-4 text-[0.64rem] text-[var(--ink-soft)]">
|
||||
© 2025–2026 Martin Dvořák — follow the licence or don’t, I don’t mind
|
||||
</p>
|
||||
</Reveal>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -577,10 +577,10 @@ export default function WindowsDesktop() {
|
|||
type="button"
|
||||
className="win-theme-toggle"
|
||||
onClick={toggle}
|
||||
title={`Switch to ${theme === "skeuo" ? "web" : "Desktop"} theme`}
|
||||
title={`Switch to ${theme === "skeuo" ? "gruvbox" : "Desktop"} theme`}
|
||||
>
|
||||
<span aria-hidden="true">{theme === "skeuo" ? "▚" : "❖"}</span>
|
||||
<span>{theme === "skeuo" ? "Web" : "Desktop"}</span>
|
||||
<span>{theme === "skeuo" ? "Gruvbox" : "Desktop"}</span>
|
||||
</button>
|
||||
|
||||
<Clock className="win-clock" />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
techProjects,
|
||||
friends,
|
||||
WALLPAPER_CREDIT,
|
||||
MOBILE_WALLPAPER_CREDIT,
|
||||
} from "../data";
|
||||
import ServiceButton from "./ServiceButton";
|
||||
import ContributionHeatmap from "./ContributionHeatmap";
|
||||
|
|
@ -45,14 +46,14 @@ function WelcomeApp() {
|
|||
<li>Drag windows around by their title bar.</li>
|
||||
<li>Use the buttons at the top-right to minimize, maximize or close.</li>
|
||||
<li>Open apps from the Gentoo launcher, bottom-left.</li>
|
||||
<li>Switch back to the web theme from the panel tray, bottom-right.</li>
|
||||
<li>Switch back to the Gruvbox theme from the panel tray, bottom-right.</li>
|
||||
</ul>
|
||||
<p className="app-preference">
|
||||
If you'd prefer a normal website{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="app-inline-link"
|
||||
onClick={() => setTheme("web")}
|
||||
onClick={() => setTheme("terminal")}
|
||||
>
|
||||
click here
|
||||
</button>
|
||||
|
|
@ -62,8 +63,12 @@ function WelcomeApp() {
|
|||
Wallpaper from{" "}
|
||||
<a href={WALLPAPER_CREDIT} target="_blank" rel="noopener noreferrer">
|
||||
wallpapercave.com/kde-plasma-wallpapers
|
||||
</a>
|
||||
. Gentoo logo by Matteo 'Peach' Pescarin (CC BY-SA 2.5), via
|
||||
</a>{" "}
|
||||
(desktop) and{" "}
|
||||
<a href={MOBILE_WALLPAPER_CREDIT} target="_blank" rel="noopener noreferrer">
|
||||
wallpapercave.com/landscape-4k-mobile-wallpapers
|
||||
</a>{" "}
|
||||
(mobile). Gentoo logo by Matteo 'Peach' Pescarin (CC BY-SA 2.5), via
|
||||
Wikimedia Commons.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,30 +2,37 @@
|
|||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// "web" is the editorial site and always the landing view. "skeuo" is the KDE
|
||||
// desktop, reachable from the editorial page's Desktop-mode button. The choice
|
||||
// is deliberately NOT persisted, so a reload always comes back to the web theme
|
||||
// (and there is no wrong-theme flash on load).
|
||||
export type Theme = "web" | "skeuo";
|
||||
export type Theme = "skeuo" | "terminal";
|
||||
|
||||
const EVENT = "theme-change";
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
window.addEventListener(EVENT, callback);
|
||||
return () => window.removeEventListener(EVENT, callback);
|
||||
window.addEventListener("storage", callback);
|
||||
return () => {
|
||||
window.removeEventListener(EVENT, callback);
|
||||
window.removeEventListener("storage", callback);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot(): Theme {
|
||||
return document.documentElement.getAttribute("data-theme") === "skeuo" ? "skeuo" : "web";
|
||||
return document.documentElement.getAttribute("data-theme") === "terminal"
|
||||
? "terminal"
|
||||
: "skeuo";
|
||||
}
|
||||
|
||||
function getServerSnapshot(): Theme {
|
||||
return "web";
|
||||
return "skeuo";
|
||||
}
|
||||
|
||||
export function setTheme(theme: Theme) {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
try {
|
||||
localStorage.setItem("theme", theme);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.dispatchEvent(new Event(EVENT));
|
||||
}
|
||||
|
||||
|
|
@ -33,7 +40,6 @@ export function useTheme() {
|
|||
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
toggle: () => setTheme(theme === "skeuo" ? "web" : "skeuo"),
|
||||
toggle: () => setTheme(theme === "skeuo" ? "terminal" : "skeuo"),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,505 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
|
||||
/* --------------------------------------------------------------- reveal */
|
||||
|
||||
export function Reveal({
|
||||
children,
|
||||
delay = 0,
|
||||
className = "",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
el.classList.add("is-in");
|
||||
return;
|
||||
}
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
el.classList.add("is-in");
|
||||
io.disconnect();
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -12% 0px", threshold: 0.06 }
|
||||
);
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const onScreen = rect.top < window.innerHeight && rect.bottom > 0;
|
||||
if (onScreen) {
|
||||
// First screenful: stagger in document order so it fills top to bottom.
|
||||
const all = Array.from(document.querySelectorAll<HTMLElement>(".reveal"));
|
||||
const order = Math.min(Math.max(0, all.indexOf(el)), 8);
|
||||
el.style.setProperty("--delay", `${delay + order * 90}ms`);
|
||||
requestAnimationFrame(() => el.classList.add("is-in"));
|
||||
} else {
|
||||
io.observe(el);
|
||||
}
|
||||
return () => io.disconnect();
|
||||
}, [delay]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className={`reveal ${className}`} style={{ "--delay": `${delay}ms` } as CSSProperties}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- hardware */
|
||||
|
||||
export function Led({
|
||||
kind = "on",
|
||||
pulse = false,
|
||||
slow = false,
|
||||
className = "",
|
||||
style,
|
||||
}: {
|
||||
kind?: "on" | "amber" | "off";
|
||||
pulse?: boolean;
|
||||
slow?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<i
|
||||
aria-hidden="true"
|
||||
className={`w-led w-led--${kind}${pulse ? (slow ? " w-led--pulse-slow" : " w-led--pulse") : ""} ${className}`}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Screws({ count = 2, className = "" }: { count?: number; className?: string }) {
|
||||
return (
|
||||
<span className={`w-unit__screws ${className}`}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<i key={i} className="w-screw" />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Section heading: a coloured chip on the desk + a painted title. */
|
||||
export function SectionHead({ index, label, title }: { index: string; label: string; title: string }) {
|
||||
const tones = [
|
||||
"var(--orange)",
|
||||
"var(--teal)",
|
||||
"var(--blue)",
|
||||
"var(--lilac)",
|
||||
"var(--mint)",
|
||||
"var(--coral)",
|
||||
"var(--yellow)",
|
||||
];
|
||||
const n = Number.parseInt(index, 10);
|
||||
const tone = tones[Number.isFinite(n) ? (n - 1) % tones.length : 0];
|
||||
|
||||
return (
|
||||
<div className="w-sechead">
|
||||
<span className="w-chip w-sechead__tab" style={{ "--tone": tone } as CSSProperties}>
|
||||
{index} · {label}
|
||||
</span>
|
||||
<h2 className="w-display w-paint w-sechead__title">{title}</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------- the machine */
|
||||
|
||||
export function Monitor({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={`w-monitor ${className}`}>
|
||||
<div className="w-monitor__frame">
|
||||
<div className="w-glass w-monitor__glass">{children}</div>
|
||||
<div className="w-monitor__chin">
|
||||
<Led kind="on" />
|
||||
<span className="w-monitor__brand">DXD</span>
|
||||
<span className="w-monitor__osd" aria-hidden="true">
|
||||
<i className="w-osdkey" />
|
||||
<i className="w-osdkey" />
|
||||
<i className="w-osdkey" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-monitor__stand" aria-hidden="true">
|
||||
<i className="w-monitor__neck" />
|
||||
<i className="w-monitor__base" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const KEY_ROWS = ["1234567890-=".split(""), "qwertyuiop[]".split(""), "asdfghjkl;'".split("")];
|
||||
const CAP_TONES = ["var(--mint)", "var(--blue)", "var(--yellow)", "var(--coral)", "var(--lilac)", "var(--teal)"];
|
||||
|
||||
export function Keyboard() {
|
||||
return (
|
||||
<div className="w-keyboard" aria-hidden="true">
|
||||
<div className="w-keyboard__deck">
|
||||
<div className="w-keyboard__row">
|
||||
<span className="w-key w-key--accent">esc</span>
|
||||
{KEY_ROWS[0].map((k, i) => (
|
||||
<span key={k} className="w-key" style={{ "--tone": CAP_TONES[i % CAP_TONES.length] } as CSSProperties}>
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{KEY_ROWS.slice(1).map((row, r) => (
|
||||
<div key={r} className="w-keyboard__row">
|
||||
{row.map((k, i) => (
|
||||
<span
|
||||
key={k}
|
||||
className="w-key"
|
||||
style={{ "--tone": CAP_TONES[(i + r + 2) % CAP_TONES.length] } as CSSProperties}
|
||||
>
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="w-keyboard__row">
|
||||
<span className="w-key w-key--space" />
|
||||
</div>
|
||||
<span className="w-keyboard__bar" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RACK_TONES = ["var(--mint)", "var(--blue)", "var(--yellow)", "var(--coral)"];
|
||||
|
||||
/** A small rack that sits on the desk in the hero. */
|
||||
export function MiniRack({ label = "DXD-01" }: { label?: string }) {
|
||||
return (
|
||||
<div className="w-chassis w-rack" aria-hidden="true">
|
||||
<div className="w-rack__top">
|
||||
<Led kind="on" pulse />
|
||||
<span className="w-display text-[0.68rem] tracking-[0.2em] text-[var(--chassis-ink)]">{label}</span>
|
||||
<span className="w-rack__vent w-vents" />
|
||||
</div>
|
||||
<div className="w-rack__grid">
|
||||
<span className="w-rails" />
|
||||
<div>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="w-unit !min-h-[48px] !gap-[10px] !py-2" style={{ "--tone": RACK_TONES[i] } as CSSProperties}>
|
||||
<span className="w-unit__grip !h-7 !w-3" />
|
||||
<span className="w-unit__grille !h-7 !w-10" />
|
||||
<Led kind="on" pulse style={{ animationDelay: `${i * 0.5}s` }} />
|
||||
<Led kind="amber" pulse slow style={{ animationDelay: `${i * 0.3}s` }} />
|
||||
<span className="w-display ml-auto text-[0.6rem] tracking-[0.14em] text-[var(--chassis-dim)]">
|
||||
{["pxe", "aio", "nas", "web"][i]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="w-rails" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A big coloured sticker with one number on it. */
|
||||
export function Plaque({ value, label, tone = "var(--orange)" }: { value: string; label: string; tone?: string }) {
|
||||
return (
|
||||
<div className="w-sticker w-plaque" style={{ "--tone": tone } as CSSProperties}>
|
||||
<span className="w-plaque__value">{value}</span>
|
||||
<span className="w-plaque__label">{label}</span>
|
||||
<i className="w-plaque__dot" aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- services */
|
||||
|
||||
export type ServiceAccess = "public" | "private" | "on_request";
|
||||
|
||||
export function ServiceUnit({
|
||||
name,
|
||||
desc,
|
||||
url,
|
||||
access,
|
||||
checkUrl,
|
||||
disabled,
|
||||
tone = "var(--teal)",
|
||||
}: {
|
||||
name: string;
|
||||
desc: string;
|
||||
url: string;
|
||||
access?: ServiceAccess;
|
||||
checkUrl?: string;
|
||||
disabled?: boolean;
|
||||
tone?: string;
|
||||
}) {
|
||||
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 live = disabled ? "up" : status;
|
||||
const kind = live === "up" ? "on" : live === "down" ? "off" : "amber";
|
||||
const stateClass = disabled ? "is-wait" : live === "up" ? "is-on" : live === "down" ? "is-off" : "is-wait";
|
||||
const label = disabled ? "STANDBY" : live === "up" ? "ONLINE" : live === "down" ? "OFFLINE" : "POLLING";
|
||||
const accessLabel = access === "public" ? "public" : access === "private" ? "private" : access === "on_request" ? "on request" : null;
|
||||
|
||||
const host = (() => {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
})();
|
||||
|
||||
const inner = (
|
||||
<>
|
||||
<span className="w-unit__grip" aria-hidden="true" />
|
||||
<span className="w-unit__grille w-grille" aria-hidden="true" />
|
||||
<span className="w-unit__id">
|
||||
<span className="w-unit__name">{name}</span>
|
||||
<span className="w-tape mt-1.5">
|
||||
{host}
|
||||
{accessLabel ? ` · ${accessLabel}` : ""}
|
||||
</span>
|
||||
<span className="sr-only">{desc}</span>
|
||||
</span>
|
||||
<span className="w-unit__state">
|
||||
<Led kind={kind} pulse={live === "up"} />
|
||||
<span className={`w-unit__status ${stateClass}`}>{label}</span>
|
||||
</span>
|
||||
<Screws />
|
||||
</>
|
||||
);
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<span className="w-unit" style={{ "--tone": tone } as CSSProperties} aria-disabled="true">
|
||||
{inner}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-unit w-unit--link"
|
||||
style={{ "--tone": tone } as CSSProperties}
|
||||
title={desc}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------- activity report */
|
||||
|
||||
interface HeatmapPoint {
|
||||
timestamp: number;
|
||||
contributions: number;
|
||||
}
|
||||
interface Activity {
|
||||
op_type: string;
|
||||
content?: string | null;
|
||||
created: string;
|
||||
repo?: { full_name?: string };
|
||||
}
|
||||
|
||||
const DOT_OPACITY = [0.12, 0.35, 0.55, 0.75, 1];
|
||||
|
||||
export function Report() {
|
||||
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;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-printout">
|
||||
<div className="w-printout__paper">
|
||||
<p className="w-print__head">
|
||||
<span>GITDXD · ACTIVITY REPORT · 12 MONTHS</span>
|
||||
<span className="ml-auto flex items-center gap-3">
|
||||
<span className="w-barcode w-[110px]" aria-hidden="true" />
|
||||
<span>{state === "ready" ? "PRINTED LIVE" : state === "error" ? "PRINTER OFFLINE" : "SPOOLING…"}</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{state === "error" ? (
|
||||
<p className="w-mono mt-5 text-[0.74rem] text-[var(--ink-soft)]">could not reach the forge.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-5 flex flex-wrap items-end gap-x-6 gap-y-2">
|
||||
<div>
|
||||
<p className="w-kicker">total / 12 months</p>
|
||||
<p className="w-display mt-1 text-[2.6rem] leading-none text-[var(--ink)]">
|
||||
{state === "loading" ? "—" : total}
|
||||
</p>
|
||||
</div>
|
||||
<p className="w-mono text-[0.64rem] leading-relaxed text-[var(--ink-soft)]">
|
||||
each square = one day
|
||||
<br />
|
||||
strongest orange = busiest
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-dotgrid w-scroll" aria-hidden={state === "loading"}>
|
||||
{weeks.map((w, i) => (
|
||||
<span key={i} className="w-dotgrid__col">
|
||||
{w.map((d) => (
|
||||
<span
|
||||
key={d.date}
|
||||
className="w-dot"
|
||||
style={{ opacity: DOT_OPACITY[d.level] }}
|
||||
title={`${d.contribs} on ${d.date}`}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activities.length > 0 ? (
|
||||
<div className="w-print__log">
|
||||
{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">
|
||||
> {a.repo?.full_name || "unknown"} — {msg}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- socials */
|
||||
|
||||
/** The bundled linkedin.svg is a filled badge, which masks to a plain square. */
|
||||
function LinkedInGlyph({ className = "size-4" }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
|
||||
<path d="M4.98 3.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5zM3 9h4v12H3zM9 9h3.8v1.7h.05c.53-.95 1.83-1.95 3.77-1.95 4.03 0 4.78 2.5 4.78 5.75V21h-4v-5.6c0-1.34-.03-3.06-1.95-3.06-1.95 0-2.25 1.45-2.25 2.96V21H9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function SocialGlyph({ name, icon }: { name: string; icon: string }) {
|
||||
if (name === "LinkedIn") return <LinkedInGlyph />;
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="social-mask size-4"
|
||||
style={{ WebkitMaskImage: `url(${icon})`, maskImage: `url(${icon})` } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import "../web.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "credits · dvorakmortonxd.dev",
|
||||
description: "Type and other borrowed work used on dvorakmortonxd.dev.",
|
||||
};
|
||||
|
||||
const FONTS = [
|
||||
{
|
||||
name: "Nunito",
|
||||
by: "Vernon Adams, Cyreal, Jacques Le Bailly",
|
||||
use: "hardware labels, headings, buttons",
|
||||
href: "https://fonts.google.com/specimen/Nunito",
|
||||
},
|
||||
{
|
||||
name: "Newsreader",
|
||||
by: "Production Type",
|
||||
use: "anything printed on paper",
|
||||
href: "https://fonts.google.com/specimen/Newsreader",
|
||||
},
|
||||
{
|
||||
name: "IBM Plex Sans",
|
||||
by: "IBM",
|
||||
use: "body copy",
|
||||
href: "https://fonts.google.com/specimen/IBM+Plex+Sans",
|
||||
},
|
||||
{
|
||||
name: "IBM Plex Mono",
|
||||
by: "IBM",
|
||||
use: "terminal text, receipt, technical labels",
|
||||
href: "https://fonts.google.com/specimen/IBM+Plex+Mono",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Credits() {
|
||||
return (
|
||||
<div className="w-page">
|
||||
<main className="mx-auto w-full max-w-[1120px] px-5 pt-24 pb-20 sm:px-8">
|
||||
<div className="w-sechead">
|
||||
<span className="w-chip w-sechead__tab" style={{ "--tone": "var(--mint)" } as React.CSSProperties}>
|
||||
credits
|
||||
</span>
|
||||
<h1 className="w-display w-paint w-sechead__title">Everything borrowed, credited.</h1>
|
||||
</div>
|
||||
<p className="w-prose w-paint max-w-[62ch] text-[var(--ink-soft)]">
|
||||
The desk contains <strong>no images at all</strong> — every surface, screw, LED, paper sheet and coffee
|
||||
mug is drawn in CSS. That means there are no photography or texture licences to track: the only
|
||||
borrowed work is type.
|
||||
</p>
|
||||
|
||||
<section className="mt-16">
|
||||
<div className="w-sechead !mb-6">
|
||||
<span className="w-chip w-sechead__tab" style={{ "--tone": "var(--teal)" } as React.CSSProperties}>
|
||||
type
|
||||
</span>
|
||||
<h2 className="w-display w-paint w-sechead__title !text-[1.3rem]">
|
||||
Google Fonts — SIL Open Font License 1.1
|
||||
</h2>
|
||||
</div>
|
||||
<ul className="grid gap-5 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{FONTS.map((f, i) => (
|
||||
<li
|
||||
key={f.name}
|
||||
className="w-paper w-cardindex"
|
||||
style={{ "--tone": ["var(--orange)", "var(--blue)", "var(--lilac)", "var(--mint)"][i % 4] } as React.CSSProperties}
|
||||
>
|
||||
<p className="w-display text-[1.05rem] text-[var(--ink)]">{f.name}</p>
|
||||
<p className="w-mono mt-2 text-[0.64rem] leading-relaxed text-[var(--ink-soft)]">
|
||||
by {f.by}
|
||||
<br />
|
||||
{f.use}
|
||||
<br />
|
||||
<a className="w-link" href={f.href} target="_blank" rel="noopener noreferrer">
|
||||
fonts.google.com
|
||||
</a>{" "}
|
||||
· OFL 1.1
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mt-16">
|
||||
<div className="w-sechead !mb-6">
|
||||
<span className="w-chip w-sechead__tab" style={{ "--tone": "var(--lilac)" } as React.CSSProperties}>
|
||||
notes
|
||||
</span>
|
||||
<h2 className="w-display w-paint w-sechead__title !text-[1.3rem]">Other marks</h2>
|
||||
</div>
|
||||
<p className="w-prose w-paint max-w-[62ch] text-[var(--ink-soft)]">
|
||||
The patch panel uses brand marks that remain the property of their owners and are used only to
|
||||
link to those accounts. Everything written on the paper — and every pixel of the desk itself — is
|
||||
mine. The desktop mode (the KDE simulation) uses its own separate assets.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p className="mt-16">
|
||||
<Link href="/" className="w-link w-mono text-[0.78rem]">
|
||||
← back to the desk
|
||||
</Link>
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -270,3 +270,6 @@ export const friends: Friend[] = [
|
|||
|
||||
export const WALLPAPER = "/wallpaper.jpg";
|
||||
export const WALLPAPER_CREDIT = "https://wallpapercave.com/kde-plasma-wallpapers";
|
||||
export const MOBILE_WALLPAPER = "/phonewallpaper.jpg";
|
||||
export const MOBILE_WALLPAPER_CREDIT =
|
||||
"https://wallpapercave.com/landscape-4k-mobile-wallpapers";
|
||||
|
|
|
|||
|
|
@ -1,297 +1,26 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* ============================================================
|
||||
WEB THEME (default)
|
||||
------------------------------------------------------------
|
||||
A "technical print" layout painted in the gruvbox dark-hard
|
||||
palette. Tokens live here; the page markup uses Tailwind
|
||||
utilities plus the @utility classes below. Everything visual
|
||||
is scoped to html[data-theme="web"] so the skeuo desktop
|
||||
(which reads its own vars from skeuo.css) is never affected.
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
/* gruvbox dark-hard */
|
||||
--bg: #1d2021;
|
||||
--bg-raised: #282828;
|
||||
--bg-well: #161819;
|
||||
|
||||
--ink: #fbf1c7;
|
||||
--body: #d5c4a1;
|
||||
--muted: #a89984;
|
||||
--faint: #928374;
|
||||
|
||||
/* one accent (orange); other gruvbox hues are for status only */
|
||||
--accent: #fe8019;
|
||||
--accent-strong: #d65d0e;
|
||||
--negative: #fb4934;
|
||||
--positive: #b8bb26;
|
||||
|
||||
--rule: rgba(235, 219, 178, 0.16);
|
||||
--rule-soft: rgba(235, 219, 178, 0.09);
|
||||
--rule-strong: rgba(235, 219, 178, 0.3);
|
||||
|
||||
--fill: rgba(235, 219, 178, 0.035);
|
||||
--fill-strong: rgba(235, 219, 178, 0.07);
|
||||
|
||||
--ease-cinematic: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/* Beats page.css's :root when the web theme is active. The desk is a light,
|
||||
colourful, cartoon-skeuomorphic surface — the dark gruvbox values in :root
|
||||
are only the fallback for the desktop mode. */
|
||||
html[data-theme="web"] {
|
||||
color-scheme: light;
|
||||
--bg: #f7e7c6;
|
||||
--bg-raised: #fffdf6;
|
||||
--bg-well: #eed9b0;
|
||||
--accent: #f4823a;
|
||||
--accent2: #d4611c;
|
||||
--accent3: #f2c14e;
|
||||
--green: #58bd72;
|
||||
--red: #ea6f6a;
|
||||
--gray: #7f7768;
|
||||
--background: #1d2021;
|
||||
--foreground: #ebdbb2;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-ink: var(--ink);
|
||||
--color-body: var(--body);
|
||||
--color-muted: var(--muted);
|
||||
--color-faint: var(--faint);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-strong: var(--accent-strong);
|
||||
--color-positive: var(--positive);
|
||||
--color-negative: var(--negative);
|
||||
--color-bg: var(--bg);
|
||||
|
||||
--font-sans: var(--font-plex);
|
||||
--font-mono: var(--font-plexmono);
|
||||
--font-display: var(--font-news);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
html[data-theme="web"] {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 5.5rem;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
html[data-theme="web"] ::selection {
|
||||
background: rgba(244, 130, 58, 0.3);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
html[data-theme="web"] :where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
html[data-theme="web"] ::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
html[data-theme="web"] ::-webkit-scrollbar-track {
|
||||
background: var(--bg-well);
|
||||
}
|
||||
html[data-theme="web"] ::-webkit-scrollbar-thumb {
|
||||
background: #d9c49b;
|
||||
border: 3px solid var(--bg-well);
|
||||
border-radius: 99px;
|
||||
}
|
||||
html[data-theme="web"] ::-webkit-scrollbar-thumb:hover {
|
||||
background: #c9b184;
|
||||
}
|
||||
|
||||
/* Phones never get the desktop-mode button. */
|
||||
html[data-device="phone"] .desktop-toggle {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Type
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
@utility display {
|
||||
font-family: var(--font-news), Georgia, "Times New Roman", serif;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.012em;
|
||||
line-height: 1.12;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@utility serif-num {
|
||||
font-family: var(--font-news), Georgia, serif;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: lining-nums tabular-nums;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* run-in metadata label: mono, sentence case — deliberately not all-caps */
|
||||
@utility label {
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@utility lede {
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.7;
|
||||
color: var(--body);
|
||||
}
|
||||
|
||||
@utility measure {
|
||||
max-width: 34rem;
|
||||
}
|
||||
|
||||
@utility measure-wide {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Surfaces — restrained translucency, hairline rules
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
@utility shell {
|
||||
width: 100%;
|
||||
max-width: 1840px;
|
||||
margin-inline: auto;
|
||||
padding-inline: clamp(1.25rem, 3.5vw, 5rem);
|
||||
}
|
||||
|
||||
@utility glass {
|
||||
background-color: var(--fill);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
@utility glass-soft {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@utility glass-sunken {
|
||||
background-color: var(--bg-well);
|
||||
border: 1px solid var(--rule-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@utility glass-bar {
|
||||
background-color: rgba(29, 32, 33, 0.94);
|
||||
border-bottom: 1px solid rgba(235, 219, 178, 0.22);
|
||||
backdrop-filter: blur(12px) saturate(120%);
|
||||
}
|
||||
|
||||
@utility glass-press-sm {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 8px;
|
||||
transition-property: background-color, border-color, color;
|
||||
transition-duration: 160ms;
|
||||
transition-timing-function: ease-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--fill-strong);
|
||||
border-color: var(--rule-strong);
|
||||
color: var(--ink);
|
||||
}
|
||||
&:active {
|
||||
background-color: rgba(0, 0, 0, 0.25);
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #1d2021;
|
||||
--foreground: #ebdbb2;
|
||||
}
|
||||
}
|
||||
|
||||
@utility rule {
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
@utility rule-strong {
|
||||
border-top: 1px solid var(--rule-strong);
|
||||
}
|
||||
|
||||
@utility rule-soft {
|
||||
border-top: 1px solid var(--rule-soft);
|
||||
}
|
||||
|
||||
@utility index-num {
|
||||
font-family: var(--font-plexmono), ui-monospace, monospace;
|
||||
font-size: 0.78rem;
|
||||
color: var(--faint);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Motion — scroll reveals only
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
/* Scroll reveal. Hidden until `.is-in` lands, and every block on the editorial
|
||||
page is wrapped, so the page fills in from top to bottom: the first screenful
|
||||
cascades in document order on load, everything else reveals as it scrolls in.
|
||||
A failed JS bundle leaves the page visible via the <noscript> rule in layout. */
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 18px, 0);
|
||||
transition:
|
||||
opacity 0.8s var(--ease-cinematic),
|
||||
transform 0.8s var(--ease-cinematic);
|
||||
transition-delay: var(--delay, 0ms);
|
||||
}
|
||||
.reveal.is-in {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
49% {
|
||||
opacity: 1;
|
||||
}
|
||||
50%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.animate-blink {
|
||||
animation: blink 1.05s step-end infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-theme="web"] {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
.reveal {
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
transition: none;
|
||||
}
|
||||
.animate-blink {
|
||||
animation: none !important;
|
||||
}
|
||||
* {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Helpers
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
html[data-theme="web"] .link {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-color: rgba(254, 128, 25, 0.4);
|
||||
transition: text-decoration-color 150ms ease-out;
|
||||
}
|
||||
html[data-theme="web"] .link:hover {
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
|
||||
html[data-theme="web"] .hide-scrollbar {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
html[data-theme="web"] .hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,8 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Newsreader, IBM_Plex_Sans, IBM_Plex_Mono, Nunito } from "next/font/google";
|
||||
// Removed unused google fonts since we use local Custom Mono
|
||||
import "./globals.css";
|
||||
import ThemeScript from "./components/ThemeScript";
|
||||
|
||||
// The web desk pairs a rounded sans (hardware labels, headings) with a printed
|
||||
// serif (anything on paper) and a mono (terminal text + technical labels).
|
||||
const news = Newsreader({
|
||||
variable: "--font-news",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
style: ["normal", "italic"],
|
||||
display: "swap",
|
||||
});
|
||||
const plex = IBM_Plex_Sans({
|
||||
variable: "--font-plex",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600"],
|
||||
display: "swap",
|
||||
});
|
||||
const plexMono = IBM_Plex_Mono({
|
||||
variable: "--font-plexmono",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500"],
|
||||
display: "swap",
|
||||
});
|
||||
const round = Nunito({
|
||||
variable: "--font-round",
|
||||
subsets: ["latin"],
|
||||
weight: ["600", "700", "800"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "dvorakmortonxd.dev",
|
||||
description: "Martin Dvořák's personal website.",
|
||||
|
|
@ -44,7 +16,7 @@ export const metadata: Metadata = {
|
|||
card: "summary_large_image",
|
||||
title: "dvorakmortonxd.dev",
|
||||
description: "Martin Dvořák's personal website.",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -53,18 +25,8 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
data-theme="web"
|
||||
suppressHydrationWarning
|
||||
className={`${news.variable} ${plex.variable} ${plexMono.variable} ${round.variable}`}
|
||||
>
|
||||
<html lang="en" data-theme="skeuo" suppressHydrationWarning>
|
||||
<body className="antialiased">
|
||||
<noscript>
|
||||
{/* Scroll-reveal is progressive enhancement: without JS every block
|
||||
must still be visible, since the base `.reveal` style is opacity 0. */}
|
||||
<style>{`.reveal{opacity:1 !important;transform:none !important}`}</style>
|
||||
</noscript>
|
||||
<ThemeScript />
|
||||
{children}
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -44,11 +44,11 @@
|
|||
--radius: 0px;
|
||||
}
|
||||
|
||||
/* RESET — margins/padding are zeroed by Tailwind's preflight, which lives in
|
||||
@layer base. Declaring them here (unlayered) would out-rank every Tailwind
|
||||
spacing utility, so this only sets box-sizing. */
|
||||
/* RESET */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Metadata } from 'next';
|
||||
import './page.css';
|
||||
import './skeuo.css';
|
||||
import './web.css';
|
||||
import ThemeRoot from './components/ThemeRoot';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
SKEUOMORPHIC THEME
|
||||
------------------------------------------------------------
|
||||
Desktop -> KDE Plasma (Breeze)
|
||||
Active when <html data-theme="skeuo">. The web editorial
|
||||
site is the default; this is the alternate, reachable from
|
||||
the editorial page's Desktop-mode button or the tray.
|
||||
Mobile -> iOS-style home screen
|
||||
Active when <html data-theme="skeuo">. The terminal theme is
|
||||
the alternate, reachable from the tray / launcher / app.
|
||||
============================================================ */
|
||||
|
||||
html[data-theme="skeuo"] {
|
||||
|
|
@ -27,8 +27,17 @@ html[data-theme="skeuo"] body {
|
|||
font-family: var(--font-custom-mono);
|
||||
}
|
||||
|
||||
/* The desktop root and its visibility are decided in JS (ThemeRoot); phones
|
||||
never mount it, and the CSS `.desktop-toggle` rule hides the entry button. */
|
||||
.skeuo-desktop-root { display: block; }
|
||||
.skeuo-mobile-root { display: none; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.skeuo-desktop-root { display: none; }
|
||||
.skeuo-mobile-root { display: block; }
|
||||
}
|
||||
|
||||
/* Real phones always get the mobile UI, even in landscape. */
|
||||
html[data-device="phone"] .skeuo-desktop-root { display: none; }
|
||||
html[data-device="phone"] .skeuo-mobile-root { display: block; }
|
||||
|
||||
/* ============================================================
|
||||
DESKTOP (Plasma)
|
||||
|
|
@ -1621,12 +1630,598 @@ a.win-icon {
|
|||
background: rgba(61, 174, 233, 0.3);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
MOBILE -> iOS-style home screen
|
||||
============================================================ */
|
||||
.mobile-root {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-home {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background-color: #101319;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
color: #ffffff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
.mobile-home::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 0.4),
|
||||
rgba(0, 0, 0, 0.12) 28%,
|
||||
rgba(0, 0, 0, 0.25) 62%,
|
||||
rgba(0, 0, 0, 0.6)
|
||||
);
|
||||
}
|
||||
|
||||
.mobile-statusbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 34px;
|
||||
z-index: 55;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 18px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.mobile-status-time {
|
||||
font-size: 0.94rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.mobile-status-icons {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.mobile-status-icons svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 2x1 iOS-style clock widget */
|
||||
.mobile-widget {
|
||||
grid-column: span 2;
|
||||
grid-row: span 1;
|
||||
border-radius: 22px;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
background-color: rgba(28, 30, 38, 0.42);
|
||||
background-image: linear-gradient(
|
||||
160deg,
|
||||
rgba(255, 255, 255, 0.22),
|
||||
rgba(255, 255, 255, 0.06) 52%,
|
||||
rgba(0, 0, 0, 0.18)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.35),
|
||||
0 6px 18px rgba(0, 0, 0, 0.32);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||
backdrop-filter: blur(20px) saturate(150%);
|
||||
}
|
||||
.mobile-widget-time {
|
||||
font-size: 1.65rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.mobile-widget-date {
|
||||
font-size: 0.66rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.mobile-grid {
|
||||
position: absolute;
|
||||
top: 46px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 112px;
|
||||
z-index: 10;
|
||||
overflow-y: auto;
|
||||
padding: 6px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-auto-rows: 86px;
|
||||
gap: 14px 6px;
|
||||
align-content: start;
|
||||
}
|
||||
.mobile-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
min-width: 0;
|
||||
touch-action: none;
|
||||
}
|
||||
.mobile-app.dragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.mobile-app-icon {
|
||||
position: relative;
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 21px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 27px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||
background-color: rgba(22, 24, 30, 0.42);
|
||||
background-image: linear-gradient(
|
||||
160deg,
|
||||
rgba(255, 255, 255, 0.38),
|
||||
rgba(255, 255, 255, 0.08) 44%,
|
||||
rgba(0, 0, 0, 0.08) 72%,
|
||||
rgba(0, 0, 0, 0.3)
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5),
|
||||
inset 0 -9px 16px rgba(0, 0, 0, 0.2),
|
||||
0 4px 12px rgba(0, 0, 0, 0.42);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(140%);
|
||||
backdrop-filter: blur(16px) saturate(140%);
|
||||
}
|
||||
.mobile-app-glyph {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: saturate(0.35) contrast(1.02);
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.mobile-app-icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.3),
|
||||
rgba(255, 255, 255, 0) 48%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
.mobile-app-icon::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.3),
|
||||
rgba(255, 255, 255, 0) 48%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
.mobile-app-label {
|
||||
max-width: 100%;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.mobile-dock {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 30px;
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
gap: 8px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 30px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28),
|
||||
0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(150%);
|
||||
backdrop-filter: blur(22px) saturate(150%);
|
||||
}
|
||||
.mobile-app.in-dock .mobile-app-icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
border-radius: 19px;
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.mobile-pagedots {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 14px;
|
||||
z-index: 15;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.mobile-pagedots span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.mobile-pagedots span.active {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.mobile-app-view {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
height: 100dvh;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(10, 13, 19, 0.66);
|
||||
color: #eef1f6;
|
||||
font-family: Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
-webkit-backdrop-filter: blur(30px) saturate(160%);
|
||||
backdrop-filter: blur(30px) saturate(160%);
|
||||
will-change: transform, opacity, border-radius;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
.mobile-app-view.opening {
|
||||
animation: mobileAppOpen 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
/* Grow out of the tapped icon. */
|
||||
transform-origin: var(--app-origin, 50% 50%);
|
||||
}
|
||||
.mobile-app-view.closing {
|
||||
animation: mobileAppClose 0.36s cubic-bezier(0.4, 0, 0.7, 0.2) forwards;
|
||||
pointer-events: none;
|
||||
/* Always shrink straight down from the centre, never toward the icon. */
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
.mobile-app-view .mobile-appbar,
|
||||
.mobile-app-view .mobile-app-body {
|
||||
animation: mobileAppContentIn 0.3s ease-out both;
|
||||
}
|
||||
|
||||
@keyframes mobileAppOpen {
|
||||
from {
|
||||
transform: scale(var(--app-scale, 0.2));
|
||||
border-radius: 27px;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
@keyframes mobileAppClose {
|
||||
from {
|
||||
translate: 0 0;
|
||||
scale: 1;
|
||||
border-radius: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
/* One full viewport height guarantees it clears the bottom edge entirely. */
|
||||
translate: 0 100vh;
|
||||
scale: var(--app-scale, 0.2);
|
||||
border-radius: 27px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes mobileAppContentIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.mobile-appbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 12px 10px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(150%);
|
||||
backdrop-filter: blur(20px) saturate(150%);
|
||||
}
|
||||
.mobile-back {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #eef1f6;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mobile-back:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.mobile-appbar-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
.mobile-app-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: transparent;
|
||||
}
|
||||
.mobile-app-body.app-surface {
|
||||
--white: #eef1f6;
|
||||
--gray: #a7b0bd;
|
||||
--accent: #7cc4ff;
|
||||
--accent2: #a99bff;
|
||||
--accent3: #7cc4ff;
|
||||
--green: #5bd67a;
|
||||
--aqua: #4fd6c9;
|
||||
--blue: #68b9ff;
|
||||
--purple: #b79bff;
|
||||
--red: #ff7a7a;
|
||||
--darkpink: #ff86c8;
|
||||
--fg: #eef1f6;
|
||||
color: #eef1f6;
|
||||
}
|
||||
.mobile-app-body .project-card,
|
||||
.mobile-app-body .service-button,
|
||||
.mobile-app-body .social-button,
|
||||
.mobile-app-body .gym-item {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* ---- Dark glassmorphism content (mobile apps only) ---- */
|
||||
.mobile-app-body .app-cmd {
|
||||
color: #aeb7c4;
|
||||
}
|
||||
.mobile-app-body .app-user {
|
||||
color: #5bd67a;
|
||||
}
|
||||
.mobile-app-body .app-path {
|
||||
color: #7cc4ff;
|
||||
}
|
||||
.mobile-app-body .app-text,
|
||||
.mobile-app-body .app-list {
|
||||
color: #c7cfda;
|
||||
}
|
||||
.mobile-app-body .about-content,
|
||||
.mobile-app-body .about-content > div {
|
||||
color: #c7cfda !important;
|
||||
}
|
||||
.mobile-app-body a {
|
||||
color: #7cc4ff;
|
||||
}
|
||||
.mobile-app-body .mail-contact {
|
||||
color: #c7cfda;
|
||||
}
|
||||
.mobile-app-body .mail-contact a {
|
||||
color: #7cc4ff;
|
||||
}
|
||||
.mobile-app-body .app-h1,
|
||||
.mobile-app-body .app-h2,
|
||||
.mobile-app-body .app-welcome-head h2 {
|
||||
color: #ffffff;
|
||||
}
|
||||
.mobile-app-body .app-muted {
|
||||
color: #98a2b0;
|
||||
}
|
||||
.mobile-app-body .app-welcome-head img {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
.mobile-app-body .app-shot {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
.mobile-app-body .app-linkrow a {
|
||||
color: #eef1f6;
|
||||
}
|
||||
|
||||
/* Glass cards */
|
||||
.mobile-app-body .social-button,
|
||||
.mobile-app-body .service-button,
|
||||
.mobile-app-body .project-card,
|
||||
.mobile-app-body .gym-item,
|
||||
.mobile-app-body .app-linkrow a {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: #eef1f6;
|
||||
border-radius: 16px;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.16),
|
||||
0 10px 24px rgba(0, 0, 0, 0.35);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(150%);
|
||||
backdrop-filter: blur(16px) saturate(150%);
|
||||
}
|
||||
.mobile-app-body .social-button:hover,
|
||||
.mobile-app-body .service-button:hover,
|
||||
.mobile-app-body .project-card:hover,
|
||||
.mobile-app-body .gym-item:hover,
|
||||
.mobile-app-body .app-linkrow a:hover {
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
border-color: rgba(124, 196, 255, 0.6);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.22),
|
||||
0 0 0 1px rgba(124, 196, 255, 0.35),
|
||||
0 12px 28px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.mobile-app-body .social-icon {
|
||||
opacity: 0.95;
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.mobile-app-body .service-name {
|
||||
color: #ffffff;
|
||||
}
|
||||
.mobile-app-body .service-desc {
|
||||
color: #a7b0bd;
|
||||
}
|
||||
.mobile-app-body .service-button:hover .service-desc {
|
||||
color: #dfe6ef;
|
||||
}
|
||||
.mobile-app-body .friend-icon {
|
||||
filter: invert(1) brightness(1.5);
|
||||
}
|
||||
.mobile-app-body .project-image {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.mobile-app-body .project-title {
|
||||
color: #ffffff;
|
||||
}
|
||||
.mobile-app-body .project-card:hover .project-title {
|
||||
color: #7cc4ff;
|
||||
}
|
||||
.mobile-app-body .project-desc {
|
||||
color: #a7b0bd;
|
||||
}
|
||||
.mobile-app-body .project-card:hover .project-desc {
|
||||
color: #dfe6ef;
|
||||
}
|
||||
.mobile-app-body .gym-label {
|
||||
color: #a7b0bd;
|
||||
}
|
||||
.mobile-app-body .gym-weight {
|
||||
color: #ffffff;
|
||||
}
|
||||
.mobile-app-body .gym-date {
|
||||
color: #8b95a3;
|
||||
}
|
||||
|
||||
/* Glass heatmap */
|
||||
.mobile-app-body .heatmap-container {
|
||||
border-color: rgba(124, 196, 255, 0.5);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.mobile-app-body .heatmap-cell,
|
||||
.mobile-app-body .heatmap-legend-cell {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.mobile-app-body .heatmap-cell[data-level="1"],
|
||||
.mobile-app-body .heatmap-legend-cell[data-level="1"] {
|
||||
background-color: rgba(124, 196, 255, 0.3);
|
||||
}
|
||||
.mobile-app-body .heatmap-cell[data-level="2"],
|
||||
.mobile-app-body .heatmap-legend-cell[data-level="2"] {
|
||||
background-color: rgba(124, 196, 255, 0.55);
|
||||
}
|
||||
.mobile-app-body .heatmap-cell[data-level="3"],
|
||||
.mobile-app-body .heatmap-legend-cell[data-level="3"] {
|
||||
background-color: rgba(124, 196, 255, 0.8);
|
||||
}
|
||||
.mobile-app-body .heatmap-cell[data-level="4"],
|
||||
.mobile-app-body .heatmap-legend-cell[data-level="4"] {
|
||||
background-color: #7cc4ff;
|
||||
}
|
||||
.mobile-app-body .heatmap-cell:hover {
|
||||
box-shadow: 0 0 8px rgba(124, 196, 255, 0.9);
|
||||
}
|
||||
.mobile-app-body .contribution-command,
|
||||
.mobile-app-body .contribution-entry,
|
||||
.mobile-app-body .contribution-total-title,
|
||||
.mobile-app-body .contribution-message {
|
||||
color: #eef1f6;
|
||||
}
|
||||
.mobile-app-body .contribution-repo {
|
||||
color: #a7b0bd;
|
||||
}
|
||||
.mobile-app-body .contribution-date {
|
||||
color: #5bd67a;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-pad {
|
||||
padding: 14px;
|
||||
}
|
||||
.app-surface .project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.app-surface .social-buttons {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.app-surface .services-buttons {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.app-surface .gym-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.app-surface .project-image {
|
||||
height: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.mobile-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Phones in landscape: compact the home so the app grid (and its labels) fit. */
|
||||
@media (orientation: landscape) and (max-height: 620px) {
|
||||
.mobile-statusbar { height: 28px; font-size: 0.72rem; padding: 0 16px; }
|
||||
.mobile-status-time { font-size: 0.84rem; }
|
||||
.mobile-grid {
|
||||
top: 30px;
|
||||
bottom: 76px;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
grid-auto-rows: 60px;
|
||||
gap: 8px 4px;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
.mobile-app { gap: 2px; }
|
||||
.mobile-app-icon { width: 40px; height: 40px; border-radius: 14px; font-size: 19px; }
|
||||
.mobile-app-label { font-size: 0.55rem; }
|
||||
.mobile-widget { border-radius: 14px; padding: 5px 8px; }
|
||||
.mobile-widget-time { font-size: 1rem; }
|
||||
.mobile-widget-date { font-size: 0.5rem; }
|
||||
.mobile-dock { bottom: 10px; padding: 6px 10px; border-radius: 22px; }
|
||||
.mobile-app.in-dock .mobile-app-icon { width: 38px; height: 38px; border-radius: 13px; font-size: 18px; }
|
||||
.mobile-pagedots { display: none; }
|
||||
}
|
||||
|
||||
/* Respect reduced-motion preferences without breaking animationend callbacks
|
||||
(a near-zero duration still fires them so closing windows/apps unmount). */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.win-window,
|
||||
.win-window.closing,
|
||||
.kickoff {
|
||||
.kickoff,
|
||||
.mobile-app-view.opening,
|
||||
.mobile-app-view.closing,
|
||||
.mobile-app-view .mobile-appbar,
|
||||
.mobile-app-view .mobile-app-body {
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
.win-icon {
|
||||
|
|
|
|||
1425
src/app/web.css
1425
src/app/web.css
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue