mobile: iOS-style widget + animations, 2-click desktop icons, new wallpapers
Mobile: - iOS-style open/close animation for app views (grows from the tapped icon, always slides straight down out of view and shrinks on close) - digital 2x1 clock widget in an iOS-style frosted-glass panel - new phone wallpaper (phonewallpaper.jpg) - larger notch Desktop: - desktop icons and shortcuts now launch on the second click - open/close/launcher animations - windows keep their state when toggling Gruvbox/Desktop themes, and the Welcome window only appears on the first page load Other: - update wallpaper credits to wallpapercave - allow LAN hosts in dev (allowedDevOrigins) so phones can load the dev bundle instead of getting a 403 and an inert page
This commit is contained in:
parent
784334d4be
commit
6b645ac636
11 changed files with 397 additions and 159 deletions
|
|
@ -1,9 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import { APPS, APP_BY_ID } from "./apps";
|
||||
import Clock from "./Clock";
|
||||
import { WALLPAPER } from "../data";
|
||||
import { MOBILE_WALLPAPER } from "../data";
|
||||
|
||||
const DOCK_IDS = ["projects", "services", "socials", "contributions"];
|
||||
|
||||
|
|
@ -12,34 +19,87 @@ function subscribe(callback: () => void) {
|
|||
return () => window.clearInterval(id);
|
||||
}
|
||||
|
||||
/** Digital iOS-style clock widget: big time, small day/date underneath. */
|
||||
function HomeWidget() {
|
||||
const date = useSyncExternalStore(
|
||||
const now = useSyncExternalStore(
|
||||
subscribe,
|
||||
() =>
|
||||
new Date().toLocaleDateString([], {
|
||||
weekday: "long",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
() => ""
|
||||
() => {
|
||||
const d = new Date();
|
||||
return [
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
d.toLocaleDateString([], { weekday: "long", day: "numeric", month: "short" }),
|
||||
].join("\n");
|
||||
},
|
||||
() => "\n"
|
||||
);
|
||||
const [time, day] = now.split("\n");
|
||||
|
||||
return (
|
||||
<div className="mobile-widget">
|
||||
<div className="mobile-widget-time">
|
||||
<Clock />
|
||||
<div className="mobile-widget-time" suppressHydrationWarning>
|
||||
{time}
|
||||
</div>
|
||||
<div className="mobile-widget-date" suppressHydrationWarning>
|
||||
{date}
|
||||
{day}
|
||||
</div>
|
||||
<div className="mobile-widget-foot">dvorakmortonxd.dev</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LaunchOrigin {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
export default function MobileHome() {
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [origin, setOrigin] = useState<LaunchOrigin | null>(null);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const openApp = openId ? APP_BY_ID[openId] : null;
|
||||
const closeTimer = useRef<number | null>(null);
|
||||
|
||||
const clearCloseTimer = () => {
|
||||
if (closeTimer.current !== null) {
|
||||
window.clearTimeout(closeTimer.current);
|
||||
closeTimer.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback for when the close animation never reports completion (e.g.
|
||||
// reduced-motion, throttled tabs or a missed animationend). Without this the
|
||||
// full-screen app view can linger and swallow every tap on the home screen.
|
||||
const closeApp = () => {
|
||||
setClosing(true);
|
||||
clearCloseTimer();
|
||||
closeTimer.current = window.setTimeout(() => {
|
||||
setOpenId(null);
|
||||
setClosing(false);
|
||||
closeTimer.current = null;
|
||||
}, 600);
|
||||
};
|
||||
|
||||
useEffect(() => () => clearCloseTimer(), []);
|
||||
|
||||
// Compute a transform-origin so the scaled-down app view sits exactly on top
|
||||
// of the tapped icon, then let it grow to fill the screen (iOS style).
|
||||
const launch = (appId: string, e: MouseEvent<HTMLButtonElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const icon = rect.width > 0 ? rect.width : rect.height;
|
||||
// Guard against a zero/degenerate measured icon (e.g. hidden layout) so the
|
||||
// scale tween never collapses to 0 and the open animation can always run.
|
||||
const scale = icon > 0 ? Math.min(icon / window.innerWidth, 0.95) : 0.2;
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const denom = 1 - scale || 1;
|
||||
setOrigin({
|
||||
x: (cx - (scale * window.innerWidth) / 2) / denom,
|
||||
y: (cy - (scale * window.innerHeight) / 2) / denom,
|
||||
scale,
|
||||
});
|
||||
setClosing(false);
|
||||
setOpenId(appId);
|
||||
};
|
||||
|
||||
const renderIcon = (appId: string, inDock = false) => {
|
||||
const app = APP_BY_ID[appId];
|
||||
|
|
@ -49,7 +109,7 @@ export default function MobileHome() {
|
|||
key={app.id}
|
||||
type="button"
|
||||
className={"mobile-app" + (inDock ? " in-dock" : "")}
|
||||
onClick={() => setOpenId(app.id)}
|
||||
onClick={(e) => launch(app.id, e)}
|
||||
>
|
||||
<span className="mobile-app-icon">
|
||||
<span className="mobile-app-glyph">{app.icon}</span>
|
||||
|
|
@ -59,15 +119,90 @@ export default function MobileHome() {
|
|||
);
|
||||
};
|
||||
|
||||
// The transform origin is exposed as a CSS variable (not an inline
|
||||
// transform-origin) so only the opening keyframe uses the tapped icon as its
|
||||
// pivot. The closing animation keeps a centred origin and always slides down.
|
||||
const viewStyle = origin
|
||||
? ({
|
||||
"--app-origin": `${origin.x}px ${origin.y}px`,
|
||||
"--app-scale": String(origin.scale),
|
||||
} as CSSProperties)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="mobile-root">
|
||||
{openApp ? (
|
||||
<div className="mobile-app-view">
|
||||
<div
|
||||
className="mobile-home"
|
||||
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 />
|
||||
</span>
|
||||
<span className="mobile-status-icons" aria-hidden="true">
|
||||
<svg width="17" height="11" viewBox="0 0 17 11" fill="currentColor">
|
||||
<rect x="0" y="7" width="3" height="4" rx="1" />
|
||||
<rect x="4.5" y="5" width="3" height="6" rx="1" />
|
||||
<rect x="9" y="2.5" width="3" height="8.5" rx="1" />
|
||||
<rect x="13.5" y="0" width="3" height="11" rx="1" />
|
||||
</svg>
|
||||
<svg width="16" height="11" viewBox="0 0 16 11" fill="currentColor">
|
||||
<path d="M8 2.2c2.3 0 4.4.9 6 2.4l-1.2 1.3A6.9 6.9 0 0 0 8 4.2a6.9 6.9 0 0 0-4.8 1.7L2 4.6A8.5 8.5 0 0 1 8 2.2Z" />
|
||||
<path d="M8 5.6c1.5 0 2.8.6 3.8 1.6l-1.2 1.3A3.7 3.7 0 0 0 8 7.6c-1 0-1.9.4-2.6 1l-1.2-1.4A5.7 5.7 0 0 1 8 5.6Z" />
|
||||
<path d="M8 9a1.4 1.4 0 1 1 0 2.8A1.4 1.4 0 0 1 8 9Z" />
|
||||
</svg>
|
||||
<svg width="25" height="12" viewBox="0 0 25 12" aria-hidden="true">
|
||||
<rect
|
||||
x="0.5"
|
||||
y="0.5"
|
||||
width="21"
|
||||
height="11"
|
||||
rx="3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
opacity="0.5"
|
||||
/>
|
||||
<rect x="2" y="2" width="14" height="8" rx="2" fill="currentColor" />
|
||||
<path d="M23 4v4a2 2 0 0 0 0-4Z" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mobile-grid">
|
||||
<HomeWidget />
|
||||
{APPS.map((app) => renderIcon(app.id))}
|
||||
</div>
|
||||
|
||||
<div className="mobile-dock">{DOCK_IDS.map((id) => renderIcon(id, true))}</div>
|
||||
|
||||
<div className="mobile-pagedots" aria-hidden="true">
|
||||
<span className="active" />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{openApp && (
|
||||
<div
|
||||
className={"mobile-app-view" + (closing ? " closing" : " opening")}
|
||||
style={viewStyle}
|
||||
onAnimationEnd={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (closing) {
|
||||
clearCloseTimer();
|
||||
setOpenId(null);
|
||||
setClosing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header className="mobile-appbar">
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-back"
|
||||
onClick={() => setOpenId(null)}
|
||||
onClick={closeApp}
|
||||
aria-label="Back"
|
||||
>
|
||||
←
|
||||
|
|
@ -76,56 +211,6 @@ export default function MobileHome() {
|
|||
</header>
|
||||
<div className="mobile-app-body app-surface">{openApp.content}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mobile-home" style={{ backgroundImage: `url('${WALLPAPER}')` }}>
|
||||
<div className="mobile-notch" aria-hidden="true" />
|
||||
|
||||
<div className="mobile-statusbar">
|
||||
<span className="mobile-status-time">
|
||||
<Clock />
|
||||
</span>
|
||||
<span className="mobile-status-icons" aria-hidden="true">
|
||||
<svg width="17" height="11" viewBox="0 0 17 11" fill="currentColor">
|
||||
<rect x="0" y="7" width="3" height="4" rx="1" />
|
||||
<rect x="4.5" y="5" width="3" height="6" rx="1" />
|
||||
<rect x="9" y="2.5" width="3" height="8.5" rx="1" />
|
||||
<rect x="13.5" y="0" width="3" height="11" rx="1" />
|
||||
</svg>
|
||||
<svg width="16" height="11" viewBox="0 0 16 11" fill="currentColor">
|
||||
<path d="M8 2.2c2.3 0 4.4.9 6 2.4l-1.2 1.3A6.9 6.9 0 0 0 8 4.2a6.9 6.9 0 0 0-4.8 1.7L2 4.6A8.5 8.5 0 0 1 8 2.2Z" />
|
||||
<path d="M8 5.6c1.5 0 2.8.6 3.8 1.6l-1.2 1.3A3.7 3.7 0 0 0 8 7.6c-1 0-1.9.4-2.6 1l-1.2-1.4A5.7 5.7 0 0 1 8 5.6Z" />
|
||||
<path d="M8 9a1.4 1.4 0 1 1 0 2.8A1.4 1.4 0 0 1 8 9Z" />
|
||||
</svg>
|
||||
<svg width="25" height="12" viewBox="0 0 25 12" aria-hidden="true">
|
||||
<rect
|
||||
x="0.5"
|
||||
y="0.5"
|
||||
width="21"
|
||||
height="11"
|
||||
rx="3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
opacity="0.5"
|
||||
/>
|
||||
<rect x="2" y="2" width="14" height="8" rx="2" fill="currentColor" />
|
||||
<path d="M23 4v4a2 2 0 0 0 0-4Z" fill="currentColor" opacity="0.5" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mobile-grid">
|
||||
<HomeWidget />
|
||||
{APPS.map((app) => renderIcon(app.id))}
|
||||
</div>
|
||||
|
||||
<div className="mobile-dock">{DOCK_IDS.map((id) => renderIcon(id, true))}</div>
|
||||
|
||||
<div className="mobile-pagedots" aria-hidden="true">
|
||||
<span className="active" />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,19 +7,26 @@ import MobileHome from "./MobileHome";
|
|||
|
||||
export default function ThemeRoot() {
|
||||
const { theme } = useTheme();
|
||||
const terminal = theme === "terminal";
|
||||
|
||||
if (theme === "terminal") {
|
||||
return <TerminalLayout />;
|
||||
}
|
||||
|
||||
// The desktop is kept mounted (just hidden) while the Gruvbox/terminal theme
|
||||
// is active. That way the Welcome window is only created on a fresh page
|
||||
// load and isn't reopened by toggling themes.
|
||||
return (
|
||||
<>
|
||||
<div className="skeuo-desktop-root">
|
||||
<div
|
||||
className="skeuo-desktop-root"
|
||||
style={terminal ? { display: "none" } : undefined}
|
||||
>
|
||||
<WindowsDesktop />
|
||||
</div>
|
||||
<div className="skeuo-mobile-root">
|
||||
<div
|
||||
className="skeuo-mobile-root"
|
||||
style={terminal ? { display: "none" } : undefined}
|
||||
>
|
||||
<MobileHome />
|
||||
</div>
|
||||
{terminal && <TerminalLayout />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ interface Props {
|
|||
win: WinState;
|
||||
app: AppDef;
|
||||
active: boolean;
|
||||
closing?: boolean;
|
||||
onFocus: () => void;
|
||||
onClose: () => void;
|
||||
onCloseAnimationEnd?: () => void;
|
||||
onMinimize: () => void;
|
||||
onToggleMaximize: () => void;
|
||||
onMove: (x: number, y: number) => void;
|
||||
|
|
@ -31,8 +33,10 @@ export default function WinWindow({
|
|||
win,
|
||||
app,
|
||||
active,
|
||||
closing,
|
||||
onFocus,
|
||||
onClose,
|
||||
onCloseAnimationEnd,
|
||||
onMinimize,
|
||||
onToggleMaximize,
|
||||
onMove,
|
||||
|
|
@ -84,10 +88,15 @@ export default function WinWindow({
|
|||
(active ? " active" : "") +
|
||||
(centered ? " centered" : "") +
|
||||
(win.minimized ? " minimized" : "") +
|
||||
(win.maximized ? " maximized" : "")
|
||||
(win.maximized ? " maximized" : "") +
|
||||
(closing ? " closing" : "")
|
||||
}
|
||||
style={style}
|
||||
onPointerDown={onFocus}
|
||||
onAnimationEnd={(e) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (closing) onCloseAnimationEnd?.();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-label={app.title}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export default function WindowsDesktop() {
|
|||
const [activeId, setActiveId] = useState<number | null>(1);
|
||||
const [startOpen, setStartOpen] = useState(false);
|
||||
const [selectedIcon, setSelectedIcon] = useState<string | null>(null);
|
||||
const [closingIds, setClosingIds] = useState<number[]>([]);
|
||||
|
||||
const idRef = useRef(2);
|
||||
const zRef = useRef(11);
|
||||
|
|
@ -65,6 +66,17 @@ export default function WindowsDesktop() {
|
|||
);
|
||||
};
|
||||
|
||||
// Desktop icons behave like a real desktop: the first click selects the
|
||||
// icon, a second click on the same icon launches it.
|
||||
const activateIcon = (id: string, action: () => void) => {
|
||||
if (selectedIcon === id) {
|
||||
setSelectedIcon(null);
|
||||
action();
|
||||
} else {
|
||||
setSelectedIcon(id);
|
||||
}
|
||||
};
|
||||
|
||||
const openApp = (appId: string) => {
|
||||
setStartOpen(false);
|
||||
const existing = wins.find((w) => w.appId === appId);
|
||||
|
|
@ -105,8 +117,13 @@ export default function WindowsDesktop() {
|
|||
setActiveId(id);
|
||||
};
|
||||
|
||||
// Animate the window out first, then drop it from state.
|
||||
const closeWin = (id: number) =>
|
||||
setClosingIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||
const finishClose = (id: number) => {
|
||||
setWins((prev) => prev.filter((w) => w.id !== id));
|
||||
setClosingIds((prev) => prev.filter((x) => x !== id));
|
||||
};
|
||||
const minimizeWin = (id: number) =>
|
||||
setWins((prev) => prev.map((w) => (w.id === id ? { ...w, minimized: true } : w)));
|
||||
const toggleMax = (id: number) =>
|
||||
|
|
@ -165,10 +182,7 @@ export default function WindowsDesktop() {
|
|||
key={app.id}
|
||||
type="button"
|
||||
className={"win-icon" + (selectedIcon === app.id ? " selected" : "")}
|
||||
onClick={() => {
|
||||
setSelectedIcon(app.id);
|
||||
openApp(app.id);
|
||||
}}
|
||||
onClick={() => activateIcon(app.id, () => openApp(app.id))}
|
||||
title={`Open ${app.title}`}
|
||||
>
|
||||
<span className="win-icon-glyph" style={{ background: app.color }}>
|
||||
|
|
@ -184,32 +198,24 @@ export default function WindowsDesktop() {
|
|||
className="win-icons win-icons-tr"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{SERVICE_SHORTCUTS.map((s) =>
|
||||
s.appId ? (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
className="win-icon"
|
||||
title={s.label}
|
||||
onClick={() => openApp(s.appId as string)}
|
||||
>
|
||||
<span className="win-icon-glyph">{s.icon}</span>
|
||||
<span className="win-icon-label">{s.label}</span>
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
key={s.id}
|
||||
href={s.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="win-icon"
|
||||
title={s.label}
|
||||
>
|
||||
<span className="win-icon-glyph">{s.icon}</span>
|
||||
<span className="win-icon-label">{s.label}</span>
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
{SERVICE_SHORTCUTS.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")
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="win-icon-glyph">{s.icon}</span>
|
||||
<span className="win-icon-label">{s.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Friends - bottom right */}
|
||||
|
|
@ -218,19 +224,22 @@ export default function WindowsDesktop() {
|
|||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{FRIEND_SHORTCUTS.map((f) => (
|
||||
<a
|
||||
<button
|
||||
key={f.id}
|
||||
href={f.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="win-icon"
|
||||
type="button"
|
||||
className={"win-icon" + (selectedIcon === f.id ? " selected" : "")}
|
||||
title={f.label}
|
||||
onClick={() =>
|
||||
activateIcon(f.id, () =>
|
||||
window.open(f.url, "_blank", "noopener,noreferrer")
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="win-icon-glyph">
|
||||
<Image src={f.img} alt={f.label} width={38} height={38} className="win-icon-img" />
|
||||
</span>
|
||||
<span className="win-icon-label">{f.label}</span>
|
||||
</a>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
|
@ -242,8 +251,10 @@ export default function WindowsDesktop() {
|
|||
win={w}
|
||||
app={APP_BY_ID[w.appId]}
|
||||
active={activeId === w.id}
|
||||
closing={closingIds.includes(w.id)}
|
||||
onFocus={() => focus(w.id)}
|
||||
onClose={() => closeWin(w.id)}
|
||||
onCloseAnimationEnd={() => finishClose(w.id)}
|
||||
onMinimize={() => minimizeWin(w.id)}
|
||||
onToggleMaximize={() => toggleMax(w.id)}
|
||||
onMove={(x, y) => moveWin(w.id, x, y)}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
techProjects,
|
||||
friends,
|
||||
WALLPAPER_CREDIT,
|
||||
MOBILE_WALLPAPER_CREDIT,
|
||||
} from "../data";
|
||||
import ServiceButton from "./ServiceButton";
|
||||
import ContributionHeatmap from "./ContributionHeatmap";
|
||||
|
|
@ -59,10 +60,14 @@ function WelcomeApp() {
|
|||
<p className="app-muted app-fineprint">
|
||||
Wallpaper from{" "}
|
||||
<a href={WALLPAPER_CREDIT} target="_blank" rel="noopener noreferrer">
|
||||
wallpaperaccess.com/open-source
|
||||
</a>
|
||||
. Gentoo logo by Matteo 'Peach' Pescarin (CC BY-SA 2.5), via Wikimedia
|
||||
Commons.
|
||||
wallpapercave.com/kde-plasma-wallpapers
|
||||
</a>{" "}
|
||||
(desktop) and{" "}
|
||||
<a href={MOBILE_WALLPAPER_CREDIT} target="_blank" rel="noopener noreferrer">
|
||||
wallpapercave.com/landscape-4k-mobile-wallpapers
|
||||
</a>{" "}
|
||||
(mobile). Gentoo logo by Matteo 'Peach' Pescarin (CC BY-SA 2.5), via
|
||||
Wikimedia Commons.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue