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:
13
.env.example
13
.env.example
@@ -15,6 +15,19 @@ RCON_PASSWORD=change-me-to-something-random
|
|||||||
# and the landing build (-> show/hide the invite-code field). Default: invite.
|
# 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
12
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
31
CLAUDE.md
31
CLAUDE.md
@@ -133,18 +133,27 @@ mirrored at apex `/launcher/`. Per guest:
|
|||||||
load harmlessly on the server but waste RAM. Hand-edit them to `client` so
|
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 WS1–WS6; 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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
84
landing/src/components/ModList.astro
Normal file
84
landing/src/components/ModList.astro
Normal 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>
|
||||||
88
landing/src/components/PlayerRoster.astro
Normal file
88
landing/src/components/PlayerRoster.astro
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
41
landing/src/data/launcher-manifest.example.json
Normal file
41
landing/src/data/launcher-manifest.example.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"product": "UlicraftLauncher",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"channel": "stable",
|
||||||
|
"releasedAt": "2026-06-01T12:00:00Z",
|
||||||
|
"minecraft": "1.21.1",
|
||||||
|
"neoforge": "21.1.233",
|
||||||
|
"builds": [
|
||||||
|
{
|
||||||
|
"os": "windows",
|
||||||
|
"arch": "x64",
|
||||||
|
"format": "exe",
|
||||||
|
"filename": "UlicraftLauncher-1.0.0-setup.exe",
|
||||||
|
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-setup.exe",
|
||||||
|
"size": 78643200,
|
||||||
|
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"os": "macos",
|
||||||
|
"arch": "universal",
|
||||||
|
"format": "dmg",
|
||||||
|
"filename": "UlicraftLauncher-1.0.0-universal.dmg",
|
||||||
|
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-universal.dmg",
|
||||||
|
"size": 92274688
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"os": "linux",
|
||||||
|
"arch": "x64",
|
||||||
|
"format": "appimage",
|
||||||
|
"filename": "UlicraftLauncher-1.0.0-x86_64.AppImage",
|
||||||
|
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-x86_64.AppImage",
|
||||||
|
"size": 85983232
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fjordPack": {
|
||||||
|
"filename": "ulicraft-1.21.1.mrpack",
|
||||||
|
"url": "https://distribution.ulicraft.net/launcher/ulicraft-1.21.1.mrpack",
|
||||||
|
"size": 12582912
|
||||||
|
}
|
||||||
|
}
|
||||||
119
landing/src/data/launcher-manifest.schema.json
Normal file
119
landing/src/data/launcher-manifest.schema.json
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://ulicraft.net/schemas/launcher-manifest.schema.json",
|
||||||
|
"title": "Ulicraft launcher download manifest",
|
||||||
|
"description": "Produced by the custom-launcher release flow, consumed by the landing build to render per-OS download buttons. One file describes the current UlicraftLauncher release plus the optional Fjord pack.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["schemaVersion", "product", "version", "builds"],
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": {
|
||||||
|
"description": "Bump on breaking shape changes. Landing pins the major it understands.",
|
||||||
|
"type": "integer",
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"product": {
|
||||||
|
"description": "Display name of the launcher.",
|
||||||
|
"type": "string",
|
||||||
|
"examples": ["UlicraftLauncher"]
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"description": "Launcher release version (semver, no leading v).",
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^\\d+\\.\\d+\\.\\d+(?:[-+].+)?$"
|
||||||
|
},
|
||||||
|
"channel": {
|
||||||
|
"description": "Release channel.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["stable", "beta"],
|
||||||
|
"default": "stable"
|
||||||
|
},
|
||||||
|
"releasedAt": {
|
||||||
|
"description": "Release timestamp (ISO 8601 / RFC 3339).",
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"minecraft": {
|
||||||
|
"description": "Target Minecraft version the launcher installs.",
|
||||||
|
"type": "string",
|
||||||
|
"examples": ["1.21.1"]
|
||||||
|
},
|
||||||
|
"neoforge": {
|
||||||
|
"description": "NeoForge version the distribution pins.",
|
||||||
|
"type": "string",
|
||||||
|
"examples": ["21.1.233"]
|
||||||
|
},
|
||||||
|
"builds": {
|
||||||
|
"description": "Downloadable launcher binaries, one entry per OS/arch/format.",
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "$ref": "#/$defs/asset" }
|
||||||
|
},
|
||||||
|
"fjordPack": {
|
||||||
|
"description": "Optional importable pack for the Fjord (Prism) alternative path. Lives on its own landing page, not the homepage.",
|
||||||
|
"$ref": "#/$defs/file"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"asset": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["os", "arch", "format", "filename", "url"],
|
||||||
|
"properties": {
|
||||||
|
"os": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["windows", "macos", "linux"]
|
||||||
|
},
|
||||||
|
"arch": {
|
||||||
|
"description": "CPU arch; 'universal' for a fat/any binary.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["x64", "arm64", "universal"]
|
||||||
|
},
|
||||||
|
"format": {
|
||||||
|
"description": "Installer/package kind, drives the button hint.",
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["exe", "msi", "dmg", "pkg", "appimage", "deb", "rpm", "tar.gz", "zip"]
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"description": "Optional human hint override (else derived from os+arch+format).",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"filename": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"description": "Absolute download URL.",
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri",
|
||||||
|
"pattern": "^https://"
|
||||||
|
},
|
||||||
|
"size": {
|
||||||
|
"description": "Bytes, for showing a size next to the button.",
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"sha256": {
|
||||||
|
"description": "Lowercase hex SHA-256 for integrity display/verify.",
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-f0-9]{64}$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"file": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["filename", "url"],
|
||||||
|
"properties": {
|
||||||
|
"filename": { "type": "string", "minLength": 1 },
|
||||||
|
"url": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uri",
|
||||||
|
"pattern": "^https://"
|
||||||
|
},
|
||||||
|
"size": { "type": "integer", "minimum": 0 },
|
||||||
|
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
landing/src/data/mods.example.json
Normal file
5
landing/src/data/mods.example.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"count": 0,
|
||||||
|
"mods": []
|
||||||
|
}
|
||||||
56
landing/src/data/mods.ts
Normal file
56
landing/src/data/mods.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Build-time loader for the auto-generated mod catalogue. tooling/build-modlist.py
|
||||||
|
// writes src/data/mods.json (gitignored, generated) from the distribution's full
|
||||||
|
// client modset; it MAY NOT EXIST on a fresh checkout, so we read it at runtime
|
||||||
|
// with fs (a static `import` would hard-fail the build when absent). Fall back to
|
||||||
|
// the committed .example.json (an empty catalogue); if even that is unreadable,
|
||||||
|
// expose an empty catalogue so the page simply renders no mods and the stat tile
|
||||||
|
// keeps its default. Same pattern as loadLauncherManifest in site.ts.
|
||||||
|
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
|
||||||
|
// `process`/`import.meta.url` reads work at build time, as in site.ts).
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
export type Mod = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
authors: string;
|
||||||
|
description: string;
|
||||||
|
logo: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ModCatalogue = {
|
||||||
|
schemaVersion: number;
|
||||||
|
count: number;
|
||||||
|
mods: Mod[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function loadMods(): ModCatalogue {
|
||||||
|
const empty: ModCatalogue = { schemaVersion: 1, count: 0, mods: [] };
|
||||||
|
// Resolve relative to this module first (works when import.meta.url points at
|
||||||
|
// the source dir), then fall back to a cwd-relative path (Astro runs the build
|
||||||
|
// from the landing root, so src/data/* resolves there even when the module has
|
||||||
|
// been bundled to a different location). Prefer the generated mods.json, then
|
||||||
|
// the committed empty mods.example.json.
|
||||||
|
// @ts-ignore — `process` is untyped here (no @types/node), same as site.ts.
|
||||||
|
const cwd: string = typeof process !== "undefined" ? process.cwd() : ".";
|
||||||
|
const candidates: Array<string | URL> = [];
|
||||||
|
for (const name of ["mods.json", "mods.example.json"]) {
|
||||||
|
try {
|
||||||
|
candidates.push(new URL(name, import.meta.url));
|
||||||
|
} catch {
|
||||||
|
// import.meta.url unavailable — skip the module-relative candidate.
|
||||||
|
}
|
||||||
|
candidates.push(`${cwd}/src/data/${name}`);
|
||||||
|
}
|
||||||
|
for (const c of candidates) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(c, "utf8")) as ModCatalogue;
|
||||||
|
if (parsed && Array.isArray(parsed.mods)) return parsed;
|
||||||
|
} catch {
|
||||||
|
// try the next candidate (file missing/unreadable → fall back)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODS = loadMods();
|
||||||
@@ -2,8 +2,88 @@
|
|||||||
// domains, URLs, theme, launcher files, and the structural shape of the
|
// 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: [
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
{b.label} <span class="hint">· {b.hint}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p>
|
)}
|
||||||
{t.join.s2.registerPre} <a href={site.authUrl}>{site.authUrl}</a>.
|
|
||||||
</p>
|
|
||||||
</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}
|
||||||
|
|||||||
357
landing/src/pages/[...lang]/account.astro
Normal file
357
landing/src/pages/[...lang]/account.astro
Normal 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>
|
||||||
226
landing/src/pages/[...lang]/fjord.astro
Normal file
226
landing/src/pages/[...lang]/fjord.astro
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|||||||
@@ -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
75
plan/17-mod-shortlist.md
Normal 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
|
||||||
|
50–100 ceiling, and raises the RAM/stability bar (`INIT_MEMORY 4G`/`MAX_MEMORY 10G`).
|
||||||
|
Curate deliberately; verify each candidate's NeoForge **1.21.1** build + the
|
||||||
|
`21.1.233` loader pin at add-time.
|
||||||
|
|
||||||
|
## Execution (external repo, when ready)
|
||||||
|
|
||||||
|
1. Add jars in the distribution repo (Nebula `forgemods/required/`), keep the NeoForge
|
||||||
|
1.21.1 builds matching the `21.1.233` pin.
|
||||||
|
2. `release-distro.sh` regenerates `distribution.json` + uploads.
|
||||||
|
3. Back in this repo: `tooling/classify-mods.py` (seed sides) → hand-edit new
|
||||||
|
client-cosmetic entries to `client` → `tooling/sync-server-mods.sh`.
|
||||||
|
4. `tooling/build-modlist.py` (new, see `18-landing-rework.md`) regenerates
|
||||||
|
`landing/src/data/mods.json` + logos → rebuild landing.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `04-mods.md` — distribution-fed mod system, classify/sync, sides.
|
||||||
|
- `18-landing-rework.md` — the landing mod-list component + `mods.json` generator.
|
||||||
204
plan/18-landing-rework.md
Normal file
204
plan/18-landing-rework.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# Landing rework — join flow, player rosters, account, mod list, footer
|
||||||
|
|
||||||
|
> **Status: PLANNED — not started.** Five landing workstreams (WS1–WS5) plus the
|
||||||
|
> mod-list component from `17-mod-shortlist.md` (WS6). Each is independent; suggested
|
||||||
|
> order: **WS5 → WS1 → WS2/3 → WS4 → WS6** (trivial first; rosters share avatar
|
||||||
|
> plumbing; account reuses register's skin JS; mods last).
|
||||||
|
|
||||||
|
The landing is an **Astro static site** (`landing/`, pnpm, output → `../www/`), built
|
||||||
|
on the host with `.env` sourced. Three locales (en/es/eu) in `i18n/ui.ts`, identical
|
||||||
|
shape; non-translatable config in `data/site.ts`. Runtime data (status, rosters, Drasl
|
||||||
|
API) is fetched client-side; everything else is SSG.
|
||||||
|
|
||||||
|
## New env keys (`.env` + `.env.example`)
|
||||||
|
|
||||||
|
| Key | Consumer | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `AVATAR_MODE` (default `headiso`) | landing build → `site.ts` | NMSR render mode for avatar tiles |
|
||||||
|
| `DRASL_ADMIN_USERNAME` / `DRASL_ADMIN_PASSWORD` | **mc-status** (compose env) | admin login for the registered-players roster — **never** reaches the browser |
|
||||||
|
|
||||||
|
Use a **dedicated, rotatable** Drasl admin account for these creds (Drasl has no
|
||||||
|
read-only role; full-admin, but isolates blast radius from your personal admin).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WS1 — Join flow off packwiz (homepage = UlicraftLauncher, 3 steps)
|
||||||
|
|
||||||
|
The modpack now ships via **UlicraftLauncher** (rebranded HeliosLauncher in the
|
||||||
|
`custom-launcher` repo) — it installs mods (packwiz), auth (Drasl, preconfigured) and
|
||||||
|
all files itself. So the homepage join flow drops the packwiz step and the manual
|
||||||
|
authlib step.
|
||||||
|
|
||||||
|
**Homepage steps (3):**
|
||||||
|
1. **Register** — create your Ulicraft account (link `/register`).
|
||||||
|
2. **Download the Ulicraft Launcher** — per-OS download (from manifest); log in with
|
||||||
|
your credentials. (No authlib URL — launcher is preconfigured.)
|
||||||
|
3. **Join the server** — launch, connect to `site.serverAddress` (IP copy chip).
|
||||||
|
|
||||||
|
**Launcher manifest (delivery = synced local file, option B):**
|
||||||
|
- Contract: `landing/src/data/launcher-manifest.schema.json` (already written;
|
||||||
|
JSON-Schema draft 2020-12).
|
||||||
|
- The `custom-launcher` release flow **produces** `launcher-manifest.json` and syncs
|
||||||
|
it into `landing/src/data/launcher-manifest.json` (gitignored, generated), same
|
||||||
|
pattern as `sync-server-mods.sh`.
|
||||||
|
- Landing **imports** it at build → renders per-OS buttons from `builds[]`. Missing
|
||||||
|
file → hide the buttons (build must not fail).
|
||||||
|
|
||||||
|
**Fjord — alternative path, OWN page, off the homepage:** new `/fjord` (+ `/es`,
|
||||||
|
`/eu`). Download Fjord (existing `/launcher/` mirror), add authlib-injector account
|
||||||
|
(`site.authlibUrl` chip), import `manifest.fjordPack` (`.mrpack`), connect.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
- [ ] `site.ts`: delete `packwizUrl`; replace `launcher` (Fjord hardcode) with the
|
||||||
|
manifest import; rename product display to "Ulicraft Launcher".
|
||||||
|
- [ ] `ui.ts`: rewrite `join` block (3 steps) in **all three** locales; eyebrow "3
|
||||||
|
Steps"; reword the `features` item that name-drops packwiz.
|
||||||
|
- [ ] `[...lang].astro`: collapse the 4-step join markup → 3; remove packwiz `CopyChip`,
|
||||||
|
keep server-IP chip; render download buttons from manifest.
|
||||||
|
- [ ] New `[...lang]/fjord.astro` + `ui.ts` `fjord` block + `LANG_FJORD_PATH`.
|
||||||
|
- [ ] Document the synced-manifest step in `12-build-order.md` / `14-deploy.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WS2 — Live server status: online players with names + 3D avatars
|
||||||
|
|
||||||
|
`ServerCard.astro` already fetches mc-status (`/api/mcstatus/v2/status/java/${BASE_DOMAIN}`,
|
||||||
|
mcstatus.io v2 JSON: `players.online/max/list[{uuid,name_clean}]`) and renders NMSR
|
||||||
|
`headiso` heads (filters the zero-UUID placeholder). Enhancement: labeled avatar tiles
|
||||||
|
(name + avatar) using `site.avatarMode`.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
- [ ] `ServerCard.astro`: per online player render a **shared avatar tile** (name +
|
||||||
|
`${avatarUrl}/${avatarMode}/<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** (4–10 friend server) with two mitigations:
|
||||||
|
isolate the roster fetch (own timeout + cache; the status path never blocks on Drasl),
|
||||||
|
and use a dedicated rotatable admin account.
|
||||||
|
|
||||||
|
mc-status (`docker/mc-status/main.go`) gains a `/players` endpoint:
|
||||||
|
- On request (cache TTL ~60s): if no cached token, `POST http://drasl:25585/drasl/api/v2/login`
|
||||||
|
with admin creds → cache `apiToken` (re-login on 401). `GET …/players` (Bearer) →
|
||||||
|
map to `[{uuid,name}]` → cache → serve JSON, CORS `*` (like the status route).
|
||||||
|
- Caddy already `handle /api/mcstatus/*` → mc-status, so the public path is
|
||||||
|
`/api/mcstatus/players` (browser fetches **same-origin**, no CORS, no admin token in
|
||||||
|
the page).
|
||||||
|
|
||||||
|
`PlayerRoster.astro` (new) fetches `/api/mcstatus/players` client-side → live grid of
|
||||||
|
shared avatar tiles. Roster is **public** (uuid+name) — accepted.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
- [ ] `main.go`: `/players` handler (Drasl login + list, token cache, TTL cache,
|
||||||
|
isolated from the status path).
|
||||||
|
- [ ] `docker-compose.yml`: pass `DRASL_ADMIN_*` env to mc-status.
|
||||||
|
- [ ] New `PlayerRoster.astro` + a "Members" section in `[...lang].astro` + `ui.ts`
|
||||||
|
keys (3 langs).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WS4 — Player account page (skin CRUD)
|
||||||
|
|
||||||
|
New `/account` (+ `/es`, `/eu`), browser-direct Drasl (proven by `/register`; CORS
|
||||||
|
already scoped to the apex). **Skins only** (capes out). One player per user
|
||||||
|
(`players[0]`, no switcher). Token **in-memory only** (refresh = re-login; no
|
||||||
|
localStorage → no XSS token theft).
|
||||||
|
|
||||||
|
- **Read:** `POST /login {username,password}` → `{apiToken, user.players[0]{uuid,name,skinUrl}}`.
|
||||||
|
- **Create/Update:** `PATCH /players/{uuid} {skinBase64, skinModel}` (Bearer).
|
||||||
|
- **Delete:** `PATCH /players/{uuid} {deleteSkin:true}` → reverts to default.
|
||||||
|
|
||||||
|
UI: login form → reveal skin panel (current avatar via NMSR `site.avatarMode`, model
|
||||||
|
radio classic/slim, file picker, [Upload], [Reset to default]). After upload, NMSR lags
|
||||||
|
~15m → show the **just-picked file** locally (FileReader dataURL) as instant
|
||||||
|
confirmation, not a fresh NMSR render.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
- [ ] New `[...lang]/account.astro` — lift skin JS near-verbatim from
|
||||||
|
`register.astro:250-307` (`readBase64`, PATCH, `apiMessage`).
|
||||||
|
- [ ] `ui.ts`: `account` block (reuse `register.skin*`) + `LANG_ACCOUNT_PATH`.
|
||||||
|
- [ ] `main.css`: reuse `.reg-*` tokens.
|
||||||
|
- [ ] `register.astro`: repoint the "Manage it here" footer link from the Drasl web UI
|
||||||
|
to `/account`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WS5 — Footer status link
|
||||||
|
|
||||||
|
Add a link to `https://status.${BASE_DOMAIN}` in every footer. Nav unchanged.
|
||||||
|
|
||||||
|
Tasks:
|
||||||
|
- [ ] `site.ts`: add `statusUrl`.
|
||||||
|
- [ ] `ui.ts`: add `footer.status` (3 langs).
|
||||||
|
- [ ] Add `<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
253
tooling/build-modlist.py
Executable 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()
|
||||||
Reference in New Issue
Block a user