feat(landing): join-flow rework, player rosters, account page, mod list

Execute plan/18 (WS1-6) + plan/17.

- WS1: homepage join = 3 steps off packwiz (register -> UlicraftLauncher
  -> join); per-OS downloads from a synced launcher-manifest.json (schema +
  example committed); Fjord moved to its own /fjord page. Drop packwizUrl.
- WS2/3: mc-status gains an isolated /players endpoint (admin login ->
  Drasl GET /players, own client+TTL+token cache, never blocks the status
  path); online + registered rosters render shared name+avatar tiles;
  AVATAR_MODE env (default headiso).
- WS4: /account skin CRUD via browser-direct Drasl (login -> upload ->
  reset), in-memory token, players[0]; register relinks "Manage it here".
- WS5: footer link to status.${BASE_DOMAIN} in every footer.
- WS6: tooling/build-modlist.py generates mods.json + extracted logos from
  the distribution forgemods; ModList.astro renders a flat list; stat tile
  fed from the count.

Manifest/mods loaders read at build with a process.cwd() fallback (the
import.meta.url-only path does not resolve in Astro's bundled build).

New env: AVATAR_MODE, DRASL_ADMIN_USERNAME, DRASL_ADMIN_PASSWORD (mc-status
only). Generated mods.json / launcher-manifest.json / logos are gitignored;
.example.json files document the shapes. Build: 12 pages; mc-status builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 18:17:14 +02:00
parent 096ef82420
commit 6c7e259fcb
24 changed files with 2319 additions and 117 deletions

View File

@@ -2,8 +2,88 @@
// domains, URLs, theme, launcher files, and the structural shape of the
// stat/feature lists. Translatable copy lives in src/i18n/ui.ts.
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
// `process` below is untyped). Used only at build time to read the manifest.
import { readFileSync } from "node:fs";
import { MODS } from "./mods";
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
// Installed-pack mod count, fed by tooling/build-modlist.py via mods.json (see
// ./mods.ts). Feeds the homepage "Mods" stat tile. When the generated catalogue
// is absent/empty (fresh checkout), fall back to the honest "50+" estimate.
const MOD_COUNT_KEY = MODS.count > 0 ? `${MODS.count}` : "50+";
// NMSR render mode for avatar tiles (online roster + members grid). Read at
// build time, same as BASE_DOMAIN. e.g. "headiso" | "head" | "fullbody".
// @ts-ignore — `process` is untyped here (no @types/node), same as BASE_DOMAIN.
const AVATAR_MODE = process.env.AVATAR_MODE ?? "headiso";
// Launcher download manifest (per launcher-manifest.schema.json). The
// custom-launcher release flow syncs launcher-manifest.json into this dir
// (gitignored, generated) — same pattern as sync-server-mods.sh. It MAY NOT
// EXIST at build time, so we read it at runtime with fs (a static `import`
// would hard-fail the build when absent). Fall back to the committed
// .example.json (the documented shape); if even that is unreadable, expose an
// empty-builds manifest so the homepage simply renders no download buttons.
type LauncherBuild = {
os: "windows" | "macos" | "linux";
arch: "x64" | "arm64" | "universal";
format: "exe" | "msi" | "dmg" | "pkg" | "appimage" | "deb" | "rpm" | "tar.gz" | "zip";
label?: string;
filename: string;
url: string;
size?: number;
sha256?: string;
};
type LauncherFile = { filename: string; url: string; size?: number; sha256?: string };
type LauncherManifest = {
schemaVersion: number;
product: string;
version: string;
channel?: string;
releasedAt?: string;
minecraft?: string;
neoforge?: string;
builds: LauncherBuild[];
fjordPack?: LauncherFile;
};
function loadLauncherManifest(): LauncherManifest {
const empty: LauncherManifest = {
schemaVersion: 1,
product: "UlicraftLauncher",
version: "0.0.0",
builds: [],
};
// Resolve relative to this module first, then fall back to a cwd-relative path:
// Astro bundles this module so import.meta.url may not point at src/data/ at
// build time, but the build runs from the landing root, so <cwd>/src/data/*
// resolves. Prefer the synced launcher-manifest.json, then the committed example.
// @ts-ignore — `process` is untyped here (no @types/node).
const cwd: string = typeof process !== "undefined" ? process.cwd() : ".";
const candidates: Array<string | URL> = [];
for (const name of ["launcher-manifest.json", "launcher-manifest.example.json"]) {
try {
candidates.push(new URL(name, import.meta.url));
} catch {
// import.meta.url unavailable — skip the module-relative candidate.
}
candidates.push(`${cwd}/src/data/${name}`);
}
for (const c of candidates) {
try {
const parsed = JSON.parse(readFileSync(c, "utf8")) as LauncherManifest;
if (parsed && Array.isArray(parsed.builds)) return parsed;
} catch {
// try the next candidate (synced file missing → fall back to example)
}
}
return empty;
}
const LAUNCHER_MANIFEST = loadLauncherManifest();
// REGISTRATION_MODE (invite|open) is read at build time, same .env as Drasl's
// render-config.sh. "invite" => the register form must collect an invite code.
const REGISTRATION_MODE = process.env.REGISTRATION_MODE ?? "invite";
@@ -43,19 +123,17 @@ export const site = {
// Requires CORSAllowOrigins to include the apex in drasl config.toml.
draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`,
// TODO: packwiz removed — the `pack.` vhost no longer exists, so this URL 404s.
// The modpack is now distributed to clients by HeliosLauncher via the
// distribution.json (server loads a filtered subset from ./server-mods/; see
// plan/04-mods.md). The join flow still references this in the launcher step
// and needs to be redesigned around the launcher/distribution flow. Kept
// (non-empty) only so the Astro build + the CopyChip in [...lang].astro don't
// break; do not treat as live.
packwizUrl: `https://pack.${BASE_DOMAIN}/pack.toml`,
// NMSR avatar renderer root. Player heads come from OUR Drasl skins, not
// Mojang/Crafatar — head URL is `${avatarUrl}/headiso/<uuid>?size=64`.
avatarUrl: `https://avatar.${BASE_DOMAIN}`,
// NMSR render mode for avatar tiles (ServerCard online row + PlayerRoster
// members grid). Avatar URL = `${avatarUrl}/${avatarMode}/<uuid>?size=N`.
avatarMode: AVATAR_MODE,
// Uptime Kuma status page (external link in footer).
statusUrl: `https://status.${BASE_DOMAIN}`,
// Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown
// 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
@@ -65,12 +143,17 @@ export const site = {
status: {
slots: 10,
statusApi: `/api/mcstatus/v2/status/java/${BASE_DOMAIN}`,
// Registered-players roster (PlayerRoster.astro). Same mc-status service,
// same-origin via caddy /api/mcstatus/* → internal /players. Returns a
// sanitized [{uuid,name}] of every Drasl player (admin login is server-side
// in mc-status; no admin token in the page).
rosterApi: `/api/mcstatus/players`,
},
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
// come from ui.ts (same order).
stats: [
{ key: "50+" },
{ key: MOD_COUNT_KEY },
{ key: MC_VERSION },
{ key: "10" },
{ key: "6h" },
@@ -85,10 +168,16 @@ export const site = {
{ glyph: "heart", gold: true },
],
// Launcher downloads. Stable filenames are symlinks created by
// UlicraftLauncher download manifest — feeds the homepage join step 2
// per-OS buttons from `builds[]` (and the Fjord page's .mrpack via
// `fjordPack`). Synced from the custom-launcher repo; see loader above.
launcherManifest: LAUNCHER_MANIFEST,
// Fjord Launcher downloads (the alternative manual path on /fjord, OFF the
// homepage flow). Stable filenames are symlinks created by
// tooling/fetch-launcher.sh — keep these in sync with that script. OS names
// and hints are technical, kept language-neutral here.
launcher: {
fjord: {
name: "Fjord Launcher",
base: "/launcher/latest",
downloads: [