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:
41
landing/src/data/launcher-manifest.example.json
Normal file
41
landing/src/data/launcher-manifest.example.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"product": "UlicraftLauncher",
|
||||
"version": "1.0.0",
|
||||
"channel": "stable",
|
||||
"releasedAt": "2026-06-01T12:00:00Z",
|
||||
"minecraft": "1.21.1",
|
||||
"neoforge": "21.1.233",
|
||||
"builds": [
|
||||
{
|
||||
"os": "windows",
|
||||
"arch": "x64",
|
||||
"format": "exe",
|
||||
"filename": "UlicraftLauncher-1.0.0-setup.exe",
|
||||
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-setup.exe",
|
||||
"size": 78643200,
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"os": "macos",
|
||||
"arch": "universal",
|
||||
"format": "dmg",
|
||||
"filename": "UlicraftLauncher-1.0.0-universal.dmg",
|
||||
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-universal.dmg",
|
||||
"size": 92274688
|
||||
},
|
||||
{
|
||||
"os": "linux",
|
||||
"arch": "x64",
|
||||
"format": "appimage",
|
||||
"filename": "UlicraftLauncher-1.0.0-x86_64.AppImage",
|
||||
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-x86_64.AppImage",
|
||||
"size": 85983232
|
||||
}
|
||||
],
|
||||
"fjordPack": {
|
||||
"filename": "ulicraft-1.21.1.mrpack",
|
||||
"url": "https://distribution.ulicraft.net/launcher/ulicraft-1.21.1.mrpack",
|
||||
"size": 12582912
|
||||
}
|
||||
}
|
||||
119
landing/src/data/launcher-manifest.schema.json
Normal file
119
landing/src/data/launcher-manifest.schema.json
Normal file
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://ulicraft.net/schemas/launcher-manifest.schema.json",
|
||||
"title": "Ulicraft launcher download manifest",
|
||||
"description": "Produced by the custom-launcher release flow, consumed by the landing build to render per-OS download buttons. One file describes the current UlicraftLauncher release plus the optional Fjord pack.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schemaVersion", "product", "version", "builds"],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"description": "Bump on breaking shape changes. Landing pins the major it understands.",
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"product": {
|
||||
"description": "Display name of the launcher.",
|
||||
"type": "string",
|
||||
"examples": ["UlicraftLauncher"]
|
||||
},
|
||||
"version": {
|
||||
"description": "Launcher release version (semver, no leading v).",
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+(?:[-+].+)?$"
|
||||
},
|
||||
"channel": {
|
||||
"description": "Release channel.",
|
||||
"type": "string",
|
||||
"enum": ["stable", "beta"],
|
||||
"default": "stable"
|
||||
},
|
||||
"releasedAt": {
|
||||
"description": "Release timestamp (ISO 8601 / RFC 3339).",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"minecraft": {
|
||||
"description": "Target Minecraft version the launcher installs.",
|
||||
"type": "string",
|
||||
"examples": ["1.21.1"]
|
||||
},
|
||||
"neoforge": {
|
||||
"description": "NeoForge version the distribution pins.",
|
||||
"type": "string",
|
||||
"examples": ["21.1.233"]
|
||||
},
|
||||
"builds": {
|
||||
"description": "Downloadable launcher binaries, one entry per OS/arch/format.",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/asset" }
|
||||
},
|
||||
"fjordPack": {
|
||||
"description": "Optional importable pack for the Fjord (Prism) alternative path. Lives on its own landing page, not the homepage.",
|
||||
"$ref": "#/$defs/file"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"asset": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["os", "arch", "format", "filename", "url"],
|
||||
"properties": {
|
||||
"os": {
|
||||
"type": "string",
|
||||
"enum": ["windows", "macos", "linux"]
|
||||
},
|
||||
"arch": {
|
||||
"description": "CPU arch; 'universal' for a fat/any binary.",
|
||||
"type": "string",
|
||||
"enum": ["x64", "arm64", "universal"]
|
||||
},
|
||||
"format": {
|
||||
"description": "Installer/package kind, drives the button hint.",
|
||||
"type": "string",
|
||||
"enum": ["exe", "msi", "dmg", "pkg", "appimage", "deb", "rpm", "tar.gz", "zip"]
|
||||
},
|
||||
"label": {
|
||||
"description": "Optional human hint override (else derived from os+arch+format).",
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"url": {
|
||||
"description": "Absolute download URL.",
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^https://"
|
||||
},
|
||||
"size": {
|
||||
"description": "Bytes, for showing a size next to the button.",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"sha256": {
|
||||
"description": "Lowercase hex SHA-256 for integrity display/verify.",
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["filename", "url"],
|
||||
"properties": {
|
||||
"filename": { "type": "string", "minLength": 1 },
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^https://"
|
||||
},
|
||||
"size": { "type": "integer", "minimum": 0 },
|
||||
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
landing/src/data/mods.example.json
Normal file
5
landing/src/data/mods.example.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"count": 0,
|
||||
"mods": []
|
||||
}
|
||||
56
landing/src/data/mods.ts
Normal file
56
landing/src/data/mods.ts
Normal 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();
|
||||
@@ -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