feat(landing): live server card backed by self-hosted mc-status
Add a featured live ServerCard to the landing (replaces the static ServerListPanel in hero + status sections): server favicon, color MOTD, online/offline pill, players online/max with fill bar, and a player-head row rendered by our own NMSR from Drasl skins. Progressive enhancement — SSG skeleton degrades gracefully when JS or the API is unavailable. Back it with a self-hosted pinger instead of the public api.mcstatus.io: mcstatus.io's API service is closed-source (only the mcutil library is open), so docker/mc-status wraps mcutil and re-emits its v2 JSON shape, keeping the frontend unchanged. The service ignores the path address and only pings MC_STATUS_TARGET (no SSRF relay), with a 30s TTL cache. Exposed same-origin via caddy at the apex /api/mcstatus/* path (no new DNS subdomain or LE cert change, no CORS). Uptime Kuma stays the uptime history + alerting backend; see plan/15-mc-status.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
232
landing/src/components/ServerCard.astro
Normal file
232
landing/src/components/ServerCard.astro
Normal file
@@ -0,0 +1,232 @@
|
||||
---
|
||||
// Featured LIVE server card. Progressive enhancement:
|
||||
// SSG → renders a static skeleton (server icon, MOTD, slot cap) identical in
|
||||
// spirit to the old ServerListPanel, so it looks right with JS off or
|
||||
// if the status API is unreachable.
|
||||
// Client → fetches mcstatus.io (CORS:*, no key, 60s cache) for the public
|
||||
// address and fills in: live online/offline pill, players online/max +
|
||||
// fill bar, the real server favicon, color MOTD, and a row of player
|
||||
// HEADS rendered by OUR NMSR from Drasl skins (not Mojang/Crafatar).
|
||||
//
|
||||
// Live labels are passed as data-* so copy stays translatable from the page
|
||||
// (defaults are English). Heads use /headiso. Promo/placeholder players with
|
||||
// the all-zero UUID are filtered out.
|
||||
import Creeper from "./Creeper.astro";
|
||||
import { site } from "../data/site";
|
||||
|
||||
interface Props {
|
||||
// SSG fallback copy (same props the old ServerListPanel took).
|
||||
modded: string;
|
||||
motd: string;
|
||||
upTo: string;
|
||||
// Live labels (optional, English defaults). Pass t.* from the page to i18n.
|
||||
onlineLabel?: string;
|
||||
offlineLabel?: string;
|
||||
emptyLabel?: string;
|
||||
}
|
||||
const {
|
||||
modded,
|
||||
motd,
|
||||
upTo,
|
||||
onlineLabel = "Online",
|
||||
offlineLabel = "Offline",
|
||||
emptyLabel = "Nobody online — be the first.",
|
||||
} = Astro.props;
|
||||
|
||||
const { status, avatarUrl } = site;
|
||||
const HEAD_MODE = "headiso"; // NMSR render mode for the head row
|
||||
---
|
||||
<div
|
||||
class="servercard"
|
||||
data-api={status.statusApi}
|
||||
data-avatar={avatarUrl}
|
||||
data-mode={HEAD_MODE}
|
||||
data-slots={status.slots}
|
||||
data-online={onlineLabel}
|
||||
data-offline={offlineLabel}
|
||||
data-empty={emptyLabel}
|
||||
>
|
||||
<div class="sc-top">
|
||||
<div class="sc-icon">
|
||||
<!-- Replaced by the real server favicon on success; Creeper otherwise. -->
|
||||
<img class="sc-favicon" alt="" hidden />
|
||||
<span class="sc-creeper"><Creeper /></span>
|
||||
</div>
|
||||
|
||||
<div class="sc-meta">
|
||||
<div class="sc-row1">
|
||||
<span class="sc-title">{site.name}</span>
|
||||
<span class="sc-ver">Java {site.mcVersion}</span>
|
||||
</div>
|
||||
<p class="sc-motd"><span class="a">⛏ {modded}</span> · <span class="sc-motd-text">{motd}</span></p>
|
||||
</div>
|
||||
|
||||
<div class="sc-state">
|
||||
<span class="sc-pill" data-up="">
|
||||
<span class="sc-dot"></span>
|
||||
<span class="sc-pill-txt">·</span>
|
||||
</span>
|
||||
<div class="sc-count">
|
||||
<span class="sc-on">{upTo}</span> <b class="sc-max">{status.slots}</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player fill bar (online/max). Hidden until a live count arrives. -->
|
||||
<div class="sc-bar" hidden><span class="sc-bar-fill"></span></div>
|
||||
|
||||
<!-- Head row + empty state. Populated client-side. -->
|
||||
<div class="sc-players" hidden>
|
||||
<div class="sc-heads" aria-label="Players online"></div>
|
||||
<p class="sc-empty" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.servercard {
|
||||
background: var(--slot);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
box-shadow:
|
||||
inset 2px 2px 0 var(--bevel-hi),
|
||||
inset -2px -2px 0 var(--bevel-lo),
|
||||
0 8px 24px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
.sc-top { display: flex; gap: 18px; align-items: center; }
|
||||
|
||||
.sc-icon {
|
||||
width: 72px; height: 72px; flex-shrink: 0;
|
||||
display: grid; place-items: center; position: relative;
|
||||
background: var(--bg-2);
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||
}
|
||||
.sc-favicon { width: 64px; height: 64px; image-rendering: pixelated; }
|
||||
.sc-creeper :global(.creeper) { width: 48px; height: 48px; }
|
||||
|
||||
.sc-meta { flex: 1; min-width: 0; }
|
||||
.sc-row1 { display: flex; align-items: baseline; gap: 12px; }
|
||||
.sc-title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
|
||||
.sc-ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
|
||||
.sc-motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
|
||||
.sc-motd .a { color: var(--accent); }
|
||||
/* mcstatus.io motd.html ships inline color styles; keep its spans inline. */
|
||||
.sc-motd-text :global(span) { display: inline; }
|
||||
|
||||
.sc-state { text-align: right; flex-shrink: 0; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||
.sc-pill {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 11px; font-family: var(--font-pixel, var(--font-mono));
|
||||
letter-spacing: 0.04em; text-transform: uppercase;
|
||||
padding: 3px 8px; color: var(--dim);
|
||||
box-shadow: inset 1px 1px 0 var(--bevel-hi), inset -1px -1px 0 var(--bevel-lo);
|
||||
}
|
||||
.sc-dot { width: 9px; height: 9px; background: var(--dim); image-rendering: pixelated; }
|
||||
.sc-pill[data-up="1"] { color: var(--accent-hi); }
|
||||
.sc-pill[data-up="1"] .sc-dot { background: var(--accent); box-shadow: 0 0 6px var(--glow); }
|
||||
.sc-pill[data-up="0"] { color: oklch(0.65 0.18 25); }
|
||||
.sc-pill[data-up="0"] .sc-dot { background: oklch(0.62 0.2 25); }
|
||||
.sc-count { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); }
|
||||
.sc-count b { color: var(--text); }
|
||||
|
||||
.sc-bar { height: 6px; background: var(--bg-2); box-shadow: inset 1px 1px 0 var(--bevel-lo); }
|
||||
.sc-bar-fill { display: block; height: 100%; width: 0; background: var(--accent); transition: width 0.6s ease; }
|
||||
|
||||
.sc-heads { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.sc-heads img {
|
||||
width: 34px; height: 34px; image-rendering: pixelated;
|
||||
background: var(--bg-2);
|
||||
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
|
||||
}
|
||||
.sc-empty { margin: 0; color: var(--dim); font-size: 13px; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const ZERO_UUID = "00000000-0000-0000-0000-000000000000";
|
||||
|
||||
async function hydrate(el: HTMLElement) {
|
||||
const { api, avatar, mode, slots, online, offline, empty } = el.dataset;
|
||||
if (!api || !avatar || !mode) return;
|
||||
|
||||
const q = <T extends HTMLElement>(s: string) => el.querySelector<T>(s);
|
||||
const pill = q(".sc-pill");
|
||||
const pillTxt = q(".sc-pill-txt");
|
||||
const favicon = q<HTMLImageElement>(".sc-favicon");
|
||||
const creeper = q(".sc-creeper");
|
||||
const onEl = q(".sc-on");
|
||||
const maxEl = q(".sc-max");
|
||||
const motdEl = q(".sc-motd-text");
|
||||
const bar = q(".sc-bar");
|
||||
const fill = q<HTMLElement>(".sc-bar-fill");
|
||||
const players = q(".sc-players");
|
||||
const heads = q(".sc-heads");
|
||||
const emptyEl = q(".sc-empty");
|
||||
|
||||
let data: any;
|
||||
try {
|
||||
const r = await fetch(api, { headers: { Accept: "application/json" } });
|
||||
if (!r.ok) return; // keep SSG skeleton
|
||||
data = await r.json();
|
||||
} catch {
|
||||
return; // offline/network → keep SSG skeleton
|
||||
}
|
||||
|
||||
const up = data.online === true;
|
||||
pill?.setAttribute("data-up", up ? "1" : "0");
|
||||
if (pillTxt) pillTxt.textContent = up ? (online ?? "Online") : (offline ?? "Offline");
|
||||
|
||||
if (!up) return; // offline pill set; leave the rest as the static fallback
|
||||
|
||||
// Live count + bar.
|
||||
const on = data.players?.online ?? 0;
|
||||
const max = data.players?.max ?? (Number(slots) || 0);
|
||||
if (onEl) onEl.textContent = String(on);
|
||||
if (maxEl) maxEl.textContent = String(max);
|
||||
if (bar && fill) {
|
||||
bar.hidden = false;
|
||||
fill.style.width = max > 0 ? `${Math.min(100, (on / max) * 100)}%` : "0%";
|
||||
}
|
||||
|
||||
// Real server favicon.
|
||||
if (favicon && typeof data.icon === "string" && data.icon.startsWith("data:image")) {
|
||||
favicon.src = data.icon;
|
||||
favicon.hidden = false;
|
||||
creeper?.setAttribute("hidden", "");
|
||||
}
|
||||
|
||||
// Color MOTD (mcstatus ships inline-styled spans for our own server).
|
||||
if (motdEl && data.motd?.html) motdEl.innerHTML = data.motd.html;
|
||||
|
||||
// Player heads from OUR NMSR (Drasl skins). Filter promo/placeholder rows.
|
||||
const list: Array<{ uuid?: string; name_clean?: string }> = data.players?.list ?? [];
|
||||
const seen = new Set<string>();
|
||||
const real = list.filter((p) => {
|
||||
const u = (p.uuid ?? "").toLowerCase();
|
||||
if (!u || u === ZERO_UUID || seen.has(u)) return false;
|
||||
seen.add(u);
|
||||
return true;
|
||||
});
|
||||
if (players && heads) {
|
||||
players.hidden = false;
|
||||
if (real.length === 0) {
|
||||
if (emptyEl) {
|
||||
emptyEl.textContent = empty ?? "Nobody online.";
|
||||
emptyEl.hidden = false;
|
||||
}
|
||||
} else {
|
||||
heads.innerHTML = "";
|
||||
for (const p of real.slice(0, 16)) {
|
||||
const img = document.createElement("img");
|
||||
img.loading = "lazy";
|
||||
img.alt = p.name_clean ?? "";
|
||||
img.title = p.name_clean ?? "";
|
||||
img.src = `${avatar}/${mode}/${p.uuid}?size=64`;
|
||||
heads.appendChild(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>(".servercard").forEach(hydrate);
|
||||
</script>
|
||||
@@ -51,12 +51,14 @@ export const site = {
|
||||
avatarUrl: `https://avatar.${BASE_DOMAIN}`,
|
||||
|
||||
// Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown
|
||||
// before/without JS; live count comes from mcstatus.io pinging the public
|
||||
// address. mcstatus.io sets Access-Control-Allow-Origin:* → direct browser
|
||||
// fetch, no proxy/key. Uptime Kuma stays the history + alerting backend.
|
||||
// before/without JS; live data comes from our SELF-HOSTED mc-status service
|
||||
// (docker/mc-status, mcutil wrapper) reverse-proxied same-origin at the apex
|
||||
// /api/mcstatus/* path — no CORS, no third-party calls. It emits mcstatus.io
|
||||
// v2 JSON, so the URL stays drop-in compatible. Uptime Kuma stays the history
|
||||
// + alerting backend (plan/10).
|
||||
status: {
|
||||
slots: 10,
|
||||
statusApi: `https://api.mcstatus.io/v2/status/java/${BASE_DOMAIN}`,
|
||||
statusApi: `/api/mcstatus/v2/status/java/${BASE_DOMAIN}`,
|
||||
},
|
||||
|
||||
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ui, LANGS, LANG_LABEL, LANG_PATH, type Lang } from "../i18n/ui";
|
||||
import Creeper from "../components/Creeper.astro";
|
||||
import PixelIcon from "../components/PixelIcon.astro";
|
||||
import CopyChip from "../components/CopyChip.astro";
|
||||
import ServerListPanel from "../components/ServerListPanel.astro";
|
||||
import ServerCard from "../components/ServerCard.astro";
|
||||
import "../styles/main.css";
|
||||
|
||||
// One static page per locale: en at "/", es at "/es/", eu at "/eu/".
|
||||
@@ -108,7 +108,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-status">
|
||||
<ServerListPanel
|
||||
<ServerCard
|
||||
modded={t.status.modded}
|
||||
motd={t.status.motd}
|
||||
upTo={t.status.upTo}
|
||||
@@ -126,7 +126,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
|
||||
<p class="lead">{t.status.lead}</p>
|
||||
</div>
|
||||
<div class="reveal">
|
||||
<ServerListPanel
|
||||
<ServerCard
|
||||
modded={t.status.modded}
|
||||
motd={t.status.motd}
|
||||
upTo={t.status.upTo}
|
||||
|
||||
Reference in New Issue
Block a user