SMP: Minecraft-style join window with live status; allow beta tunnel origin for dev HMR

This commit is contained in:
Martin Dvořák 2026-09-24 09:20:06 +00:00
commit d540a6a873
7 changed files with 318 additions and 6 deletions

View file

@ -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 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 iframe). The service must allow framing; SMP (Discord) keeps a link card because Discord
sends `X-Frame-Options: DENY`. 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 - **Live GitDXD stats** — contribution heatmap + latest commit activity proxied server-side
from the self-hosted Forgejo instance. from the self-hosted Forgejo instance.
- **Extras** — a playable `Revival Inc.` game embedded at `/revival-inc`, `llms.txt`, - **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 <files>`, `npm run
### Testing from a phone on your LAN ### Testing from a phone on your LAN
`next.config.ts` sets `allowedDevOrigins` so the dev server can be reached from other devices `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 ## 📡 API Routes

View file

@ -4,7 +4,16 @@ const nextConfig: NextConfig = {
// Allow testing the dev server from other devices on the LAN (e.g. a phone). // 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 // 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. // 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) { webpack(config) {
config.module.rules.push({ config.module.rules.push({
test: /\.svg$/i, test: /\.svg$/i,

View file

@ -83,7 +83,7 @@ src/app/
MobileHome.tsx → "skeuo" theme, phone: app grid + dock MobileHome.tsx → "skeuo" theme, phone: app grid + dock
WinWindow.tsx → a single draggable/resizable window WinWindow.tsx → a single draggable/resizable window
apps.tsx → APPS registry + the content of each app + in-app terminal 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) useDesktopIcons.ts → desktop icon grid + drag/drop (THE KEY FILE, see below)
useDragOrder.ts → simple list reordering (mobile home screen only) useDragOrder.ts → simple list reordering (mobile home screen only)
useTheme.ts → theme state via useSyncExternalStore 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. `eslint-disable-next-line` with a comment. Don't add more without a reason.
- Watch for `pointercancel`: any custom pointer gesture over an `<img>`, link, - Watch for `pointercancel`: any custom pointer gesture over an `<img>`, link,
or text selection needs `draggable=false` / `user-select: none`. 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 - Service shortcuts show the real site in an iframe via
`createWebsiteApp({ embed: true })` (the shortcut's `embed` flag). It only `createWebsiteApp({ embed: true })` (the shortcut's `embed` flag). It only
works for sites that don't send `X-Frame-Options` / a `frame-ancestors` CSP. works for sites that don't send `X-Frame-Options` / a `frame-ancestors` CSP.

View file

@ -15,7 +15,16 @@ export async function GET(request: Request) {
cache: 'no-store', cache: 'no-store',
}); });
const data = await res.json(); 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 { } catch {
return NextResponse.json({ status: 'down' }); return NextResponse.json({ status: 'down' });
} }

View file

@ -40,7 +40,7 @@ const SERVICE_SHORTCUTS: Shortcut[] = [
{ id: "svc-proxmox", icon: "🖥️", label: "Proxmox", appId: "alacritty", color: "#1257a0", desc: "Server Management" }, { 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-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-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-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 }, { id: "svc-gitdxd", icon: "🐙", label: "GitDXD", url: "https://git.dvorakmortonxd.dev", color: "#6a4fb3", desc: "Best Git Hosting OAT", embed: true },
]; ];

View file

@ -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 (
<div className="app-pad smp">
<div className="smp-header">
<h2 className="smp-title">Play Multiplayer</h2>
<p className="smp-subtitle">dvorakmortonxd SMP</p>
</div>
<div className={"smp-server" + (online ? "" : " smp-server-offline")}>
<div className="smp-server-icon">
{info.icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={info.icon} alt="" />
) : (
<span className="smp-server-icon-fallback">⛏️</span>
)}
</div>
<div className="smp-server-info">
<div className="smp-server-name">dvorakmortonxd SMP</div>
<div className="smp-server-motd">
{pending
? "Pinging server..."
: online
? info.motd || "A survival Minecraft server"
: "Can't connect to the server."}
</div>
<div className="smp-server-addr">
{address}
{online && info.version ? ` · ${info.version}` : ""}
</div>
</div>
<div className="smp-server-meta">
<div className="smp-server-players">
{online ? `${info.players ?? 0}/${info.maxPlayers ?? "?"}` : "—"}
</div>
<div
className={"smp-ping" + (online ? " smp-ping-on" : "")}
aria-label={online ? "online" : "offline"}
>
{[0, 1, 2, 3, 4].map((i) => (
<span key={i} style={{ height: `${4 + i * 3}px` }} />
))}
</div>
</div>
</div>
<div className="smp-actions">
<button type="button" className="smp-btn smp-btn-green" onClick={copyAddress}>
{copied ? "Address Copied!" : "Join Server"}
</button>
<a
className="smp-btn"
href="https://discord.gg/vyvhk7d8bX"
target="_blank"
rel="noopener noreferrer"
>
Discord
</a>
</div>
<p className="smp-hint">
{copied
? "Paste it in Minecraft → Multiplayer → Add Server."
: "Join Server copies the address. Add it in Minecraft → Multiplayer → Add Server."}
</p>
</div>
);
}
function RevivalApp() { function RevivalApp() {
return ( return (
<div className="app-pad app-center"> <div className="app-pad app-center">
@ -622,6 +745,17 @@ export const ALACRITTY_APP: AppDef = {
content: <Terminal />, content: <Terminal />,
}; };
/** 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: <SmpApp />,
};
function WebsiteApp({ function WebsiteApp({
title, title,
url, url,
@ -724,5 +858,5 @@ export const APPS: AppDef[] = [
]; ];
export const APP_BY_ID: Record<string, AppDef> = Object.fromEntries( export const APP_BY_ID: Record<string, AppDef> = Object.fromEntries(
[...APPS, ALACRITTY_APP].map((a) => [a.id, a]) [...APPS, ALACRITTY_APP, SMP_APP].map((a) => [a.id, a])
); );

View file

@ -619,6 +619,152 @@ a.win-icon {
background: #ffffff; 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 { .app-surface .project-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));