"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 { WALLPAPER, 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; image?: 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; image?: 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: "🫂", image: f.img, 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 { theme, toggle } = useTheme(); const [wins, setWins] = useState(() => [ { id: 1, appId: "welcome", x: 110, y: 64, w: 620, h: 430, z: 10, minimized: false, maximized: false, centered: true, }, ]); const [activeId, setActiveId] = useState(1); const [startOpen, setStartOpen] = useState(false); const [startClosing, setStartClosing] = useState(false); const [selectedIcon, setSelectedIcon] = useState(null); const [closingIds, setClosingIds] = useState([]); const [minimizingIds, setMinimizingIds] = useState([]); const leftApps = LEFT_APPS; const serviceItems = SERVICE_SHORTCUTS; const friendItems = FRIEND_SHORTCUTS; const desktopRef = useRef(null); const tbRef = useRef(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(".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>({}); 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, image: s.image, 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 wallpaper = `url('${WALLPAPER}')`; 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: "🫂", image: f.image, onOpen: () => openShortcut(f), })); const allEntries = [...leftEntries, ...serviceEntries, ...friendEntries]; const renderIcon = (entry: IconEntry, interactive = true) => { const pos = positions[entry.id]; return ( ); }; return (
{ if (e.target === e.currentTarget) { closeStart(); setSelectedIcon(null); } }} > ); }