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
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">$ </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 /> },
|
||||
|
|
|
|||
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);
|
||||
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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue