dvorakmortonxd.dev/src/app/components/WindowsDesktop.tsx

569 lines
19 KiB
TypeScript

"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import Image from "next/image";
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 { useDesktopIcons } from "./useDesktopIcons";
import { friends } from "../data";
// The panel sits at `bottom: 10px` with a height of 44px, so its top edge is
// 54px above the viewport bottom. Windows clamp 5px above that (59), so a
// dragged or maximized window stops just above the taskbar.
const TASKBAR_HEIGHT = 59;
const LEFT_APPS = APPS.filter((a) => a.id !== "services" && a.id !== "friends");
interface Shortcut {
id: string;
icon: ReactNode;
label: string;
color: string;
url?: string;
appId?: string;
desc?: string;
/** Show the real site in an iframe. Only for sites that allow framing. */
embed?: boolean;
/** Frame src when it differs from `url` (e.g. a local mirror of a blocking site). */
embedUrl?: string;
}
interface IconEntry {
id: string;
label: string;
title: string;
glyph: ReactNode;
color?: string;
onOpen: () => void;
}
// `embed: true` renders the real site in an iframe, which the browser refuses
// (blank window) when the server sends X-Frame-Options / frame-ancestors.
// Llama.cpp and Jellyfin allow framing out of the box; Navidrome and Forgejo
// need to be told to stop sending a restrictive value:
// - Navidrome: `ND_HTTPHEADERS_FRAMEOPTIONS=ALLOWALL`
// - Forgejo: `[cors] X_FRAME_OPTIONS = unset` (Forgejo reads this from
// [cors], NOT [security]) then restart Forgejo.
// Any X-Frame-Options value a browser doesn't recognise is ignored, so the
// frame is allowed. These live on the service hosts — without them the windows
// go blank again.
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", embed: true },
{ id: "svc-jellyfin", icon: "🎬", label: "Jellyfin", url: "https://jelly.dvorakmortonxd.dev/", color: "#6a4fb3", desc: "Local Media Server", embed: true },
{ id: "svc-smp", icon: "🎮", label: "SMP", appId: "smp", color: "#c0392b", desc: "My Survival Minecraft Server" },
{ id: "svc-navidrome", icon: "🎸", label: "Navidrome", url: "https://navidrome.dvorakmortonxd.dev/", color: "#b3005f", desc: "Music Streaming", embed: true },
{ id: "svc-gitdxd", icon: "🐙", label: "GitDXD", url: "https://git.dvorakmortonxd.dev", color: "#6a4fb3", desc: "Best Git Hosting OAT", embed: true },
];
// Friends' sites, opened in a live window when they allow framing. bukovinafilip
// and vlcak.me send no framing headers, so `url` (== `realUrl`) is framed
// directly. skopec.dev sends `X-Frame-Options: SAMEORIGIN` on every path, so
// `proxy: true` routes it through /api/frame, which strips the framing headers
// and serves the live page from our origin. The www button always opens the live
// site (`realUrl`).
const FRIEND_SHORTCUTS: Shortcut[] = friends.map((f, i) => ({
id: `friend-${i}`,
label: f.name,
url: f.realUrl || f.url,
embedUrl:
f.proxy && f.realUrl
? `/api/frame?url=${encodeURIComponent(f.realUrl)}`
: f.url,
embed: f.iframe,
color: "#c0392b",
icon: f.emoji,
desc: f.desc,
}));
/** Stable list of every desktop icon, used to seed the placement grid. */
const ICON_KEYS = [
...LEFT_APPS.map((a) => a.id),
...SERVICE_SHORTCUTS.map((s) => s.id),
...FRIEND_SHORTCUTS.map((f) => f.id),
];
export default function WindowsDesktop() {
const { toggle } = useTheme();
const [wins, setWins] = useState<WinState[]>(() => [
{
id: 1,
appId: "welcome",
x: 110,
y: 64,
w: 620,
h: 430,
z: 10,
minimized: false,
maximized: false,
centered: true,
},
]);
const [activeId, setActiveId] = useState<number | null>(1);
const [startOpen, setStartOpen] = useState(false);
const [startClosing, setStartClosing] = useState(false);
const [selectedIcon, setSelectedIcon] = useState<string | null>(null);
const [closingIds, setClosingIds] = useState<number[]>([]);
const [minimizingIds, setMinimizingIds] = useState<number[]>([]);
const leftApps = LEFT_APPS;
const serviceItems = SERVICE_SHORTCUTS;
const friendItems = FRIEND_SHORTCUTS;
const desktopRef = useRef<HTMLDivElement>(null);
const tbRef = useRef<HTMLDivElement>(null);
const { positions, dragId, onPointerDown, consumeDrag } =
useDesktopIcons(desktopRef, ICON_KEYS);
// When the taskbar buttons overflow, keep the focused window's button visible
// so opening an app doesn't leave its button off the (scrollable) right edge.
useEffect(() => {
const bar = tbRef.current;
const active = bar?.querySelector<HTMLElement>(".win-tb-btn.active");
if (!bar || !active) return;
const barRect = bar.getBoundingClientRect();
const btnRect = active.getBoundingClientRect();
if (btnRect.left < barRect.left) {
bar.scrollLeft += btnRect.left - barRect.left;
} else if (btnRect.right > barRect.right) {
bar.scrollLeft += btnRect.right - barRect.right;
}
}, [activeId]);
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);
const focus = (id: number) => {
setActiveId(id);
setWins((prev) =>
prev.map((w) => (w.id === id ? { ...w, z: ++zRef.current } : w))
);
};
// 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 openAppDef = (app: AppDef) => {
if (startOpen) setStartClosing(true);
const existing = wins.find((w) => w.appId === app.id);
if (existing) {
setWins((prev) =>
prev.map((w) =>
w.id === existing.id ? { ...w, minimized: false, z: ++zRef.current } : w
)
);
setActiveId(existing.id);
return;
}
const id = idRef.current++;
const vw = window.innerWidth;
const vh = window.innerHeight - TASKBAR_HEIGHT;
// Fit the window to the viewport so it never opens off-screen or under the
// panel on a narrow/short monitor.
const w = Math.min(app.width, Math.max(260, vw - 20));
const h = Math.min(app.height, Math.max(150, vh - 20));
const cascade = Math.min(wins.length, 8) * 30;
let x = Math.round((vw - w) / 2) + cascade;
let y = Math.round((vh - h) / 2) + cascade;
x = Math.max(10, Math.min(x, vw - w - 10));
y = Math.max(10, Math.min(y, vh - h - 10));
setWins((prev) => [
...prev,
{
id,
appId: app.id,
x,
y,
w,
h,
z: ++zRef.current,
minimized: false,
maximized: false,
centered: false,
},
]);
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,
embed: s.embed,
embedUrl: s.embedUrl,
});
setDynamicApps((prev) => (prev[id] ? prev : { ...prev, [id]: app }));
openAppDef(app);
};
const closeStart = () => {
if (startOpen && !startClosing) setStartClosing(true);
};
const toggleStart = () => {
if (!startOpen) {
setStartClosing(false);
setStartOpen(true);
} else if (startClosing) {
setStartClosing(false);
} else {
setStartClosing(true);
}
};
// 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));
};
// 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))
);
const moveWin = (id: number, x: number, y: number) => {
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
const vh = (typeof window !== "undefined" ? window.innerHeight : 800) - TASKBAR_HEIGHT;
setWins((prev) =>
prev.map((w) => {
if (w.id !== id) return w;
// Keep the whole window on-screen: no dragging it off any edge or under
// the panel. (max() guards the case where the window is bigger than the
// viewport, which the open/resize clamps normally prevent.)
const maxX = Math.max(0, vw - w.w);
const maxY = Math.max(0, vh - w.h);
return {
...w,
centered: false,
x: Math.max(0, Math.min(maxX, x)),
y: Math.max(0, Math.min(maxY, y)),
};
})
);
};
const resizeWin = (id: number, x: number, y: number, w: number, h: number) => {
const vw = typeof window !== "undefined" ? window.innerWidth : 1280;
const vh = (typeof window !== "undefined" ? window.innerHeight : 800) - TASKBAR_HEIGHT;
const nw = Math.min(w, Math.max(260, vw - 20));
const nh = Math.min(h, Math.max(150, vh - 20));
setWins((prev) =>
prev.map((win) =>
win.id === id ? { ...win, centered: false, x, y, w: nw, h: nh } : win
)
);
};
// Keep windows on-screen when the viewport shrinks (narrow monitor, or the
// user resizing the browser): clamp size to the viewport and pull anything
// that would fall off the edge back into view.
useEffect(() => {
const clampToViewport = () => {
const vw = window.innerWidth;
const vh = window.innerHeight - TASKBAR_HEIGHT;
setWins((prev) =>
prev.map((w) => {
if (w.maximized) return w;
const nw = Math.min(w.w, Math.max(260, vw - 20));
const nh = Math.min(w.h, Math.max(150, vh - 20));
if (w.centered) {
return nw === w.w && nh === w.h ? w : { ...w, w: nw, h: nh };
}
const nx = Math.max(10, Math.min(w.x, vw - nw - 10));
const ny = Math.max(10, Math.min(w.y, vh - nh - 10));
if (nw === w.w && nh === w.h && nx === w.x && ny === w.y) return w;
return { ...w, x: nx, y: ny, w: nw, h: nh };
})
);
};
window.addEventListener("resize", clampToViewport);
return () => window.removeEventListener("resize", clampToViewport);
}, []);
const taskbarClick = (w: WinState) => {
if (w.minimized) {
setWins((prev) =>
prev.map((x) => (x.id === w.id ? { ...x, minimized: false, z: ++zRef.current } : x))
);
setActiveId(w.id);
} else if (activeId === w.id) {
minimizeWin(w.id);
} else {
focus(w.id);
}
};
const iconClick = (id: string, action: () => void) => {
if (consumeDrag()) return;
activateIcon(id, action);
};
const leftEntries: IconEntry[] = leftApps.map((app) => ({
id: app.id,
label: app.title,
title: `Open ${app.title}`,
glyph: app.icon,
color: app.color,
onOpen: () => openApp(app.id),
}));
const serviceEntries: IconEntry[] = serviceItems.map((s) => ({
id: s.id,
label: s.label,
title: s.label,
glyph: s.icon,
onOpen: () => openShortcut(s),
}));
const friendEntries: IconEntry[] = friendItems.map((f) => ({
id: f.id,
label: f.label,
title: f.label,
glyph: f.icon,
onOpen: () => openShortcut(f),
}));
const allEntries = [...leftEntries, ...serviceEntries, ...friendEntries];
const renderIcon = (entry: IconEntry, interactive = true) => {
const pos = positions[entry.id];
return (
<button
key={entry.id}
type="button"
data-drag-id={entry.id}
className={
"win-icon" +
(selectedIcon === entry.id ? " selected" : "") +
(dragId === entry.id ? " dragging" : "")
}
style={interactive && pos ? { left: pos.x, top: pos.y } : undefined}
onPointerDown={interactive ? onPointerDown(entry.id) : undefined}
onClick={interactive ? () => iconClick(entry.id, entry.onOpen) : undefined}
tabIndex={interactive ? undefined : -1}
title={entry.title}
>
<span
className="win-icon-glyph"
style={entry.color ? { background: entry.color } : undefined}
>
{entry.glyph}
</span>
<span className="win-icon-label">{entry.label}</span>
</button>
);
};
return (
<div
ref={desktopRef}
className="win-desktop"
onPointerDown={(e) => {
if (e.target === e.currentTarget) {
closeStart();
setSelectedIcon(null);
}
}}
>
<div className="win-wallpaper-tint" aria-hidden="true" />
{/* Desktop icons. The three stacks below are rendered once so the grid
can be measured from them, then the same icons are shown as an
absolutely positioned grid. */}
<div className="win-icons win-measure" aria-hidden="true">
{leftEntries.map((e) => renderIcon(e, false))}
</div>
<div className="win-icons win-icons-tr win-measure" aria-hidden="true">
{serviceEntries.map((e) => renderIcon(e, false))}
</div>
<div className="win-icons win-icons-br win-measure" aria-hidden="true">
{friendEntries.map((e) => renderIcon(e, false))}
</div>
<div className="win-icon-layer">{allEntries.map((e) => renderIcon(e))}</div>
{/* Windows */}
<div className="win-layer">
{wins.map((w) => (
<WinWindow
key={w.id}
win={w}
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>
{/* Application launcher */}
{startOpen && (
<div
className={"kickoff" + (startClosing ? " closing" : "")}
onPointerDown={(e) => e.stopPropagation()}
onAnimationEnd={(e) => {
if (e.target !== e.currentTarget) return;
if (startClosing) {
setStartOpen(false);
setStartClosing(false);
}
}}
>
<div className="kickoff-content">
<div className="kickoff-section-title">Applications</div>
<div className="kickoff-list">
{leftApps.map((app) => (
<button
key={app.id}
type="button"
className="kickoff-item"
onClick={() => openApp(app.id)}
>
<span className="kickoff-icon">{app.icon}</span>
<span className="kickoff-label">{app.title}</span>
</button>
))}
{serviceItems.map((s) => (
<button
key={s.id}
type="button"
className="kickoff-item"
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">{f.icon}</span>
<span className="kickoff-label">{f.label}</span>
</button>
))}
</div>
</div>
</div>
)}
{/* Plasma panel */}
<div className="win-taskbar" onPointerDown={(e) => e.stopPropagation()}>
<button
type="button"
className={"win-start" + (startOpen ? " open" : "")}
onClick={() => toggleStart()}
aria-label="Application Launcher"
title="Application Launcher"
>
<span className="win-start-orb">
<Image src="/gentoo.svg" alt="Gentoo" width={26} height={27} className="gentoo-logo" />
</span>
</button>
<div
className="win-tb-buttons"
ref={tbRef}
onWheel={(e) => {
const bar = tbRef.current;
// Translate a vertical mouse wheel into horizontal scrolling while
// the button strip overflows, so every open app stays reachable.
if (bar && bar.scrollWidth > bar.clientWidth) bar.scrollLeft += e.deltaY;
}}
>
{wins.map((w) => {
const app = getApp(w.appId) ?? APP_BY_ID.welcome;
return (
<button
key={w.id}
type="button"
className={
"win-tb-btn" +
(activeId === w.id && !w.minimized ? " active" : "") +
(w.minimized ? " minimized" : "") +
(closingIds.includes(w.id) ? " closing" : "")
}
onClick={() => taskbarClick(w)}
title={app.title}
>
<span className="win-tb-btn-icon" aria-hidden="true">
{app.icon}
</span>
<span className="win-tb-btn-label">{app.title}</span>
</button>
);
})}
</div>
<div className="win-tray">
<button
type="button"
className="win-theme-toggle"
onClick={toggle}
title="Switch to the web theme"
>
<span aria-hidden="true">▚</span>
<span>Web</span>
</button>
<Clock className="win-clock" />
<button
type="button"
className="win-showdesktop"
aria-label="Show desktop"
onClick={() => setWins((prev) => prev.map((w) => ({ ...w, minimized: true })))}
/>
</div>
</div>
</div>
);
}