feat(landing): self-serve registration page via Drasl API (B1)
Add a static /register page (en/es/eu) that calls the Drasl REST API v2 directly from the browser: register -> login -> optional skin upload, all in the pixel design. Guests never touch Drasl's own web UI. Drasl config gains the pieces this needs: - CORSAllowOrigins scoped to the apex (the API has no CORS until set; never "*"). - [RateLimit] for the now public-facing anonymous POST /users. - [RegistrationNewPlayer] with RequireInvite driven by a new REGISTRATION_MODE (invite|open) .env flag. REGISTRATION_MODE has two consumers from one key: render-config.sh derives the TOML boolean for Drasl (drasl is TOML-only, no env), and the landing build reads it to show/hide the invite-code field. render-config.sh halts on any value other than invite/open. Security verified against drasl source: anonymous POST /users cannot set privileged fields (isAdmin/isLocked/chosenUuid/maxPlayerCount are gated on callerIsAdmin in CreateUser), so browser-direct registration is safe. Docs: plan/16-landing-registration.md captures the design + the B1 vs fork decision; build-order, deploy, and the deploy skill wire REGISTRATION_MODE and the landing-rebuild requirement (www/ is gitignored, not updated by a git pull). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,10 @@
|
||||
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
|
||||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
|
||||
|
||||
// REGISTRATION_MODE (invite|open) is read at build time, same .env as Drasl's
|
||||
// render-config.sh. "invite" => the register form must collect an invite code.
|
||||
const REGISTRATION_MODE = process.env.REGISTRATION_MODE ?? "invite";
|
||||
|
||||
// Minecraft / pack facts, surfaced in a few places.
|
||||
const MC_VERSION = "1.21.1";
|
||||
|
||||
@@ -12,6 +16,11 @@ export const site = {
|
||||
baseDomain: BASE_DOMAIN,
|
||||
mcVersion: MC_VERSION,
|
||||
|
||||
// Register form behaviour, driven by REGISTRATION_MODE in .env.
|
||||
// true => show the invite-code field (Drasl RequireInvite = true)
|
||||
// false => open self-registration
|
||||
registrationRequiresInvite: REGISTRATION_MODE === "invite",
|
||||
|
||||
// Design knobs (replace the design's in-browser TweaksPanel). Baked at build.
|
||||
// mood: "grass" | "nether" | "end"
|
||||
// hero: "centered" | "split" | "spotlight"
|
||||
@@ -30,14 +39,24 @@ export const site = {
|
||||
// Drasl auth web UI + authlib-injector API root (public HTTPS).
|
||||
authUrl: `https://auth.${BASE_DOMAIN}`,
|
||||
authlibUrl: `https://auth.${BASE_DOMAIN}/authlib-injector`,
|
||||
// Drasl REST API v2 root — the /register form calls this from the browser.
|
||||
// Requires CORSAllowOrigins to include the apex in drasl config.toml.
|
||||
draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`,
|
||||
|
||||
// packwiz modpack entrypoint (public HTTPS, pack. subdomain).
|
||||
packwizUrl: `https://pack.${BASE_DOMAIN}/pack.toml`,
|
||||
|
||||
// Static server-list panel (Status section). No fake live count — see plan
|
||||
// 09-landing.md option B.
|
||||
// NMSR avatar renderer root. Player heads come from OUR Drasl skins, not
|
||||
// Mojang/Crafatar — head URL is `${avatarUrl}/headiso/<uuid>?size=64`.
|
||||
avatarUrl: `https://avatar.${BASE_DOMAIN}`,
|
||||
|
||||
// Live server card (ServerCard.astro). `slots` is the SSG fallback cap shown
|
||||
// before/without JS; live count comes from mcstatus.io pinging the public
|
||||
// address. mcstatus.io sets Access-Control-Allow-Origin:* → direct browser
|
||||
// fetch, no proxy/key. Uptime Kuma stays the history + alerting backend.
|
||||
status: {
|
||||
slots: 10,
|
||||
statusApi: `https://api.mcstatus.io/v2/status/java/${BASE_DOMAIN}`,
|
||||
},
|
||||
|
||||
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
|
||||
|
||||
@@ -13,6 +13,12 @@ export const LANGS: Lang[] = ["en", "es", "eu"];
|
||||
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
|
||||
// URL path per locale (en is default, served at root).
|
||||
export const LANG_PATH: Record<Lang, string> = { en: "/", es: "/es/", eu: "/eu/" };
|
||||
// Register page path per locale (mirrors LANG_PATH: en at /register).
|
||||
export const LANG_REGISTER_PATH: Record<Lang, string> = {
|
||||
en: "/register",
|
||||
es: "/es/register",
|
||||
eu: "/eu/register",
|
||||
};
|
||||
|
||||
export interface UI {
|
||||
htmlLang: string;
|
||||
@@ -65,6 +71,39 @@ export interface UI {
|
||||
};
|
||||
};
|
||||
footer: { join: string; disc: string };
|
||||
register: {
|
||||
navHome: string; // "← Home" link in nav
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
lead: string;
|
||||
usernameLabel: string;
|
||||
usernamePlaceholder: string;
|
||||
passwordLabel: string;
|
||||
passwordPlaceholder: string;
|
||||
inviteLabel: string;
|
||||
invitePlaceholder: string;
|
||||
inviteHint: string; // where to get a code
|
||||
submit: string;
|
||||
submitting: string;
|
||||
haveAccount: string; // "Already have an account?"
|
||||
loginLink: string; // links to the Drasl web UI
|
||||
successTitle: string;
|
||||
successBody: string; // {name} = player name
|
||||
uuidLabel: string;
|
||||
skinTitle: string;
|
||||
skinLead: string;
|
||||
skinChoose: string;
|
||||
modelLabel: string;
|
||||
modelClassic: string;
|
||||
modelSlim: string;
|
||||
skinUpload: string;
|
||||
skinUploading: string;
|
||||
skinOk: string;
|
||||
finish: string; // proceed to How to Join
|
||||
errFields: string;
|
||||
errSkinType: string;
|
||||
errNetwork: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const ui: Record<Lang, UI> = {
|
||||
@@ -152,6 +191,39 @@ export const ui: Record<Lang, UI> = {
|
||||
join: "How to Join",
|
||||
disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
|
||||
},
|
||||
register: {
|
||||
navHome: "← Home",
|
||||
eyebrow: "Account",
|
||||
title: "Create your Ulicraft account.",
|
||||
lead: "One username and password for the server, your skin, and your cape. Takes a minute.",
|
||||
usernameLabel: "Username",
|
||||
usernamePlaceholder: "your in-game name",
|
||||
passwordLabel: "Password",
|
||||
passwordPlaceholder: "choose a password",
|
||||
inviteLabel: "Invite code",
|
||||
invitePlaceholder: "paste your invite code",
|
||||
inviteHint: "Ask an admin for an invite code.",
|
||||
submit: "Create account",
|
||||
submitting: "Creating…",
|
||||
haveAccount: "Already have an account?",
|
||||
loginLink: "Manage it here",
|
||||
successTitle: "Account created.",
|
||||
successBody: "Welcome, {name}. You can set a skin now, or head straight to How to Join.",
|
||||
uuidLabel: "Player UUID",
|
||||
skinTitle: "Set your skin (optional)",
|
||||
skinLead: "Upload a Minecraft skin PNG. You can always change it later in the account page.",
|
||||
skinChoose: "Choose PNG…",
|
||||
modelLabel: "Model",
|
||||
modelClassic: "Classic",
|
||||
modelSlim: "Slim",
|
||||
skinUpload: "Upload skin",
|
||||
skinUploading: "Uploading…",
|
||||
skinOk: "Skin set!",
|
||||
finish: "Continue to How to Join",
|
||||
errFields: "Fill in every field.",
|
||||
errSkinType: "Pick a PNG image.",
|
||||
errNetwork: "Network error — try again.",
|
||||
},
|
||||
},
|
||||
|
||||
es: {
|
||||
@@ -238,6 +310,39 @@ export const ui: Record<Lang, UI> = {
|
||||
join: "Cómo entrar",
|
||||
disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
|
||||
},
|
||||
register: {
|
||||
navHome: "← Inicio",
|
||||
eyebrow: "Cuenta",
|
||||
title: "Crea tu cuenta de Ulicraft.",
|
||||
lead: "Un usuario y contraseña para el servidor, tu skin y tu capa. Cosa de un minuto.",
|
||||
usernameLabel: "Usuario",
|
||||
usernamePlaceholder: "tu nombre en el juego",
|
||||
passwordLabel: "Contraseña",
|
||||
passwordPlaceholder: "elige una contraseña",
|
||||
inviteLabel: "Código de invitación",
|
||||
invitePlaceholder: "pega tu código de invitación",
|
||||
inviteHint: "Pide un código de invitación a un admin.",
|
||||
submit: "Crear cuenta",
|
||||
submitting: "Creando…",
|
||||
haveAccount: "¿Ya tienes cuenta?",
|
||||
loginLink: "Gestiónala aquí",
|
||||
successTitle: "Cuenta creada.",
|
||||
successBody: "Bienvenido, {name}. Puedes poner una skin ahora o ir directo a Cómo entrar.",
|
||||
uuidLabel: "UUID del jugador",
|
||||
skinTitle: "Pon tu skin (opcional)",
|
||||
skinLead: "Sube un PNG de skin de Minecraft. Siempre puedes cambiarla luego en tu cuenta.",
|
||||
skinChoose: "Elegir PNG…",
|
||||
modelLabel: "Modelo",
|
||||
modelClassic: "Clásico",
|
||||
modelSlim: "Fino",
|
||||
skinUpload: "Subir skin",
|
||||
skinUploading: "Subiendo…",
|
||||
skinOk: "¡Skin puesta!",
|
||||
finish: "Continuar a Cómo entrar",
|
||||
errFields: "Rellena todos los campos.",
|
||||
errSkinType: "Elige una imagen PNG.",
|
||||
errNetwork: "Error de red: inténtalo de nuevo.",
|
||||
},
|
||||
},
|
||||
|
||||
eu: {
|
||||
@@ -324,5 +429,38 @@ export const ui: Record<Lang, UI> = {
|
||||
join: "Nola sartu",
|
||||
disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
|
||||
},
|
||||
register: {
|
||||
navHome: "← Hasiera",
|
||||
eyebrow: "Kontua",
|
||||
title: "Sortu zure Ulicraft kontua.",
|
||||
lead: "Erabiltzaile eta pasahitz bat zerbitzarirako, zure azalerako eta kaparako. Minutu batean.",
|
||||
usernameLabel: "Erabiltzailea",
|
||||
usernamePlaceholder: "zure jokoko izena",
|
||||
passwordLabel: "Pasahitza",
|
||||
passwordPlaceholder: "aukeratu pasahitz bat",
|
||||
inviteLabel: "Gonbidapen-kodea",
|
||||
invitePlaceholder: "itsatsi zure gonbidapen-kodea",
|
||||
inviteHint: "Eskatu gonbidapen-kode bat admin bati.",
|
||||
submit: "Sortu kontua",
|
||||
submitting: "Sortzen…",
|
||||
haveAccount: "Baduzu kontua?",
|
||||
loginLink: "Kudeatu hemen",
|
||||
successTitle: "Kontua sortuta.",
|
||||
successBody: "Ongi etorri, {name}. Azala orain jar dezakezu, edo zuzenean Nola sartu atalera joan.",
|
||||
uuidLabel: "Jokalariaren UUIDa",
|
||||
skinTitle: "Jarri zure azala (aukerakoa)",
|
||||
skinLead: "Igo Minecraft azal PNG bat. Beti alda dezakezu gero zure kontuan.",
|
||||
skinChoose: "Aukeratu PNGa…",
|
||||
modelLabel: "Eredua",
|
||||
modelClassic: "Klasikoa",
|
||||
modelSlim: "Mehea",
|
||||
skinUpload: "Igo azala",
|
||||
skinUploading: "Igotzen…",
|
||||
skinOk: "Azala jarrita!",
|
||||
finish: "Jarraitu Nola sartura",
|
||||
errFields: "Bete eremu guztiak.",
|
||||
errSkinType: "Aukeratu PNG irudi bat.",
|
||||
errNetwork: "Sare-errorea: saiatu berriro.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
310
landing/src/pages/[...lang]/register.astro
Normal file
310
landing/src/pages/[...lang]/register.astro
Normal file
@@ -0,0 +1,310 @@
|
||||
---
|
||||
import { site, HEAD_FONTS } from "../../data/site";
|
||||
import {
|
||||
ui,
|
||||
LANGS,
|
||||
LANG_LABEL,
|
||||
LANG_PATH,
|
||||
LANG_REGISTER_PATH,
|
||||
type Lang,
|
||||
} from "../../i18n/ui";
|
||||
import Creeper from "../../components/Creeper.astro";
|
||||
import "../../styles/main.css";
|
||||
|
||||
// One static register page per locale: /register, /es/register, /eu/register.
|
||||
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 r = t.register;
|
||||
const { theme } = site;
|
||||
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||
const homePath = LANG_PATH[locale];
|
||||
const requiresInvite = site.registrationRequiresInvite;
|
||||
|
||||
// Strings the client script needs at runtime (kept minimal, passed via define:vars).
|
||||
const clientCfg = {
|
||||
apiUrl: site.draslApiUrl,
|
||||
requiresInvite,
|
||||
submit: r.submit,
|
||||
submitting: r.submitting,
|
||||
skinUpload: r.skinUpload,
|
||||
skinUploading: r.skinUploading,
|
||||
skinOk: r.skinOk,
|
||||
successBody: r.successBody,
|
||||
errFields: r.errFields,
|
||||
errSkinType: r.errSkinType,
|
||||
errNetwork: r.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} — {r.title}</title>
|
||||
<meta name="description" content={r.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_REGISTER_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}>{r.navHome}</a>
|
||||
</nav>
|
||||
<span class="nav-spacer"></span>
|
||||
<div class="lang-switch" aria-label="Language">
|
||||
{LANGS.map((l) => (
|
||||
<a
|
||||
href={LANG_REGISTER_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">{r.eyebrow}</span>
|
||||
<h1>{r.title}</h1>
|
||||
<p class="lead">{r.lead}</p>
|
||||
|
||||
<!-- REGISTER FORM -->
|
||||
<form class="reg-form" id="reg-form" novalidate>
|
||||
<div class="reg-field">
|
||||
<label for="reg-username">{r.usernameLabel}</label>
|
||||
<input class="reg-input" id="reg-username" name="username"
|
||||
type="text" autocomplete="username" placeholder={r.usernamePlaceholder} required />
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label for="reg-password">{r.passwordLabel}</label>
|
||||
<input class="reg-input" id="reg-password" name="password"
|
||||
type="password" autocomplete="new-password" placeholder={r.passwordPlaceholder} required />
|
||||
</div>
|
||||
{requiresInvite && (
|
||||
<div class="reg-field">
|
||||
<label for="reg-invite">{r.inviteLabel}</label>
|
||||
<input class="reg-input" id="reg-invite" name="invite"
|
||||
type="text" placeholder={r.invitePlaceholder} required />
|
||||
<span class="hint">{r.inviteHint}</span>
|
||||
</div>
|
||||
)}
|
||||
<div class="reg-msg err" id="reg-error" role="alert" hidden></div>
|
||||
<button class="mc-btn primary" id="reg-submit" type="submit">{r.submit}</button>
|
||||
</form>
|
||||
|
||||
<p class="reg-foot">
|
||||
{r.haveAccount} <a href={site.authUrl}>{r.loginLink}</a>
|
||||
</p>
|
||||
|
||||
<!-- SUCCESS + SKIN -->
|
||||
<div class="reg-success" id="reg-success" hidden>
|
||||
<div>
|
||||
<span class="eyebrow" style="color:var(--accent)">{r.successTitle}</span>
|
||||
<p class="lead" id="reg-success-body" style="margin-top:10px"></p>
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label>{r.uuidLabel}</label>
|
||||
<div class="reg-uuid" id="reg-uuid"></div>
|
||||
</div>
|
||||
|
||||
<div class="reg-skin" id="reg-skin">
|
||||
<div>
|
||||
<h3>{r.skinTitle}</h3>
|
||||
<p class="lead">{r.skinLead}</p>
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label class="mc-btn sm" for="reg-skin-file" style="align-self:flex-start">{r.skinChoose}</label>
|
||||
<input id="reg-skin-file" type="file" accept="image/png" hidden />
|
||||
<span class="reg-file-name" id="reg-skin-name"></span>
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label>{r.modelLabel}</label>
|
||||
<div class="reg-models">
|
||||
<label><input type="radio" name="skin-model" value="classic" checked /> {r.modelClassic}</label>
|
||||
<label><input type="radio" name="skin-model" value="slim" /> {r.modelSlim}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reg-msg" id="reg-skin-msg" role="status" hidden></div>
|
||||
<button class="mc-btn" id="reg-skin-upload" type="button">{r.skinUpload}</button>
|
||||
</div>
|
||||
|
||||
<a class="mc-btn primary" href={`${homePath}#join`}>{r.finish}</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>
|
||||
<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 = $("reg-form");
|
||||
const errBox = $("reg-error");
|
||||
const submitBtn = $("reg-submit");
|
||||
const successBox = $("reg-success");
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
let apiToken = null;
|
||||
let player = null;
|
||||
|
||||
const showErr = (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;
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errBox.hidden = true;
|
||||
const username = $("reg-username").value.trim();
|
||||
const password = $("reg-password").value;
|
||||
const inviteEl = $("reg-invite");
|
||||
const invite = inviteEl ? inviteEl.value.trim() : null;
|
||||
if (!username || !password || (cfg.requiresInvite && !invite)) {
|
||||
showErr(errBox, cfg.errFields);
|
||||
return;
|
||||
}
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = cfg.submitting;
|
||||
try {
|
||||
const body = { username, password, playerName: username };
|
||||
if (invite) body.inviteCode = invite;
|
||||
let res = await fetch(`${cfg.apiUrl}/users`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
showErr(errBox, await apiMessage(res, cfg.errNetwork));
|
||||
return;
|
||||
}
|
||||
// Log in to get an API token for the (optional) skin upload.
|
||||
res = await fetch(`${cfg.apiUrl}/login`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
showErr(errBox, await apiMessage(res, cfg.errNetwork));
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
apiToken = data.apiToken;
|
||||
player = data.user.players[0];
|
||||
form.hidden = true;
|
||||
$("reg-uuid").textContent = player.uuid;
|
||||
$("reg-success-body").textContent = cfg.successBody.replace("{name}", player.name);
|
||||
successBox.hidden = false;
|
||||
} catch {
|
||||
showErr(errBox, cfg.errNetwork);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = cfg.submit;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Skin upload ----
|
||||
const fileInput = $("reg-skin-file");
|
||||
const fileName = $("reg-skin-name");
|
||||
const skinMsg = $("reg-skin-msg");
|
||||
const skinBtn = $("reg-skin-upload");
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
skinMsg.hidden = true;
|
||||
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");
|
||||
showErr(skinMsg, cfg.errSkinType);
|
||||
return;
|
||||
}
|
||||
if (file.type !== "image/png") {
|
||||
skinMsg.classList.add("err");
|
||||
showErr(skinMsg, cfg.errSkinType);
|
||||
return;
|
||||
}
|
||||
const model = document.querySelector('input[name="skin-model"]:checked').value;
|
||||
skinBtn.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");
|
||||
showErr(skinMsg, await apiMessage(res, cfg.errNetwork));
|
||||
return;
|
||||
}
|
||||
skinMsg.classList.add("ok");
|
||||
showErr(skinMsg, cfg.skinOk);
|
||||
} catch {
|
||||
skinMsg.classList.add("err");
|
||||
showErr(skinMsg, cfg.errNetwork);
|
||||
} finally {
|
||||
skinBtn.disabled = false;
|
||||
skinBtn.textContent = cfg.skinUpload;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -588,6 +588,64 @@ section { position: relative; z-index: 1; }
|
||||
.reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
REGISTER PAGE
|
||||
============================================================ */
|
||||
.reg-wrap { width: min(560px, calc(100% - 48px)); margin-inline: auto; }
|
||||
.reg-card {
|
||||
background: var(--surface);
|
||||
padding: clamp(24px, 4vw, 40px);
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo), 0 8px 24px oklch(0 0 0 / 0.4);
|
||||
}
|
||||
.reg-card .eyebrow { display: block; margin-bottom: 12px; }
|
||||
.reg-card h1 { font-size: clamp(28px, 4vw, 40px); }
|
||||
.reg-card .lead { margin: 14px 0 0; font-size: 17px; }
|
||||
|
||||
.reg-form { display: flex; flex-direction: column; gap: 18px; margin-top: 28px; }
|
||||
.reg-field { display: flex; flex-direction: column; gap: 7px; }
|
||||
.reg-field label {
|
||||
font-family: var(--font-pixel); font-size: 9px; letter-spacing: 0.1em;
|
||||
color: var(--dim); text-transform: uppercase; line-height: 1.8;
|
||||
}
|
||||
.reg-input {
|
||||
font-family: var(--font-body); font-size: 16px; color: var(--text);
|
||||
background: var(--slot); border: 0; padding: 13px 14px;
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||
}
|
||||
.reg-input::placeholder { color: var(--dim); }
|
||||
.reg-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.reg-field .hint { color: var(--dim); font-size: 13px; }
|
||||
|
||||
.reg-models { display: flex; gap: 10px; }
|
||||
.reg-models label {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: var(--font-body); font-size: 15px; letter-spacing: 0; text-transform: none;
|
||||
color: var(--muted); cursor: pointer;
|
||||
background: var(--slot); padding: 9px 14px;
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||
}
|
||||
.reg-models input { accent-color: var(--accent); }
|
||||
|
||||
.reg-msg { font-size: 15px; padding: 12px 14px; box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi); }
|
||||
.reg-msg[hidden] { display: none; }
|
||||
.reg-msg.err { background: oklch(0.30 0.10 30); color: oklch(0.92 0.04 40); }
|
||||
.reg-msg.ok { background: var(--slot); color: var(--accent); }
|
||||
|
||||
.reg-foot { margin-top: 22px; color: var(--dim); font-size: 14.5px; }
|
||||
.reg-foot a { color: var(--accent); }
|
||||
|
||||
.reg-success[hidden], .reg-skin[hidden] { display: none; }
|
||||
.reg-success { margin-top: 26px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.reg-uuid {
|
||||
font-family: var(--font-mono); font-size: 17px; color: var(--gold);
|
||||
background: var(--slot); padding: 8px 12px; word-break: break-all;
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
|
||||
}
|
||||
.reg-skin { margin-top: 8px; display: flex; flex-direction: column; gap: 16px; border-top: 1px solid var(--line); padding-top: 22px; }
|
||||
.reg-skin h3 { font-size: 22px; }
|
||||
.reg-skin .lead { font-size: 15px; }
|
||||
.reg-file-name { color: var(--muted); font-size: 14px; }
|
||||
|
||||
/* ============================================================
|
||||
RESPONSIVE
|
||||
============================================================ */
|
||||
|
||||
Reference in New Issue
Block a user