From 6c7e259fcb4a4a06e22e8a0075ba724b3bacb399 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Wed, 10 Jun 2026 18:17:14 +0200 Subject: [PATCH] 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 --- .env.example | 13 + .gitignore | 12 + CLAUDE.md | 31 +- docker-compose.yml | 8 + docker/mc-status/main.go | 203 +++++++++- landing/src/components/CopyChip.astro | 6 +- landing/src/components/ModList.astro | 84 +++++ landing/src/components/PlayerRoster.astro | 88 +++++ landing/src/components/ServerCard.astro | 51 +-- .../src/data/launcher-manifest.example.json | 41 ++ .../src/data/launcher-manifest.schema.json | 119 ++++++ landing/src/data/mods.example.json | 5 + landing/src/data/mods.ts | 56 +++ landing/src/data/site.ts | 113 +++++- landing/src/i18n/ui.ts | 344 +++++++++++++++-- landing/src/pages/[...lang].astro | 102 +++-- landing/src/pages/[...lang]/account.astro | 357 ++++++++++++++++++ landing/src/pages/[...lang]/fjord.astro | 226 +++++++++++ landing/src/pages/[...lang]/register.astro | 6 +- landing/src/styles/main.css | 37 +- plan/00-overview.md | 2 + plan/17-mod-shortlist.md | 75 ++++ plan/18-landing-rework.md | 204 ++++++++++ tooling/build-modlist.py | 253 +++++++++++++ 24 files changed, 2319 insertions(+), 117 deletions(-) create mode 100644 landing/src/components/ModList.astro create mode 100644 landing/src/components/PlayerRoster.astro create mode 100644 landing/src/data/launcher-manifest.example.json create mode 100644 landing/src/data/launcher-manifest.schema.json create mode 100644 landing/src/data/mods.example.json create mode 100644 landing/src/data/mods.ts create mode 100644 landing/src/pages/[...lang]/account.astro create mode 100644 landing/src/pages/[...lang]/fjord.astro create mode 100644 plan/17-mod-shortlist.md create mode 100644 plan/18-landing-rework.md create mode 100755 tooling/build-modlist.py diff --git a/.env.example b/.env.example index 50df8ca..8fce978 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,19 @@ RCON_PASSWORD=change-me-to-something-random # and the landing build (-> show/hide the invite-code field). Default: invite. REGISTRATION_MODE=invite +# NMSR avatar render mode for the landing's avatar tiles (online players + +# Members roster). Read by the landing BUILD (data/site.ts) only. Avatar URL = +# https://avatar.${BASE_DOMAIN}//?size=N. e.g. headiso | head | fullbody. +AVATAR_MODE=headiso + +# Drasl admin creds for the registered-players roster (/api/mcstatus/players). +# Consumed ONLY by the mc-status container (server-side login → sanitized +# [{uuid,name}] list); these NEVER reach the browser. Use a DEDICATED, rotatable +# admin account (Drasl has no read-only role) to limit blast radius. Leave blank +# to disable the roster (the Members grid then just shows its empty state). +DRASL_ADMIN_USERNAME= +DRASL_ADMIN_PASSWORD= + # caddy host-published port. The host's own nginx terminates TLS and # reverse-proxies to caddy by Host header (nginx/ulicraft-caddy.conf.tmpl), so # bind caddy to a high localhost-only port — only nginx should reach it. diff --git a/.gitignore b/.gitignore index ab3f85f..682bb49 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,17 @@ landing/node_modules/ landing/.astro/ landing/dist/ +# launcher download manifest: produced/synced by the custom-launcher release +# flow (the .example.json is committed as the documented shape) +landing/src/data/launcher-manifest.json + +# mod catalogue + extracted logos: generated by tooling/build-modlist.py from +# the distribution forgemods (the .example.json is committed as the empty shape) +landing/src/data/mods.json +landing/public/mod-logos/ + +# compiled mc-status binary (built into the image; stray local `go build` output) +docker/mc-status/mc-status + # Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh) certs/ diff --git a/CLAUDE.md b/CLAUDE.md index 2813e55..88fe0dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,18 +133,27 @@ mirrored at apex `/launcher/`. Per guest: load harmlessly on the server but waste RAM. Hand-edit them to `client` so `sync-server-mods.sh` drops them from `./server-mods`. (Also review `appleskin`, flagged `client` — flip to `both` if server-side food data wanted.) -- [ ] **Rework the landing join flow off packwiz.** `pack.` vhost is gone; - `site.ts packwizUrl` is a dead-URL placeholder (kept w/ TODO so the build - passes) and `i18n/ui.ts` join steps (all 3 langs) still tell guests to import - `pack.toml`. Redesign step 3 around the HeliosLauncher/`distribution.json` - install flow. See `plan/04-mods.md` + `site.ts` TODO. +### Landing rework + mod list (PLANNED — see `plan/18-landing-rework.md`) +Full design settled across WS1–WS6; all tasks unchecked, ready to execute. +- [ ] **WS1 — Join flow off packwiz.** Homepage = UlicraftLauncher, 3 steps + (register → download launcher → join); drop `packwizUrl` + packwiz step. + Per-OS downloads from a synced `launcher-manifest.json` (contract: + `landing/src/data/launcher-manifest.schema.json`, produced by the + `custom-launcher` repo). Fjord moves to its own `/fjord` page. +- [ ] **WS2/3 — Player rosters.** Online (mc-status, live) + all-registered (new + `mc-status /api/mcstatus/players`, admin-login → Drasl `GET /players`, ~60s + cache). Shared avatar tile, `AVATAR_MODE` env (default `headiso`). Admin creds + → mc-status only. +- [ ] **WS4 — Account page** (`/account`): skin CRUD via browser-direct Drasl + (reuses `register.astro` skin JS). Skins only, in-memory token, `players[0]`. +- [ ] **WS5 — Footer status link** to `status.${BASE_DOMAIN}` (nav unchanged). -- [ ] **Mod shortlist** for the kitchen-sink pack: Performance (Embeddium, - FerriteCore, ModernFix), Tech (Mekanism, Create, IE, AE2), Magic (Ars Nouveau, - Botania, Iron's Spells), Storage (Sophisticated, Functional), Exploration - (YUNG's, Repurposed Structures), QoL (JEI, Jade, IPN, AppleSkin), World gen - (Tectonic, Terralith — verify NeoForge 1.21.1), Compat (Polymorph), Map (Xaero), - Voice (Simple Voice Chat — 24454/udp already in compose). +- [ ] **WS6 — Mod shortlist + list component (`plan/17-mod-shortlist.md`).** + Light gap doc: current 76-jar pack is vanilla+ QoL/perf; **empty** on tech & + magic, thin worldgen, no map; orphan deps `ponderjs`/`Necronomicon`. Landing + mod-list component = auto-generated `mods.json` + extracted logos from the + distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty + names, no categories/links; feeds the "50+" stat tile. - [ ] Server-side perf mods (C2ME, etc.). - [ ] **Verify NeoForge version** against current stable before first deploy. - [ ] **Bootstrap Drasl admin** account. diff --git a/docker-compose.yml b/docker-compose.yml index e175766..c768f49 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -174,6 +174,14 @@ services: environment: MC_STATUS_TARGET: "minecraft:25565" MC_STATUS_CACHE_TTL: "30s" + # Registered-players roster (/api/mcstatus/players). mc-status logs into + # the Drasl admin API server-side and exposes a sanitized [{uuid,name}] + # list; these creds NEVER reach the browser. Use a dedicated, rotatable + # admin account. Isolated from the status pinger (own TTL/client). + DRASL_API_URL: "http://drasl:25585/drasl/api/v2" + DRASL_ADMIN_USERNAME: ${DRASL_ADMIN_USERNAME} + DRASL_ADMIN_PASSWORD: ${DRASL_ADMIN_PASSWORD} + MC_STATUS_ROSTER_TTL: "60s" networks: - mcnet diff --git a/docker/mc-status/main.go b/docker/mc-status/main.go index 7316639..daa17bf 100644 --- a/docker/mc-status/main.go +++ b/docker/mc-status/main.go @@ -15,8 +15,10 @@ package main import ( + "bytes" "context" "encoding/json" + "io" "log" "net/http" "os" @@ -81,6 +83,166 @@ func deref[T any](p *T) (v T) { return } +// --- registered-players roster (Drasl admin) --- +// +// The roster path is DELIBERATELY isolated from the status path: its own +// http.Client (with a timeout), its own TTL cache, and its own cached admin +// token. Drasl being slow or down must never block or break /v2/status — on any +// failure we serve stale-or-empty JSON rather than hanging. Mirrors the status +// cache style above (single entry, mutex-guarded). + +// rosterPlayer is the sanitized shape we expose publicly: uuid + name only, +// everything else from Drasl stripped (no emails, capes, admin flags, etc.). +type rosterPlayer struct { + UUID string `json:"uuid"` + Name string `json:"name"` +} + +// draslPlayer is the subset of Drasl's GET /players entries we read. +type draslPlayer struct { + UUID string `json:"uuid"` + Name string `json:"name"` +} + +// roster holds the cached public payload plus the cached admin token. Both are +// guarded by the same mutex; token reuse avoids a login per request. +type roster struct { + mu sync.Mutex + payload []byte + expires time.Time + + token string + + apiURL string + username string + password string + ttl time.Duration + client *http.Client +} + +// payloadOrEmpty returns the cached payload, or an empty JSON array if nothing +// has ever been fetched. Used so a Drasl failure on the very first request +// still yields valid JSON ("[]") instead of an error. +func (rs *roster) payloadOrEmpty() []byte { + if rs.payload != nil { + return rs.payload + } + return []byte("[]") +} + +// fetch logs in (if no token) and lists players, re-logging in once on a 401. +// On any error it returns the previous payload (stale-or-empty), never an error +// to the caller — the handler always serves something. +func (rs *roster) fetch() []byte { + players, err := rs.listPlayers(false) + if err != nil { + log.Printf("roster: list players failed: %v", err) + return rs.payloadOrEmpty() + } + + out := make([]rosterPlayer, 0, len(players)) + for _, p := range players { + out = append(out, rosterPlayer{UUID: p.UUID, Name: p.Name}) + } + b, err := json.Marshal(out) + if err != nil { + return rs.payloadOrEmpty() + } + return b +} + +// listPlayers performs GET {apiURL}/players with the cached token, logging in +// first if needed. On 401 it clears the token and retries once (retried=true +// prevents a loop). Caller holds rs.mu. +func (rs *roster) listPlayers(retried bool) ([]draslPlayer, error) { + if rs.token == "" { + if err := rs.login(); err != nil { + return nil, err + } + } + + req, err := http.NewRequest(http.MethodGet, rs.apiURL+"/players", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+rs.token) + req.Header.Set("Accept", "application/json") + + resp, err := rs.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized && !retried { + // Token expired/invalid — drop it and re-login once. + rs.token = "" + io.Copy(io.Discard, resp.Body) + return rs.listPlayers(true) + } + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, resp.Body) + return nil, &httpError{status: resp.StatusCode, op: "list players"} + } + + var players []draslPlayer + if err := json.NewDecoder(resp.Body).Decode(&players); err != nil { + return nil, err + } + return players, nil +} + +// login posts admin creds to {apiURL}/login and caches the returned apiToken. +// Caller holds rs.mu. +func (rs *roster) login() error { + body, err := json.Marshal(map[string]string{ + "username": rs.username, + "password": rs.password, + }) + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, rs.apiURL+"/login", bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := rs.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + io.Copy(io.Discard, resp.Body) + return &httpError{status: resp.StatusCode, op: "login"} + } + + var out struct { + APIToken string `json:"apiToken"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return err + } + if out.APIToken == "" { + return &httpError{status: resp.StatusCode, op: "login (no apiToken)"} + } + rs.token = out.APIToken + return nil +} + +type httpError struct { + status int + op string +} + +func (e *httpError) Error() string { + return "drasl " + e.op + ": status " + strconv.Itoa(e.status) +} + func main() { target := envOr("MC_STATUS_TARGET", "minecraft:25565") listen := envOr("MC_STATUS_LISTEN", ":8080") @@ -89,9 +251,25 @@ func main() { log.Fatalf("invalid MC_STATUS_CACHE_TTL: %v", err) } + rosterTTL, err := time.ParseDuration(envOr("MC_STATUS_ROSTER_TTL", "60s")) + if err != nil { + log.Fatalf("invalid MC_STATUS_ROSTER_TTL: %v", err) + } + host, port := splitHostPort(target) c := &cache{} + // Registered-players roster, fed by the Drasl admin API. Isolated from the + // status pinger above (own client + timeout + cache). If the admin creds are + // unset, /players still works — it just always serves "[]". + rs := &roster{ + apiURL: strings.TrimRight(envOr("DRASL_API_URL", "http://drasl:25585/drasl/api/v2"), "/"), + username: os.Getenv("DRASL_ADMIN_USERNAME"), + password: os.Getenv("DRASL_ADMIN_PASSWORD"), + ttl: rosterTTL, + client: &http.Client{Timeout: 5 * time.Second}, + } + mux := http.NewServeMux() // Drop-in path: /v2/status/java/ is ignored (see file header). @@ -119,11 +297,34 @@ func main() { w.Write(payload) }) + // Registered-players roster — public path /api/mcstatus/players (caddy + // strips the /api/mcstatus prefix → internal /players). Serves a sanitized + // [{uuid,name}] of all Drasl players. Same CORS:* as the status route. + // Isolated from the status path: own TTL cache, own token, own client; on + // any Drasl failure it serves stale-or-empty rather than hanging. + mux.HandleFunc("/players", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + + rs.mu.Lock() + defer rs.mu.Unlock() + + if rs.payload != nil && time.Now().Before(rs.expires) { + w.Write(rs.payload) + return + } + + payload := rs.fetch() + rs.payload = payload + rs.expires = time.Now().Add(rs.ttl) + w.Write(payload) + }) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) }) - log.Printf("mc-status listening on %s, target=%s:%d, ttl=%s", listen, host, port, ttl) + log.Printf("mc-status listening on %s, target=%s:%d, ttl=%s, roster-ttl=%s", listen, host, port, ttl, rosterTTL) log.Fatal(http.ListenAndServe(listen, mux)) } diff --git a/landing/src/components/CopyChip.astro b/landing/src/components/CopyChip.astro index 10bc22e..05e0d18 100644 --- a/landing/src/components/CopyChip.astro +++ b/landing/src/components/CopyChip.astro @@ -1,8 +1,8 @@ --- // MC-GUI slot + copy button. variant "ip" = big gold mono (server address); -// "url" = smaller, wraps (auth / packwiz URLs). Copy handled by the global -// [data-copy] script in [...lang].astro; data-label / data-copied drive the -// (localized) button text and transient feedback. +// "url" = smaller, wraps (auth / authlib URLs). Copy handled by the global +// [data-copy] script in [...lang].astro (and the equivalent on /fjord); +// data-label / data-copied drive the (localized) button text + feedback. interface Props { value: string; variant?: "ip" | "url"; diff --git a/landing/src/components/ModList.astro b/landing/src/components/ModList.astro new file mode 100644 index 0000000..8a74ff4 --- /dev/null +++ b/landing/src/components/ModList.astro @@ -0,0 +1,84 @@ +--- +// Installed-pack mod list (the "Mods" section). Build-time baked from the +// auto-generated catalogue (tooling/build-modlist.py → src/data/mods.json), +// loaded via ./data/mods.ts (resilient: missing file → empty list, build never +// fails). Flat alphabetical grid: extracted logo (or a pixel placeholder) + +// pretty name + version. description is exposed as a tooltip. No categories, no +// external links. Tiles share the MC-GUI bevel treatment used by .feat/.tile. +import { MODS } from "../data/mods"; + +interface Props { + emptyLabel?: string; +} +const { emptyLabel = "Mod list not generated yet." } = Astro.props; + +const mods = MODS.mods; +--- +{mods.length === 0 ? ( +

{emptyLabel}

+) : ( +
+ {mods.map((m) => ( +
+ +
+ {m.name} + {m.version && {m.version}} +
+
+ ))} +
+)} + + diff --git a/landing/src/components/PlayerRoster.astro b/landing/src/components/PlayerRoster.astro new file mode 100644 index 0000000..82bbe93 --- /dev/null +++ b/landing/src/components/PlayerRoster.astro @@ -0,0 +1,88 @@ +--- +// Registered-players roster (the "Members" section). Progressive enhancement, +// same shape as ServerCard: +// SSG → renders an empty skeleton (a single .roster grid + an empty-state +// line) so the page is valid with JS off. +// Client → fetches OUR mc-status roster endpoint (site.status.rosterApi, +// same-origin via caddy /api/mcstatus/* → internal /players). It +// returns a sanitized [{uuid,name}] of every Drasl player (admin login +// is server-side in mc-status — no admin token reaches the page). Each +// player renders as a SHARED avatar tile (.roster-tile, styled in +// main.css alongside ServerCard's online tiles). +// +// Empty/error → hide the grid and show the "no members yet" line (data-empty). +import { site } from "../data/site"; + +interface Props { + // Empty-state copy (passed from the page for i18n; English default). + emptyLabel?: string; +} +const { emptyLabel = "No members yet." } = Astro.props; + +const { status, avatarUrl, avatarMode } = site; +--- +
+
+ +
+ + diff --git a/landing/src/components/ServerCard.astro b/landing/src/components/ServerCard.astro index 3e51745..cda7f5b 100644 --- a/landing/src/components/ServerCard.astro +++ b/landing/src/components/ServerCard.astro @@ -33,14 +33,13 @@ const { emptyLabel = "Nobody online — be the first.", } = Astro.props; -const { status, avatarUrl } = site; -const HEAD_MODE = "headiso"; // NMSR render mode for the head row +const { status, avatarUrl, avatarMode } = site; --- @@ -133,12 +132,8 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row .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); - } + /* Online avatar tiles use the shared .roster / .roster-tile styles in + main.css (also used by PlayerRoster.astro). */ .sc-empty { margin: 0; color: var(--dim); font-size: 13px; } @@ -160,7 +155,7 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row const bar = q(".sc-bar"); const fill = q(".sc-bar-fill"); const players = q(".sc-players"); - const heads = q(".sc-heads"); + const roster = q(".roster"); const emptyEl = q(".sc-empty"); let data: any; @@ -198,7 +193,9 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row // 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. + // Labeled avatar tiles from OUR NMSR (Drasl skins). Filter promo/placeholder + // rows (all-zero UUID) and dedupe. Tiles share .roster styles with the + // Members grid (PlayerRoster.astro) — see rosterTile() below. const list: Array<{ uuid?: string; name_clean?: string }> = data.players?.list ?? []; const seen = new Set(); const real = list.filter((p) => { @@ -207,7 +204,7 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row seen.add(u); return true; }); - if (players && heads) { + if (players && roster) { players.hidden = false; if (real.length === 0) { if (emptyEl) { @@ -215,18 +212,30 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row emptyEl.hidden = false; } } else { - heads.innerHTML = ""; + roster.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); + roster.appendChild(rosterTile(p.uuid!, p.name_clean ?? "", avatar, mode)); } } } } + // Build one shared avatar tile (avatar + name). Same markup PlayerRoster + // builds, so .roster / .roster-tile (global, in main.css) style both. + function rosterTile(uuid: string, name: string, avatar: string, mode: string) { + const tile = document.createElement("div"); + tile.className = "roster-tile"; + const img = document.createElement("img"); + img.loading = "lazy"; + img.alt = name; + img.src = `${avatar}/${mode}/${uuid}?size=64`; + const label = document.createElement("span"); + label.className = "roster-name"; + label.textContent = name; + label.title = name; + tile.append(img, label); + return tile; + } + document.querySelectorAll(".servercard").forEach(hydrate); diff --git a/landing/src/data/launcher-manifest.example.json b/landing/src/data/launcher-manifest.example.json new file mode 100644 index 0000000..cec8afd --- /dev/null +++ b/landing/src/data/launcher-manifest.example.json @@ -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 + } +} diff --git a/landing/src/data/launcher-manifest.schema.json b/landing/src/data/launcher-manifest.schema.json new file mode 100644 index 0000000..2b53510 --- /dev/null +++ b/landing/src/data/launcher-manifest.schema.json @@ -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}$" } + } + } + } +} diff --git a/landing/src/data/mods.example.json b/landing/src/data/mods.example.json new file mode 100644 index 0000000..311afd4 --- /dev/null +++ b/landing/src/data/mods.example.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "count": 0, + "mods": [] +} diff --git a/landing/src/data/mods.ts b/landing/src/data/mods.ts new file mode 100644 index 0000000..2b3d204 --- /dev/null +++ b/landing/src/data/mods.ts @@ -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 = []; + 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(); diff --git a/landing/src/data/site.ts b/landing/src/data/site.ts index 7d6705d..b801376 100644 --- a/landing/src/data/site.ts +++ b/landing/src/data/site.ts @@ -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 /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 = []; + 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/?size=64`. avatarUrl: `https://avatar.${BASE_DOMAIN}`, + // NMSR render mode for avatar tiles (ServerCard online row + PlayerRoster + // members grid). Avatar URL = `${avatarUrl}/${avatarMode}/?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: [ diff --git a/landing/src/i18n/ui.ts b/landing/src/i18n/ui.ts index b37c5a8..7bddc1b 100644 --- a/landing/src/i18n/ui.ts +++ b/landing/src/i18n/ui.ts @@ -2,7 +2,7 @@ // locales; the page (src/pages/[...lang].astro) renders against it. // // Conventions: -// - "{name}" in step1 is replaced with the launcher name at render time. +// - "{name}" is replaced with the launcher name at render time. // - Sentences containing links/kbd/literals are split into parts (a/b/pre/ // post/link) so the markup stays in the template, not in the strings. // - Product / in-game literals (authlib-injector, Multiplayer, Add Server) @@ -19,11 +19,23 @@ export const LANG_REGISTER_PATH: Record = { es: "/es/register", eu: "/eu/register", }; +// Fjord (alternative path) page path per locale (mirrors LANG_REGISTER_PATH). +export const LANG_FJORD_PATH: Record = { + en: "/fjord", + es: "/es/fjord", + eu: "/eu/fjord", +}; +// Account page path per locale (mirrors LANG_REGISTER_PATH). +export const LANG_ACCOUNT_PATH: Record = { + en: "/account", + es: "/es/account", + eu: "/eu/account", +}; export interface UI { htmlLang: string; copied: string; // transient copy-button feedback - nav: { status: string; features: string; join: string; play: string }; + nav: { status: string; members: string; features: string; mods: string; join: string; play: string }; hero: { eyebrow: string; // "Modded LAN · NeoForge" — version appended at render tagline: string; @@ -42,13 +54,46 @@ export interface UI { upTo: string; }; statLabels: [string, string, string, string]; + members: { + eyebrow: string; + title: string; + lead: string; + empty: string; // shown when the roster is empty or unreachable + }; features: { eyebrow: string; title: string; lead: string; items: { h: string; p: string }[]; }; + mods: { + eyebrow: string; + title: string; + lead: string; + empty: string; // shown when the catalogue is absent/empty (not yet generated) + }; join: { + eyebrow: string; + title: string; + lead: string; + s1: { + kicker: string; + h: string; + pa: string; // before the register link + pb: string; // after it + registerLink: string; + }; + s2: { kicker: string; h: string; p: string }; + s3: { + kicker: string; + h: string; + pa: string; // before the Multiplayer/Add Server kbds + pb: string; // after them + copy: string; + }; + }; + fjord: { + navHome: string; // "← Home" link in nav eyebrow: string; title: string; lead: string; @@ -59,7 +104,6 @@ export interface UI { pa: string; // before the authlib-injector literal pb: string; // after it copy: string; - registerPre: string; }; s3: { kicker: string; h: string; p: string }; s4: { @@ -69,8 +113,9 @@ export interface UI { pb: string; // after them copy: string; }; + noPack: string; // shown when fjordPack is missing from the manifest }; - footer: { join: string; disc: string }; + footer: { join: string; status: string; disc: string }; register: { navHome: string; // "← Home" link in nav eyebrow: string; @@ -86,7 +131,7 @@ export interface UI { submit: string; submitting: string; haveAccount: string; // "Already have an account?" - loginLink: string; // links to the Drasl web UI + loginLink: string; // links to the account page successTitle: string; successBody: string; // {name} = player name uuidLabel: string; @@ -104,13 +149,47 @@ export interface UI { errSkinType: string; errNetwork: string; }; + account: { + navHome: string; // "← Home" link in nav + eyebrow: string; + title: string; + lead: string; + // Login form + usernameLabel: string; + usernamePlaceholder: string; + passwordLabel: string; + passwordPlaceholder: string; + submit: string; + submitting: string; + noAccount: string; // "No account yet?" + registerLink: string; // links to /register + // Skin panel + playerLabel: string; // "Logged in as" + skinTitle: string; + skinLead: string; // reuses register.skinLead concept + skinChoose: string; + modelLabel: string; + modelClassic: string; + modelSlim: string; + skinUpload: string; + skinUploading: string; + skinOk: string; + skinPreviewNote: string; // "NMSR takes ~15 min to update" hint + resetSkin: string; + resetConfirm: string; // window.confirm text + resetOk: string; + // Errors + errFields: string; + errSkinType: string; + errNetwork: string; + }; } export const ui: Record = { en: { htmlLang: "en", copied: "Copied!", - nav: { status: "Status", features: "Features", join: "How to Join", play: "Play Now" }, + nav: { status: "Status", members: "Members", features: "Features", mods: "Mods", join: "How to Join", play: "Play Now" }, hero: { eyebrow: "Modded LAN · NeoForge", tagline: "Kitchen-sink survival, built to outlast the weekend.", @@ -132,6 +211,12 @@ export const ui: Record = { upTo: "up to", }, statLabels: ["Mods", "NeoForge", "Player slots", "World backups"], + members: { + eyebrow: "The crew", + title: "Who's on the server.", + lead: "Everyone registered on Ulicraft, with their own self-hosted skin.", + empty: "No members yet — be the first to register.", + }, features: { eyebrow: "What makes it Ulicraft", title: "Built for the crew.", @@ -149,7 +234,7 @@ export const ui: Record = { }, { h: "One-click setup", - p: "packwiz installs and updates the whole modpack inside your launcher, so everyone stays in sync.", + p: "The Ulicraft Launcher installs and updates the whole modpack from our distribution, so everyone stays in sync.", }, { h: "Built to last", @@ -157,27 +242,57 @@ export const ui: Record = { }, ], }, + mods: { + eyebrow: "The pack", + title: "Every mod in the pack.", + lead: "The full client modset, installed automatically by the launcher. No hunting, no version mismatches.", + empty: "Mod list not generated yet — check back soon.", + }, join: { - eyebrow: "How to Join · 4 Steps", + eyebrow: "How to Join · 3 Steps", title: "From zero to spawning in.", lead: "One-time setup. After this, the launcher keeps you in sync.", s1: { + kicker: "Account", + h: "Register", + pa: "Create your Ulicraft account at", + pb: "— one username and password for the server and your skin.", + registerLink: "Register", + }, + s2: { kicker: "Launcher", h: "Download {name}", - p: "Grab {name} for your system — a Prism-based launcher with authlib-injector support built in.", + p: "Grab {name} for your system and log in with your Ulicraft credentials — it installs the modpack and everything else automatically.", + }, + s3: { + kicker: "Connect", + h: "Join the server", + pa: "Launch the launcher, open", + pb: ", paste the address, and join.", + copy: "Copy IP", + }, + }, + fjord: { + navHome: "← Home", + eyebrow: "Alternative · Fjord Launcher", + title: "Play with Fjord Launcher.", + lead: "Prefer a vanilla Prism-based launcher? Set it up manually with Fjord and import the Ulicraft pack.", + s1: { + kicker: "Launcher", + h: "Download Fjord", + p: "Grab Fjord Launcher for your system — a Prism-based launcher with authlib-injector support built in.", }, s2: { kicker: "Account", h: "Add your account", - pa: "In the launcher, add an", + pa: "In Fjord, add an", pb: "account using this URL, then log in with your Ulicraft credentials.", copy: "Copy", - registerPre: "Need credentials? Register at", }, s3: { kicker: "Modpack", - h: "Import the modpack", - p: "Add a new instance from this packwiz URL — it installs and updates the whole pack.", + h: "Import the Ulicraft pack", + p: "Download the Ulicraft modpack and import it into Fjord as a new instance — it installs the whole pack.", }, s4: { kicker: "Connect", @@ -186,9 +301,11 @@ export const ui: Record = { pb: ", paste the address, and join.", copy: "Copy IP", }, + noPack: "The pack download isn't available yet — check back soon.", }, footer: { join: "How to Join", + status: "Server status", disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.", }, register: { @@ -224,12 +341,43 @@ export const ui: Record = { errSkinType: "Pick a PNG image.", errNetwork: "Network error — try again.", }, + account: { + navHome: "← Home", + eyebrow: "Account", + title: "Manage your skin.", + lead: "Log in to upload or reset your Minecraft skin.", + usernameLabel: "Username", + usernamePlaceholder: "your in-game name", + passwordLabel: "Password", + passwordPlaceholder: "your password", + submit: "Log in", + submitting: "Logging in…", + noAccount: "No account yet?", + registerLink: "Register here", + playerLabel: "Logged in as", + skinTitle: "Your skin", + skinLead: "Upload a Minecraft skin PNG. The 3D preview below updates instantly; the in-game avatar takes ~15 minutes.", + skinChoose: "Choose PNG…", + modelLabel: "Model", + modelClassic: "Classic", + modelSlim: "Slim", + skinUpload: "Upload skin", + skinUploading: "Uploading…", + skinOk: "Skin updated!", + skinPreviewNote: "Avatar preview updates in ~15 min.", + resetSkin: "Reset to default", + resetConfirm: "Reset your skin to the default? This cannot be undone.", + resetOk: "Skin reset to default.", + errFields: "Fill in every field.", + errSkinType: "Pick a PNG image.", + errNetwork: "Network error — try again.", + }, }, es: { htmlLang: "es", copied: "¡Copiado!", - nav: { status: "Estado", features: "Características", join: "Cómo entrar", play: "Jugar ahora" }, + nav: { status: "Estado", members: "Miembros", features: "Características", mods: "Mods", join: "Cómo entrar", play: "Jugar ahora" }, hero: { eyebrow: "LAN con mods · NeoForge", tagline: "Supervivencia todo incluido, para durar más que el finde.", @@ -251,6 +399,12 @@ export const ui: Record = { upTo: "hasta", }, statLabels: ["Mods", "NeoForge", "Plazas", "Copias del mundo"], + members: { + eyebrow: "La cuadrilla", + title: "Quién está en el servidor.", + lead: "Todos los registrados en Ulicraft, cada uno con su skin autoalojada.", + empty: "Aún no hay miembros — sé el primero en registrarte.", + }, features: { eyebrow: "Qué hace especial a Ulicraft", title: "Hecho para la cuadrilla.", @@ -268,7 +422,7 @@ export const ui: Record = { }, { h: "Configuración en un clic", - p: "packwiz instala y actualiza todo el modpack dentro de tu launcher, así todos vais sincronizados.", + p: "El Ulicraft Launcher instala y actualiza todo el modpack desde nuestra distribución, así todos vais sincronizados.", }, { h: "Hecho para durar", @@ -276,27 +430,57 @@ export const ui: Record = { }, ], }, + mods: { + eyebrow: "El pack", + title: "Todos los mods del pack.", + lead: "El modset completo del cliente, instalado automáticamente por el launcher. Sin búsquedas ni versiones incompatibles.", + empty: "La lista de mods aún no está generada — vuelve pronto.", + }, join: { - eyebrow: "Cómo entrar · 4 pasos", + eyebrow: "Cómo entrar · 3 pasos", title: "De cero a aparecer en el mundo.", lead: "Configuración única. Después, el launcher te mantiene sincronizado.", s1: { + kicker: "Cuenta", + h: "Regístrate", + pa: "Crea tu cuenta de Ulicraft en", + pb: "— un usuario y contraseña para el servidor y tu skin.", + registerLink: "Registro", + }, + s2: { kicker: "Launcher", h: "Descarga {name}", - p: "Coge {name} para tu sistema: un launcher basado en Prism con soporte authlib-injector integrado.", + p: "Coge {name} para tu sistema e inicia sesión con tus credenciales de Ulicraft: instala el modpack y todo lo demás automáticamente.", + }, + s3: { + kicker: "Conectar", + h: "Entra al servidor", + pa: "Lanza el launcher, abre", + pb: ", pega la dirección y entra.", + copy: "Copiar IP", + }, + }, + fjord: { + navHome: "← Inicio", + eyebrow: "Alternativa · Fjord Launcher", + title: "Juega con Fjord Launcher.", + lead: "¿Prefieres un launcher vanilla basado en Prism? Configúralo a mano con Fjord e importa el pack de Ulicraft.", + s1: { + kicker: "Launcher", + h: "Descarga Fjord", + p: "Coge Fjord Launcher para tu sistema: un launcher basado en Prism con soporte authlib-injector integrado.", }, s2: { kicker: "Cuenta", h: "Añade tu cuenta", - pa: "En el launcher, añade una cuenta", + pa: "En Fjord, añade una cuenta", pb: "con esta URL y luego inicia sesión con tus credenciales de Ulicraft.", copy: "Copiar", - registerPre: "¿Sin credenciales? Regístrate en", }, s3: { kicker: "Modpack", - h: "Importa el modpack", - p: "Añade una instancia nueva desde esta URL de packwiz: instala y actualiza todo el pack.", + h: "Importa el pack de Ulicraft", + p: "Descarga el modpack de Ulicraft e impórtalo en Fjord como instancia nueva: instala todo el pack.", }, s4: { kicker: "Conectar", @@ -305,9 +489,11 @@ export const ui: Record = { pb: ", pega la dirección y entra.", copy: "Copiar IP", }, + noPack: "La descarga del pack aún no está disponible — vuelve pronto.", }, footer: { join: "Cómo entrar", + status: "Estado del servidor", disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.", }, register: { @@ -343,12 +529,43 @@ export const ui: Record = { errSkinType: "Elige una imagen PNG.", errNetwork: "Error de red: inténtalo de nuevo.", }, + account: { + navHome: "← Inicio", + eyebrow: "Cuenta", + title: "Gestiona tu skin.", + lead: "Inicia sesión para subir o restablecer tu skin de Minecraft.", + usernameLabel: "Usuario", + usernamePlaceholder: "tu nombre en el juego", + passwordLabel: "Contraseña", + passwordPlaceholder: "tu contraseña", + submit: "Iniciar sesión", + submitting: "Entrando…", + noAccount: "¿Aún no tienes cuenta?", + registerLink: "Regístrate aquí", + playerLabel: "Sesión iniciada como", + skinTitle: "Tu skin", + skinLead: "Sube un PNG de skin de Minecraft. La vista previa 3D se actualiza al instante; el avatar en el juego tarda ~15 minutos.", + skinChoose: "Elegir PNG…", + modelLabel: "Modelo", + modelClassic: "Clásico", + modelSlim: "Fino", + skinUpload: "Subir skin", + skinUploading: "Subiendo…", + skinOk: "¡Skin actualizada!", + skinPreviewNote: "La vista previa del avatar se actualiza en ~15 min.", + resetSkin: "Restablecer skin", + resetConfirm: "¿Restablecer tu skin a la predeterminada? Esta acción no se puede deshacer.", + resetOk: "Skin restablecida.", + errFields: "Rellena todos los campos.", + errSkinType: "Elige una imagen PNG.", + errNetwork: "Error de red: inténtalo de nuevo.", + }, }, eu: { htmlLang: "eu", copied: "Kopiatuta!", - nav: { status: "Egoera", features: "Ezaugarriak", join: "Nola sartu", play: "Jokatu orain" }, + nav: { status: "Egoera", members: "Kideak", features: "Ezaugarriak", mods: "Mods", join: "Nola sartu", play: "Jokatu orain" }, hero: { eyebrow: "Mod-dun LANa · NeoForge", tagline: "Mod ugariko biziraupena, irauteko egina.", @@ -370,6 +587,12 @@ export const ui: Record = { upTo: "gehienez", }, statLabels: ["Modak", "NeoForge", "Plazak", "Munduaren babeskopiak"], + members: { + eyebrow: "Koadrila", + title: "Nor dago zerbitzarian.", + lead: "Ulicraft-en erregistratutako guztiak, bakoitza bere azal auto-ostatatuarekin.", + empty: "Oraindik ez dago kiderik — izan zaitez lehena erregistratzen.", + }, features: { eyebrow: "Zerk egiten du Ulicraft berezi", title: "Koadrilarentzat eginda.", @@ -387,7 +610,7 @@ export const ui: Record = { }, { h: "Konfigurazioa klik batean", - p: "packwiz-ek modpack osoa instalatu eta eguneratzen du zure launcherrean, denok sinkronizatuta egoteko.", + p: "Ulicraft Launcher-ek modpack osoa instalatu eta eguneratzen du gure banaketatik, denok sinkronizatuta egoteko.", }, { h: "Irauteko eginda", @@ -395,27 +618,57 @@ export const ui: Record = { }, ], }, + mods: { + eyebrow: "Packa", + title: "Packeko mod guztiak.", + lead: "Bezeroaren mod-multzo osoa, launcherrak automatikoki instalatua. Bilaketarik gabe, bertsio-gatazkarik gabe.", + empty: "Mod zerrenda oraindik ez da sortu — itzuli laster.", + }, join: { - eyebrow: "Nola sartu · 4 urrats", + eyebrow: "Nola sartu · 3 urrats", title: "Zerotik mundura agertzera.", lead: "Behin bakarrik konfiguratu. Gero, launcherrak sinkronizatuta mantenduko zaitu.", s1: { + kicker: "Kontua", + h: "Erregistratu", + pa: "Sortu zure Ulicraft kontua hemen:", + pb: "— erabiltzaile eta pasahitz bat zerbitzarirako eta zure azalerako.", + registerLink: "Erregistroa", + }, + s2: { kicker: "Launcherra", h: "Deskargatu {name}", - p: "Hartu {name} zure sistemarako: Prism-en oinarritutako launcherra, authlib-injector euskarriarekin.", + p: "Hartu {name} zure sistemarako eta hasi saioa zure Ulicraft kredentzialekin: modpacka eta gainerako guztia automatikoki instalatzen du.", + }, + s3: { + kicker: "Konektatu", + h: "Sartu zerbitzarira", + pa: "Abiarazi launcherra, ireki", + pb: ", itsatsi helbidea eta sartu.", + copy: "Kopiatu IPa", + }, + }, + fjord: { + navHome: "← Hasiera", + eyebrow: "Alternatiba · Fjord Launcher", + title: "Jokatu Fjord Launcher-ekin.", + lead: "Prism-en oinarritutako launcher vanilla nahiago duzu? Konfiguratu eskuz Fjord-ekin eta inportatu Ulicraft packa.", + s1: { + kicker: "Launcherra", + h: "Deskargatu Fjord", + p: "Hartu Fjord Launcher zure sistemarako: Prism-en oinarritutako launcherra, authlib-injector euskarriarekin.", }, s2: { kicker: "Kontua", h: "Gehitu zure kontua", - pa: "Launcherrean, gehitu", + pa: "Fjord-en, gehitu", pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.", copy: "Kopiatu", - registerPre: "Kredentzialik ez? Erregistratu hemen:", }, s3: { kicker: "Modpacka", - h: "Inportatu modpacka", - p: "Gehitu instantzia berri bat packwiz URL honetatik: pack osoa instalatu eta eguneratzen du.", + h: "Inportatu Ulicraft packa", + p: "Deskargatu Ulicraft modpacka eta inportatu Fjord-en instantzia berri gisa: pack osoa instalatzen du.", }, s4: { kicker: "Konektatu", @@ -424,9 +677,11 @@ export const ui: Record = { pb: ", itsatsi helbidea eta sartu.", copy: "Kopiatu IPa", }, + noPack: "Packaren deskarga ez dago oraindik eskuragarri — itzuli laster.", }, footer: { join: "Nola sartu", + status: "Zerbitzariaren egoera", disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.", }, register: { @@ -462,5 +717,36 @@ export const ui: Record = { errSkinType: "Aukeratu PNG irudi bat.", errNetwork: "Sare-errorea: saiatu berriro.", }, + account: { + navHome: "← Hasiera", + eyebrow: "Kontua", + title: "Kudeatu zure azala.", + lead: "Hasi saioa zure Minecraft azala igotzeko edo berrezartzeko.", + usernameLabel: "Erabiltzailea", + usernamePlaceholder: "zure jokoko izena", + passwordLabel: "Pasahitza", + passwordPlaceholder: "zure pasahitza", + submit: "Hasi saioa", + submitting: "Sartzen…", + noAccount: "Oraindik konturik ez?", + registerLink: "Erregistratu hemen", + playerLabel: "Saioa hasita:", + skinTitle: "Zure azala", + skinLead: "Igo Minecraft azal PNG bat. 3D aurrebista berehala eguneratzen da; jokoko avatarra ~15 minutuan.", + skinChoose: "Aukeratu PNGa…", + modelLabel: "Eredua", + modelClassic: "Klasikoa", + modelSlim: "Mehea", + skinUpload: "Igo azala", + skinUploading: "Igotzen…", + skinOk: "Azala eguneratuta!", + skinPreviewNote: "Avatararen aurrebista ~15 min barru eguneratzen da.", + resetSkin: "Berrezarri azala", + resetConfirm: "Zure azala lehenetsi nahi duzu? Ezin da desegin.", + resetOk: "Azala berrezarrita.", + errFields: "Bete eremu guztiak.", + errSkinType: "Aukeratu PNG irudi bat.", + errNetwork: "Sare-errorea: saiatu berriro.", + }, }, }; diff --git a/landing/src/pages/[...lang].astro b/landing/src/pages/[...lang].astro index beeb4c9..f57e708 100644 --- a/landing/src/pages/[...lang].astro +++ b/landing/src/pages/[...lang].astro @@ -1,10 +1,12 @@ --- import { site, LITERALS, HEAD_FONTS } from "../data/site"; -import { ui, LANGS, LANG_LABEL, LANG_PATH, type Lang } from "../i18n/ui"; +import { ui, LANGS, LANG_LABEL, LANG_PATH, LANG_REGISTER_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 ServerCard from "../components/ServerCard.astro"; +import PlayerRoster from "../components/PlayerRoster.astro"; +import ModList from "../components/ModList.astro"; import "../styles/main.css"; // One static page per locale: en at "/", es at "/es/", eu at "/eu/". @@ -18,9 +20,19 @@ export function getStaticPaths() { const { locale } = Astro.props; const t = ui[locale]; -const { theme, launcher } = site; +const { theme, launcherManifest } = site; const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify; -const launcherName = launcher.name; +const launcherName = launcherManifest.product; +const registerPath = LANG_REGISTER_PATH[locale]; + +// Per-OS download buttons (join step 2) from the synced manifest. Missing +// manifest → empty builds → no buttons (the build never fails on it). +const OS_LABELS: Record = { windows: "Windows", macos: "macOS", linux: "Linux" }; +const launcherBuilds = launcherManifest.builds.map((b) => ({ + label: b.label ?? `${OS_LABELS[b.os] ?? b.os}`, + hint: `${b.arch} · ${b.format}`, + url: b.url, +})); // Build-time floating dust bits (index-derived, no runtime RNG). const dustBits = Array.from({ length: 16 }, (_, i) => { @@ -62,7 +74,9 @@ const dustBits = Array.from({ length: 16 }, (_, i) => { @@ -143,8 +157,22 @@ const dustBits = Array.from({ length: 16 }, (_, i) => { + +
+
+
+ {t.members.eyebrow} +

{t.members.title}

+

{t.members.lead}

+
+
+ +
+
+
+ -
+
{t.features.eyebrow} @@ -165,6 +193,20 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
+ +
+
+
+ {t.mods.eyebrow} +

{t.mods.title}

+

{t.mods.lead}

+
+
+ +
+
+
+
@@ -180,15 +222,10 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
1
{t.join.s1.kicker} -

{t.join.s1.h.replace("{name}", launcherName)}

-

{t.join.s1.p.replace(/\{name\}/g, launcherName)}

-
- {launcher.downloads.map((d) => ( - - {d.os} · {d.hint} - - ))} -
+

{t.join.s1.h}

+

+ {t.join.s1.pa} {t.join.s1.registerLink} {t.join.s1.pb} +

@@ -197,16 +234,17 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
2
{t.join.s2.kicker} -

{t.join.s2.h}

-

- {t.join.s2.pa} {LITERALS.authlib} {t.join.s2.pb} -

-
- -
-

- {t.join.s2.registerPre} {site.authUrl}. -

+

{t.join.s2.h.replace("{name}", launcherName)}

+

{t.join.s2.p.replace(/\{name\}/g, launcherName)}

+ {launcherBuilds.length > 0 && ( +
+ {launcherBuilds.map((b) => ( + + {b.label} · {b.hint} + + ))} +
+ )}
@@ -216,24 +254,11 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
{t.join.s3.kicker}

{t.join.s3.h}

-

{t.join.s3.p}

-
- -
-
- - - -
-
4
-
- {t.join.s4.kicker} -

{t.join.s4.h}

- {t.join.s4.pa} {LITERALS.multiplayer}{LITERALS.addServer}{t.join.s4.pb} + {t.join.s3.pa} {LITERALS.multiplayer}{LITERALS.addServer}{t.join.s3.pb}

- +
@@ -251,6 +276,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {

{t.footer.disc} {site.name} · {site.baseDomain} diff --git a/landing/src/pages/[...lang]/account.astro b/landing/src/pages/[...lang]/account.astro new file mode 100644 index 0000000..c9cfe94 --- /dev/null +++ b/landing/src/pages/[...lang]/account.astro @@ -0,0 +1,357 @@ +--- +import { site, HEAD_FONTS } from "../../data/site"; +import { + ui, + LANGS, + LANG_LABEL, + LANG_PATH, + LANG_ACCOUNT_PATH, + LANG_REGISTER_PATH, + type Lang, +} from "../../i18n/ui"; +import Creeper from "../../components/Creeper.astro"; +import "../../styles/main.css"; + +// One static account page per locale: /account, /es/account, /eu/account. +export function getStaticPaths() { + return [ + { params: { lang: undefined }, props: { locale: "en" as Lang } }, + { params: { lang: "es" }, props: { locale: "es" as Lang } }, + { params: { lang: "eu" }, props: { locale: "eu" as Lang } }, + ]; +} + +const { locale } = Astro.props; +const t = ui[locale]; +const a = t.account; +const { theme } = site; +const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify; +const homePath = LANG_PATH[locale]; +const registerPath = LANG_REGISTER_PATH[locale]; + +// Strings the client script needs at runtime (kept minimal, passed via define:vars). +const clientCfg = { + apiUrl: site.draslApiUrl, + avatarUrl: site.avatarUrl, + avatarMode: site.avatarMode, + submit: a.submit, + submitting: a.submitting, + skinUpload: a.skinUpload, + skinUploading: a.skinUploading, + skinOk: a.skinOk, + skinPreviewNote: a.skinPreviewNote, + resetSkin: a.resetSkin, + resetConfirm: a.resetConfirm, + resetOk: a.resetOk, + errFields: a.errFields, + errSkinType: a.errSkinType, + errNetwork: a.errNetwork, +}; +--- + + + + + + + {site.name} — {a.title} + + + + {LANGS.map((l) => )} + + + +

+ +
+
+
+
+ {a.eyebrow} +

{a.title}

+

{a.lead}

+ + +
+
+ + +
+
+ + +
+ + +
+ +

+ {a.noAccount} {a.registerLink} +

+ + + +
+
+
+
+ + + + + + + diff --git a/landing/src/pages/[...lang]/fjord.astro b/landing/src/pages/[...lang]/fjord.astro new file mode 100644 index 0000000..1c0b31d --- /dev/null +++ b/landing/src/pages/[...lang]/fjord.astro @@ -0,0 +1,226 @@ +--- +import { site, LITERALS, HEAD_FONTS } from "../../data/site"; +import { + ui, + LANGS, + LANG_LABEL, + LANG_PATH, + LANG_FJORD_PATH, + type Lang, +} from "../../i18n/ui"; +import Creeper from "../../components/Creeper.astro"; +import CopyChip from "../../components/CopyChip.astro"; +import "../../styles/main.css"; + +// One static Fjord page per locale: /fjord, /es/fjord, /eu/fjord. This is the +// alternative manual path — intentionally NOT linked from the homepage nav. +export function getStaticPaths() { + return [ + { params: { lang: undefined }, props: { locale: "en" as Lang } }, + { params: { lang: "es" }, props: { locale: "es" as Lang } }, + { params: { lang: "eu" }, props: { locale: "eu" as Lang } }, + ]; +} + +const { locale } = Astro.props; +const t = ui[locale]; +const f = t.fjord; +const { theme, fjord } = site; +const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify; +const homePath = LANG_PATH[locale]; +const fjordPack = site.launcherManifest.fjordPack; +--- + + + + + + + {site.name} — {f.title} + + + + {LANGS.map((l) => )} + + + + + +
+
+
+
+ {f.eyebrow} +

{f.title}

+

{f.lead}

+
+ +
+ +
+
1
+
+ {f.s1.kicker} +

{f.s1.h}

+

{f.s1.p}

+
+ {fjord.downloads.map((d) => ( + + {d.os} · {d.hint} + + ))} +
+
+
+ + +
+
2
+
+ {f.s2.kicker} +

{f.s2.h}

+

+ {f.s2.pa} {LITERALS.authlib} {f.s2.pb} +

+
+ +
+
+
+ + +
+
3
+
+ {f.s3.kicker} +

{f.s3.h}

+

{f.s3.p}

+
+ {fjordPack ? ( + + {fjordPack.filename} · .mrpack + + ) : ( +

{f.noPack}

+ )} +
+
+
+ + +
+
4
+
+ {f.s4.kicker} +

{f.s4.h}

+

+ {f.s4.pa} {LITERALS.multiplayer}{LITERALS.addServer}{f.s4.pb} +

+
+ +
+
+
+
+
+
+
+ + + + + + + diff --git a/landing/src/pages/[...lang]/register.astro b/landing/src/pages/[...lang]/register.astro index 5ea347a..eda4652 100644 --- a/landing/src/pages/[...lang]/register.astro +++ b/landing/src/pages/[...lang]/register.astro @@ -6,6 +6,7 @@ import { LANG_LABEL, LANG_PATH, LANG_REGISTER_PATH, + LANG_ACCOUNT_PATH, type Lang, } from "../../i18n/ui"; import Creeper from "../../components/Creeper.astro"; @@ -118,7 +119,7 @@ const clientCfg = {

- {r.haveAccount} {r.loginLink} + {r.haveAccount} {r.loginLink}

@@ -167,6 +168,9 @@ const clientCfg = { ULICRAFT +

{t.footer.disc} {site.name} · {site.baseDomain}

diff --git a/landing/src/styles/main.css b/landing/src/styles/main.css index 4c162ed..2886ba1 100644 --- a/landing/src/styles/main.css +++ b/landing/src/styles/main.css @@ -245,7 +245,7 @@ section { position: relative; z-index: 1; } .ip-chip button:hover { filter: brightness(1.1); } .ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); } -/* url variant: long auth/packwiz URLs — smaller, wraps instead of overflowing */ +/* url variant: long auth URLs — smaller, wraps instead of overflowing */ .ip-chip.url { max-width: 100%; } .ip-chip.url .ip-val { font-family: var(--font-mono); @@ -467,6 +467,41 @@ section { position: relative; z-index: 1; } .tile .k .u { color: var(--accent); font-size: 22px; } .tile .l { margin-top: 8px; color: var(--dim); font-size: 13px; letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-pixel); font-size: 9px; line-height: 1.8; } +/* ============================================================ + PLAYER ROSTER (shared avatar tiles) + Used by ServerCard.astro (online row) and PlayerRoster.astro + (Members grid). A tile is an NMSR avatar + the player name. + ============================================================ */ +.roster { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); + gap: 12px; +} +.roster-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 12px 8px; + background: var(--bg-2); + box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi); +} +.roster-tile img { + width: 48px; + height: 48px; + image-rendering: pixelated; +} +.roster-name { + max-width: 100%; + color: var(--muted); + font-size: 13px; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.roster-empty { margin: 0; color: var(--dim); font-size: 14px; } + /* ============================================================ FEATURES ============================================================ */ diff --git a/plan/00-overview.md b/plan/00-overview.md index de5f7ed..65287d6 100644 --- a/plan/00-overview.md +++ b/plan/00-overview.md @@ -144,6 +144,8 @@ and uptime-kuma. - `12-build-order.md` — commit-per-task build sequence - `14-deploy.md` — redeploy to the production host - `15-letsencrypt.md` — Let's Encrypt (OVH DNS-01) + host nginx ingress +- `17-mod-shortlist.md` — kitchen-sink gap doc + landing mod-list dataset (PLANNED) +- `18-landing-rework.md` — join flow / rosters / account / mod list / footer (PLANNED) ## Boot/dependency order diff --git a/plan/17-mod-shortlist.md b/plan/17-mod-shortlist.md new file mode 100644 index 0000000..c060e67 --- /dev/null +++ b/plan/17-mod-shortlist.md @@ -0,0 +1,75 @@ +# Mod shortlist + landing mod-list component (WS6) + +> **Status: PLANNED — not started.** Two separable deliverables: (1) a curated +> kitchen-sink gap doc (this file), (2) an auto-generated `mods.json` + logos that +> feed a flat mod-list component on the landing page (see also `18-landing-rework.md`). + +## TL;DR + +- Current pack (76 jars, `mods-sides.json`) is a polished **vanilla+ QoL/perf** base, + **not yet kitchen-sink**. Strong perf + QoL + storage + FTB + voice; **empty** on + tech and magic, **thin** on worldgen, **no** map mod. +- Jars live in the **external distribution repo** (`$DISTRIBUTION_WEB_ROOT/servers/ + ulicraft-1.21.1/forgemods/required/*.jar`, Nebula-managed). Adding mods happens + there, **not** in this repo. This doc is a shortlist only; depth = **light** (names + by area, verify NeoForge 1.21.1 at add-time — no per-mod version/link research). +- The landing component shows the **installed pack** (full client set), auto-generated + from the jars — a different dataset from this shortlist. Flat alphabetical, pretty + names, extracted logos, no categories, no Modrinth/CF links. + +## Current pack — what's already in (76 jars) + +- **Perf:** sodium, sodium-extra, iris, immediatelyfast, entityculling, ferritecore, + lithium, spark. +- **QoL:** JEI, Jade(+addons), JER, JEProfessions, appleskin, MouseTweaks, Controlling, + Searchables, inventoryessentials, polymorph, rightclickharvest, simplemagnets, + fastleafdecay, torchmaster, trashcans, corpse(+curioscompat), lootr, comforts, + BetterF3, tagtooltips, cardboardboxes. +- **Storage:** sophisticatedbackpacks(+core), functionalstorage(+more). +- **Farm/decor/build:** FarmersDelight, FramedBlocks, handcrafted. +- **FTB utils:** chunks, essentials, library, teams, ultimine. +- **Voice:** Simple Voice Chat (`voicechat`). +- **Scripting/guide:** KubeJS(+rhino, +delight), ponderjs, Patchouli. +- **Libs:** architectury, balm, cloth-config, configured, curios, resourcefullib, + supermartijn642 core/config, jamlib, baguettelib, titanium, utilitarian, YungsApi, + Necronomicon, yacl. + +## Gaps vs kitchen-sink (tech + magic + exploration + QoL) + +- [ ] **Tech — empty.** Candidates: Create, Mekanism, Immersive Engineering, Applied + Energistics 2, Thermal Series, Powah. (Pick a subset; Create is the anchor.) +- [ ] **Magic — empty.** Candidates: Ars Nouveau, Botania, Iron's Spells 'n Spellbooks, + Eidolon. +- [ ] **Worldgen — thin** (only `YungsApi` + `ExplorersCompass`). Candidates: Terralith, + Tectonic, YUNG's structure set (Better Dungeons/Mineshafts/Strongholds…), + Repurposed Structures, William Wythers' Overhauled Overworld. +- [ ] **Map — none.** Candidate: Xaero's Minimap + Worldmap. +- [ ] **Server-side perf (optional):** ModernFix, C2ME. + +## Orphaned dependencies (cleanup) + +- [ ] **`ponderjs`** present but **no Create** — ponder is Create's. Add Create or drop. +- [ ] **`Necronomicon`** present but **no Iron's Spells** — it's Iron's Spells' lib. + Add Iron's Spells or drop. + +## Curation tradeoff + +Adding the tech+magic+worldgen anchors pushes 76 → 100+ jars, near the stated +50–100 ceiling, and raises the RAM/stability bar (`INIT_MEMORY 4G`/`MAX_MEMORY 10G`). +Curate deliberately; verify each candidate's NeoForge **1.21.1** build + the +`21.1.233` loader pin at add-time. + +## Execution (external repo, when ready) + +1. Add jars in the distribution repo (Nebula `forgemods/required/`), keep the NeoForge + 1.21.1 builds matching the `21.1.233` pin. +2. `release-distro.sh` regenerates `distribution.json` + uploads. +3. Back in this repo: `tooling/classify-mods.py` (seed sides) → hand-edit new + client-cosmetic entries to `client` → `tooling/sync-server-mods.sh`. +4. `tooling/build-modlist.py` (new, see `18-landing-rework.md`) regenerates + `landing/src/data/mods.json` + logos → rebuild landing. + +## Related + +- `04-mods.md` — distribution-fed mod system, classify/sync, sides. +- `18-landing-rework.md` — the landing mod-list component + `mods.json` generator. diff --git a/plan/18-landing-rework.md b/plan/18-landing-rework.md new file mode 100644 index 0000000..0ad7f82 --- /dev/null +++ b/plan/18-landing-rework.md @@ -0,0 +1,204 @@ +# Landing rework — join flow, player rosters, account, mod list, footer + +> **Status: PLANNED — not started.** Five landing workstreams (WS1–WS5) plus the +> mod-list component from `17-mod-shortlist.md` (WS6). Each is independent; suggested +> order: **WS5 → WS1 → WS2/3 → WS4 → WS6** (trivial first; rosters share avatar +> plumbing; account reuses register's skin JS; mods last). + +The landing is an **Astro static site** (`landing/`, pnpm, output → `../www/`), built +on the host with `.env` sourced. Three locales (en/es/eu) in `i18n/ui.ts`, identical +shape; non-translatable config in `data/site.ts`. Runtime data (status, rosters, Drasl +API) is fetched client-side; everything else is SSG. + +## New env keys (`.env` + `.env.example`) + +| Key | Consumer | Purpose | +|---|---|---| +| `AVATAR_MODE` (default `headiso`) | landing build → `site.ts` | NMSR render mode for avatar tiles | +| `DRASL_ADMIN_USERNAME` / `DRASL_ADMIN_PASSWORD` | **mc-status** (compose env) | admin login for the registered-players roster — **never** reaches the browser | + +Use a **dedicated, rotatable** Drasl admin account for these creds (Drasl has no +read-only role; full-admin, but isolates blast radius from your personal admin). + +--- + +## WS1 — Join flow off packwiz (homepage = UlicraftLauncher, 3 steps) + +The modpack now ships via **UlicraftLauncher** (rebranded HeliosLauncher in the +`custom-launcher` repo) — it installs mods (packwiz), auth (Drasl, preconfigured) and +all files itself. So the homepage join flow drops the packwiz step and the manual +authlib step. + +**Homepage steps (3):** +1. **Register** — create your Ulicraft account (link `/register`). +2. **Download the Ulicraft Launcher** — per-OS download (from manifest); log in with + your credentials. (No authlib URL — launcher is preconfigured.) +3. **Join the server** — launch, connect to `site.serverAddress` (IP copy chip). + +**Launcher manifest (delivery = synced local file, option B):** +- Contract: `landing/src/data/launcher-manifest.schema.json` (already written; + JSON-Schema draft 2020-12). +- The `custom-launcher` release flow **produces** `launcher-manifest.json` and syncs + it into `landing/src/data/launcher-manifest.json` (gitignored, generated), same + pattern as `sync-server-mods.sh`. +- Landing **imports** it at build → renders per-OS buttons from `builds[]`. Missing + file → hide the buttons (build must not fail). + +**Fjord — alternative path, OWN page, off the homepage:** new `/fjord` (+ `/es`, +`/eu`). Download Fjord (existing `/launcher/` mirror), add authlib-injector account +(`site.authlibUrl` chip), import `manifest.fjordPack` (`.mrpack`), connect. + +Tasks: +- [ ] `site.ts`: delete `packwizUrl`; replace `launcher` (Fjord hardcode) with the + manifest import; rename product display to "Ulicraft Launcher". +- [ ] `ui.ts`: rewrite `join` block (3 steps) in **all three** locales; eyebrow "3 + Steps"; reword the `features` item that name-drops packwiz. +- [ ] `[...lang].astro`: collapse the 4-step join markup → 3; remove packwiz `CopyChip`, + keep server-IP chip; render download buttons from manifest. +- [ ] New `[...lang]/fjord.astro` + `ui.ts` `fjord` block + `LANG_FJORD_PATH`. +- [ ] Document the synced-manifest step in `12-build-order.md` / `14-deploy.md`. + +--- + +## WS2 — Live server status: online players with names + 3D avatars + +`ServerCard.astro` already fetches mc-status (`/api/mcstatus/v2/status/java/${BASE_DOMAIN}`, +mcstatus.io v2 JSON: `players.online/max/list[{uuid,name_clean}]`) and renders NMSR +`headiso` heads (filters the zero-UUID placeholder). Enhancement: labeled avatar tiles +(name + avatar) using `site.avatarMode`. + +Tasks: +- [ ] `ServerCard.astro`: per online player render a **shared avatar tile** (name + + `${avatarUrl}/${avatarMode}/?size=N`). Keep count + fill bar. +- [ ] `main.css`: `.roster`/tile styles (shared with WS3). + +--- + +## WS3 — Registered players roster (live, via mc-status) + +`GET /drasl/api/v2/players` (list all) is **admin-only** → cannot be a browser fetch. +Serve it **live** through mc-status (the trusted internal proxy that already emits +sanitized public JSON). **Coupling accepted** (4–10 friend server) with two mitigations: +isolate the roster fetch (own timeout + cache; the status path never blocks on Drasl), +and use a dedicated rotatable admin account. + +mc-status (`docker/mc-status/main.go`) gains a `/players` endpoint: +- On request (cache TTL ~60s): if no cached token, `POST http://drasl:25585/drasl/api/v2/login` + with admin creds → cache `apiToken` (re-login on 401). `GET …/players` (Bearer) → + map to `[{uuid,name}]` → cache → serve JSON, CORS `*` (like the status route). +- Caddy already `handle /api/mcstatus/*` → mc-status, so the public path is + `/api/mcstatus/players` (browser fetches **same-origin**, no CORS, no admin token in + the page). + +`PlayerRoster.astro` (new) fetches `/api/mcstatus/players` client-side → live grid of +shared avatar tiles. Roster is **public** (uuid+name) — accepted. + +Tasks: +- [ ] `main.go`: `/players` handler (Drasl login + list, token cache, TTL cache, + isolated from the status path). +- [ ] `docker-compose.yml`: pass `DRASL_ADMIN_*` env to mc-status. +- [ ] New `PlayerRoster.astro` + a "Members" section in `[...lang].astro` + `ui.ts` + keys (3 langs). + +--- + +## WS4 — Player account page (skin CRUD) + +New `/account` (+ `/es`, `/eu`), browser-direct Drasl (proven by `/register`; CORS +already scoped to the apex). **Skins only** (capes out). One player per user +(`players[0]`, no switcher). Token **in-memory only** (refresh = re-login; no +localStorage → no XSS token theft). + +- **Read:** `POST /login {username,password}` → `{apiToken, user.players[0]{uuid,name,skinUrl}}`. +- **Create/Update:** `PATCH /players/{uuid} {skinBase64, skinModel}` (Bearer). +- **Delete:** `PATCH /players/{uuid} {deleteSkin:true}` → reverts to default. + +UI: login form → reveal skin panel (current avatar via NMSR `site.avatarMode`, model +radio classic/slim, file picker, [Upload], [Reset to default]). After upload, NMSR lags +~15m → show the **just-picked file** locally (FileReader dataURL) as instant +confirmation, not a fresh NMSR render. + +Tasks: +- [ ] New `[...lang]/account.astro` — lift skin JS near-verbatim from + `register.astro:250-307` (`readBase64`, PATCH, `apiMessage`). +- [ ] `ui.ts`: `account` block (reuse `register.skin*`) + `LANG_ACCOUNT_PATH`. +- [ ] `main.css`: reuse `.reg-*` tokens. +- [ ] `register.astro`: repoint the "Manage it here" footer link from the Drasl web UI + to `/account`. + +--- + +## WS5 — Footer status link + +Add a link to `https://status.${BASE_DOMAIN}` in every footer. Nav unchanged. + +Tasks: +- [ ] `site.ts`: add `statusUrl`. +- [ ] `ui.ts`: add `footer.status` (3 langs). +- [ ] Add `` in `.footer-links` of `[...lang].astro`, + `register.astro`, and the new `account.astro` / `fjord.astro` footers. + +--- + +## WS6 — Mod-list component (auto-generated, see `17-mod-shortlist.md`) + +Shows the **installed pack** (full client set from the distribution forgemods), flat +alphabetical, pretty names + extracted logos, no categories, no external links. +Build-time baked. + +New `tooling/build-modlist.py` (mirrors `classify-mods.py`: `tomllib` + `zipfile`, +offline) reads `$DISTRIBUTION_WEB_ROOT/servers/ulicraft-1.21.1/forgemods/required/*.jar`: +- Per jar: parse `neoforge.mods.toml`, take the primary `[[mods]]` → `displayName` + (fallback modId), `version`, `description`, `authors`; extract `logoFile` PNG → write + `landing/public/mod-logos/.png` (skip if absent → `logo: null`). +- Write `landing/src/data/mods.json` (gitignored, generated), sorted by `name`: + +```json +{ + "schemaVersion": 1, + "count": 76, + "mods": [ + { "id": "jei", "name": "Just Enough Items (JEI)", "version": "19.27.0.340", + "authors": "mezz", "description": "…", "logo": "/mod-logos/jei.png" } + ] +} +``` + +`ModList.astro` (new) imports `mods.json` → flat grid (logo + name + version). The +homepage stat tile (`site.ts` `stats[0].key "50+"`) is fed from `mods.json.count`. + +Tasks: +- [ ] `tooling/build-modlist.py` (jar parse + logo extract + `mods.json`). +- [ ] New `ModList.astro` + a "Mods" section in `[...lang].astro` + `ui.ts` heading + keys (3 langs). +- [ ] `site.ts`: feed `stats[0]` count from `mods.json`. +- [ ] Run the generator pre-`pnpm build`; document in `12-build-order.md` / + `14-deploy.md`. `.gitignore`: `landing/src/data/mods.json`, + `landing/src/data/launcher-manifest.json`, `landing/src/data/players.json` (if + any baked), `landing/public/mod-logos/`. + +--- + +## Verification (whole rework) + +- **Build:** `cd landing && set -a && . ../.env && set +a && pnpm run build` then + `pnpm check`. Must pass offline even if `launcher-manifest.json` / `mods.json` are + absent (buttons/list hidden). +- `grep -ri packwiz landing/` → no live join references remain (Fjord page may mention + its own import). +- **Local preview** (`pnpm dev`): all three locales — 3-step join, online roster, members + grid, account page, footer status link, mod grid, `/fjord` page. +- **Rosters (running stack):** online tiles appear with players on; `/api/mcstatus/players` + returns `[{uuid,name}]`; the members grid renders avatars. +- **Account (running stack):** login → upload PNG → instant local preview → in-game/NMSR + reflects after ~15m → reset reverts to default. +- Deploy via the `deploy` skill; the manifest sync, `build-modlist.py`, and landing + rebuild are **host** steps (not compose) — fold into `14-deploy.md`. + +## Related + +- `09-landing.md` — landing stack, i18n, theme. +- `15-mc-status.md` — the mc-status service (now also serves the roster). +- `16-landing-registration.md` — the browser-direct Drasl pattern WS4 reuses. +- `17-mod-shortlist.md` — the mod gap doc + the `mods.json` dataset. +- `landing/src/data/launcher-manifest.schema.json` — the launcher manifest contract. diff --git a/tooling/build-modlist.py b/tooling/build-modlist.py new file mode 100755 index 0000000..92c8792 --- /dev/null +++ b/tooling/build-modlist.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +# build-modlist.py — generate landing/src/data/mods.json (+ extracted logos) +# from the distribution's full client modset (offline, tomllib + zipfile only). +# +# Mirrors classify-mods.py: enumerates the distribution's forgemods/required +# jars, reads each jar's META-INF/neoforge.mods.toml, takes the PRIMARY [[mods]] +# entry, and emits a flat, alphabetically-sorted catalogue for the landing +# mod-list component (no categories, no external links). Each mod's logoFile PNG +# (if declared and present in the jar) is extracted to +# landing/public/mod-logos/.png. No network calls. Pure stdlib (Python +# 3.11+ for tomllib). +# +# Source dir resolution (first match wins) — identical to classify-mods.py: +# 1. CLI arg: build-modlist.py +# 2. env MODS_DIST_DIR +# 3. $DISTRIBUTION_WEB_ROOT from .env / environment +# The path may point at the dist ROOT or directly at the +# …/servers/ulicraft-1.21.1/forgemods/required dir — both are accepted. + +import glob +import json +import os +import sys +import zipfile + +try: + import tomllib +except ModuleNotFoundError: + sys.exit("build-modlist.py requires Python 3.11+ (tomllib is stdlib)") + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +LANDING = os.path.join(REPO_ROOT, "landing") +MODS_JSON = os.path.join(LANDING, "src", "data", "mods.json") +LOGOS_DIR = os.path.join(LANDING, "public", "mod-logos") +REQUIRED_SUBPATH = os.path.join("servers", "ulicraft-1.21.1", "forgemods", "required") +MANIFEST = "META-INF/neoforge.mods.toml" +SCHEMA_VERSION = 1 + + +def read_env_dist_root(): + """Read DISTRIBUTION_WEB_ROOT from environment, else from repo .env.""" + val = os.environ.get("DISTRIBUTION_WEB_ROOT") + if val: + return val + env_path = os.path.join(REPO_ROOT, ".env") + if os.path.isfile(env_path): + with open(env_path, encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if line.startswith("DISTRIBUTION_WEB_ROOT="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return None + + +def resolve_required_dir(): + """Resolve the forgemods/required dir from override or .env. Halt if missing.""" + src = None + origin = None + if len(sys.argv) > 1 and sys.argv[1]: + src, origin = sys.argv[1], "CLI arg" + elif os.environ.get("MODS_DIST_DIR"): + src, origin = os.environ["MODS_DIST_DIR"], "MODS_DIST_DIR env" + else: + src = read_env_dist_root() + origin = "$DISTRIBUTION_WEB_ROOT" + if not src: + sys.exit( + "no source dir: pass a path, set MODS_DIST_DIR, or DISTRIBUTION_WEB_ROOT in .env" + ) + candidates = [os.path.join(src, REQUIRED_SUBPATH), src] + for cand in candidates: + if os.path.isdir(cand) and os.path.basename(os.path.normpath(cand)) == "required": + return cand, origin + for cand in candidates: + if os.path.isdir(cand): + return cand, origin + sys.exit( + f"forgemods/required dir not found (from {origin}={src!r}). " + f"Tried: {', '.join(candidates)}" + ) + + +def stringify_authors(value): + """authors may be a string or a list; normalise to a comma-joined string.""" + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, list): + parts = [str(v).strip() for v in value if str(v).strip()] + return ", ".join(parts) + return str(value).strip() + + +def resolve_version(version, manifest): + """Resolve a trivial ${file.jarVersion} placeholder; else leave as-is. + + NeoForge substitutes ${file.jarVersion} from the jar's MANIFEST.MF at load + time. We don't have that here, so only resolve when there's nothing better; + leave other ${...} placeholders untouched rather than guess. + """ + if not isinstance(version, str): + return str(version) if version is not None else "" + v = version.strip() + if v in ("${file.jarVersion}", "${global.mcVersion}"): + # Can't resolve from the toml alone — surface empty, caller falls back. + return "" + return v + + +def primary_mod_entry(manifest): + """Return the first [[mods]] table, or None.""" + mods = manifest.get("mods") + if isinstance(mods, list) and mods and isinstance(mods[0], dict): + return mods[0] + return None + + +def extract_logo(zf, manifest, mod, modid): + """Extract the logoFile PNG to LOGOS_DIR/.png. Return public path or None. + + logoFile may be declared per-[[mods]] or as a top-level key. If absent, or + the named entry isn't in the jar, return None (logo: null). + """ + logo_file = None + if isinstance(mod, dict) and mod.get("logoFile"): + logo_file = mod["logoFile"] + elif manifest.get("logoFile"): + logo_file = manifest["logoFile"] + if not logo_file or not isinstance(logo_file, str): + return None + logo_file = logo_file.strip().lstrip("/") + if not logo_file: + return None + # Logos in NeoForge jars live at the resource root (e.g. "icon.png" or + # "assets//icon.png"). Try the declared path, then a couple of common + # fallbacks, all by exact zip member name. + candidates = [logo_file] + if "/" not in logo_file: + candidates.append(f"assets/{modid}/{logo_file}") + member = None + names = set(zf.namelist()) + for cand in candidates: + if cand in names: + member = cand + break + if member is None: + return None + try: + data = zf.read(member) + except (KeyError, zipfile.BadZipFile, OSError): + return None + if not data: + return None + out_path = os.path.join(LOGOS_DIR, f"{modid}.png") + with open(out_path, "wb") as fh: + fh.write(data) + return f"/mod-logos/{modid}.png" + + +def read_jar(jar_path): + """Parse one jar → mod dict, or None to skip (with a warning printed).""" + name = os.path.basename(jar_path) + try: + with zipfile.ZipFile(jar_path) as zf: + try: + raw = zf.read(MANIFEST) + except KeyError: + print(f" skip (no {MANIFEST}): {name}", file=sys.stderr) + return None + try: + manifest = tomllib.loads(raw.decode("utf-8")) + except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc: + print(f" skip (bad toml: {exc}): {name}", file=sys.stderr) + return None + + mod = primary_mod_entry(manifest) + if mod is None or not mod.get("modId"): + print(f" skip (no [[mods]] / modId): {name}", file=sys.stderr) + return None + + modid = str(mod["modId"]).strip() + display = mod.get("displayName") + display = str(display).strip() if display else modid + display = display.replace("${file.jarVersion}", "").strip() or modid + + version = resolve_version(mod.get("version"), manifest) or "" + authors = stringify_authors( + mod.get("authors", manifest.get("authors")) + ) + description = mod.get("description") + description = str(description).strip() if description else "" + + logo = extract_logo(zf, manifest, mod, modid) + except (zipfile.BadZipFile, OSError) as exc: + print(f" skip (unreadable jar: {exc}): {name}", file=sys.stderr) + return None + + return { + "id": modid, + "name": display, + "version": version, + "authors": authors, + "description": description, + "logo": logo, + } + + +def main(): + required_dir, origin = resolve_required_dir() + jars = sorted(glob.glob(os.path.join(required_dir, "*.jar"))) + if not jars: + sys.exit(f"no jars found in {required_dir} (from {origin})") + + # Deterministic logo dir: clear stale PNGs, recreate. + if os.path.isdir(LOGOS_DIR): + for f in glob.glob(os.path.join(LOGOS_DIR, "*.png")): + os.remove(f) + else: + os.makedirs(LOGOS_DIR, exist_ok=True) + os.makedirs(os.path.dirname(MODS_JSON), exist_ok=True) + + mods = [] + skipped = 0 + by_id = {} + for jar in jars: + entry = read_jar(jar) + if entry is None: + skipped += 1 + continue + # Dedupe by modId (a modset shouldn't have two, but be safe — first wins). + if entry["id"] in by_id: + print(f" warn (duplicate modId {entry['id']!r}), keeping first", file=sys.stderr) + continue + by_id[entry["id"]] = entry + mods.append(entry) + + mods.sort(key=lambda m: m["name"].casefold()) + with_logos = sum(1 for m in mods if m["logo"]) + + out = {"schemaVersion": SCHEMA_VERSION, "count": len(mods), "mods": mods} + with open(MODS_JSON, "w", encoding="utf-8") as fh: + json.dump(out, fh, indent=2, ensure_ascii=False) + fh.write("\n") + + print(f"source: {required_dir} (from {origin})") + print(f"jars: {len(jars)} | mods written: {len(mods)} | skipped: {skipped}") + print(f"logos extracted: {with_logos}/{len(mods)} -> {LOGOS_DIR}") + print(f"wrote {MODS_JSON}") + + +if __name__ == "__main__": + main()