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:
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user