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

@ -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' });
}

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-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 },
];

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() {
return (
<div className="app-pad app-center">
@ -622,6 +745,17 @@ export const ALACRITTY_APP: AppDef = {
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({
title,
url,
@ -724,5 +858,5 @@ export const APPS: AppDef[] = [
];
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;
}
/* ============================================================
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));