version bump, 2.1.0, some bug fixes and animations and a full on working terminal (WiP)

This commit is contained in:
dvorakmortonxd 2026-09-23 14:40:21 +02:00
commit d4ea37f29e
9 changed files with 780 additions and 143 deletions

View file

@ -10,6 +10,7 @@ import {
} 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"];
@ -59,6 +60,11 @@ export default function MobileHome() {
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);
@ -101,15 +107,24 @@ export default function MobileHome() {
setOpenId(appId);
};
const renderIcon = (appId: string, inDock = false) => {
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"
className={"mobile-app" + (inDock ? " in-dock" : "")}
onClick={(e) => launch(app.id, e)}
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>
@ -136,8 +151,6 @@ export default function MobileHome() {
style={{ backgroundImage: `url('${MOBILE_WALLPAPER}')` }}
aria-hidden={openApp ? true : undefined}
>
<div className="mobile-notch" aria-hidden="true" />
<div className="mobile-statusbar">
<span className="mobile-status-time">
<Clock />
@ -173,10 +186,12 @@ export default function MobileHome() {
<div className="mobile-grid">
<HomeWidget />
{APPS.map((app) => renderIcon(app.id))}
{gridApps.map((app) => renderIcon(app.id, false, gridDrag))}
</div>
<div className="mobile-dock">{DOCK_IDS.map((id) => renderIcon(id, true))}</div>
<div className="mobile-dock">
{dockApps.map((d) => renderIcon(d.id, true, dockDrag))}
</div>
<div className="mobile-pagedots" aria-hidden="true">
<span className="active" />

View file

@ -593,7 +593,7 @@ export default function TerminalLayout() {
</section>
<footer className="footer">
<span className="footer-label">v2.0.0</span>
<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>

View file

@ -21,25 +21,33 @@ interface Props {
app: AppDef;
active: boolean;
closing?: boolean;
minimizing?: boolean;
onFocus: () => void;
onClose: () => void;
onCloseAnimationEnd?: () => void;
onMinimize: () => void;
onMinimizeAnimationEnd?: () => void;
onToggleMaximize: () => void;
onMove: (x: number, y: number) => void;
onResize: (x: number, y: number, w: number, h: number) => void;
}
const RESIZE_DIRS = ["n", "s", "e", "w", "ne", "nw", "se", "sw"] as const;
export default function WinWindow({
win,
app,
active,
closing,
minimizing,
onFocus,
onClose,
onCloseAnimationEnd,
onMinimize,
onMinimizeAnimationEnd,
onToggleMaximize,
onMove,
onResize,
}: Props) {
const centered = win.centered && !win.maximized;
const style: CSSProperties = win.maximized
@ -81,6 +89,64 @@ export default function WinWindow({
window.addEventListener("pointercancel", handleUp);
};
const startResize =
(dir: (typeof RESIZE_DIRS)[number]) => (e: ReactPointerEvent<HTMLDivElement>) => {
onFocus();
if (win.maximized) return;
e.preventDefault();
e.stopPropagation();
const node = e.currentTarget.closest(".win-window") as HTMLElement | null;
const rect = node?.getBoundingClientRect();
const startX = rect ? rect.left : win.x;
const startY = rect ? rect.top : win.y;
const startW = rect ? rect.width : win.w;
const startH = rect ? rect.height : win.h;
const px = e.clientX;
const py = e.clientY;
const minW = 260;
const minH = 150;
const handleMove = (ev: PointerEvent) => {
const dx = ev.clientX - px;
const dy = ev.clientY - py;
let x = startX;
let y = startY;
let w = startW;
let h = startH;
if (dir.includes("e")) w = startW + dx;
if (dir.includes("s")) h = startH + dy;
if (dir.includes("w")) {
w = startW - dx;
x = startX + dx;
}
if (dir.includes("n")) {
h = startH - dy;
y = startY + dy;
}
if (w < minW) {
if (dir.includes("w")) x = startX + startW - minW;
w = minW;
}
if (h < minH) {
if (dir.includes("n")) y = startY + startH - minH;
h = minH;
}
onResize(Math.round(x), Math.round(y), Math.round(w), Math.round(h));
};
const handleUp = () => {
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
window.removeEventListener("pointercancel", handleUp);
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
window.addEventListener("pointercancel", handleUp);
};
return (
<section
className={
@ -89,13 +155,15 @@ export default function WinWindow({
(centered ? " centered" : "") +
(win.minimized ? " minimized" : "") +
(win.maximized ? " maximized" : "") +
(closing ? " closing" : "")
(closing ? " closing" : "") +
(minimizing ? " minimizing" : "")
}
style={style}
onPointerDown={onFocus}
onAnimationEnd={(e) => {
if (e.target !== e.currentTarget) return;
if (closing) onCloseAnimationEnd?.();
else if (minimizing) onMinimizeAnimationEnd?.();
}}
role="dialog"
aria-label={app.title}
@ -103,6 +171,17 @@ export default function WinWindow({
<div className="win-titlebar" onPointerDown={startDrag}>
<span className="win-titlebar-text">{app.title}</span>
<div className="win-controls" onPointerDown={(e) => e.stopPropagation()}>
{app.url && (
<button
type="button"
className="win-ctl win-ctl-www"
onClick={() => window.open(app.url, "_blank", "noopener,noreferrer")}
title={`Open ${app.url}`}
aria-label="Open website"
>
www
</button>
)}
<button
type="button"
className="win-ctl win-ctl-min"
@ -124,6 +203,18 @@ export default function WinWindow({
</div>
</div>
<div className="win-body app-surface">{app.content}</div>
{!win.maximized && (
<div className="win-resize-layer" aria-hidden="true">
{RESIZE_DIRS.map((dir) => (
<div
key={dir}
className={`win-resize win-resize-${dir}`}
onPointerDown={startResize(dir)}
/>
))}
</div>
)}
</section>
);
}

View file

@ -1,37 +1,46 @@
"use client";
import { useRef, useState } from "react";
import { useRef, useState, type ReactNode } from "react";
import Image from "next/image";
import { APPS, APP_BY_ID } from "./apps";
import { APPS, APP_BY_ID, createWebsiteApp, type AppDef } from "./apps";
import WinWindow, { type WinState } from "./WinWindow";
import Clock from "./Clock";
import { useTheme } from "./useTheme";
import { useDragOrder } from "./useDragOrder";
import { WALLPAPER, friends } from "../data";
const TASKBAR_HEIGHT = 48;
const LEFT_APPS = APPS.filter((a) => a.id !== "services" && a.id !== "friends");
const SERVICE_SHORTCUTS: {
interface Shortcut {
id: string;
icon: string;
icon: ReactNode;
label: string;
color: string;
url?: string;
appId?: string;
}[] = [
{ id: "svc-proxmox", icon: "🖥️", label: "Proxmox", appId: "alacritty" },
{ id: "svc-ai", icon: "🦾", label: "Llama.cpp", url: "https://ai.dvorakmortonxd.dev/" },
{ id: "svc-jellyfin", icon: "🎬", label: "Jellyfin", url: "https://jelly.dvorakmortonxd.dev/" },
{ id: "svc-smp", icon: "🎮", label: "SMP", url: "https://discord.gg/vyvhk7d8bX" },
{ id: "svc-navidrome", icon: "🎸", label: "Navidrome", url: "https://navidrome.dvorakmortonxd.dev/" },
{ id: "svc-gitdxd", icon: "🐙", label: "GitDXD", url: "https://git.dvorakmortonxd.dev" },
desc?: string;
image?: string;
}
const SERVICE_SHORTCUTS: Shortcut[] = [
{ id: "svc-proxmox", icon: "🖥️", label: "Proxmox", appId: "alacritty", color: "#1257a0", desc: "Server Management" },
{ id: "svc-ai", icon: "🦾", label: "Llama.cpp", url: "https://ai.dvorakmortonxd.dev/", color: "#157347", desc: "Local AI Server" },
{ id: "svc-jellyfin", icon: "🎬", label: "Jellyfin", url: "https://jelly.dvorakmortonxd.dev/", color: "#6a4fb3", desc: "Local Media Server" },
{ id: "svc-smp", icon: "🎮", label: "SMP", url: "https://discord.gg/vyvhk7d8bX", color: "#c0392b", desc: "My Survival Minecraft Server" },
{ id: "svc-navidrome", icon: "🎸", label: "Navidrome", url: "https://navidrome.dvorakmortonxd.dev/", color: "#b3005f", desc: "Music Streaming" },
{ id: "svc-gitdxd", icon: "🐙", label: "GitDXD", url: "https://git.dvorakmortonxd.dev", color: "#6a4fb3", desc: "Best Git Hosting OAT" },
];
const FRIEND_SHORTCUTS = friends.map((f, i) => ({
const FRIEND_SHORTCUTS: Shortcut[] = friends.map((f, i) => ({
id: `friend-${i}`,
label: f.name,
url: f.realUrl || f.url,
img: f.img,
color: "#c0392b",
icon: "🫂",
image: f.img,
desc: f.desc,
}));
export default function WindowsDesktop() {
@ -56,6 +65,18 @@ export default function WindowsDesktop() {
const [startClosing, setStartClosing] = useState(false);
const [selectedIcon, setSelectedIcon] = useState<string | null>(null);
const [closingIds, setClosingIds] = useState<number[]>([]);
const [minimizingIds, setMinimizingIds] = useState<number[]>([]);
const [leftApps, setLeftApps] = useState(LEFT_APPS);
const [serviceItems, setServiceItems] = useState(SERVICE_SHORTCUTS);
const [friendItems, setFriendItems] = useState(FRIEND_SHORTCUTS);
const leftDrag = useDragOrder(setLeftApps);
const serviceDrag = useDragOrder(setServiceItems);
const friendDrag = useDragOrder(setFriendItems);
const [dynamicApps, setDynamicApps] = useState<Record<string, AppDef>>({});
const getApp = (id: string): AppDef | undefined => APP_BY_ID[id] ?? dynamicApps[id];
const idRef = useRef(2);
const zRef = useRef(11);
@ -78,9 +99,9 @@ export default function WindowsDesktop() {
}
};
const openApp = (appId: string) => {
const openAppDef = (app: AppDef) => {
if (startOpen) setStartClosing(true);
const existing = wins.find((w) => w.appId === appId);
const existing = wins.find((w) => w.appId === app.id);
if (existing) {
setWins((prev) =>
prev.map((w) =>
@ -90,8 +111,6 @@ export default function WindowsDesktop() {
setActiveId(existing.id);
return;
}
const app = APP_BY_ID[appId];
if (!app) return;
const id = idRef.current++;
const vw = window.innerWidth;
const vh = window.innerHeight - TASKBAR_HEIGHT;
@ -104,7 +123,7 @@ export default function WindowsDesktop() {
...prev,
{
id,
appId,
appId: app.id,
x,
y,
w: app.width,
@ -118,6 +137,35 @@ export default function WindowsDesktop() {
setActiveId(id);
};
const openApp = (appId: string) => {
const app = getApp(appId);
if (app) openAppDef(app);
};
// Service and friend shortcuts either open an app (Proxmox) or an in-desktop
// window representing the external website, with a www button to actually go
// there.
const openShortcut = (s: Shortcut) => {
if (s.appId) {
openApp(s.appId);
return;
}
if (!s.url) return;
const id = `web:${s.url}`;
const app =
getApp(id) ??
createWebsiteApp({
id,
title: s.label,
url: s.url,
desc: s.desc,
icon: s.icon,
image: s.image,
});
setDynamicApps((prev) => (prev[id] ? prev : { ...prev, [id]: app }));
openAppDef(app);
};
const closeStart = () => {
if (startOpen && !startClosing) setStartClosing(true);
};
@ -140,8 +188,13 @@ export default function WindowsDesktop() {
setWins((prev) => prev.filter((w) => w.id !== id));
setClosingIds((prev) => prev.filter((x) => x !== id));
};
// Minimize plays the same animation as closing, then hides the window.
const minimizeWin = (id: number) =>
setMinimizingIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
const finishMinimize = (id: number) => {
setWins((prev) => prev.map((w) => (w.id === id ? { ...w, minimized: true } : w)));
setMinimizingIds((prev) => prev.filter((x) => x !== id));
};
const toggleMax = (id: number) =>
setWins((prev) =>
prev.map((w) => (w.id === id ? { ...w, maximized: !w.maximized } : w))
@ -162,6 +215,11 @@ export default function WindowsDesktop() {
)
);
};
const resizeWin = (id: number, x: number, y: number, w: number, h: number) => {
setWins((prev) =>
prev.map((win) => (win.id === id ? { ...win, centered: false, x, y, w, h } : win))
);
};
const taskbarClick = (w: WinState) => {
if (w.minimized) {
@ -178,6 +236,11 @@ export default function WindowsDesktop() {
const wallpaper = `url('${WALLPAPER}')`;
const iconClick = (drag: ReturnType<typeof useDragOrder>, id: string, action: () => void) => {
if (drag.consumeDragged()) return;
activateIcon(id, action);
};
return (
<div
className="win-desktop"
@ -193,12 +256,18 @@ export default function WindowsDesktop() {
{/* Desktop icons */}
<div className="win-icons" onPointerDown={(e) => e.stopPropagation()}>
{LEFT_APPS.map((app) => (
{leftApps.map((app) => (
<button
key={app.id}
type="button"
className={"win-icon" + (selectedIcon === app.id ? " selected" : "")}
onClick={() => activateIcon(app.id, () => openApp(app.id))}
data-drag-id={app.id}
className={
"win-icon" +
(selectedIcon === app.id ? " selected" : "") +
(leftDrag.dragId === app.id ? " dragging" : "")
}
onPointerDown={leftDrag.onPointerDown(app.id)}
onClick={() => iconClick(leftDrag, app.id, () => openApp(app.id))}
title={`Open ${app.title}`}
>
<span className="win-icon-glyph" style={{ background: app.color }}>
@ -214,19 +283,19 @@ export default function WindowsDesktop() {
className="win-icons win-icons-tr"
onPointerDown={(e) => e.stopPropagation()}
>
{SERVICE_SHORTCUTS.map((s) => (
{serviceItems.map((s) => (
<button
key={s.id}
type="button"
className={"win-icon" + (selectedIcon === s.id ? " selected" : "")}
title={s.label}
onClick={() =>
activateIcon(s.id, () =>
s.appId
? openApp(s.appId)
: window.open(s.url, "_blank", "noopener,noreferrer")
)
data-drag-id={s.id}
className={
"win-icon" +
(selectedIcon === s.id ? " selected" : "") +
(serviceDrag.dragId === s.id ? " dragging" : "")
}
onPointerDown={serviceDrag.onPointerDown(s.id)}
title={s.label}
onClick={() => iconClick(serviceDrag, s.id, () => openShortcut(s))}
>
<span className="win-icon-glyph">{s.icon}</span>
<span className="win-icon-label">{s.label}</span>
@ -239,20 +308,24 @@ export default function WindowsDesktop() {
className="win-icons win-icons-br"
onPointerDown={(e) => e.stopPropagation()}
>
{FRIEND_SHORTCUTS.map((f) => (
{friendItems.map((f) => (
<button
key={f.id}
type="button"
className={"win-icon" + (selectedIcon === f.id ? " selected" : "")}
title={f.label}
onClick={() =>
activateIcon(f.id, () =>
window.open(f.url, "_blank", "noopener,noreferrer")
)
data-drag-id={f.id}
className={
"win-icon" +
(selectedIcon === f.id ? " selected" : "") +
(friendDrag.dragId === f.id ? " dragging" : "")
}
onPointerDown={friendDrag.onPointerDown(f.id)}
title={f.label}
onClick={() => iconClick(friendDrag, f.id, () => openShortcut(f))}
>
<span className="win-icon-glyph">
<Image src={f.img} alt={f.label} width={38} height={38} className="win-icon-img" />
{f.image && (
<Image src={f.image} alt={f.label} width={38} height={38} className="win-icon-img" />
)}
</span>
<span className="win-icon-label">{f.label}</span>
</button>
@ -265,15 +338,18 @@ export default function WindowsDesktop() {
<WinWindow
key={w.id}
win={w}
app={APP_BY_ID[w.appId]}
app={getApp(w.appId) ?? APP_BY_ID.welcome}
active={activeId === w.id}
closing={closingIds.includes(w.id)}
minimizing={minimizingIds.includes(w.id)}
onFocus={() => focus(w.id)}
onClose={() => closeWin(w.id)}
onCloseAnimationEnd={() => finishClose(w.id)}
onMinimize={() => minimizeWin(w.id)}
onMinimizeAnimationEnd={() => finishMinimize(w.id)}
onToggleMaximize={() => toggleMax(w.id)}
onMove={(x, y) => moveWin(w.id, x, y)}
onResize={(x, y, width, height) => resizeWin(w.id, x, y, width, height)}
/>
))}
</div>
@ -294,7 +370,7 @@ export default function WindowsDesktop() {
<div className="kickoff-content">
<div className="kickoff-section-title">Applications</div>
<div className="kickoff-list">
{LEFT_APPS.map((app) => (
{leftApps.map((app) => (
<button
key={app.id}
type="button"
@ -305,49 +381,37 @@ export default function WindowsDesktop() {
<span className="kickoff-label">{app.title}</span>
</button>
))}
{SERVICE_SHORTCUTS.map((s) =>
s.appId ? (
<button
key={s.id}
type="button"
className="kickoff-item"
onClick={() => openApp(s.appId as string)}
>
<span className="kickoff-icon">{s.icon}</span>
<span className="kickoff-label">{s.label}</span>
</button>
) : (
<a
key={s.id}
className="kickoff-item"
href={s.url}
target="_blank"
rel="noopener noreferrer"
>
<span className="kickoff-icon">{s.icon}</span>
<span className="kickoff-label">{s.label}</span>
</a>
)
)}
{FRIEND_SHORTCUTS.map((f) => (
<a
key={f.id}
{serviceItems.map((s) => (
<button
key={s.id}
type="button"
className="kickoff-item"
href={f.url}
target="_blank"
rel="noopener noreferrer"
onClick={() => openShortcut(s)}
>
<span className="kickoff-icon">{s.icon}</span>
<span className="kickoff-label">{s.label}</span>
</button>
))}
{friendItems.map((f) => (
<button
key={f.id}
type="button"
className="kickoff-item"
onClick={() => openShortcut(f)}
>
<span className="kickoff-icon">
<Image
src={f.img}
alt={f.label}
width={20}
height={20}
className="kickoff-icon-img"
/>
{f.image && (
<Image
src={f.image}
alt={f.label}
width={20}
height={20}
className="kickoff-icon-img"
/>
)}
</span>
<span className="kickoff-label">{f.label}</span>
</a>
</button>
))}
</div>
</div>
@ -370,7 +434,7 @@ export default function WindowsDesktop() {
<div className="win-tb-buttons">
{wins.map((w) => {
const app = APP_BY_ID[w.appId];
const app = getApp(w.appId) ?? APP_BY_ID.welcome;
return (
<button
key={w.id}

View file

@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import type { ReactNode } from "react";
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
import {
socials,
codingProjects,
@ -18,10 +18,12 @@ import { setTheme } from "./useTheme";
export interface AppDef {
id: string;
title: string;
icon: string;
icon: ReactNode;
color: string;
width: number;
height: number;
/** When set the app represents an external website: a www button opens it. */
url?: string;
content: ReactNode;
}
@ -406,20 +408,205 @@ const SIKE_ART = [
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
].join("\n");
function SikeTerminal() {
const TERMINAL_FILES: Record<string, string> = {
"about_me.md":
"Hey, I'm Martin Dvorak, a 17 year old IT student from the Czech Republic.\nI self-host way too many things and tinker with GNU/Linux.",
"now.txt":
"Currently hosting an SMP and building an education app with Pepa.",
"resume.txt":
"sssvt.cz - network management & administration (2025-)\nGentoo, SwayFX, RustyWolf, dxdwm, Revival Inc.",
};
const NEOFETCH = [
" .-/+oossssoo+/-. martin@gentoo",
" `:+ssssssssssssssssss+:` ------------",
"-+ssssssssssssssssssyyssss+- OS: Gentoo Linux x86_64",
".ossssssssssssssssssdMMMNysssso Kernel: 6.6.21-gentoo",
":sssssssssssssssssssssssssss+ Shell: bash 5.2.21",
" osssssssssssssssssssssssso DE: Plasma (web)",
" /ssssssssssssssssssssss+/ Terminal: Alacritty",
" `:+sssssssssssssssss+:` CPU: Ryzen 5 3600",
" .-/+oossssoo+/-. GPU: RX 580 8GB",
].join("\n");
function Terminal() {
const [lines, setLines] = useState<{ id: number; kind: "in" | "out" | "art"; text: string }[]>([
{ id: 0, kind: "out", text: "Type 'help' for a list of commands." },
]);
const [input, setInput] = useState("");
const [history, setHistory] = useState<string[]>([]);
const [histIndex, setHistIndex] = useState<number | null>(null);
const idRef = useRef(1);
const inputRef = useRef<HTMLInputElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
useEffect(() => {
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight });
}, [lines]);
const push = (next: { kind: "in" | "out" | "art"; text: string }[]) => {
setLines((prev) => [...prev, ...next.map((l) => ({ ...l, id: idRef.current++ }))]);
};
const run = (raw: string) => {
const cmd = raw.trim();
if (!cmd) {
push([{ kind: "in", text: "$" }]);
return;
}
setHistory((h) => [...h, cmd]);
setHistIndex(null);
const output: { kind: "in" | "out" | "art"; text: string }[] = [
{ kind: "in", text: `$ ${cmd}` },
];
const [name, ...args] = cmd.split(/\s+/);
switch (name) {
case "help":
output.push({
kind: "out",
text: "Available: help, ls, cat, echo, whoami, pwd, date, uname, neofetch, history, clear, ssh, sudo, emerge, exit",
});
break;
case "ls":
output.push({ kind: "out", text: "about_me.md now.txt resume.txt projects/ services/" });
break;
case "cat": {
const file = args[0];
if (!file) output.push({ kind: "out", text: "cat: missing operand" });
else if (TERMINAL_FILES[file]) output.push({ kind: "out", text: TERMINAL_FILES[file] });
else output.push({ kind: "out", text: `cat: ${file}: No such file or directory` });
break;
}
case "echo":
output.push({ kind: "out", text: args.join(" ") });
break;
case "whoami":
output.push({ kind: "out", text: "martin" });
break;
case "pwd":
output.push({ kind: "out", text: "/home/martin" });
break;
case "date":
output.push({ kind: "out", text: new Date().toString() });
break;
case "uname":
output.push({
kind: "out",
text: args.includes("-a")
? "Linux gentoo 6.6.21-gentoo #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux"
: "Linux",
});
break;
case "neofetch":
output.push({ kind: "art", text: NEOFETCH });
break;
case "history":
output.push({
kind: "out",
text:
history.map((h, i) => `${String(i + 1).padStart(4)} ${h}`).join("\n") ||
"(no history yet)",
});
break;
case "clear":
setLines([]);
return;
case "ssh":
output.push({ kind: "out", text: "Connecting to host..." });
output.push({ kind: "art", text: SIKE_ART });
break;
case "sudo":
output.push({
kind: "out",
text: "martin is not in the sudoers file. This incident has been reported.",
});
break;
case "emerge":
output.push({
kind: "out",
text: args.length
? `Calculating dependencies... done!\n>>> Emerging (1 of 1) ${args.join(" ")}`
: "emerge: no packages specified",
});
break;
case "cd":
output.push({ kind: "out", text: "" });
break;
case "exit":
output.push({ kind: "out", text: "logout" });
break;
default:
output.push({ kind: "out", text: `bash: ${name}: command not found` });
}
push(output);
};
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
const value = input;
setInput("");
run(value);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!history.length) return;
const next = histIndex === null ? history.length - 1 : Math.max(0, histIndex - 1);
setHistIndex(next);
setInput(history[next]);
} else if (e.key === "ArrowDown") {
e.preventDefault();
if (histIndex === null) return;
const next = histIndex + 1;
if (next >= history.length) {
setHistIndex(null);
setInput("");
} else {
setHistIndex(next);
setInput(history[next]);
}
} else if (e.ctrlKey && e.key === "l") {
e.preventDefault();
setLines([]);
}
};
return (
<div className="alacritty">
<div className="alacritty-line">
<div
className="alacritty"
ref={bodyRef}
onClick={() => inputRef.current?.focus()}
>
{lines.map((line) => (
<div key={line.id} className="alacritty-line">
{line.kind === "art" ? (
<pre className="alacritty-art">{line.text}</pre>
) : (
<span className={line.kind === "in" ? "alacritty-cmd" : "alacritty-out-text"}>
{line.text}
</span>
)}
</div>
))}
<div className="alacritty-line alacritty-inputline">
<span className="alacritty-prompt">martin@gentoo</span>
<span className="alacritty-path"> ~ </span>
<span className="alacritty-cmd">$ ssh root@proxmox.dvorakmortonxd.dev</span>
</div>
<pre className="alacritty-art">{SIKE_ART}</pre>
<div className="alacritty-line">
<span className="alacritty-prompt">martin@gentoo</span>
<span className="alacritty-path"> ~ </span>
<span className="alacritty-cmd">$ </span>
<span className="alacritty-cursor" aria-hidden="true" />
<span className="alacritty-cmd">$&nbsp;</span>
<input
ref={inputRef}
className="alacritty-input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={onKeyDown}
spellCheck={false}
autoComplete="off"
aria-label="Terminal input"
/>
</div>
</div>
);
@ -432,9 +619,72 @@ export const ALACRITTY_APP: AppDef = {
color: "#1d1f21",
width: 760,
height: 440,
content: <SikeTerminal />,
content: <Terminal />,
};
function WebsiteApp({
title,
url,
desc,
icon,
image,
}: {
title: string;
url: string;
desc?: string;
icon?: ReactNode;
image?: string;
}) {
return (
<div className="app-pad app-center website-app">
<div className="website-app-icon">
{image ? (
<Image src={image} alt={title} width={84} height={84} className="website-app-img" />
) : (
<span className="website-app-emoji">{icon ?? "🌐"}</span>
)}
</div>
<h2 className="app-h1">{title}</h2>
{desc && <p className="app-h2">{desc}</p>}
<p className="app-text">
This icon opens an external website. Use the <strong>www</strong> button in the title
bar (or the link below) to open it in a new tab.
</p>
<a className="website-app-link" href={url} target="_blank" rel="noopener noreferrer">
{url}
</a>
</div>
);
}
export function createWebsiteApp(opts: {
id: string;
title: string;
url: string;
desc?: string;
icon?: ReactNode;
image?: string;
}): AppDef {
return {
id: opts.id,
title: opts.title,
icon: opts.icon ?? "🌐",
color: "#0a7cc1",
width: 620,
height: 440,
url: opts.url,
content: (
<WebsiteApp
title={opts.title}
url={opts.url}
desc={opts.desc}
icon={opts.icon}
image={opts.image}
/>
),
};
}
export const APPS: AppDef[] = [
{ id: "welcome", title: "Welcome", icon: "👋", color: "#2f7fc4", width: 620, height: 430, content: <WelcomeApp /> },
{ id: "about", title: "About Me", icon: "🧑‍💻", color: "#0d5aa7", width: 720, height: 470, content: <AboutApp /> },

View file

@ -0,0 +1,83 @@
"use client";
import {
useRef,
useState,
type Dispatch,
type PointerEvent as ReactPointerEvent,
type SetStateAction,
} from "react";
/**
* Pointer-based reordering for a list of items rendered with
* `data-drag-id={item.id}`. Dragging an item over another swaps them. Works
* with both mouse and touch.
*
* The click that follows a drag is swallowed via `consumeDragged()` so tapping
* still launches the icon while dragging only rearranges it.
*/
export function useDragOrder<T extends { id: string }>(
setItems: Dispatch<SetStateAction<T[]>>
) {
const [dragId, setDragId] = useState<string | null>(null);
const suppressRef = useRef(false);
const onPointerDown = (id: string) => (e: ReactPointerEvent) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
const startX = e.clientX;
const startY = e.clientY;
let active = false;
const move = (ev: PointerEvent) => {
if (!active) {
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < 8) return;
active = true;
suppressRef.current = true;
setDragId(id);
}
const el = document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null;
const target = el?.closest<HTMLElement>("[data-drag-id]");
const overId = target?.dataset.dragId;
if (!overId || overId === id) return;
setItems((prev) => {
const from = prev.findIndex((x) => x.id === id);
const to = prev.findIndex((x) => x.id === overId);
if (from < 0 || to < 0 || from === to) return prev;
const next = prev.slice();
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
return next;
});
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", up);
setDragId(null);
if (active) {
active = false;
window.setTimeout(() => {
suppressRef.current = false;
}, 300);
}
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
};
/** Returns true (once) when a drag just happened, so the click is ignored. */
const consumeDragged = () => {
if (suppressRef.current) {
suppressRef.current = false;
return true;
}
return false;
};
return { onPointerDown, dragId, consumeDragged };
}

View file

@ -99,6 +99,10 @@ html[data-device="phone"] .skeuo-mobile-root { display: block; }
background: rgba(61, 174, 233, 0.16);
border-color: rgba(61, 174, 233, 0.4);
}
.win-icon.dragging {
opacity: 0.55;
border-color: rgba(61, 174, 233, 0.8);
}
.win-icon-glyph {
width: 48px;
height: 48px;
@ -196,6 +200,78 @@ a.win-icon {
animation: winClose 0.14s ease-in forwards;
pointer-events: none;
}
.win-window.minimizing {
animation: winClose 0.14s ease-in forwards;
pointer-events: none;
}
/* Resize handles */
.win-resize-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.win-resize {
position: absolute;
pointer-events: auto;
z-index: 6;
}
.win-resize-n {
top: 0;
left: 8px;
right: 8px;
height: 5px;
cursor: ns-resize;
}
.win-resize-s {
bottom: 0;
left: 8px;
right: 8px;
height: 5px;
cursor: ns-resize;
}
.win-resize-e {
right: 0;
top: 8px;
bottom: 8px;
width: 5px;
cursor: ew-resize;
}
.win-resize-w {
left: 0;
top: 8px;
bottom: 8px;
width: 5px;
cursor: ew-resize;
}
.win-resize-ne {
top: 0;
right: 0;
width: 12px;
height: 12px;
cursor: nesw-resize;
}
.win-resize-nw {
top: 0;
left: 0;
width: 12px;
height: 12px;
cursor: nwse-resize;
}
.win-resize-se {
bottom: 0;
right: 0;
width: 14px;
height: 14px;
cursor: nwse-resize;
}
.win-resize-sw {
bottom: 0;
left: 0;
width: 12px;
height: 12px;
cursor: nesw-resize;
}
@keyframes winOpen {
from {
@ -310,6 +386,17 @@ a.win-icon {
.win-ctl-close::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.win-ctl-www {
width: 36px;
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.02em;
color: #9fd0f5;
cursor: pointer;
}
.win-ctl-www:hover {
background: rgba(61, 174, 233, 0.3);
}
.win-body {
flex: 1;
@ -422,6 +509,49 @@ a.win-icon {
color: #1d3e5c;
}
/* External website "apps" opened from a shortcut icon */
.website-app {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.website-app-icon {
display: grid;
place-items: center;
width: 96px;
height: 96px;
margin-bottom: 8px;
border-radius: 22px;
background: linear-gradient(to bottom, #ffffff, #eef2f8);
border: 1px solid rgba(0, 0, 0, 0.15);
box-shadow: inset 0 1px 0 #ffffff, 0 2px 6px rgba(0, 0, 0, 0.12);
}
.website-app-emoji {
font-size: 3rem;
line-height: 1;
}
.website-app-img {
width: 84px;
height: 84px;
object-fit: contain;
filter: brightness(0);
}
.website-app-link {
margin-top: 14px;
padding: 8px 16px;
border-radius: 6px;
background: linear-gradient(to bottom, #ffffff, #dfeaf6);
border: 1px solid #7db9e8;
color: #0d5aa7;
font-family: var(--font-custom-mono), "JetBrains Mono", monospace;
font-size: 0.8rem;
text-decoration: none;
}
.website-app-link:hover {
box-shadow: 0 0 0 1px #7db9e8, 0 0 12px rgba(95, 175, 255, 0.5);
}
.app-surface .project-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
@ -835,6 +965,23 @@ a.win-icon {
.alacritty-prompt { color: #b8bb26; }
.alacritty-path { color: #83a598; }
.alacritty-cmd { color: #ebdbb2; }
.alacritty-out-text { color: #c5c8c6; white-space: pre-wrap; }
.alacritty-inputline {
display: flex;
align-items: center;
margin-bottom: 0;
}
.alacritty-input {
flex: 1;
min-width: 0;
padding: 0;
border: none;
outline: none;
background: transparent;
color: #ebdbb2;
font: inherit;
caret-color: #ebdbb2;
}
.alacritty-art {
margin: 0 0 12px;
font-family: inherit;
@ -1139,6 +1286,7 @@ a.win-icon {
.mobile-home {
position: fixed;
inset: 0;
height: 100dvh;
overflow: hidden;
background-color: #101319;
background-size: cover;
@ -1165,45 +1313,25 @@ a.win-icon {
);
}
.mobile-notch {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 164px;
height: 34px;
z-index: 60;
background: #000;
border-radius: 0 0 22px 22px;
pointer-events: none;
}
.mobile-notch::after {
content: "";
position: absolute;
right: 26px;
top: 11px;
width: 9px;
height: 9px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #2b3a55, #05070b 70%);
}
.mobile-statusbar {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 30px;
height: 34px;
z-index: 55;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
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 {
@ -1285,6 +1413,11 @@ a.win-icon {
background: transparent;
cursor: pointer;
color: inherit;
min-width: 0;
touch-action: none;
}
.mobile-app.dragging {
opacity: 0.55;
}
.mobile-app-icon {
position: relative;
@ -1400,6 +1533,7 @@ a.win-icon {
.mobile-app-view {
position: fixed;
inset: 0;
height: 100dvh;
z-index: 40;
display: flex;
flex-direction: column;
@ -1703,24 +1837,24 @@ a.win-icon {
/* Phones in landscape: compact the home so the app grid (and its labels) fit. */
@media (orientation: landscape) and (max-height: 620px) {
.mobile-notch { width: 108px; height: 22px; }
.mobile-statusbar { height: 24px; font-size: 0.66rem; padding: 0 14px; }
.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, 1fr);
grid-auto-rows: 62px;
gap: 8px 6px;
padding: 4px 22px;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-auto-rows: 60px;
gap: 8px 4px;
padding: 4px 16px;
}
.mobile-app { gap: 3px; }
.mobile-app-icon { width: 42px; height: 42px; border-radius: 15px; font-size: 20px; }
.mobile-app-label { font-size: 0.58rem; }
.mobile-widget { border-radius: 16px; padding: 6px 10px; }
.mobile-widget-time { font-size: 1.1rem; }
.mobile-widget-date { font-size: 0.55rem; }
.mobile-dock { bottom: 14px; padding: 7px 12px; border-radius: 24px; }
.mobile-app.in-dock .mobile-app-icon { width: 40px; height: 40px; border-radius: 14px; font-size: 19px; }
.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; }
}