version bump, 2.1.0, some bug fixes and animations and a full on working terminal (WiP)
This commit is contained in:
parent
ee11b4f71b
commit
d4ea37f29e
9 changed files with 780 additions and 143 deletions
4
package-lock.json
generated
4
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "dvorakmortonxd",
|
"name": "dvorakmortonxd",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "dvorakmortonxd",
|
"name": "dvorakmortonxd",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "^16.3.5",
|
"next": "^16.3.5",
|
||||||
"react": "^19.3.0",
|
"react": "^19.3.0",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "dvorakmortonxd",
|
"name": "dvorakmortonxd",
|
||||||
"version": "2.0.0",
|
"version": "2.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { APPS, APP_BY_ID } from "./apps";
|
import { APPS, APP_BY_ID } from "./apps";
|
||||||
import Clock from "./Clock";
|
import Clock from "./Clock";
|
||||||
|
import { useDragOrder } from "./useDragOrder";
|
||||||
import { MOBILE_WALLPAPER } from "../data";
|
import { MOBILE_WALLPAPER } from "../data";
|
||||||
|
|
||||||
const DOCK_IDS = ["projects", "services", "socials", "contributions"];
|
const DOCK_IDS = ["projects", "services", "socials", "contributions"];
|
||||||
|
|
@ -59,6 +60,11 @@ export default function MobileHome() {
|
||||||
const openApp = openId ? APP_BY_ID[openId] : null;
|
const openApp = openId ? APP_BY_ID[openId] : null;
|
||||||
const closeTimer = useRef<number | null>(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 = () => {
|
const clearCloseTimer = () => {
|
||||||
if (closeTimer.current !== null) {
|
if (closeTimer.current !== null) {
|
||||||
window.clearTimeout(closeTimer.current);
|
window.clearTimeout(closeTimer.current);
|
||||||
|
|
@ -101,15 +107,24 @@ export default function MobileHome() {
|
||||||
setOpenId(appId);
|
setOpenId(appId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderIcon = (appId: string, inDock = false) => {
|
const renderIcon = (appId: string, inDock: boolean, drag: ReturnType<typeof useDragOrder>) => {
|
||||||
const app = APP_BY_ID[appId];
|
const app = APP_BY_ID[appId];
|
||||||
if (!app) return null;
|
if (!app) return null;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={app.id}
|
key={app.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={"mobile-app" + (inDock ? " in-dock" : "")}
|
data-drag-id={app.id}
|
||||||
onClick={(e) => launch(app.id, e)}
|
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-icon">
|
||||||
<span className="mobile-app-glyph">{app.icon}</span>
|
<span className="mobile-app-glyph">{app.icon}</span>
|
||||||
|
|
@ -136,8 +151,6 @@ export default function MobileHome() {
|
||||||
style={{ backgroundImage: `url('${MOBILE_WALLPAPER}')` }}
|
style={{ backgroundImage: `url('${MOBILE_WALLPAPER}')` }}
|
||||||
aria-hidden={openApp ? true : undefined}
|
aria-hidden={openApp ? true : undefined}
|
||||||
>
|
>
|
||||||
<div className="mobile-notch" aria-hidden="true" />
|
|
||||||
|
|
||||||
<div className="mobile-statusbar">
|
<div className="mobile-statusbar">
|
||||||
<span className="mobile-status-time">
|
<span className="mobile-status-time">
|
||||||
<Clock />
|
<Clock />
|
||||||
|
|
@ -173,10 +186,12 @@ export default function MobileHome() {
|
||||||
|
|
||||||
<div className="mobile-grid">
|
<div className="mobile-grid">
|
||||||
<HomeWidget />
|
<HomeWidget />
|
||||||
{APPS.map((app) => renderIcon(app.id))}
|
{gridApps.map((app) => renderIcon(app.id, false, gridDrag))}
|
||||||
</div>
|
</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">
|
<div className="mobile-pagedots" aria-hidden="true">
|
||||||
<span className="active" />
|
<span className="active" />
|
||||||
|
|
|
||||||
|
|
@ -593,7 +593,7 @@ export default function TerminalLayout() {
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer className="footer">
|
<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
|
© 2025-2026 Martin Dvořák. All rights reserved (not really I dont care, just follow
|
||||||
the GPL3.0 License)
|
the GPL3.0 License)
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -21,25 +21,33 @@ interface Props {
|
||||||
app: AppDef;
|
app: AppDef;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
closing?: boolean;
|
closing?: boolean;
|
||||||
|
minimizing?: boolean;
|
||||||
onFocus: () => void;
|
onFocus: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCloseAnimationEnd?: () => void;
|
onCloseAnimationEnd?: () => void;
|
||||||
onMinimize: () => void;
|
onMinimize: () => void;
|
||||||
|
onMinimizeAnimationEnd?: () => void;
|
||||||
onToggleMaximize: () => void;
|
onToggleMaximize: () => void;
|
||||||
onMove: (x: number, y: number) => 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({
|
export default function WinWindow({
|
||||||
win,
|
win,
|
||||||
app,
|
app,
|
||||||
active,
|
active,
|
||||||
closing,
|
closing,
|
||||||
|
minimizing,
|
||||||
onFocus,
|
onFocus,
|
||||||
onClose,
|
onClose,
|
||||||
onCloseAnimationEnd,
|
onCloseAnimationEnd,
|
||||||
onMinimize,
|
onMinimize,
|
||||||
|
onMinimizeAnimationEnd,
|
||||||
onToggleMaximize,
|
onToggleMaximize,
|
||||||
onMove,
|
onMove,
|
||||||
|
onResize,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const centered = win.centered && !win.maximized;
|
const centered = win.centered && !win.maximized;
|
||||||
const style: CSSProperties = win.maximized
|
const style: CSSProperties = win.maximized
|
||||||
|
|
@ -81,6 +89,64 @@ export default function WinWindow({
|
||||||
window.addEventListener("pointercancel", handleUp);
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className={
|
className={
|
||||||
|
|
@ -89,13 +155,15 @@ export default function WinWindow({
|
||||||
(centered ? " centered" : "") +
|
(centered ? " centered" : "") +
|
||||||
(win.minimized ? " minimized" : "") +
|
(win.minimized ? " minimized" : "") +
|
||||||
(win.maximized ? " maximized" : "") +
|
(win.maximized ? " maximized" : "") +
|
||||||
(closing ? " closing" : "")
|
(closing ? " closing" : "") +
|
||||||
|
(minimizing ? " minimizing" : "")
|
||||||
}
|
}
|
||||||
style={style}
|
style={style}
|
||||||
onPointerDown={onFocus}
|
onPointerDown={onFocus}
|
||||||
onAnimationEnd={(e) => {
|
onAnimationEnd={(e) => {
|
||||||
if (e.target !== e.currentTarget) return;
|
if (e.target !== e.currentTarget) return;
|
||||||
if (closing) onCloseAnimationEnd?.();
|
if (closing) onCloseAnimationEnd?.();
|
||||||
|
else if (minimizing) onMinimizeAnimationEnd?.();
|
||||||
}}
|
}}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={app.title}
|
aria-label={app.title}
|
||||||
|
|
@ -103,6 +171,17 @@ export default function WinWindow({
|
||||||
<div className="win-titlebar" onPointerDown={startDrag}>
|
<div className="win-titlebar" onPointerDown={startDrag}>
|
||||||
<span className="win-titlebar-text">{app.title}</span>
|
<span className="win-titlebar-text">{app.title}</span>
|
||||||
<div className="win-controls" onPointerDown={(e) => e.stopPropagation()}>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="win-ctl win-ctl-min"
|
className="win-ctl win-ctl-min"
|
||||||
|
|
@ -124,6 +203,18 @@ export default function WinWindow({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="win-body app-surface">{app.content}</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,46 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState, type ReactNode } from "react";
|
||||||
import Image from "next/image";
|
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 WinWindow, { type WinState } from "./WinWindow";
|
||||||
import Clock from "./Clock";
|
import Clock from "./Clock";
|
||||||
import { useTheme } from "./useTheme";
|
import { useTheme } from "./useTheme";
|
||||||
|
import { useDragOrder } from "./useDragOrder";
|
||||||
import { WALLPAPER, friends } from "../data";
|
import { WALLPAPER, friends } from "../data";
|
||||||
|
|
||||||
const TASKBAR_HEIGHT = 48;
|
const TASKBAR_HEIGHT = 48;
|
||||||
|
|
||||||
const LEFT_APPS = APPS.filter((a) => a.id !== "services" && a.id !== "friends");
|
const LEFT_APPS = APPS.filter((a) => a.id !== "services" && a.id !== "friends");
|
||||||
|
|
||||||
const SERVICE_SHORTCUTS: {
|
interface Shortcut {
|
||||||
id: string;
|
id: string;
|
||||||
icon: string;
|
icon: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
color: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
appId?: string;
|
appId?: string;
|
||||||
}[] = [
|
desc?: string;
|
||||||
{ id: "svc-proxmox", icon: "🖥️", label: "Proxmox", appId: "alacritty" },
|
image?: string;
|
||||||
{ 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" },
|
const SERVICE_SHORTCUTS: Shortcut[] = [
|
||||||
{ id: "svc-navidrome", icon: "🎸", label: "Navidrome", url: "https://navidrome.dvorakmortonxd.dev/" },
|
{ id: "svc-proxmox", icon: "🖥️", label: "Proxmox", appId: "alacritty", color: "#1257a0", desc: "Server Management" },
|
||||||
{ id: "svc-gitdxd", icon: "🐙", label: "GitDXD", url: "https://git.dvorakmortonxd.dev" },
|
{ 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}`,
|
id: `friend-${i}`,
|
||||||
label: f.name,
|
label: f.name,
|
||||||
url: f.realUrl || f.url,
|
url: f.realUrl || f.url,
|
||||||
img: f.img,
|
color: "#c0392b",
|
||||||
|
icon: "🫂",
|
||||||
|
image: f.img,
|
||||||
|
desc: f.desc,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default function WindowsDesktop() {
|
export default function WindowsDesktop() {
|
||||||
|
|
@ -56,6 +65,18 @@ export default function WindowsDesktop() {
|
||||||
const [startClosing, setStartClosing] = useState(false);
|
const [startClosing, setStartClosing] = useState(false);
|
||||||
const [selectedIcon, setSelectedIcon] = useState<string | null>(null);
|
const [selectedIcon, setSelectedIcon] = useState<string | null>(null);
|
||||||
const [closingIds, setClosingIds] = useState<number[]>([]);
|
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 idRef = useRef(2);
|
||||||
const zRef = useRef(11);
|
const zRef = useRef(11);
|
||||||
|
|
@ -78,9 +99,9 @@ export default function WindowsDesktop() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openApp = (appId: string) => {
|
const openAppDef = (app: AppDef) => {
|
||||||
if (startOpen) setStartClosing(true);
|
if (startOpen) setStartClosing(true);
|
||||||
const existing = wins.find((w) => w.appId === appId);
|
const existing = wins.find((w) => w.appId === app.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setWins((prev) =>
|
setWins((prev) =>
|
||||||
prev.map((w) =>
|
prev.map((w) =>
|
||||||
|
|
@ -90,8 +111,6 @@ export default function WindowsDesktop() {
|
||||||
setActiveId(existing.id);
|
setActiveId(existing.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const app = APP_BY_ID[appId];
|
|
||||||
if (!app) return;
|
|
||||||
const id = idRef.current++;
|
const id = idRef.current++;
|
||||||
const vw = window.innerWidth;
|
const vw = window.innerWidth;
|
||||||
const vh = window.innerHeight - TASKBAR_HEIGHT;
|
const vh = window.innerHeight - TASKBAR_HEIGHT;
|
||||||
|
|
@ -104,7 +123,7 @@ export default function WindowsDesktop() {
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
appId,
|
appId: app.id,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
w: app.width,
|
w: app.width,
|
||||||
|
|
@ -118,6 +137,35 @@ export default function WindowsDesktop() {
|
||||||
setActiveId(id);
|
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 = () => {
|
const closeStart = () => {
|
||||||
if (startOpen && !startClosing) setStartClosing(true);
|
if (startOpen && !startClosing) setStartClosing(true);
|
||||||
};
|
};
|
||||||
|
|
@ -140,8 +188,13 @@ export default function WindowsDesktop() {
|
||||||
setWins((prev) => prev.filter((w) => w.id !== id));
|
setWins((prev) => prev.filter((w) => w.id !== id));
|
||||||
setClosingIds((prev) => prev.filter((x) => x !== id));
|
setClosingIds((prev) => prev.filter((x) => x !== id));
|
||||||
};
|
};
|
||||||
|
// Minimize plays the same animation as closing, then hides the window.
|
||||||
const minimizeWin = (id: number) =>
|
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)));
|
setWins((prev) => prev.map((w) => (w.id === id ? { ...w, minimized: true } : w)));
|
||||||
|
setMinimizingIds((prev) => prev.filter((x) => x !== id));
|
||||||
|
};
|
||||||
const toggleMax = (id: number) =>
|
const toggleMax = (id: number) =>
|
||||||
setWins((prev) =>
|
setWins((prev) =>
|
||||||
prev.map((w) => (w.id === id ? { ...w, maximized: !w.maximized } : w))
|
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) => {
|
const taskbarClick = (w: WinState) => {
|
||||||
if (w.minimized) {
|
if (w.minimized) {
|
||||||
|
|
@ -178,6 +236,11 @@ export default function WindowsDesktop() {
|
||||||
|
|
||||||
const wallpaper = `url('${WALLPAPER}')`;
|
const wallpaper = `url('${WALLPAPER}')`;
|
||||||
|
|
||||||
|
const iconClick = (drag: ReturnType<typeof useDragOrder>, id: string, action: () => void) => {
|
||||||
|
if (drag.consumeDragged()) return;
|
||||||
|
activateIcon(id, action);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="win-desktop"
|
className="win-desktop"
|
||||||
|
|
@ -193,12 +256,18 @@ export default function WindowsDesktop() {
|
||||||
|
|
||||||
{/* Desktop icons */}
|
{/* Desktop icons */}
|
||||||
<div className="win-icons" onPointerDown={(e) => e.stopPropagation()}>
|
<div className="win-icons" onPointerDown={(e) => e.stopPropagation()}>
|
||||||
{LEFT_APPS.map((app) => (
|
{leftApps.map((app) => (
|
||||||
<button
|
<button
|
||||||
key={app.id}
|
key={app.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={"win-icon" + (selectedIcon === app.id ? " selected" : "")}
|
data-drag-id={app.id}
|
||||||
onClick={() => activateIcon(app.id, () => openApp(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}`}
|
title={`Open ${app.title}`}
|
||||||
>
|
>
|
||||||
<span className="win-icon-glyph" style={{ background: app.color }}>
|
<span className="win-icon-glyph" style={{ background: app.color }}>
|
||||||
|
|
@ -214,19 +283,19 @@ export default function WindowsDesktop() {
|
||||||
className="win-icons win-icons-tr"
|
className="win-icons win-icons-tr"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{SERVICE_SHORTCUTS.map((s) => (
|
{serviceItems.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={"win-icon" + (selectedIcon === s.id ? " selected" : "")}
|
data-drag-id={s.id}
|
||||||
title={s.label}
|
className={
|
||||||
onClick={() =>
|
"win-icon" +
|
||||||
activateIcon(s.id, () =>
|
(selectedIcon === s.id ? " selected" : "") +
|
||||||
s.appId
|
(serviceDrag.dragId === s.id ? " dragging" : "")
|
||||||
? openApp(s.appId)
|
|
||||||
: window.open(s.url, "_blank", "noopener,noreferrer")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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-glyph">{s.icon}</span>
|
||||||
<span className="win-icon-label">{s.label}</span>
|
<span className="win-icon-label">{s.label}</span>
|
||||||
|
|
@ -239,20 +308,24 @@ export default function WindowsDesktop() {
|
||||||
className="win-icons win-icons-br"
|
className="win-icons win-icons-br"
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{FRIEND_SHORTCUTS.map((f) => (
|
{friendItems.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.id}
|
key={f.id}
|
||||||
type="button"
|
type="button"
|
||||||
className={"win-icon" + (selectedIcon === f.id ? " selected" : "")}
|
data-drag-id={f.id}
|
||||||
title={f.label}
|
className={
|
||||||
onClick={() =>
|
"win-icon" +
|
||||||
activateIcon(f.id, () =>
|
(selectedIcon === f.id ? " selected" : "") +
|
||||||
window.open(f.url, "_blank", "noopener,noreferrer")
|
(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">
|
<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>
|
||||||
<span className="win-icon-label">{f.label}</span>
|
<span className="win-icon-label">{f.label}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -265,15 +338,18 @@ export default function WindowsDesktop() {
|
||||||
<WinWindow
|
<WinWindow
|
||||||
key={w.id}
|
key={w.id}
|
||||||
win={w}
|
win={w}
|
||||||
app={APP_BY_ID[w.appId]}
|
app={getApp(w.appId) ?? APP_BY_ID.welcome}
|
||||||
active={activeId === w.id}
|
active={activeId === w.id}
|
||||||
closing={closingIds.includes(w.id)}
|
closing={closingIds.includes(w.id)}
|
||||||
|
minimizing={minimizingIds.includes(w.id)}
|
||||||
onFocus={() => focus(w.id)}
|
onFocus={() => focus(w.id)}
|
||||||
onClose={() => closeWin(w.id)}
|
onClose={() => closeWin(w.id)}
|
||||||
onCloseAnimationEnd={() => finishClose(w.id)}
|
onCloseAnimationEnd={() => finishClose(w.id)}
|
||||||
onMinimize={() => minimizeWin(w.id)}
|
onMinimize={() => minimizeWin(w.id)}
|
||||||
|
onMinimizeAnimationEnd={() => finishMinimize(w.id)}
|
||||||
onToggleMaximize={() => toggleMax(w.id)}
|
onToggleMaximize={() => toggleMax(w.id)}
|
||||||
onMove={(x, y) => moveWin(w.id, x, y)}
|
onMove={(x, y) => moveWin(w.id, x, y)}
|
||||||
|
onResize={(x, y, width, height) => resizeWin(w.id, x, y, width, height)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -294,7 +370,7 @@ export default function WindowsDesktop() {
|
||||||
<div className="kickoff-content">
|
<div className="kickoff-content">
|
||||||
<div className="kickoff-section-title">Applications</div>
|
<div className="kickoff-section-title">Applications</div>
|
||||||
<div className="kickoff-list">
|
<div className="kickoff-list">
|
||||||
{LEFT_APPS.map((app) => (
|
{leftApps.map((app) => (
|
||||||
<button
|
<button
|
||||||
key={app.id}
|
key={app.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -305,49 +381,37 @@ export default function WindowsDesktop() {
|
||||||
<span className="kickoff-label">{app.title}</span>
|
<span className="kickoff-label">{app.title}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{SERVICE_SHORTCUTS.map((s) =>
|
{serviceItems.map((s) => (
|
||||||
s.appId ? (
|
<button
|
||||||
<button
|
key={s.id}
|
||||||
key={s.id}
|
type="button"
|
||||||
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}
|
|
||||||
className="kickoff-item"
|
className="kickoff-item"
|
||||||
href={f.url}
|
onClick={() => openShortcut(s)}
|
||||||
target="_blank"
|
>
|
||||||
rel="noopener noreferrer"
|
<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">
|
<span className="kickoff-icon">
|
||||||
<Image
|
{f.image && (
|
||||||
src={f.img}
|
<Image
|
||||||
alt={f.label}
|
src={f.image}
|
||||||
width={20}
|
alt={f.label}
|
||||||
height={20}
|
width={20}
|
||||||
className="kickoff-icon-img"
|
height={20}
|
||||||
/>
|
className="kickoff-icon-img"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="kickoff-label">{f.label}</span>
|
<span className="kickoff-label">{f.label}</span>
|
||||||
</a>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -370,7 +434,7 @@ export default function WindowsDesktop() {
|
||||||
|
|
||||||
<div className="win-tb-buttons">
|
<div className="win-tb-buttons">
|
||||||
{wins.map((w) => {
|
{wins.map((w) => {
|
||||||
const app = APP_BY_ID[w.appId];
|
const app = getApp(w.appId) ?? APP_BY_ID.welcome;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={w.id}
|
key={w.id}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import type { ReactNode } from "react";
|
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
socials,
|
socials,
|
||||||
codingProjects,
|
codingProjects,
|
||||||
|
|
@ -18,10 +18,12 @@ import { setTheme } from "./useTheme";
|
||||||
export interface AppDef {
|
export interface AppDef {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon: string;
|
icon: ReactNode;
|
||||||
color: string;
|
color: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
/** When set the app represents an external website: a www button opens it. */
|
||||||
|
url?: string;
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -406,20 +408,205 @@ const SIKE_ART = [
|
||||||
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
|
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
|
||||||
].join("\n");
|
].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 (
|
return (
|
||||||
<div className="alacritty">
|
<div
|
||||||
<div className="alacritty-line">
|
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-prompt">martin@gentoo</span>
|
||||||
<span className="alacritty-path"> ~ </span>
|
<span className="alacritty-path"> ~ </span>
|
||||||
<span className="alacritty-cmd">$ ssh root@proxmox.dvorakmortonxd.dev</span>
|
<span className="alacritty-cmd">$ </span>
|
||||||
</div>
|
<input
|
||||||
<pre className="alacritty-art">{SIKE_ART}</pre>
|
ref={inputRef}
|
||||||
<div className="alacritty-line">
|
className="alacritty-input"
|
||||||
<span className="alacritty-prompt">martin@gentoo</span>
|
value={input}
|
||||||
<span className="alacritty-path"> ~ </span>
|
onChange={(e) => setInput(e.target.value)}
|
||||||
<span className="alacritty-cmd">$ </span>
|
onKeyDown={onKeyDown}
|
||||||
<span className="alacritty-cursor" aria-hidden="true" />
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
aria-label="Terminal input"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -432,9 +619,72 @@ export const ALACRITTY_APP: AppDef = {
|
||||||
color: "#1d1f21",
|
color: "#1d1f21",
|
||||||
width: 760,
|
width: 760,
|
||||||
height: 440,
|
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[] = [
|
export const APPS: AppDef[] = [
|
||||||
{ id: "welcome", title: "Welcome", icon: "👋", color: "#2f7fc4", width: 620, height: 430, content: <WelcomeApp /> },
|
{ 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 /> },
|
{ id: "about", title: "About Me", icon: "🧑💻", color: "#0d5aa7", width: 720, height: 470, content: <AboutApp /> },
|
||||||
|
|
|
||||||
83
src/app/components/useDragOrder.ts
Normal file
83
src/app/components/useDragOrder.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -99,6 +99,10 @@ html[data-device="phone"] .skeuo-mobile-root { display: block; }
|
||||||
background: rgba(61, 174, 233, 0.16);
|
background: rgba(61, 174, 233, 0.16);
|
||||||
border-color: rgba(61, 174, 233, 0.4);
|
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 {
|
.win-icon-glyph {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
|
|
@ -196,6 +200,78 @@ a.win-icon {
|
||||||
animation: winClose 0.14s ease-in forwards;
|
animation: winClose 0.14s ease-in forwards;
|
||||||
pointer-events: none;
|
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 {
|
@keyframes winOpen {
|
||||||
from {
|
from {
|
||||||
|
|
@ -310,6 +386,17 @@ a.win-icon {
|
||||||
.win-ctl-close::after {
|
.win-ctl-close::after {
|
||||||
transform: translate(-50%, -50%) rotate(-45deg);
|
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 {
|
.win-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -422,6 +509,49 @@ a.win-icon {
|
||||||
color: #1d3e5c;
|
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 {
|
.app-surface .project-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
|
@ -835,6 +965,23 @@ a.win-icon {
|
||||||
.alacritty-prompt { color: #b8bb26; }
|
.alacritty-prompt { color: #b8bb26; }
|
||||||
.alacritty-path { color: #83a598; }
|
.alacritty-path { color: #83a598; }
|
||||||
.alacritty-cmd { color: #ebdbb2; }
|
.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 {
|
.alacritty-art {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
|
@ -1139,6 +1286,7 @@ a.win-icon {
|
||||||
.mobile-home {
|
.mobile-home {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
height: 100dvh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #101319;
|
background-color: #101319;
|
||||||
background-size: cover;
|
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 {
|
.mobile-statusbar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 30px;
|
height: 34px;
|
||||||
z-index: 55;
|
z-index: 55;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 16px;
|
padding: 0 18px;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
.mobile-status-time {
|
.mobile-status-time {
|
||||||
|
font-size: 0.94rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0;
|
||||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
.mobile-status-icons {
|
.mobile-status-icons {
|
||||||
|
|
@ -1285,6 +1413,11 @@ a.win-icon {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
min-width: 0;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.mobile-app.dragging {
|
||||||
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
.mobile-app-icon {
|
.mobile-app-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -1400,6 +1533,7 @@ a.win-icon {
|
||||||
.mobile-app-view {
|
.mobile-app-view {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
height: 100dvh;
|
||||||
z-index: 40;
|
z-index: 40;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -1703,24 +1837,24 @@ a.win-icon {
|
||||||
|
|
||||||
/* Phones in landscape: compact the home so the app grid (and its labels) fit. */
|
/* Phones in landscape: compact the home so the app grid (and its labels) fit. */
|
||||||
@media (orientation: landscape) and (max-height: 620px) {
|
@media (orientation: landscape) and (max-height: 620px) {
|
||||||
.mobile-notch { width: 108px; height: 22px; }
|
.mobile-statusbar { height: 28px; font-size: 0.72rem; padding: 0 16px; }
|
||||||
.mobile-statusbar { height: 24px; font-size: 0.66rem; padding: 0 14px; }
|
.mobile-status-time { font-size: 0.84rem; }
|
||||||
.mobile-grid {
|
.mobile-grid {
|
||||||
top: 30px;
|
top: 30px;
|
||||||
bottom: 76px;
|
bottom: 76px;
|
||||||
grid-template-columns: repeat(7, 1fr);
|
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||||
grid-auto-rows: 62px;
|
grid-auto-rows: 60px;
|
||||||
gap: 8px 6px;
|
gap: 8px 4px;
|
||||||
padding: 4px 22px;
|
padding: 4px 16px;
|
||||||
}
|
}
|
||||||
.mobile-app { gap: 3px; }
|
.mobile-app { gap: 2px; }
|
||||||
.mobile-app-icon { width: 42px; height: 42px; border-radius: 15px; font-size: 20px; }
|
.mobile-app-icon { width: 40px; height: 40px; border-radius: 14px; font-size: 19px; }
|
||||||
.mobile-app-label { font-size: 0.58rem; }
|
.mobile-app-label { font-size: 0.55rem; }
|
||||||
.mobile-widget { border-radius: 16px; padding: 6px 10px; }
|
.mobile-widget { border-radius: 14px; padding: 5px 8px; }
|
||||||
.mobile-widget-time { font-size: 1.1rem; }
|
.mobile-widget-time { font-size: 1rem; }
|
||||||
.mobile-widget-date { font-size: 0.55rem; }
|
.mobile-widget-date { font-size: 0.5rem; }
|
||||||
.mobile-dock { bottom: 14px; padding: 7px 12px; border-radius: 24px; }
|
.mobile-dock { bottom: 10px; padding: 6px 10px; border-radius: 22px; }
|
||||||
.mobile-app.in-dock .mobile-app-icon { width: 40px; height: 40px; border-radius: 14px; font-size: 19px; }
|
.mobile-app.in-dock .mobile-app-icon { width: 38px; height: 38px; border-radius: 13px; font-size: 18px; }
|
||||||
.mobile-pagedots { display: none; }
|
.mobile-pagedots { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue