diff --git a/README.md b/README.md index 73f2cc9..ec87222 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ heatmap**, a gym SBD panel and links to friends' sites. Jellyfin, Navidrome, GitDXD) from the desktop shows the real site inside the window (an iframe). The service must allow framing; SMP (Discord) keeps a link card because Discord sends `X-Frame-Options: DENY`. +- **Minecraft-style SMP window** โ€” the SMP shortcut opens a join menu modelled on + Minecraft's multiplayer screen, with live status, player count, MOTD and version pulled + from mcstatus.io through the site's own ping API. - **Live GitDXD stats** โ€” contribution heatmap + latest commit activity proxied server-side from the self-hosted Forgejo instance. - **Extras** โ€” a playable `Revival Inc.` game embedded at `/revival-inc`, `llms.txt`, @@ -92,7 +95,9 @@ Handy checks before shipping: `npx tsc --noEmit`, `npx eslint `, `npm run ### Testing from a phone on your LAN `next.config.ts` sets `allowedDevOrigins` so the dev server can be reached from other devices -on the local network (otherwise Next.js 403s the dev asset requests). +on the local network and from the `beta.dvorakmortonxd.dev` tunnel. Without it Next.js +rejects the dev asset requests *and* the HMR WebSocket, which leaves the page un-hydrated +(stacked icons, dead buttons). ## ๐Ÿ“ก API Routes diff --git a/next.config.ts b/next.config.ts index ecc62e0..1d41c65 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,7 +4,16 @@ const nextConfig: NextConfig = { // Allow testing the dev server from other devices on the LAN (e.g. a phone). // Without this Next.js blocks cross-origin requests to /_next/* dev assets // with a 403, which stops the JS from loading and makes the page inert. - allowedDevOrigins: ["192.168.10.124", "192.168.*.*", "10.*.*.*"], + // The HMR WebSocket is checked against this list too: if the tunnel/beta host + // isn't listed, the socket is rejected and the dev client never hydrates + // (SSR-only page: stacked icons, dead buttons). + allowedDevOrigins: [ + "192.168.10.124", + "192.168.*.*", + "10.*.*.*", + "beta.dvorakmortonxd.dev", + "*.dvorakmortonxd.dev", + ], webpack(config) { config.module.rules.push({ test: /\.svg$/i, diff --git a/opencode.md b/opencode.md index 6ef67f2..f20a8fb 100644 --- a/opencode.md +++ b/opencode.md @@ -83,7 +83,7 @@ src/app/ MobileHome.tsx โ†’ "skeuo" theme, phone: app grid + dock WinWindow.tsx โ†’ a single draggable/resizable window apps.tsx โ†’ APPS registry + the content of each app + in-app terminal - + embedded-site windows + + embedded-site windows + the Minecraft-style SMP menu useDesktopIcons.ts โ†’ desktop icon grid + drag/drop (THE KEY FILE, see below) useDragOrder.ts โ†’ simple list reordering (mobile home screen only) useTheme.ts โ†’ theme state via useSyncExternalStore @@ -211,6 +211,15 @@ list reordering, not free placement. Don't confuse the two. `eslint-disable-next-line` with a comment. Don't add more without a reason. - Watch for `pointercancel`: any custom pointer gesture over an ``, link, or text selection needs `draggable=false` / `user-select: none`. +- Exposing the **dev** server over a tunnel needs the tunnel host in + `allowedDevOrigins` (`next.config.ts`). Without it Next dev rejects the + cross-origin HMR WebSocket, so the client never hydrates โ€” the page is SSR + only: icons all stacked at `(0,0)`, buttons dead. Changing `next.config.ts` + needs a dev-server restart. For a public URL prefer a production build + (`npm run build && npm run start`): no HMR socket, so nothing to reject. +- The SMP service shortcut opens `SMP_APP` (in `apps.tsx`, registered in + `APP_BY_ID` only, like `ALACRITTY_APP`) โ€” a Minecraft-style join menu that + reads live data from `/api/ping?url=minecraft://โ€ฆ`. - Service shortcuts show the real site in an iframe via `createWebsiteApp({ embed: true })` (the shortcut's `embed` flag). It only works for sites that don't send `X-Frame-Options` / a `frame-ancestors` CSP. diff --git a/src/app/api/ping/route.ts b/src/app/api/ping/route.ts index 98a2bec..c7074a3 100644 --- a/src/app/api/ping/route.ts +++ b/src/app/api/ping/route.ts @@ -15,7 +15,16 @@ export async function GET(request: Request) { cache: 'no-store', }); const data = await res.json(); - return NextResponse.json({ status: data.online ? 'up' : 'down' }); + // Extra fields power the Minecraft-style SMP join window; ServiceButton + // only reads `status`, so this stays backward compatible. + return NextResponse.json({ + status: data.online ? 'up' : 'down', + players: data.players?.online ?? null, + maxPlayers: data.players?.max ?? null, + version: data.version?.name_clean ?? null, + motd: data.motd?.clean ?? null, + icon: data.icon ?? null, + }); } catch { return NextResponse.json({ status: 'down' }); } diff --git a/src/app/components/WindowsDesktop.tsx b/src/app/components/WindowsDesktop.tsx index a5874fc..335e279 100644 --- a/src/app/components/WindowsDesktop.tsx +++ b/src/app/components/WindowsDesktop.tsx @@ -40,7 +40,7 @@ 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", url: "https://discord.gg/vyvhk7d8bX", color: "#c0392b", desc: "My Survival Minecraft Server" }, + { 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 }, ]; diff --git a/src/app/components/apps.tsx b/src/app/components/apps.tsx index e5f1697..d198dc7 100644 --- a/src/app/components/apps.tsx +++ b/src/app/components/apps.tsx @@ -343,6 +343,129 @@ function GymApp() { ); } +function SmpApp() { + const address = "mc.dvorakmortonxd.dev"; + const [info, setInfo] = useState<{ + status: "pending" | "up" | "down"; + players: number | null; + maxPlayers: number | null; + version: string | null; + motd: string | null; + icon: string | null; + }>({ + status: "pending", + players: null, + maxPlayers: null, + version: null, + motd: null, + icon: null, + }); + const [copied, setCopied] = useState(false); + + useEffect(() => { + let alive = true; + fetch(`/api/ping?url=${encodeURIComponent(`minecraft://${address}:25565`)}`) + .then((r) => r.json()) + .then((d) => { + if (!alive) return; + setInfo({ + status: d.status === "up" ? "up" : "down", + players: d.players ?? null, + maxPlayers: d.maxPlayers ?? null, + version: d.version ?? null, + motd: d.motd ?? null, + icon: d.icon ?? null, + }); + }) + .catch(() => { + if (alive) setInfo((prev) => ({ ...prev, status: "down" })); + }); + return () => { + alive = false; + }; + }, []); + + const online = info.status === "up"; + const pending = info.status === "pending"; + + const copyAddress = async () => { + try { + await navigator.clipboard.writeText(address); + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + } catch { + /* clipboard unavailable - the address is shown below anyway */ + } + }; + + return ( +
+
+

Play Multiplayer

+

dvorakmortonxd SMP

+
+ +
+
+ {info.icon ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + โ›๏ธ + )} +
+
+
dvorakmortonxd SMP
+
+ {pending + ? "Pinging server..." + : online + ? info.motd || "A survival Minecraft server" + : "Can't connect to the server."} +
+
+ {address} + {online && info.version ? ` ยท ${info.version}` : ""} +
+
+
+
+ {online ? `${info.players ?? 0}/${info.maxPlayers ?? "?"}` : "โ€”"} +
+
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+
+
+ +
+ + + Discord + +
+ +

+ {copied + ? "Paste it in Minecraft โ†’ Multiplayer โ†’ Add Server." + : "Join Server copies the address. Add it in Minecraft โ†’ Multiplayer โ†’ Add Server."} +

+
+ ); +} + function RevivalApp() { return (
@@ -622,6 +745,17 @@ export const ALACRITTY_APP: AppDef = { content: , }; +/** Opened from the SMP service shortcut; styled like the MC multiplayer menu. */ +export const SMP_APP: AppDef = { + id: "smp", + title: "SMP", + icon: "๐ŸŽฎ", + color: "#c0392b", + width: 560, + height: 430, + content: , +}; + function WebsiteApp({ title, url, @@ -724,5 +858,5 @@ export const APPS: AppDef[] = [ ]; export const APP_BY_ID: Record = Object.fromEntries( - [...APPS, ALACRITTY_APP].map((a) => [a.id, a]) + [...APPS, ALACRITTY_APP, SMP_APP].map((a) => [a.id, a]) ); diff --git a/src/app/skeuo.css b/src/app/skeuo.css index 16f936c..64fcf36 100644 --- a/src/app/skeuo.css +++ b/src/app/skeuo.css @@ -619,6 +619,152 @@ a.win-icon { background: #ffffff; } +/* ============================================================ + MINECRAFT SMP JOIN MENU (opened from the SMP service shortcut) + ============================================================ */ +.smp { + display: flex; + flex-direction: column; + gap: 14px; + font-family: "Monocraft", "JetBrains Mono", monospace; + color: #e6e6e6; + background-color: #313131; + background-image: + linear-gradient(rgba(255, 255, 255, 0.045) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.045) 1px, transparent 1px); + background-size: 16px 16px; +} +.smp-header { + text-align: center; +} +.smp-title { + margin: 0; + font-size: 1.4rem; + color: #ffffff; + text-shadow: 2px 2px 0 #1d1d1d; +} +.smp-subtitle { + margin: 6px 0 0; + font-size: 0.8rem; + color: #a8a8a8; +} +.smp-server { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 10px; + background: rgba(0, 0, 0, 0.4); + border: 2px solid #000000; + box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.08); +} +.smp-server-offline { + opacity: 0.85; +} +.smp-server-icon { + flex-shrink: 0; + width: 48px; + height: 48px; + display: grid; + place-items: center; + overflow: hidden; + background: #111111; + border: 2px solid #000000; + image-rendering: pixelated; +} +.smp-server-icon img { + width: 100%; + height: 100%; + image-rendering: pixelated; +} +.smp-server-icon-fallback { + font-size: 1.6rem; +} +.smp-server-info { + flex: 1; + min-width: 0; +} +.smp-server-name { + color: #ffffff; + font-weight: 700; + text-shadow: 2px 2px 0 #1d1d1d; +} +.smp-server-motd { + margin-top: 3px; + font-size: 0.76rem; + color: #a8a8a8; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.smp-server-addr { + margin-top: 4px; + font-size: 0.7rem; + color: #7a7a7a; +} +.smp-server-meta { + flex-shrink: 0; + text-align: right; +} +.smp-server-players { + font-size: 0.85rem; + color: #ffffff; +} +.smp-ping { + display: flex; + align-items: flex-end; + justify-content: flex-end; + gap: 2px; + height: 16px; + margin-top: 5px; +} +.smp-ping span { + width: 3px; + background: #4a4a4a; +} +.smp-ping-on span { + background: #4ade80; +} +.smp-actions { + display: flex; + gap: 10px; +} +.smp .smp-btn { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 14px; + font-family: inherit; + font-size: 0.82rem; + color: #ffffff; + text-decoration: none; + text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.55); + background: #6c6c6c; + border: 2px solid; + border-color: #a6a6a6 #2e2e2e #2e2e2e #a6a6a6; + cursor: pointer; +} +.smp .smp-btn:hover { + background: #7d7d7d; +} +.smp .smp-btn:active { + border-color: #2e2e2e #a6a6a6 #a6a6a6 #2e2e2e; +} +.smp .smp-btn-green { + background: #3f8f3f; + border-color: #74d174 #1f4f1f #1f4f1f #74d174; +} +.smp .smp-btn-green:hover { + background: #4aa34a; +} +.smp-hint { + margin: 0; + font-size: 0.7rem; + line-height: 1.5; + text-align: center; + color: #9a9a9a; +} + .app-surface .project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));