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

56
landing/src/data/mods.ts Normal file
View File

@@ -0,0 +1,56 @@
// Build-time loader for the auto-generated mod catalogue. tooling/build-modlist.py
// writes src/data/mods.json (gitignored, generated) from the distribution's full
// client modset; it MAY NOT EXIST on a fresh checkout, 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 (an empty catalogue); if even that is unreadable,
// expose an empty catalogue so the page simply renders no mods and the stat tile
// keeps its default. Same pattern as loadLauncherManifest in site.ts.
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
// `process`/`import.meta.url` reads work at build time, as in site.ts).
import { readFileSync } from "node:fs";
export type Mod = {
id: string;
name: string;
version: string;
authors: string;
description: string;
logo: string | null;
};
export type ModCatalogue = {
schemaVersion: number;
count: number;
mods: Mod[];
};
export function loadMods(): ModCatalogue {
const empty: ModCatalogue = { schemaVersion: 1, count: 0, mods: [] };
// Resolve relative to this module first (works when import.meta.url points at
// the source dir), then fall back to a cwd-relative path (Astro runs the build
// from the landing root, so src/data/* resolves there even when the module has
// been bundled to a different location). Prefer the generated mods.json, then
// the committed empty mods.example.json.
// @ts-ignore — `process` is untyped here (no @types/node), same as site.ts.
const cwd: string = typeof process !== "undefined" ? process.cwd() : ".";
const candidates: Array<string | URL> = [];
for (const name of ["mods.json", "mods.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 ModCatalogue;
if (parsed && Array.isArray(parsed.mods)) return parsed;
} catch {
// try the next candidate (file missing/unreadable → fall back)
}
}
return empty;
}
export const MODS = loadMods();