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

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

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

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

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

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

View File

@@ -15,6 +15,19 @@ RCON_PASSWORD=change-me-to-something-random
# and the landing build (-> show/hide the invite-code field). Default: invite. # and the landing build (-> show/hide the invite-code field). Default: invite.
REGISTRATION_MODE=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}/<mode>/<uuid>?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 # 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 # 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. # bind caddy to a high localhost-only port — only nginx should reach it.

12
.gitignore vendored
View File

@@ -20,5 +20,17 @@ landing/node_modules/
landing/.astro/ landing/.astro/
landing/dist/ 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) # Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
certs/ certs/

View File

@@ -133,18 +133,27 @@ mirrored at apex `/launcher/`. Per guest:
load harmlessly on the server but waste RAM. Hand-edit them to `client` so 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`, `sync-server-mods.sh` drops them from `./server-mods`. (Also review `appleskin`,
flagged `client` — flip to `both` if server-side food data wanted.) flagged `client` — flip to `both` if server-side food data wanted.)
- [ ] **Rework the landing join flow off packwiz.** `pack.` vhost is gone; ### Landing rework + mod list (PLANNED — see `plan/18-landing-rework.md`)
`site.ts packwizUrl` is a dead-URL placeholder (kept w/ TODO so the build Full design settled across WS1WS6; all tasks unchecked, ready to execute.
passes) and `i18n/ui.ts` join steps (all 3 langs) still tell guests to import - [ ] **WS1 — Join flow off packwiz.** Homepage = UlicraftLauncher, 3 steps
`pack.toml`. Redesign step 3 around the HeliosLauncher/`distribution.json` (register → download launcher → join); drop `packwizUrl` + packwiz step.
install flow. See `plan/04-mods.md` + `site.ts` TODO. 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, - [ ] **WS6 — Mod shortlist + list component (`plan/17-mod-shortlist.md`).**
FerriteCore, ModernFix), Tech (Mekanism, Create, IE, AE2), Magic (Ars Nouveau, Light gap doc: current 76-jar pack is vanilla+ QoL/perf; **empty** on tech &
Botania, Iron's Spells), Storage (Sophisticated, Functional), Exploration magic, thin worldgen, no map; orphan deps `ponderjs`/`Necronomicon`. Landing
(YUNG's, Repurposed Structures), QoL (JEI, Jade, IPN, AppleSkin), World gen mod-list component = auto-generated `mods.json` + extracted logos from the
(Tectonic, Terralith — verify NeoForge 1.21.1), Compat (Polymorph), Map (Xaero), distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty
Voice (Simple Voice Chat — 24454/udp already in compose). names, no categories/links; feeds the "50+" stat tile.
- [ ] Server-side perf mods (C2ME, etc.). - [ ] Server-side perf mods (C2ME, etc.).
- [ ] **Verify NeoForge version** against current stable before first deploy. - [ ] **Verify NeoForge version** against current stable before first deploy.
- [ ] **Bootstrap Drasl admin** account. - [ ] **Bootstrap Drasl admin** account.

View File

@@ -174,6 +174,14 @@ services:
environment: environment:
MC_STATUS_TARGET: "minecraft:25565" MC_STATUS_TARGET: "minecraft:25565"
MC_STATUS_CACHE_TTL: "30s" 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: networks:
- mcnet - mcnet

View File

@@ -15,8 +15,10 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"io"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -81,6 +83,166 @@ func deref[T any](p *T) (v T) {
return 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() { func main() {
target := envOr("MC_STATUS_TARGET", "minecraft:25565") target := envOr("MC_STATUS_TARGET", "minecraft:25565")
listen := envOr("MC_STATUS_LISTEN", ":8080") listen := envOr("MC_STATUS_LISTEN", ":8080")
@@ -89,9 +251,25 @@ func main() {
log.Fatalf("invalid MC_STATUS_CACHE_TTL: %v", err) 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) host, port := splitHostPort(target)
c := &cache{} 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() mux := http.NewServeMux()
// Drop-in path: /v2/status/java/<addr> — <addr> is ignored (see file header). // Drop-in path: /v2/status/java/<addr> — <addr> is ignored (see file header).
@@ -119,11 +297,34 @@ func main() {
w.Write(payload) 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) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok")) 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)) log.Fatal(http.ListenAndServe(listen, mux))
} }

View File

@@ -1,8 +1,8 @@
--- ---
// MC-GUI slot + copy button. variant "ip" = big gold mono (server address); // MC-GUI slot + copy button. variant "ip" = big gold mono (server address);
// "url" = smaller, wraps (auth / packwiz URLs). Copy handled by the global // "url" = smaller, wraps (auth / authlib URLs). Copy handled by the global
// [data-copy] script in [...lang].astro; data-label / data-copied drive the // [data-copy] script in [...lang].astro (and the equivalent on /fjord);
// (localized) button text and transient feedback. // data-label / data-copied drive the (localized) button text + feedback.
interface Props { interface Props {
value: string; value: string;
variant?: "ip" | "url"; variant?: "ip" | "url";

View File

@@ -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 ? (
<p class="mods-empty">{emptyLabel}</p>
) : (
<div class="mod-grid">
{mods.map((m) => (
<div class="mod" title={m.description || m.name}>
<div class="mod-logo">
{m.logo ? (
<img src={m.logo} alt="" width="40" height="40" loading="lazy" />
) : (
<span class="mod-noicon" aria-hidden="true">{m.name.slice(0, 1).toUpperCase()}</span>
)}
</div>
<div class="mod-meta">
<span class="mod-name">{m.name}</span>
{m.version && <span class="mod-ver">{m.version}</span>}
</div>
</div>
))}
</div>
)}
<style>
.mod-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.mod {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--surface);
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
transition: transform .14s ease, filter .14s ease;
}
.mod:hover { transform: translateY(-2px); filter: brightness(1.06); }
.mod-logo {
width: 40px; height: 40px; flex-shrink: 0;
display: grid; place-items: center;
background: var(--bg-2);
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
}
.mod-logo img { width: 40px; height: 40px; image-rendering: pixelated; }
.mod-noicon {
font-family: var(--font-head);
font-weight: 600;
font-size: 20px;
color: var(--accent);
line-height: 1;
}
.mod-meta { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.mod-name {
font-size: 15px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mod-ver {
font-family: var(--font-mono);
font-size: 12px;
color: var(--dim);
font-variant-numeric: tabular-nums;
}
.mods-empty { margin: 0; color: var(--dim); font-size: 14px; }
</style>

View File

@@ -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;
---
<div
class="roster-block"
data-api={status.rosterApi}
data-avatar={avatarUrl}
data-mode={avatarMode}
data-empty={emptyLabel}
>
<div class="roster" aria-label="Registered players"></div>
<p class="roster-empty" hidden></p>
</div>
<script>
async function hydrate(el: HTMLElement) {
const { api, avatar, mode, empty } = el.dataset;
if (!api || !avatar || !mode) return;
const grid = el.querySelector<HTMLElement>(".roster");
const emptyEl = el.querySelector<HTMLElement>(".roster-empty");
if (!grid) return;
const showEmpty = () => {
grid.hidden = true;
if (emptyEl) {
emptyEl.textContent = empty ?? "No members yet.";
emptyEl.hidden = false;
}
};
let data: Array<{ uuid?: string; name?: string }>;
try {
const r = await fetch(api, { headers: { Accept: "application/json" } });
if (!r.ok) return showEmpty();
data = await r.json();
} catch {
return showEmpty();
}
const players = Array.isArray(data) ? data.filter((p) => p && p.uuid) : [];
if (players.length === 0) return showEmpty();
grid.innerHTML = "";
for (const p of players) {
grid.appendChild(rosterTile(p.uuid!, p.name ?? "", avatar, mode));
}
}
// Shared avatar tile (avatar + name) — identical markup to ServerCard's
// online tiles, so .roster / .roster-tile (global, 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<HTMLElement>(".roster-block").forEach(hydrate);
</script>

View File

@@ -33,14 +33,13 @@ const {
emptyLabel = "Nobody online — be the first.", emptyLabel = "Nobody online — be the first.",
} = Astro.props; } = Astro.props;
const { status, avatarUrl } = site; const { status, avatarUrl, avatarMode } = site;
const HEAD_MODE = "headiso"; // NMSR render mode for the head row
--- ---
<div <div
class="servercard" class="servercard"
data-api={status.statusApi} data-api={status.statusApi}
data-avatar={avatarUrl} data-avatar={avatarUrl}
data-mode={HEAD_MODE} data-mode={avatarMode}
data-slots={status.slots} data-slots={status.slots}
data-online={onlineLabel} data-online={onlineLabel}
data-offline={offlineLabel} data-offline={offlineLabel}
@@ -75,9 +74,9 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row
<!-- Player fill bar (online/max). Hidden until a live count arrives. --> <!-- Player fill bar (online/max). Hidden until a live count arrives. -->
<div class="sc-bar" hidden><span class="sc-bar-fill"></span></div> <div class="sc-bar" hidden><span class="sc-bar-fill"></span></div>
<!-- Head row + empty state. Populated client-side. --> <!-- Avatar tiles + empty state. Populated client-side. -->
<div class="sc-players" hidden> <div class="sc-players" hidden>
<div class="sc-heads" aria-label="Players online"></div> <div class="roster" aria-label="Players online"></div>
<p class="sc-empty" hidden></p> <p class="sc-empty" hidden></p>
</div> </div>
</div> </div>
@@ -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 { 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-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; } /* Online avatar tiles use the shared .roster / .roster-tile styles in
.sc-heads img { main.css (also used by PlayerRoster.astro). */
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);
}
.sc-empty { margin: 0; color: var(--dim); font-size: 13px; } .sc-empty { margin: 0; color: var(--dim); font-size: 13px; }
</style> </style>
@@ -160,7 +155,7 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row
const bar = q(".sc-bar"); const bar = q(".sc-bar");
const fill = q<HTMLElement>(".sc-bar-fill"); const fill = q<HTMLElement>(".sc-bar-fill");
const players = q(".sc-players"); const players = q(".sc-players");
const heads = q(".sc-heads"); const roster = q(".roster");
const emptyEl = q(".sc-empty"); const emptyEl = q(".sc-empty");
let data: any; 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). // Color MOTD (mcstatus ships inline-styled spans for our own server).
if (motdEl && data.motd?.html) motdEl.innerHTML = data.motd.html; 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 list: Array<{ uuid?: string; name_clean?: string }> = data.players?.list ?? [];
const seen = new Set<string>(); const seen = new Set<string>();
const real = list.filter((p) => { const real = list.filter((p) => {
@@ -207,7 +204,7 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row
seen.add(u); seen.add(u);
return true; return true;
}); });
if (players && heads) { if (players && roster) {
players.hidden = false; players.hidden = false;
if (real.length === 0) { if (real.length === 0) {
if (emptyEl) { if (emptyEl) {
@@ -215,18 +212,30 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row
emptyEl.hidden = false; emptyEl.hidden = false;
} }
} else { } else {
heads.innerHTML = ""; roster.innerHTML = "";
for (const p of real.slice(0, 16)) { for (const p of real.slice(0, 16)) {
const img = document.createElement("img"); roster.appendChild(rosterTile(p.uuid!, p.name_clean ?? "", avatar, mode));
img.loading = "lazy";
img.alt = p.name_clean ?? "";
img.title = p.name_clean ?? "";
img.src = `${avatar}/${mode}/${p.uuid}?size=64`;
heads.appendChild(img);
} }
} }
} }
} }
// 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<HTMLElement>(".servercard").forEach(hydrate); document.querySelectorAll<HTMLElement>(".servercard").forEach(hydrate);
</script> </script>

View 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
}
}

View 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}$" }
}
}
}
}

View File

@@ -0,0 +1,5 @@
{
"schemaVersion": 1,
"count": 0,
"mods": []
}

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

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

View File

@@ -2,8 +2,88 @@
// domains, URLs, theme, launcher files, and the structural shape of the // domains, URLs, theme, launcher files, and the structural shape of the
// stat/feature lists. Translatable copy lives in src/i18n/ui.ts. // stat/feature lists. Translatable copy lives in src/i18n/ui.ts.
// BASE_DOMAIN is read at build time; falls back to ulicraft.local. // 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"; 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 // 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. // render-config.sh. "invite" => the register form must collect an invite code.
const REGISTRATION_MODE = process.env.REGISTRATION_MODE ?? "invite"; 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. // Requires CORSAllowOrigins to include the apex in drasl config.toml.
draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`, 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 // NMSR avatar renderer root. Player heads come from OUR Drasl skins, not
// Mojang/Crafatar — head URL is `${avatarUrl}/headiso/<uuid>?size=64`. // Mojang/Crafatar — head URL is `${avatarUrl}/headiso/<uuid>?size=64`.
avatarUrl: `https://avatar.${BASE_DOMAIN}`, 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 // 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 // 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 // (docker/mc-status, mcutil wrapper) reverse-proxied same-origin at the apex
@@ -65,12 +143,17 @@ export const site = {
status: { status: {
slots: 10, slots: 10,
statusApi: `/api/mcstatus/v2/status/java/${BASE_DOMAIN}`, 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 // Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
// come from ui.ts (same order). // come from ui.ts (same order).
stats: [ stats: [
{ key: "50+" }, { key: MOD_COUNT_KEY },
{ key: MC_VERSION }, { key: MC_VERSION },
{ key: "10" }, { key: "10" },
{ key: "6h" }, { key: "6h" },
@@ -85,10 +168,16 @@ export const site = {
{ glyph: "heart", gold: true }, { 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 // tooling/fetch-launcher.sh — keep these in sync with that script. OS names
// and hints are technical, kept language-neutral here. // and hints are technical, kept language-neutral here.
launcher: { fjord: {
name: "Fjord Launcher", name: "Fjord Launcher",
base: "/launcher/latest", base: "/launcher/latest",
downloads: [ downloads: [

View File

@@ -2,7 +2,7 @@
// locales; the page (src/pages/[...lang].astro) renders against it. // locales; the page (src/pages/[...lang].astro) renders against it.
// //
// Conventions: // 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/ // - 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. // post/link) so the markup stays in the template, not in the strings.
// - Product / in-game literals (authlib-injector, Multiplayer, Add Server) // - Product / in-game literals (authlib-injector, Multiplayer, Add Server)
@@ -19,11 +19,23 @@ export const LANG_REGISTER_PATH: Record<Lang, string> = {
es: "/es/register", es: "/es/register",
eu: "/eu/register", eu: "/eu/register",
}; };
// Fjord (alternative path) page path per locale (mirrors LANG_REGISTER_PATH).
export const LANG_FJORD_PATH: Record<Lang, string> = {
en: "/fjord",
es: "/es/fjord",
eu: "/eu/fjord",
};
// Account page path per locale (mirrors LANG_REGISTER_PATH).
export const LANG_ACCOUNT_PATH: Record<Lang, string> = {
en: "/account",
es: "/es/account",
eu: "/eu/account",
};
export interface UI { export interface UI {
htmlLang: string; htmlLang: string;
copied: string; // transient copy-button feedback 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: { hero: {
eyebrow: string; // "Modded LAN · NeoForge" — version appended at render eyebrow: string; // "Modded LAN · NeoForge" — version appended at render
tagline: string; tagline: string;
@@ -42,13 +54,46 @@ export interface UI {
upTo: string; upTo: string;
}; };
statLabels: [string, string, string, string]; statLabels: [string, string, string, string];
members: {
eyebrow: string;
title: string;
lead: string;
empty: string; // shown when the roster is empty or unreachable
};
features: { features: {
eyebrow: string; eyebrow: string;
title: string; title: string;
lead: string; lead: string;
items: { h: string; p: 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: { 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; eyebrow: string;
title: string; title: string;
lead: string; lead: string;
@@ -59,7 +104,6 @@ export interface UI {
pa: string; // before the authlib-injector literal pa: string; // before the authlib-injector literal
pb: string; // after it pb: string; // after it
copy: string; copy: string;
registerPre: string;
}; };
s3: { kicker: string; h: string; p: string }; s3: { kicker: string; h: string; p: string };
s4: { s4: {
@@ -69,8 +113,9 @@ export interface UI {
pb: string; // after them pb: string; // after them
copy: string; 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: { register: {
navHome: string; // "← Home" link in nav navHome: string; // "← Home" link in nav
eyebrow: string; eyebrow: string;
@@ -86,7 +131,7 @@ export interface UI {
submit: string; submit: string;
submitting: string; submitting: string;
haveAccount: string; // "Already have an account?" haveAccount: string; // "Already have an account?"
loginLink: string; // links to the Drasl web UI loginLink: string; // links to the account page
successTitle: string; successTitle: string;
successBody: string; // {name} = player name successBody: string; // {name} = player name
uuidLabel: string; uuidLabel: string;
@@ -104,13 +149,47 @@ export interface UI {
errSkinType: string; errSkinType: string;
errNetwork: 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<Lang, UI> = { export const ui: Record<Lang, UI> = {
en: { en: {
htmlLang: "en", htmlLang: "en",
copied: "Copied!", 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: { hero: {
eyebrow: "Modded LAN · NeoForge", eyebrow: "Modded LAN · NeoForge",
tagline: "Kitchen-sink survival, built to outlast the weekend.", tagline: "Kitchen-sink survival, built to outlast the weekend.",
@@ -132,6 +211,12 @@ export const ui: Record<Lang, UI> = {
upTo: "up to", upTo: "up to",
}, },
statLabels: ["Mods", "NeoForge", "Player slots", "World backups"], 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: { features: {
eyebrow: "What makes it Ulicraft", eyebrow: "What makes it Ulicraft",
title: "Built for the crew.", title: "Built for the crew.",
@@ -149,7 +234,7 @@ export const ui: Record<Lang, UI> = {
}, },
{ {
h: "One-click setup", 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", h: "Built to last",
@@ -157,27 +242,57 @@ export const ui: Record<Lang, UI> = {
}, },
], ],
}, },
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: { join: {
eyebrow: "How to Join · 4 Steps", eyebrow: "How to Join · 3 Steps",
title: "From zero to spawning in.", title: "From zero to spawning in.",
lead: "One-time setup. After this, the launcher keeps you in sync.", lead: "One-time setup. After this, the launcher keeps you in sync.",
s1: { 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", kicker: "Launcher",
h: "Download {name}", 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: { s2: {
kicker: "Account", kicker: "Account",
h: "Add your 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.", pb: "account using this URL, then log in with your Ulicraft credentials.",
copy: "Copy", copy: "Copy",
registerPre: "Need credentials? Register at",
}, },
s3: { s3: {
kicker: "Modpack", kicker: "Modpack",
h: "Import the modpack", h: "Import the Ulicraft pack",
p: "Add a new instance from this packwiz URL — it installs and updates the whole pack.", p: "Download the Ulicraft modpack and import it into Fjord as a new instance — it installs the whole pack.",
}, },
s4: { s4: {
kicker: "Connect", kicker: "Connect",
@@ -186,9 +301,11 @@ export const ui: Record<Lang, UI> = {
pb: ", paste the address, and join.", pb: ", paste the address, and join.",
copy: "Copy IP", copy: "Copy IP",
}, },
noPack: "The pack download isn't available yet — check back soon.",
}, },
footer: { footer: {
join: "How to Join", join: "How to Join",
status: "Server status",
disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.", disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
}, },
register: { register: {
@@ -224,12 +341,43 @@ export const ui: Record<Lang, UI> = {
errSkinType: "Pick a PNG image.", errSkinType: "Pick a PNG image.",
errNetwork: "Network error — try again.", 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: { es: {
htmlLang: "es", htmlLang: "es",
copied: "¡Copiado!", 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: { hero: {
eyebrow: "LAN con mods · NeoForge", eyebrow: "LAN con mods · NeoForge",
tagline: "Supervivencia todo incluido, para durar más que el finde.", tagline: "Supervivencia todo incluido, para durar más que el finde.",
@@ -251,6 +399,12 @@ export const ui: Record<Lang, UI> = {
upTo: "hasta", upTo: "hasta",
}, },
statLabels: ["Mods", "NeoForge", "Plazas", "Copias del mundo"], 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: { features: {
eyebrow: "Qué hace especial a Ulicraft", eyebrow: "Qué hace especial a Ulicraft",
title: "Hecho para la cuadrilla.", title: "Hecho para la cuadrilla.",
@@ -268,7 +422,7 @@ export const ui: Record<Lang, UI> = {
}, },
{ {
h: "Configuración en un clic", 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", h: "Hecho para durar",
@@ -276,27 +430,57 @@ export const ui: Record<Lang, UI> = {
}, },
], ],
}, },
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: { join: {
eyebrow: "Cómo entrar · 4 pasos", eyebrow: "Cómo entrar · 3 pasos",
title: "De cero a aparecer en el mundo.", title: "De cero a aparecer en el mundo.",
lead: "Configuración única. Después, el launcher te mantiene sincronizado.", lead: "Configuración única. Después, el launcher te mantiene sincronizado.",
s1: { 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", kicker: "Launcher",
h: "Descarga {name}", 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: { s2: {
kicker: "Cuenta", kicker: "Cuenta",
h: "Añade tu 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.", pb: "con esta URL y luego inicia sesión con tus credenciales de Ulicraft.",
copy: "Copiar", copy: "Copiar",
registerPre: "¿Sin credenciales? Regístrate en",
}, },
s3: { s3: {
kicker: "Modpack", kicker: "Modpack",
h: "Importa el modpack", h: "Importa el pack de Ulicraft",
p: "Añade una instancia nueva desde esta URL de packwiz: instala y actualiza todo el pack.", p: "Descarga el modpack de Ulicraft e impórtalo en Fjord como instancia nueva: instala todo el pack.",
}, },
s4: { s4: {
kicker: "Conectar", kicker: "Conectar",
@@ -305,9 +489,11 @@ export const ui: Record<Lang, UI> = {
pb: ", pega la dirección y entra.", pb: ", pega la dirección y entra.",
copy: "Copiar IP", copy: "Copiar IP",
}, },
noPack: "La descarga del pack aún no está disponible — vuelve pronto.",
}, },
footer: { footer: {
join: "Cómo entrar", join: "Cómo entrar",
status: "Estado del servidor",
disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.", disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
}, },
register: { register: {
@@ -343,12 +529,43 @@ export const ui: Record<Lang, UI> = {
errSkinType: "Elige una imagen PNG.", errSkinType: "Elige una imagen PNG.",
errNetwork: "Error de red: inténtalo de nuevo.", 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: { eu: {
htmlLang: "eu", htmlLang: "eu",
copied: "Kopiatuta!", 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: { hero: {
eyebrow: "Mod-dun LANa · NeoForge", eyebrow: "Mod-dun LANa · NeoForge",
tagline: "Mod ugariko biziraupena, irauteko egina.", tagline: "Mod ugariko biziraupena, irauteko egina.",
@@ -370,6 +587,12 @@ export const ui: Record<Lang, UI> = {
upTo: "gehienez", upTo: "gehienez",
}, },
statLabels: ["Modak", "NeoForge", "Plazak", "Munduaren babeskopiak"], 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: { features: {
eyebrow: "Zerk egiten du Ulicraft berezi", eyebrow: "Zerk egiten du Ulicraft berezi",
title: "Koadrilarentzat eginda.", title: "Koadrilarentzat eginda.",
@@ -387,7 +610,7 @@ export const ui: Record<Lang, UI> = {
}, },
{ {
h: "Konfigurazioa klik batean", 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", h: "Irauteko eginda",
@@ -395,27 +618,57 @@ export const ui: Record<Lang, UI> = {
}, },
], ],
}, },
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: { join: {
eyebrow: "Nola sartu · 4 urrats", eyebrow: "Nola sartu · 3 urrats",
title: "Zerotik mundura agertzera.", title: "Zerotik mundura agertzera.",
lead: "Behin bakarrik konfiguratu. Gero, launcherrak sinkronizatuta mantenduko zaitu.", lead: "Behin bakarrik konfiguratu. Gero, launcherrak sinkronizatuta mantenduko zaitu.",
s1: { 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", kicker: "Launcherra",
h: "Deskargatu {name}", 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: { s2: {
kicker: "Kontua", kicker: "Kontua",
h: "Gehitu zure kontua", h: "Gehitu zure kontua",
pa: "Launcherrean, gehitu", pa: "Fjord-en, gehitu",
pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.", pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.",
copy: "Kopiatu", copy: "Kopiatu",
registerPre: "Kredentzialik ez? Erregistratu hemen:",
}, },
s3: { s3: {
kicker: "Modpacka", kicker: "Modpacka",
h: "Inportatu modpacka", h: "Inportatu Ulicraft packa",
p: "Gehitu instantzia berri bat packwiz URL honetatik: pack osoa instalatu eta eguneratzen du.", p: "Deskargatu Ulicraft modpacka eta inportatu Fjord-en instantzia berri gisa: pack osoa instalatzen du.",
}, },
s4: { s4: {
kicker: "Konektatu", kicker: "Konektatu",
@@ -424,9 +677,11 @@ export const ui: Record<Lang, UI> = {
pb: ", itsatsi helbidea eta sartu.", pb: ", itsatsi helbidea eta sartu.",
copy: "Kopiatu IPa", copy: "Kopiatu IPa",
}, },
noPack: "Packaren deskarga ez dago oraindik eskuragarri — itzuli laster.",
}, },
footer: { footer: {
join: "Nola sartu", join: "Nola sartu",
status: "Zerbitzariaren egoera",
disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.", disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
}, },
register: { register: {
@@ -462,5 +717,36 @@ export const ui: Record<Lang, UI> = {
errSkinType: "Aukeratu PNG irudi bat.", errSkinType: "Aukeratu PNG irudi bat.",
errNetwork: "Sare-errorea: saiatu berriro.", 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.",
},
}, },
}; };

View File

@@ -1,10 +1,12 @@
--- ---
import { site, LITERALS, HEAD_FONTS } from "../data/site"; 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 Creeper from "../components/Creeper.astro";
import PixelIcon from "../components/PixelIcon.astro"; import PixelIcon from "../components/PixelIcon.astro";
import CopyChip from "../components/CopyChip.astro"; import CopyChip from "../components/CopyChip.astro";
import ServerCard from "../components/ServerCard.astro"; import ServerCard from "../components/ServerCard.astro";
import PlayerRoster from "../components/PlayerRoster.astro";
import ModList from "../components/ModList.astro";
import "../styles/main.css"; import "../styles/main.css";
// One static page per locale: en at "/", es at "/es/", eu at "/eu/". // 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 { locale } = Astro.props;
const t = ui[locale]; const t = ui[locale];
const { theme, launcher } = site; const { theme, launcherManifest } = site;
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify; 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<string, string> = { 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). // Build-time floating dust bits (index-derived, no runtime RNG).
const dustBits = Array.from({ length: 16 }, (_, i) => { const dustBits = Array.from({ length: 16 }, (_, i) => {
@@ -62,7 +74,9 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
</a> </a>
<nav class="nav-links"> <nav class="nav-links">
<a href="#status">{t.nav.status}</a> <a href="#status">{t.nav.status}</a>
<a href="#members">{t.nav.members}</a>
<a href="#features">{t.nav.features}</a> <a href="#features">{t.nav.features}</a>
<a href="#mods">{t.nav.mods}</a>
<a href="#join">{t.nav.join}</a> <a href="#join">{t.nav.join}</a>
</nav> </nav>
<span class="nav-spacer"></span> <span class="nav-spacer"></span>
@@ -143,8 +157,22 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
</div> </div>
</section> </section>
<!-- MEMBERS (registered players roster) -->
<section id="members" class="pad" style="background: var(--bg-2)">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{t.members.eyebrow}</span>
<h2 class="section-title">{t.members.title}</h2>
<p class="lead">{t.members.lead}</p>
</div>
<div class="reveal">
<PlayerRoster emptyLabel={t.members.empty} />
</div>
</div>
</section>
<!-- FEATURES --> <!-- FEATURES -->
<section id="features" class="pad" style="background: var(--bg-2)"> <section id="features" class="pad">
<div class="wrap"> <div class="wrap">
<div class="sec-head reveal"> <div class="sec-head reveal">
<span class="eyebrow">{t.features.eyebrow}</span> <span class="eyebrow">{t.features.eyebrow}</span>
@@ -165,6 +193,20 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
</div> </div>
</section> </section>
<!-- MODS (installed-pack list, auto-generated) -->
<section id="mods" class="pad" style="background: var(--bg-2)">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{t.mods.eyebrow}</span>
<h2 class="section-title">{t.mods.title}</h2>
<p class="lead">{t.mods.lead}</p>
</div>
<div class="reveal">
<ModList emptyLabel={t.mods.empty} />
</div>
</div>
</section>
<!-- HOW TO JOIN --> <!-- HOW TO JOIN -->
<section id="join" class="pad"> <section id="join" class="pad">
<div class="wrap"> <div class="wrap">
@@ -180,15 +222,10 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
<div class="num">1</div> <div class="num">1</div>
<div class="body"> <div class="body">
<span class="kicker">{t.join.s1.kicker}</span> <span class="kicker">{t.join.s1.kicker}</span>
<h3>{t.join.s1.h.replace("{name}", launcherName)}</h3> <h3>{t.join.s1.h}</h3>
<p>{t.join.s1.p.replace(/\{name\}/g, launcherName)}</p> <p>
<div class="actions"> {t.join.s1.pa} <a href={registerPath}>{t.join.s1.registerLink}</a> {t.join.s1.pb}
{launcher.downloads.map((d) => ( </p>
<a class="mc-btn sm" href={`${launcher.base}/${d.file}`}>
{d.os} <span class="hint">· {d.hint}</span>
</a>
))}
</div>
</div> </div>
</div> </div>
@@ -197,16 +234,17 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
<div class="num">2</div> <div class="num">2</div>
<div class="body"> <div class="body">
<span class="kicker">{t.join.s2.kicker}</span> <span class="kicker">{t.join.s2.kicker}</span>
<h3>{t.join.s2.h}</h3> <h3>{t.join.s2.h.replace("{name}", launcherName)}</h3>
<p> <p>{t.join.s2.p.replace(/\{name\}/g, launcherName)}</p>
{t.join.s2.pa} <strong style="color:var(--text)">{LITERALS.authlib}</strong> {t.join.s2.pb} {launcherBuilds.length > 0 && (
</p> <div class="actions">
<div class="actions" style="margin-bottom:14px"> {launcherBuilds.map((b) => (
<CopyChip value={site.authlibUrl} variant="url" label={t.join.s2.copy} copiedLabel={t.copied} /> <a class="mc-btn sm" href={b.url}>
</div> {b.label} <span class="hint">· {b.hint}</span>
<p> </a>
{t.join.s2.registerPre} <a href={site.authUrl}>{site.authUrl}</a>. ))}
</p> </div>
)}
</div> </div>
</div> </div>
@@ -216,24 +254,11 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
<div class="body"> <div class="body">
<span class="kicker">{t.join.s3.kicker}</span> <span class="kicker">{t.join.s3.kicker}</span>
<h3>{t.join.s3.h}</h3> <h3>{t.join.s3.h}</h3>
<p>{t.join.s3.p}</p>
<div class="actions">
<CopyChip value={site.packwizUrl} variant="url" label={t.join.s2.copy} copiedLabel={t.copied} />
</div>
</div>
</div>
<!-- STEP 4 -->
<div class="step reveal">
<div class="num">4</div>
<div class="body">
<span class="kicker">{t.join.s4.kicker}</span>
<h3>{t.join.s4.h}</h3>
<p> <p>
{t.join.s4.pa} <kbd>{LITERALS.multiplayer}</kbd> → <kbd>{LITERALS.addServer}</kbd>{t.join.s4.pb} {t.join.s3.pa} <kbd>{LITERALS.multiplayer}</kbd> → <kbd>{LITERALS.addServer}</kbd>{t.join.s3.pb}
</p> </p>
<div class="actions"> <div class="actions">
<CopyChip value={site.serverAddress} variant="ip" label={t.join.s4.copy} copiedLabel={t.copied} /> <CopyChip value={site.serverAddress} variant="ip" label={t.join.s3.copy} copiedLabel={t.copied} />
</div> </div>
</div> </div>
</div> </div>
@@ -251,6 +276,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
</a> </a>
<div class="footer-links"> <div class="footer-links">
<a class="mc-btn primary sm" href="#join">{t.footer.join}</a> <a class="mc-btn primary sm" href="#join">{t.footer.join}</a>
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
</div> </div>
<p class="disc"> <p class="disc">
{t.footer.disc} {site.name} · {site.baseDomain} {t.footer.disc} {site.name} · {site.baseDomain}

View File

@@ -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,
};
---
<!doctype html>
<html
lang={t.htmlLang}
data-mood={theme.mood}
data-head={theme.headFont}
style={`--font-head: ${headFont}`}
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{site.name} — {a.title}</title>
<meta name="description" content={a.lead} />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="stylesheet" href="/fonts/fonts.css" />
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_ACCOUNT_PATH[l]} />)}
</head>
<body>
<!-- NAV -->
<header class="nav">
<div class="wrap nav-in">
<a class="brand" href={homePath}>
<Creeper />
<span class="name">ULICRAFT</span>
</a>
<nav class="nav-links">
<a href={homePath}>{a.navHome}</a>
</nav>
<span class="nav-spacer"></span>
<div class="lang-switch" aria-label="Language">
{LANGS.map((l) => (
<a
href={LANG_ACCOUNT_PATH[l]}
class:list={[l === locale && "on"]}
aria-current={l === locale ? "true" : undefined}
>{LANG_LABEL[l]}</a>
))}
</div>
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
</div>
</header>
<main id="top">
<section class="pad">
<div class="reg-wrap">
<div class="reg-card">
<span class="eyebrow">{a.eyebrow}</span>
<h1>{a.title}</h1>
<p class="lead">{a.lead}</p>
<!-- LOGIN FORM -->
<form class="reg-form" id="acc-form" novalidate>
<div class="reg-field">
<label for="acc-username">{a.usernameLabel}</label>
<input class="reg-input" id="acc-username" name="username"
type="text" autocomplete="username" placeholder={a.usernamePlaceholder} required />
</div>
<div class="reg-field">
<label for="acc-password">{a.passwordLabel}</label>
<input class="reg-input" id="acc-password" name="password"
type="password" autocomplete="current-password" placeholder={a.passwordPlaceholder} required />
</div>
<div class="reg-msg err" id="acc-error" role="alert" hidden></div>
<button class="mc-btn primary" id="acc-submit" type="submit">{a.submit}</button>
</form>
<p class="reg-foot">
{a.noAccount} <a href={registerPath}>{a.registerLink}</a>
</p>
<!-- SKIN PANEL (revealed after login) -->
<div class="reg-success" id="acc-panel" hidden>
<div>
<span class="eyebrow" style="color:var(--accent)">{a.playerLabel}</span>
<p class="lead" id="acc-player-name" style="margin-top:6px;font-size:20px;color:var(--text)"></p>
</div>
<!-- Current avatar preview (NMSR) -->
<div id="acc-avatar-wrap" style="display:flex;align-items:flex-start;gap:20px;flex-wrap:wrap">
<img id="acc-avatar" src="" alt="avatar" width="96" height="96"
style="image-rendering:pixelated;width:96px;height:96px;background:var(--slot);box-shadow:inset 2px 2px 0 var(--bevel-lo),inset -2px -2px 0 var(--bevel-hi)" />
<div style="flex:1;min-width:0">
<p class="reg-skin" style="border:none;padding-top:0;margin:0;color:var(--dim);font-size:13px" id="acc-preview-note"></p>
</div>
</div>
<div class="reg-skin" id="acc-skin">
<div>
<h3>{a.skinTitle}</h3>
<p class="lead">{a.skinLead}</p>
</div>
<div class="reg-field">
<label class="mc-btn sm" for="acc-skin-file" style="align-self:flex-start">{a.skinChoose}</label>
<input id="acc-skin-file" type="file" accept="image/png" hidden />
<span class="reg-file-name" id="acc-skin-name"></span>
</div>
<div class="reg-field">
<label>{a.modelLabel}</label>
<div class="reg-models">
<label><input type="radio" name="skin-model" value="classic" checked /> {a.modelClassic}</label>
<label><input type="radio" name="skin-model" value="slim" /> {a.modelSlim}</label>
</div>
</div>
<div class="reg-msg" id="acc-skin-msg" role="status" hidden></div>
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center">
<button class="mc-btn primary" id="acc-skin-upload" type="button">{a.skinUpload}</button>
<button class="mc-btn" id="acc-skin-reset" type="button" style="color:var(--dim)">{a.resetSkin}</button>
</div>
</div>
<a class="mc-btn sm" href={homePath}>{a.navHome}</a>
</div>
</div>
</div>
</section>
</main>
<!-- FOOTER -->
<footer class="footer">
<div class="wrap footer-in">
<a class="brand" href={homePath}>
<Creeper />
<span class="name">ULICRAFT</span>
</a>
<div class="footer-links">
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
</div>
<p class="disc">
{t.footer.disc} {site.name} · {site.baseDomain}
</p>
</div>
</footer>
<script define:vars={{ cfg: clientCfg }}>
const $ = (id) => document.getElementById(id);
const form = $("acc-form");
const errBox = $("acc-error");
const submitBtn = $("acc-submit");
const panel = $("acc-panel");
const headers = { "Content-Type": "application/json" };
// Token + player kept in memory only — never stored in localStorage.
let apiToken = null;
let player = null;
const showMsg = (box, msg) => {
box.textContent = msg;
box.hidden = false;
};
const apiMessage = async (res, fallback) => {
try {
const data = await res.json();
return data && data.message ? data.message : fallback;
} catch {
return fallback;
}
};
// ---- Login ----
form.addEventListener("submit", async (e) => {
e.preventDefault();
errBox.hidden = true;
const username = $("acc-username").value.trim();
const password = $("acc-password").value;
if (!username || !password) {
errBox.classList.add("err");
showMsg(errBox, cfg.errFields);
return;
}
submitBtn.disabled = true;
submitBtn.textContent = cfg.submitting;
try {
const res = await fetch(`${cfg.apiUrl}/login`, {
method: "POST",
headers,
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
errBox.classList.add("err");
showMsg(errBox, await apiMessage(res, cfg.errNetwork));
return;
}
const data = await res.json();
apiToken = data.apiToken;
player = data.user.players[0];
// Show panel
form.hidden = true;
$("acc-player-name").textContent = player.name;
// Set NMSR avatar
const avatarImg = $("acc-avatar");
avatarImg.src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96`;
avatarImg.alt = player.name;
// Show NMSR lag hint
$("acc-preview-note").textContent = cfg.skinPreviewNote;
panel.hidden = false;
} catch {
errBox.classList.add("err");
showMsg(errBox, cfg.errNetwork);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = cfg.submit;
}
});
// ---- Skin upload ----
const fileInput = $("acc-skin-file");
const fileName = $("acc-skin-name");
const skinMsg = $("acc-skin-msg");
const skinBtn = $("acc-skin-upload");
const resetBtn = $("acc-skin-reset");
fileInput.addEventListener("change", () => {
skinMsg.hidden = true;
skinMsg.classList.remove("ok", "err");
fileName.textContent = fileInput.files && fileInput.files[0] ? fileInput.files[0].name : "";
});
const readBase64 = (file) =>
new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result).split(",")[1]);
fr.onerror = reject;
fr.readAsDataURL(file);
});
skinBtn.addEventListener("click", async () => {
skinMsg.hidden = true;
skinMsg.classList.remove("ok", "err");
const file = fileInput.files && fileInput.files[0];
if (!file) {
skinMsg.classList.add("err");
showMsg(skinMsg, cfg.errSkinType);
return;
}
if (file.type !== "image/png") {
skinMsg.classList.add("err");
showMsg(skinMsg, cfg.errSkinType);
return;
}
const model = document.querySelector('input[name="skin-model"]:checked').value;
skinBtn.disabled = true;
resetBtn.disabled = true;
skinBtn.textContent = cfg.skinUploading;
try {
const skinBase64 = await readBase64(file);
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
method: "PATCH",
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
body: JSON.stringify({ skinBase64, skinModel: model }),
});
if (!res.ok) {
skinMsg.classList.add("err");
showMsg(skinMsg, await apiMessage(res, cfg.errNetwork));
return;
}
// Show the local file immediately as instant confirmation (NMSR lags ~15m).
const dataUrl = URL.createObjectURL(file);
$("acc-avatar").src = dataUrl;
skinMsg.classList.add("ok");
showMsg(skinMsg, cfg.skinOk);
// Clear file picker
fileInput.value = "";
fileName.textContent = "";
} catch {
skinMsg.classList.add("err");
showMsg(skinMsg, cfg.errNetwork);
} finally {
skinBtn.disabled = false;
resetBtn.disabled = false;
skinBtn.textContent = cfg.skinUpload;
}
});
// ---- Reset to default ----
resetBtn.addEventListener("click", async () => {
if (!window.confirm(cfg.resetConfirm)) return;
skinMsg.hidden = true;
skinMsg.classList.remove("ok", "err");
skinBtn.disabled = true;
resetBtn.disabled = true;
try {
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
method: "PATCH",
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
body: JSON.stringify({ deleteSkin: true }),
});
if (!res.ok) {
skinMsg.classList.add("err");
showMsg(skinMsg, await apiMessage(res, cfg.errNetwork));
return;
}
// Reset preview back to NMSR (will eventually show the default skin).
$("acc-avatar").src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96&t=${Date.now()}`;
skinMsg.classList.add("ok");
showMsg(skinMsg, cfg.resetOk);
} catch {
skinMsg.classList.add("err");
showMsg(skinMsg, cfg.errNetwork);
} finally {
skinBtn.disabled = false;
resetBtn.disabled = false;
}
});
</script>
</body>
</html>

View File

@@ -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;
---
<!doctype html>
<html
lang={t.htmlLang}
data-mood={theme.mood}
data-head={theme.headFont}
style={`--font-head: ${headFont}`}
>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{site.name} — {f.title}</title>
<meta name="description" content={f.lead} />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="stylesheet" href="/fonts/fonts.css" />
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_FJORD_PATH[l]} />)}
</head>
<body>
<!-- NAV -->
<header class="nav">
<div class="wrap nav-in">
<a class="brand" href={homePath}>
<Creeper />
<span class="name">ULICRAFT</span>
</a>
<nav class="nav-links">
<a href={homePath}>{f.navHome}</a>
</nav>
<span class="nav-spacer"></span>
<div class="lang-switch" aria-label="Language">
{LANGS.map((l) => (
<a
href={LANG_FJORD_PATH[l]}
class:list={[l === locale && "on"]}
aria-current={l === locale ? "true" : undefined}
>{LANG_LABEL[l]}</a>
))}
</div>
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
</div>
</header>
<main id="top">
<section id="join" class="pad">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{f.eyebrow}</span>
<h2 class="section-title">{f.title}</h2>
<p class="lead">{f.lead}</p>
</div>
<div class="steps">
<!-- STEP 1 -->
<div class="step reveal">
<div class="num">1</div>
<div class="body">
<span class="kicker">{f.s1.kicker}</span>
<h3>{f.s1.h}</h3>
<p>{f.s1.p}</p>
<div class="actions">
{fjord.downloads.map((d) => (
<a class="mc-btn sm" href={`${fjord.base}/${d.file}`}>
{d.os} <span class="hint">· {d.hint}</span>
</a>
))}
</div>
</div>
</div>
<!-- STEP 2 -->
<div class="step reveal">
<div class="num">2</div>
<div class="body">
<span class="kicker">{f.s2.kicker}</span>
<h3>{f.s2.h}</h3>
<p>
{f.s2.pa} <strong style="color:var(--text)">{LITERALS.authlib}</strong> {f.s2.pb}
</p>
<div class="actions">
<CopyChip value={site.authlibUrl} variant="url" label={f.s2.copy} copiedLabel={t.copied} />
</div>
</div>
</div>
<!-- STEP 3 -->
<div class="step reveal">
<div class="num">3</div>
<div class="body">
<span class="kicker">{f.s3.kicker}</span>
<h3>{f.s3.h}</h3>
<p>{f.s3.p}</p>
<div class="actions">
{fjordPack ? (
<a class="mc-btn sm" href={fjordPack.url}>
{fjordPack.filename} <span class="hint">· .mrpack</span>
</a>
) : (
<p class="hint">{f.noPack}</p>
)}
</div>
</div>
</div>
<!-- STEP 4 -->
<div class="step reveal">
<div class="num">4</div>
<div class="body">
<span class="kicker">{f.s4.kicker}</span>
<h3>{f.s4.h}</h3>
<p>
{f.s4.pa} <kbd>{LITERALS.multiplayer}</kbd> → <kbd>{LITERALS.addServer}</kbd>{f.s4.pb}
</p>
<div class="actions">
<CopyChip value={site.serverAddress} variant="ip" label={f.s4.copy} copiedLabel={t.copied} />
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<!-- FOOTER -->
<footer class="footer">
<div class="wrap footer-in">
<a class="brand" href={homePath}>
<Creeper />
<span class="name">ULICRAFT</span>
</a>
<div class="footer-links">
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.footer.join}</a>
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
</div>
<p class="disc">
{t.footer.disc} {site.name} · {site.baseDomain}
</p>
</div>
</footer>
<script>
// Copy-to-clipboard for every [data-copy] button (HTTP-LAN safe fallback).
// Mirrors the global handler in [...lang].astro (CopyChip depends on it).
function copyText(text: string) {
try {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text);
return;
}
} catch {}
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
} catch {}
document.body.removeChild(ta);
}
document.querySelectorAll<HTMLButtonElement>("button[data-copy]").forEach((btn) => {
let timer: number | undefined;
btn.addEventListener("click", () => {
copyText(btn.dataset.copy ?? "");
const label = btn.dataset.label ?? "Copy";
btn.textContent = btn.dataset.copied ?? "Copied!";
btn.classList.add("copied");
clearTimeout(timer);
timer = window.setTimeout(() => {
btn.textContent = label;
btn.classList.remove("copied");
}, 1600);
});
});
// Scroll reveal — visible at rest; .anim added when scrolled into view.
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
const els = [...document.querySelectorAll<HTMLElement>(".reveal")];
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) {
e.target.classList.add("anim");
io.unobserve(e.target);
}
}
},
{ rootMargin: "0px 0px -10% 0px" }
);
els.forEach((el) => io.observe(el));
}
</script>
</body>
</html>

View File

@@ -6,6 +6,7 @@ import {
LANG_LABEL, LANG_LABEL,
LANG_PATH, LANG_PATH,
LANG_REGISTER_PATH, LANG_REGISTER_PATH,
LANG_ACCOUNT_PATH,
type Lang, type Lang,
} from "../../i18n/ui"; } from "../../i18n/ui";
import Creeper from "../../components/Creeper.astro"; import Creeper from "../../components/Creeper.astro";
@@ -118,7 +119,7 @@ const clientCfg = {
</form> </form>
<p class="reg-foot"> <p class="reg-foot">
{r.haveAccount} <a href={site.authUrl}>{r.loginLink}</a> {r.haveAccount} <a href={LANG_ACCOUNT_PATH[locale]}>{r.loginLink}</a>
</p> </p>
<!-- SUCCESS + SKIN --> <!-- SUCCESS + SKIN -->
@@ -167,6 +168,9 @@ const clientCfg = {
<Creeper /> <Creeper />
<span class="name">ULICRAFT</span> <span class="name">ULICRAFT</span>
</a> </a>
<div class="footer-links">
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
</div>
<p class="disc"> <p class="disc">
{t.footer.disc} {site.name} · {site.baseDomain} {t.footer.disc} {site.name} · {site.baseDomain}
</p> </p>

View File

@@ -245,7 +245,7 @@ section { position: relative; z-index: 1; }
.ip-chip button:hover { filter: brightness(1.1); } .ip-chip button:hover { filter: brightness(1.1); }
.ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); } .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 { max-width: 100%; }
.ip-chip.url .ip-val { .ip-chip.url .ip-val {
font-family: var(--font-mono); 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 .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; } .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 FEATURES
============================================================ */ ============================================================ */

View File

@@ -144,6 +144,8 @@ and uptime-kuma.
- `12-build-order.md` — commit-per-task build sequence - `12-build-order.md` — commit-per-task build sequence
- `14-deploy.md` — redeploy to the production host - `14-deploy.md` — redeploy to the production host
- `15-letsencrypt.md` — Let's Encrypt (OVH DNS-01) + host nginx ingress - `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 ## Boot/dependency order

75
plan/17-mod-shortlist.md Normal file
View File

@@ -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
50100 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.

204
plan/18-landing-rework.md Normal file
View File

@@ -0,0 +1,204 @@
# Landing rework — join flow, player rosters, account, mod list, footer
> **Status: PLANNED — not started.** Five landing workstreams (WS1WS5) 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}/<uuid>?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** (410 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 `<a target="_blank" rel="noopener">` 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/<modid>.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.

253
tooling/build-modlist.py Executable file
View File

@@ -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/<modid>.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 <forgemods-required-dir-or-dist-root>
# 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/<modid>.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/<id>/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()