feat(landing): rework — es default, 2-step join, roster + account polish

- Default language Spanish at root /, English moves to /en/ (eu unchanged):
  getStaticPaths {lang:undefined}->es + LANG_*_PATH maps swapped in ui.ts.
- Section order: hero -> join -> status -> members -> mods -> features.
- Join flow 3 -> 2 steps (drop the Connect/IP step; launcher handles the
  address). Remove the hero IP CopyChip. Header CTA renamed Play/Jugar/Jolastu
  and gains Register + Login links on all four pages.
- Account page: hide the whole login block once authenticated (not just the
  form), add an avatar Refresh button (cache-busted NMSR re-fetch), flatten the
  avatar (no bevel). New i18n key account.refresh.
- Register page: new REGISTRATION_SHOW_INVITE env (default false/hidden) toggles
  the invite-code field independently of the REGISTRATION_MODE backend gate.
- Members roster: drop the `admin` account, render full-body via a new
  ROSTER_AVATAR_MODE (default fullbodyiso); portrait tiles.
- Style: flatten emboss on feature icons (.picon) + player tiles (.roster-tile).
  PixelIcon gains optional image support (/icons/<name>.png|svg).
- Favicon now /favicon.png (committed) on all pages.
- Docs: plan/09-landing.md Assets section (logo/favicon/features-icons drop
  points) + default-language note; .env.example documents the two new vars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 22:03:18 +02:00
parent c233b1bfbc
commit c8a6c77a8e
12 changed files with 267 additions and 207 deletions

View File

@@ -7,18 +7,29 @@ BASE_DOMAIN=ulicraft.net
RCON_PASSWORD=change-me-to-something-random RCON_PASSWORD=change-me-to-something-random
# Registration mode for self-hosted accounts (Drasl) + landing register form. # Registration mode (BACKEND gate) for self-hosted accounts (Drasl).
# invite = closed; guests need an admin-minted invite code (recommended on a # invite = closed; guests need an admin-minted invite code (recommended on a
# public, internet-present host). Admin mints via POST /drasl/api/v2/invites. # public, internet-present host). Admin mints via POST /drasl/api/v2/invites.
# open = anyone can self-register with username+password (set [RateLimit]!). # open = anyone can self-register with username+password (set [RateLimit]!).
# Consumed by BOTH render-config.sh (-> Drasl RegistrationNewPlayer.RequireInvite) # Consumed by render-config.sh (-> Drasl RegistrationNewPlayer.RequireInvite).
# and the landing build (-> show/hide the invite-code field). Default: invite. # Default: invite. NOTE: this no longer controls the landing invite FIELD — that's
# REGISTRATION_SHOW_INVITE below.
REGISTRATION_MODE=invite REGISTRATION_MODE=invite
# NMSR avatar render mode for the landing's avatar tiles (online players + # Show the invite-code FIELD on the landing register form. Read by the landing
# Members roster). Read by the landing BUILD (data/site.ts) only. Avatar URL = # BUILD (data/site.ts) only; default false (hidden). Independent of the backend
# gate above. CAVEAT: if REGISTRATION_MODE=invite (Drasl requires a code) but this
# is false, public self-registration can't supply one — enable both together, or
# mint accounts admin-side. true | false.
REGISTRATION_SHOW_INVITE=false
# NMSR avatar render mode for the landing's avatar tiles. Read by the landing
# BUILD (data/site.ts) only. Avatar URL =
# https://avatar.${BASE_DOMAIN}/<mode>/<uuid>?size=N. e.g. headiso | head | fullbody. # https://avatar.${BASE_DOMAIN}/<mode>/<uuid>?size=N. e.g. headiso | head | fullbody.
# AVATAR_MODE → ServerCard online row + account preview (heads)
# ROSTER_AVATAR_MODE → Members grid (full body; default fullbodyiso)
AVATAR_MODE=headiso AVATAR_MODE=headiso
ROSTER_AVATAR_MODE=fullbodyiso
# Drasl admin creds for the registered-players roster (/api/mcstatus/players). # Drasl admin creds for the registered-players roster (/api/mcstatus/players).
# Consumed ONLY by the mc-status container (server-side login → sanitized # Consumed ONLY by the mc-status container (server-side login → sanitized

BIN
landing/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -1,10 +1,13 @@
--- ---
// 7x7 feature glyphs — styled by .picon in main.css. // Feature icon. Default: a 7x7 procedural glyph (styled by .picon in main.css).
// Optional: pass `img` (an absolute path like "/icons/foo.png|svg" under
// landing/public/) to render a custom image instead of a glyph.
interface Props { interface Props {
glyph: "shield" | "diamond" | "orb" | "heart"; glyph: "shield" | "diamond" | "orb" | "heart";
gold?: boolean; gold?: boolean;
img?: string; // custom icon path in /public/icons/ — overrides the glyph
} }
const { glyph, gold = false } = Astro.props; const { glyph, gold = false, img } = Astro.props;
const GLYPHS: Record<Props["glyph"], string[]> = { const GLYPHS: Record<Props["glyph"], string[]> = {
shield: ["0111110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"], shield: ["0111110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"],
@@ -15,6 +18,10 @@ const GLYPHS: Record<Props["glyph"], string[]> = {
const cells = GLYPHS[glyph].join("").split(""); const cells = GLYPHS[glyph].join("").split("");
const onClass = gold ? "go" : "on"; const onClass = gold ? "go" : "on";
--- ---
<div class="picon" aria-hidden="true"> {img ? (
<img class="picon-img" src={img} alt="" aria-hidden="true" width="52" height="52" />
) : (
<div class="picon" aria-hidden="true">
{cells.map((c) => <i class={c === "1" ? onClass : undefined} />)} {cells.map((c) => <i class={c === "1" ? onClass : undefined} />)}
</div> </div>
)}

View File

@@ -19,13 +19,13 @@ interface Props {
} }
const { emptyLabel = "No members yet." } = Astro.props; const { emptyLabel = "No members yet." } = Astro.props;
const { status, avatarUrl, avatarMode } = site; const { status, avatarUrl, rosterAvatarMode } = site;
--- ---
<div <div
class="roster-block" class="roster-block"
data-api={status.rosterApi} data-api={status.rosterApi}
data-avatar={avatarUrl} data-avatar={avatarUrl}
data-mode={avatarMode} data-mode={rosterAvatarMode}
data-empty={emptyLabel} data-empty={emptyLabel}
> >
<div class="roster" aria-label="Registered players"></div> <div class="roster" aria-label="Registered players"></div>
@@ -58,7 +58,10 @@ const { status, avatarUrl, avatarMode } = site;
return showEmpty(); return showEmpty();
} }
const players = Array.isArray(data) ? data.filter((p) => p && p.uuid) : []; // Drop the service `admin` account from the public members grid.
const players = Array.isArray(data)
? data.filter((p) => p && p.uuid && p.name?.toLowerCase() !== "admin")
: [];
if (players.length === 0) return showEmpty(); if (players.length === 0) return showEmpty();
grid.innerHTML = ""; grid.innerHTML = "";
@@ -75,7 +78,7 @@ const { status, avatarUrl, avatarMode } = site;
const img = document.createElement("img"); const img = document.createElement("img");
img.loading = "lazy"; img.loading = "lazy";
img.alt = name; img.alt = name;
img.src = `${avatar}/${mode}/${uuid}?size=64`; img.src = `${avatar}/${mode}/${uuid}?size=128`;
const label = document.createElement("span"); const label = document.createElement("span");
label.className = "roster-name"; label.className = "roster-name";
label.textContent = name; label.textContent = name;

View File

@@ -14,10 +14,14 @@ const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
// is absent/empty (fresh checkout), fall back to the honest "50+" estimate. // is absent/empty (fresh checkout), fall back to the honest "50+" estimate.
const MOD_COUNT_KEY = MODS.count > 0 ? `${MODS.count}` : "50+"; const MOD_COUNT_KEY = MODS.count > 0 ? `${MODS.count}` : "50+";
// NMSR render mode for avatar tiles (online roster + members grid). Read at // NMSR render mode for avatar tiles. AVATAR_MODE is the ServerCard online row +
// build time, same as BASE_DOMAIN. e.g. "headiso" | "head" | "fullbody". // account preview (heads); ROSTER_AVATAR_MODE is the Members grid (full body by
// default). Read at build time, same as BASE_DOMAIN. e.g. "headiso" | "head" |
// "fullbody" | "fullbodyiso".
// @ts-ignore — `process` is untyped here (no @types/node), same as BASE_DOMAIN. // @ts-ignore — `process` is untyped here (no @types/node), same as BASE_DOMAIN.
const AVATAR_MODE = process.env.AVATAR_MODE ?? "headiso"; const AVATAR_MODE = process.env.AVATAR_MODE ?? "headiso";
// @ts-ignore — `process` is untyped here (no @types/node).
const ROSTER_AVATAR_MODE = process.env.ROSTER_AVATAR_MODE ?? "fullbodyiso";
// Launcher download manifest (per launcher-manifest.schema.json). The // Launcher download manifest (per launcher-manifest.schema.json). The
// custom-launcher release flow syncs launcher-manifest.json into this dir // custom-launcher release flow syncs launcher-manifest.json into this dir
@@ -88,6 +92,15 @@ const LAUNCHER_MANIFEST = loadLauncherManifest();
// 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";
// REGISTRATION_SHOW_INVITE (true|false, default false=hidden) ONLY toggles the
// visibility of the invite-code field on the register form. The backend invite
// gate stays on Drasl's RequireInvite (driven by REGISTRATION_MODE). Decoupled on
// purpose: invites are usually admin-minted and the field is hidden by default.
// CAVEAT: if REGISTRATION_MODE=invite (backend requires a code) but this is
// false, public self-registration can't supply one — set both together.
// @ts-ignore — `process` is untyped here (no @types/node).
const REGISTRATION_SHOW_INVITE = process.env.REGISTRATION_SHOW_INVITE ?? "false";
// Minecraft / pack facts, surfaced in a few places. // Minecraft / pack facts, surfaced in a few places.
const MC_VERSION = "1.21.1"; const MC_VERSION = "1.21.1";
@@ -97,9 +110,12 @@ export const site = {
mcVersion: MC_VERSION, mcVersion: MC_VERSION,
// Register form behaviour, driven by REGISTRATION_MODE in .env. // Register form behaviour, driven by REGISTRATION_MODE in .env.
// true => show the invite-code field (Drasl RequireInvite = true) // true => Drasl RequireInvite = true (backend gate)
// false => open self-registration // false => open self-registration
registrationRequiresInvite: REGISTRATION_MODE === "invite", registrationRequiresInvite: REGISTRATION_MODE === "invite",
// Whether the invite-code FIELD is shown on the register form (default false).
// Independent of the backend gate above — see REGISTRATION_SHOW_INVITE note.
registrationShowInvite: REGISTRATION_SHOW_INVITE === "true",
// Design knobs (replace the design's in-browser TweaksPanel). Baked at build. // Design knobs (replace the design's in-browser TweaksPanel). Baked at build.
// mood: "grass" | "nether" | "end" // mood: "grass" | "nether" | "end"
@@ -127,9 +143,11 @@ export const site = {
// 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 // NMSR render mode for avatar tiles. Avatar URL = `${avatarUrl}/<mode>/<uuid>?size=N`.
// members grid). Avatar URL = `${avatarUrl}/${avatarMode}/<uuid>?size=N`. // avatarMode → ServerCard online row + account preview (heads)
// rosterAvatarMode → PlayerRoster members grid (full body)
avatarMode: AVATAR_MODE, avatarMode: AVATAR_MODE,
rosterAvatarMode: ROSTER_AVATAR_MODE,
// Uptime Kuma status page (external link in footer). // Uptime Kuma status page (external link in footer).
statusUrl: `https://status.${BASE_DOMAIN}`, statusUrl: `https://status.${BASE_DOMAIN}`,

View File

@@ -11,31 +11,31 @@
export type Lang = "en" | "es" | "eu"; export type Lang = "en" | "es" | "eu";
export const LANGS: Lang[] = ["en", "es", "eu"]; export const LANGS: Lang[] = ["en", "es", "eu"];
export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" }; export const LANG_LABEL: Record<Lang, string> = { en: "EN", es: "ES", eu: "EU" };
// URL path per locale (en is default, served at root). // URL path per locale (es is default, served at root; en moves to /en/).
export const LANG_PATH: Record<Lang, string> = { en: "/", es: "/es/", eu: "/eu/" }; export const LANG_PATH: Record<Lang, string> = { es: "/", en: "/en/", eu: "/eu/" };
// Register page path per locale (mirrors LANG_PATH: en at /register). // Register page path per locale (mirrors LANG_PATH: es at /register).
export const LANG_REGISTER_PATH: Record<Lang, string> = { export const LANG_REGISTER_PATH: Record<Lang, string> = {
en: "/register", es: "/register",
es: "/es/register", en: "/en/register",
eu: "/eu/register", eu: "/eu/register",
}; };
// Fjord (alternative path) page path per locale (mirrors LANG_REGISTER_PATH). // Fjord (alternative path) page path per locale (mirrors LANG_REGISTER_PATH).
export const LANG_FJORD_PATH: Record<Lang, string> = { export const LANG_FJORD_PATH: Record<Lang, string> = {
en: "/fjord", es: "/fjord",
es: "/es/fjord", en: "/en/fjord",
eu: "/eu/fjord", eu: "/eu/fjord",
}; };
// Account page path per locale (mirrors LANG_REGISTER_PATH). // Account page path per locale (mirrors LANG_REGISTER_PATH).
export const LANG_ACCOUNT_PATH: Record<Lang, string> = { export const LANG_ACCOUNT_PATH: Record<Lang, string> = {
en: "/account", es: "/account",
es: "/es/account", en: "/en/account",
eu: "/eu/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; members: string; features: string; mods: string; join: string; play: string }; nav: { status: string; members: string; features: string; mods: string; join: string; play: string; register: string; login: 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;
@@ -84,13 +84,6 @@ export interface UI {
registerLink: string; registerLink: string;
}; };
s2: { kicker: string; h: string; p: 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: { fjord: {
navHome: string; // "← Home" link in nav navHome: string; // "← Home" link in nav
@@ -164,6 +157,7 @@ export interface UI {
noAccount: string; // "No account yet?" noAccount: string; // "No account yet?"
registerLink: string; // links to /register registerLink: string; // links to /register
// Skin panel // Skin panel
refresh: string; // re-fetch the NMSR avatar
playerLabel: string; // "Logged in as" playerLabel: string; // "Logged in as"
skinTitle: string; skinTitle: string;
skinLead: string; // reuses register.skinLead concept skinLead: string; // reuses register.skinLead concept
@@ -189,7 +183,7 @@ export const ui: Record<Lang, UI> = {
en: { en: {
htmlLang: "en", htmlLang: "en",
copied: "Copied!", copied: "Copied!",
nav: { status: "Status", members: "Members", features: "Features", mods: "Mods", join: "How to Join", play: "Play Now" }, nav: { status: "Status", members: "Members", features: "Features", mods: "Mods", join: "How to Join", play: "Play", register: "Register", login: "Log in" },
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.",
@@ -249,7 +243,7 @@ export const ui: Record<Lang, UI> = {
empty: "Mod list not generated yet — check back soon.", empty: "Mod list not generated yet — check back soon.",
}, },
join: { join: {
eyebrow: "How to Join · 3 Steps", eyebrow: "How to Join · 2 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: {
@@ -262,14 +256,7 @@ export const ui: Record<Lang, UI> = {
s2: { s2: {
kicker: "Launcher", kicker: "Launcher",
h: "Download {name}", h: "Download {name}",
p: "Grab {name} for your system and log in with your Ulicraft credentials — it installs the modpack and everything else automatically.", p: "Grab {name}, log in with your Ulicraft credentials, and hit play. Everything else — the modpack, the server address, updates — is handled inside the launcher.",
},
s3: {
kicker: "Connect",
h: "Join the server",
pa: "Launch the launcher, open",
pb: ", paste the address, and join.",
copy: "Copy IP",
}, },
}, },
fjord: { fjord: {
@@ -354,6 +341,7 @@ export const ui: Record<Lang, UI> = {
submitting: "Logging in…", submitting: "Logging in…",
noAccount: "No account yet?", noAccount: "No account yet?",
registerLink: "Register here", registerLink: "Register here",
refresh: "Refresh",
playerLabel: "Logged in as", playerLabel: "Logged in as",
skinTitle: "Your skin", skinTitle: "Your skin",
skinLead: "Upload a Minecraft skin PNG. The 3D preview below updates instantly; the in-game avatar takes ~1 minute.", skinLead: "Upload a Minecraft skin PNG. The 3D preview below updates instantly; the in-game avatar takes ~1 minute.",
@@ -377,7 +365,7 @@ export const ui: Record<Lang, UI> = {
es: { es: {
htmlLang: "es", htmlLang: "es",
copied: "¡Copiado!", copied: "¡Copiado!",
nav: { status: "Estado", members: "Miembros", features: "Características", mods: "Mods", join: "Cómo entrar", play: "Jugar ahora" }, nav: { status: "Estado", members: "Miembros", features: "Características", mods: "Mods", join: "Cómo entrar", play: "Jugar", register: "Registro", login: "Entrar" },
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.",
@@ -437,7 +425,7 @@ export const ui: Record<Lang, UI> = {
empty: "La lista de mods aún no está generada — vuelve pronto.", empty: "La lista de mods aún no está generada — vuelve pronto.",
}, },
join: { join: {
eyebrow: "Cómo entrar · 3 pasos", eyebrow: "Cómo entrar · 2 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: {
@@ -450,14 +438,7 @@ export const ui: Record<Lang, UI> = {
s2: { s2: {
kicker: "Launcher", kicker: "Launcher",
h: "Descarga {name}", h: "Descarga {name}",
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.", p: "Coge {name}, inicia sesión con tus credenciales de Ulicraft y dale a jugar. Todo lo demás — el modpack, la dirección del servidor, las actualizaciones — se gestiona dentro del launcher.",
},
s3: {
kicker: "Conectar",
h: "Entra al servidor",
pa: "Lanza el launcher, abre",
pb: ", pega la dirección y entra.",
copy: "Copiar IP",
}, },
}, },
fjord: { fjord: {
@@ -542,6 +523,7 @@ export const ui: Record<Lang, UI> = {
submitting: "Entrando…", submitting: "Entrando…",
noAccount: "¿Aún no tienes cuenta?", noAccount: "¿Aún no tienes cuenta?",
registerLink: "Regístrate aquí", registerLink: "Regístrate aquí",
refresh: "Actualizar",
playerLabel: "Sesión iniciada como", playerLabel: "Sesión iniciada como",
skinTitle: "Tu skin", 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 ~1 minuto.", skinLead: "Sube un PNG de skin de Minecraft. La vista previa 3D se actualiza al instante; el avatar en el juego tarda ~1 minuto.",
@@ -565,7 +547,7 @@ export const ui: Record<Lang, UI> = {
eu: { eu: {
htmlLang: "eu", htmlLang: "eu",
copied: "Kopiatuta!", copied: "Kopiatuta!",
nav: { status: "Egoera", members: "Kideak", features: "Ezaugarriak", mods: "Mods", join: "Nola sartu", play: "Jokatu orain" }, nav: { status: "Egoera", members: "Kideak", features: "Ezaugarriak", mods: "Mods", join: "Nola sartu", play: "Jolastu", register: "Erregistroa", login: "Sartu" },
hero: { hero: {
eyebrow: "Mod-dun LANa · NeoForge", eyebrow: "Mod-dun LANa · NeoForge",
tagline: "Mod ugariko biziraupena, irauteko egina.", tagline: "Mod ugariko biziraupena, irauteko egina.",
@@ -625,7 +607,7 @@ export const ui: Record<Lang, UI> = {
empty: "Mod zerrenda oraindik ez da sortu — itzuli laster.", empty: "Mod zerrenda oraindik ez da sortu — itzuli laster.",
}, },
join: { join: {
eyebrow: "Nola sartu · 3 urrats", eyebrow: "Nola sartu · 2 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: {
@@ -638,14 +620,7 @@ export const ui: Record<Lang, UI> = {
s2: { s2: {
kicker: "Launcherra", kicker: "Launcherra",
h: "Deskargatu {name}", h: "Deskargatu {name}",
p: "Hartu {name} zure sistemarako eta hasi saioa zure Ulicraft kredentzialekin: modpacka eta gainerako guztia automatikoki instalatzen du.", p: "Hartu {name}, hasi saioa zure Ulicraft kredentzialekin eta sakatu jolastu. Gainerako guztia — modpacka, zerbitzariaren helbidea, eguneraketak — launcherraren barruan kudeatzen da.",
},
s3: {
kicker: "Konektatu",
h: "Sartu zerbitzarira",
pa: "Abiarazi launcherra, ireki",
pb: ", itsatsi helbidea eta sartu.",
copy: "Kopiatu IPa",
}, },
}, },
fjord: { fjord: {
@@ -730,6 +705,7 @@ export const ui: Record<Lang, UI> = {
submitting: "Sartzen…", submitting: "Sartzen…",
noAccount: "Oraindik konturik ez?", noAccount: "Oraindik konturik ez?",
registerLink: "Erregistratu hemen", registerLink: "Erregistratu hemen",
refresh: "Eguneratu",
playerLabel: "Saioa hasita:", playerLabel: "Saioa hasita:",
skinTitle: "Zure azala", skinTitle: "Zure azala",
skinLead: "Igo Minecraft azal PNG bat. 3D aurrebista berehala eguneratzen da; jokoko avatarra ~1 minutuan.", skinLead: "Igo Minecraft azal PNG bat. 3D aurrebista berehala eguneratzen da; jokoko avatarra ~1 minutuan.",

View File

@@ -1,9 +1,8 @@
--- ---
import { site, LITERALS, HEAD_FONTS } from "../data/site"; import { site, HEAD_FONTS } from "../data/site";
import { ui, LANGS, LANG_LABEL, LANG_PATH, LANG_REGISTER_PATH, type Lang } from "../i18n/ui"; import { ui, LANGS, LANG_LABEL, LANG_PATH, LANG_REGISTER_PATH, LANG_ACCOUNT_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 ServerCard from "../components/ServerCard.astro"; import ServerCard from "../components/ServerCard.astro";
import PlayerRoster from "../components/PlayerRoster.astro"; import PlayerRoster from "../components/PlayerRoster.astro";
import ModList from "../components/ModList.astro"; import ModList from "../components/ModList.astro";
@@ -12,8 +11,8 @@ 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/".
export function getStaticPaths() { export function getStaticPaths() {
return [ return [
{ params: { lang: undefined }, props: { locale: "en" as Lang } }, { params: { lang: undefined }, props: { locale: "es" as Lang } },
{ params: { lang: "es" }, props: { locale: "es" as Lang } }, { params: { lang: "en" }, props: { locale: "en" as Lang } },
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } }, { params: { lang: "eu" }, props: { locale: "eu" as Lang } },
]; ];
} }
@@ -24,6 +23,7 @@ const { theme, launcherManifest } = site;
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify; const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
const launcherName = launcherManifest.product; const launcherName = launcherManifest.product;
const registerPath = LANG_REGISTER_PATH[locale]; const registerPath = LANG_REGISTER_PATH[locale];
const accountPath = LANG_ACCOUNT_PATH[locale];
// Per-OS download buttons (join step 2) from the synced manifest. Missing // Per-OS download buttons (join step 2) from the synced manifest. Missing
// manifest → empty builds → no buttons (the build never fails on it). // manifest → empty builds → no buttons (the build never fails on it).
@@ -60,7 +60,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{site.name} — {t.hero.howToJoin}</title> <title>{site.name} — {t.hero.howToJoin}</title>
<meta name="description" content={`${site.name}: ${t.hero.tagline}`} /> <meta name="description" content={`${site.name}: ${t.hero.tagline}`} />
<link rel="icon" type="image/png" href="/logo.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="/fonts/fonts.css" /> <link rel="stylesheet" href="/fonts/fonts.css" />
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_PATH[l]} />)} {LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_PATH[l]} />)}
</head> </head>
@@ -73,11 +73,11 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
<span class="name">ULICRAFT</span> <span class="name">ULICRAFT</span>
</a> </a>
<nav class="nav-links"> <nav class="nav-links">
<a href="#join">{t.nav.join}</a>
<a href="#status">{t.nav.status}</a> <a href="#status">{t.nav.status}</a>
<a href="#members">{t.nav.members}</a> <a href="#members">{t.nav.members}</a>
<a href="#features">{t.nav.features}</a>
<a href="#mods">{t.nav.mods}</a> <a href="#mods">{t.nav.mods}</a>
<a href="#join">{t.nav.join}</a> <a href="#features">{t.nav.features}</a>
</nav> </nav>
<span class="nav-spacer"></span> <span class="nav-spacer"></span>
<div class="lang-switch" aria-label="Language"> <div class="lang-switch" aria-label="Language">
@@ -85,6 +85,8 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
<a href={LANG_PATH[l]} class:list={[l === locale && "on"]} aria-current={l === locale ? "true" : undefined}>{LANG_LABEL[l]}</a> <a href={LANG_PATH[l]} class:list={[l === locale && "on"]} aria-current={l === locale ? "true" : undefined}>{LANG_LABEL[l]}</a>
))} ))}
</div> </div>
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
<a class="mc-btn primary sm" href="#join">{t.nav.play}</a> <a class="mc-btn primary sm" href="#join">{t.nav.play}</a>
</div> </div>
</header> </header>
@@ -110,8 +112,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
<h1 class="hero-tagline">{t.hero.tagline}</h1> <h1 class="hero-tagline">{t.hero.tagline}</h1>
<p class="hero-sub">{t.hero.sub}</p> <p class="hero-sub">{t.hero.sub}</p>
<div class="hero-ip-row"> <div class="hero-ip-row">
<CopyChip value={site.serverAddress} variant="ip" label={t.hero.copyIp} copiedLabel={t.copied} /> <a class="mc-btn primary" href="#join">{t.hero.howToJoin}</a>
<a class="mc-btn" href="#join">{t.hero.howToJoin}</a>
</div> </div>
<div class="hero-meta"> <div class="hero-meta">
<span>Java {site.mcVersion}</span> <span>Java {site.mcVersion}</span>
@@ -131,82 +132,6 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
</div> </div>
</section> </section>
<!-- STATUS -->
<section id="status" class="pad">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{t.status.eyebrow}</span>
<h2 class="section-title">{t.status.title}</h2>
<p class="lead">{t.status.lead}</p>
</div>
<div class="reveal">
<ServerCard
modded={t.status.modded}
motd={t.status.motd}
upTo={t.status.upTo}
/>
</div>
<div class="stat-grid">
{site.stats.map((s, i) => (
<div class="tile reveal" style={`transition-delay:${i * 60}ms`}>
<div class="k">{s.key}</div>
<div class="l">{t.statLabels[i]}</div>
</div>
))}
</div>
</div>
</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 -->
<section id="features" class="pad">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{t.features.eyebrow}</span>
<h2 class="section-title">{t.features.title}</h2>
<p class="lead">{t.features.lead}</p>
</div>
<div class="feat-grid">
{site.features.map((f, i) => (
<div class="feat reveal" style={`transition-delay:${(i % 2) * 80}ms`}>
<PixelIcon glyph={f.glyph} gold={f.gold} />
<div>
<h3>{t.features.items[i].h}</h3>
<p>{t.features.items[i].p}</p>
</div>
</div>
))}
</div>
</div>
</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">
@@ -247,21 +172,82 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
)} )}
</div> </div>
</div> </div>
</div>
</div>
</section>
<!-- STEP 3 --> <!-- STATUS -->
<div class="step reveal"> <section id="status" class="pad">
<div class="num">3</div> <div class="wrap">
<div class="body"> <div class="sec-head reveal">
<span class="kicker">{t.join.s3.kicker}</span> <span class="eyebrow">{t.status.eyebrow}</span>
<h3>{t.join.s3.h}</h3> <h2 class="section-title">{t.status.title}</h2>
<p> <p class="lead">{t.status.lead}</p>
{t.join.s3.pa} <kbd>{LITERALS.multiplayer}</kbd> → <kbd>{LITERALS.addServer}</kbd>{t.join.s3.pb} </div>
</p> <div class="reveal">
<div class="actions"> <ServerCard
<CopyChip value={site.serverAddress} variant="ip" label={t.join.s3.copy} copiedLabel={t.copied} /> modded={t.status.modded}
motd={t.status.motd}
upTo={t.status.upTo}
/>
</div>
<div class="stat-grid">
{site.stats.map((s, i) => (
<div class="tile reveal" style={`transition-delay:${i * 60}ms`}>
<div class="k">{s.key}</div>
<div class="l">{t.statLabels[i]}</div>
</div>
))}
</div> </div>
</div> </div>
</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>
<div class="reveal">
<PlayerRoster emptyLabel={t.members.empty} />
</div>
</div>
</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>
<!-- FEATURES -->
<section id="features" class="pad">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{t.features.eyebrow}</span>
<h2 class="section-title">{t.features.title}</h2>
<p class="lead">{t.features.lead}</p>
</div>
<div class="feat-grid">
{site.features.map((f, i) => (
<div class="feat reveal" style={`transition-delay:${(i % 2) * 80}ms`}>
<PixelIcon glyph={f.glyph} gold={f.gold} />
<div>
<h3>{t.features.items[i].h}</h3>
<p>{t.features.items[i].p}</p>
</div>
</div>
))}
</div> </div>
</div> </div>
</section> </section>

View File

@@ -15,8 +15,8 @@ import "../../styles/main.css";
// One static account page per locale: /account, /es/account, /eu/account. // One static account page per locale: /account, /es/account, /eu/account.
export function getStaticPaths() { export function getStaticPaths() {
return [ return [
{ params: { lang: undefined }, props: { locale: "en" as Lang } }, { params: { lang: undefined }, props: { locale: "es" as Lang } },
{ params: { lang: "es" }, props: { locale: "es" as Lang } }, { params: { lang: "en" }, props: { locale: "en" as Lang } },
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } }, { params: { lang: "eu" }, props: { locale: "eu" as Lang } },
]; ];
} }
@@ -28,6 +28,7 @@ const { theme } = site;
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify; const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
const homePath = LANG_PATH[locale]; const homePath = LANG_PATH[locale];
const registerPath = LANG_REGISTER_PATH[locale]; const registerPath = LANG_REGISTER_PATH[locale];
const accountPath = LANG_ACCOUNT_PATH[locale];
// Strings the client script needs at runtime (kept minimal, passed via define:vars). // Strings the client script needs at runtime (kept minimal, passed via define:vars).
const clientCfg = { const clientCfg = {
@@ -39,6 +40,7 @@ const clientCfg = {
skinUpload: a.skinUpload, skinUpload: a.skinUpload,
skinUploading: a.skinUploading, skinUploading: a.skinUploading,
skinOk: a.skinOk, skinOk: a.skinOk,
refresh: a.refresh,
skinPreviewNote: a.skinPreviewNote, skinPreviewNote: a.skinPreviewNote,
resetSkin: a.resetSkin, resetSkin: a.resetSkin,
resetConfirm: a.resetConfirm, resetConfirm: a.resetConfirm,
@@ -61,7 +63,7 @@ const clientCfg = {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{site.name} — {a.title}</title> <title>{site.name} — {a.title}</title>
<meta name="description" content={a.lead} /> <meta name="description" content={a.lead} />
<link rel="icon" type="image/png" href="/logo.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="/fonts/fonts.css" /> <link rel="stylesheet" href="/fonts/fonts.css" />
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_ACCOUNT_PATH[l]} />)} {LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_ACCOUNT_PATH[l]} />)}
</head> </head>
@@ -86,6 +88,8 @@ const clientCfg = {
>{LANG_LABEL[l]}</a> >{LANG_LABEL[l]}</a>
))} ))}
</div> </div>
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a> <a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
</div> </div>
</header> </header>
@@ -98,7 +102,8 @@ const clientCfg = {
<h1>{a.title}</h1> <h1>{a.title}</h1>
<p class="lead">{a.lead}</p> <p class="lead">{a.lead}</p>
<!-- LOGIN FORM --> <!-- LOGIN (hidden once authenticated) -->
<div id="acc-login">
<form class="reg-form" id="acc-form" novalidate> <form class="reg-form" id="acc-form" novalidate>
<div class="reg-field"> <div class="reg-field">
<label for="acc-username">{a.usernameLabel}</label> <label for="acc-username">{a.usernameLabel}</label>
@@ -117,6 +122,7 @@ const clientCfg = {
<p class="reg-foot"> <p class="reg-foot">
{a.noAccount} <a href={registerPath}>{a.registerLink}</a> {a.noAccount} <a href={registerPath}>{a.registerLink}</a>
</p> </p>
</div>
<!-- SKIN PANEL (revealed after login) --> <!-- SKIN PANEL (revealed after login) -->
<div class="reg-success" id="acc-panel" hidden> <div class="reg-success" id="acc-panel" hidden>
@@ -128,8 +134,9 @@ const clientCfg = {
<!-- Current avatar preview (NMSR) --> <!-- Current avatar preview (NMSR) -->
<div id="acc-avatar-wrap" style="display:flex;align-items:flex-start;gap:20px;flex-wrap:wrap"> <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" <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)" /> style="image-rendering:pixelated;width:96px;height:96px;background:var(--slot)" />
<div style="flex:1;min-width:0"> <div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:10px;align-items:flex-start">
<button class="mc-btn sm" id="acc-avatar-refresh" type="button">{a.refresh}</button>
<p class="reg-skin" style="border:none;padding-top:0;margin:0;color:var(--dim);font-size:13px" id="acc-preview-note"></p> <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> </div>
@@ -184,6 +191,7 @@ const clientCfg = {
<script define:vars={{ cfg: clientCfg }}> <script define:vars={{ cfg: clientCfg }}>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const form = $("acc-form"); const form = $("acc-form");
const loginBox = $("acc-login");
const errBox = $("acc-error"); const errBox = $("acc-error");
const submitBtn = $("acc-submit"); const submitBtn = $("acc-submit");
const panel = $("acc-panel"); const panel = $("acc-panel");
@@ -234,8 +242,8 @@ const clientCfg = {
apiToken = data.apiToken; apiToken = data.apiToken;
player = data.user.players[0]; player = data.user.players[0];
// Show panel // Show panel — hide the whole login block (form + heading foot).
form.hidden = true; loginBox.hidden = true;
$("acc-player-name").textContent = player.name; $("acc-player-name").textContent = player.name;
// Set NMSR avatar // Set NMSR avatar
const avatarImg = $("acc-avatar"); const avatarImg = $("acc-avatar");
@@ -253,6 +261,12 @@ const clientCfg = {
} }
}); });
// ---- Avatar refresh (re-fetch NMSR with a cache-bust) ----
$("acc-avatar-refresh").addEventListener("click", () => {
if (!player) return;
$("acc-avatar").src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96&t=${Date.now()}`;
});
// ---- Skin upload ---- // ---- Skin upload ----
const fileInput = $("acc-skin-file"); const fileInput = $("acc-skin-file");
const fileName = $("acc-skin-name"); const fileName = $("acc-skin-name");

View File

@@ -6,6 +6,8 @@ import {
LANG_LABEL, LANG_LABEL,
LANG_PATH, LANG_PATH,
LANG_FJORD_PATH, LANG_FJORD_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";
@@ -16,8 +18,8 @@ import "../../styles/main.css";
// alternative manual path — intentionally NOT linked from the homepage nav. // alternative manual path — intentionally NOT linked from the homepage nav.
export function getStaticPaths() { export function getStaticPaths() {
return [ return [
{ params: { lang: undefined }, props: { locale: "en" as Lang } }, { params: { lang: undefined }, props: { locale: "es" as Lang } },
{ params: { lang: "es" }, props: { locale: "es" as Lang } }, { params: { lang: "en" }, props: { locale: "en" as Lang } },
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } }, { params: { lang: "eu" }, props: { locale: "eu" as Lang } },
]; ];
} }
@@ -28,6 +30,8 @@ const f = t.fjord;
const { theme, fjord } = site; const { theme, fjord } = site;
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify; const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
const homePath = LANG_PATH[locale]; const homePath = LANG_PATH[locale];
const registerPath = LANG_REGISTER_PATH[locale];
const accountPath = LANG_ACCOUNT_PATH[locale];
const fjordPack = site.launcherManifest.fjordPack; const fjordPack = site.launcherManifest.fjordPack;
--- ---
@@ -43,7 +47,7 @@ const fjordPack = site.launcherManifest.fjordPack;
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{site.name} — {f.title}</title> <title>{site.name} — {f.title}</title>
<meta name="description" content={f.lead} /> <meta name="description" content={f.lead} />
<link rel="icon" type="image/png" href="/logo.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="/fonts/fonts.css" /> <link rel="stylesheet" href="/fonts/fonts.css" />
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_FJORD_PATH[l]} />)} {LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_FJORD_PATH[l]} />)}
</head> </head>
@@ -68,6 +72,8 @@ const fjordPack = site.launcherManifest.fjordPack;
>{LANG_LABEL[l]}</a> >{LANG_LABEL[l]}</a>
))} ))}
</div> </div>
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a> <a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
</div> </div>
</header> </header>

View File

@@ -15,8 +15,8 @@ import "../../styles/main.css";
// One static register page per locale: /register, /es/register, /eu/register. // One static register page per locale: /register, /es/register, /eu/register.
export function getStaticPaths() { export function getStaticPaths() {
return [ return [
{ params: { lang: undefined }, props: { locale: "en" as Lang } }, { params: { lang: undefined }, props: { locale: "es" as Lang } },
{ params: { lang: "es" }, props: { locale: "es" as Lang } }, { params: { lang: "en" }, props: { locale: "en" as Lang } },
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } }, { params: { lang: "eu" }, props: { locale: "eu" as Lang } },
]; ];
} }
@@ -27,12 +27,16 @@ const r = t.register;
const { theme } = site; const { theme } = site;
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify; const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
const homePath = LANG_PATH[locale]; const homePath = LANG_PATH[locale];
const requiresInvite = site.registrationRequiresInvite; const registerPath = LANG_REGISTER_PATH[locale];
const accountPath = LANG_ACCOUNT_PATH[locale];
// Visibility of the invite-code field (default hidden). Independent of the
// backend gate (site.registrationRequiresInvite / Drasl RequireInvite).
const showInvite = site.registrationShowInvite;
// Strings the client script needs at runtime (kept minimal, passed via define:vars). // Strings the client script needs at runtime (kept minimal, passed via define:vars).
const clientCfg = { const clientCfg = {
apiUrl: site.draslApiUrl, apiUrl: site.draslApiUrl,
requiresInvite, showInvite,
submit: r.submit, submit: r.submit,
submitting: r.submitting, submitting: r.submitting,
skinUpload: r.skinUpload, skinUpload: r.skinUpload,
@@ -57,7 +61,7 @@ const clientCfg = {
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{site.name} — {r.title}</title> <title>{site.name} — {r.title}</title>
<meta name="description" content={r.lead} /> <meta name="description" content={r.lead} />
<link rel="icon" type="image/png" href="/logo.png" /> <link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="/fonts/fonts.css" /> <link rel="stylesheet" href="/fonts/fonts.css" />
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_REGISTER_PATH[l]} />)} {LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_REGISTER_PATH[l]} />)}
</head> </head>
@@ -82,6 +86,8 @@ const clientCfg = {
>{LANG_LABEL[l]}</a> >{LANG_LABEL[l]}</a>
))} ))}
</div> </div>
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a> <a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
</div> </div>
</header> </header>
@@ -106,7 +112,7 @@ const clientCfg = {
<input class="reg-input" id="reg-password" name="password" <input class="reg-input" id="reg-password" name="password"
type="password" autocomplete="new-password" placeholder={r.passwordPlaceholder} required /> type="password" autocomplete="new-password" placeholder={r.passwordPlaceholder} required />
</div> </div>
{requiresInvite && ( {showInvite && (
<div class="reg-field"> <div class="reg-field">
<label for="reg-invite">{r.inviteLabel}</label> <label for="reg-invite">{r.inviteLabel}</label>
<input class="reg-input" id="reg-invite" name="invite" <input class="reg-input" id="reg-invite" name="invite"
@@ -208,7 +214,7 @@ const clientCfg = {
const password = $("reg-password").value; const password = $("reg-password").value;
const inviteEl = $("reg-invite"); const inviteEl = $("reg-invite");
const invite = inviteEl ? inviteEl.value.trim() : null; const invite = inviteEl ? inviteEl.value.trim() : null;
if (!username || !password || (cfg.requiresInvite && !invite)) { if (!username || !password || (cfg.showInvite && !invite)) {
showErr(errBox, cfg.errFields); showErr(errBox, cfg.errFields);
return; return;
} }

View File

@@ -484,11 +484,11 @@ section { position: relative; z-index: 1; }
gap: 8px; gap: 8px;
padding: 12px 8px; padding: 12px 8px;
background: var(--bg-2); background: var(--bg-2);
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
} }
.roster-tile img { .roster-tile img {
width: 48px; width: auto;
height: 48px; height: 72px;
object-fit: contain;
image-rendering: pixelated; image-rendering: pixelated;
} }
.roster-name { .roster-name {
@@ -522,7 +522,7 @@ section { position: relative; z-index: 1; }
.feat h3 { font-size: 21px; margin-bottom: 8px; } .feat h3 { font-size: 21px; margin-bottom: 8px; }
.feat p { margin: 0; color: var(--muted); font-size: 16px; } .feat p { margin: 0; color: var(--muted); font-size: 16px; }
/* pixel icon */ /* pixel icon — plain (no bevel) */
.picon { .picon {
width: 52px; height: 52px; flex-shrink: 0; width: 52px; height: 52px; flex-shrink: 0;
display: grid; display: grid;
@@ -530,12 +530,17 @@ section { position: relative; z-index: 1; }
grid-template-rows: repeat(7, 1fr); grid-template-rows: repeat(7, 1fr);
background: var(--slot); background: var(--slot);
padding: 4px; padding: 4px;
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
image-rendering: pixelated; image-rendering: pixelated;
} }
.picon i { background: transparent; } .picon i { background: transparent; }
.picon i.on { background: var(--accent); } .picon i.on { background: var(--accent); }
.picon i.go { background: var(--gold); } .picon i.go { background: var(--gold); }
/* custom image icon (PixelIcon `img` prop) */
.picon-img {
width: 52px; height: 52px; flex-shrink: 0;
object-fit: contain;
image-rendering: pixelated;
}
/* ============================================================ /* ============================================================
HOW TO JOIN — steps HOW TO JOIN — steps

View File

@@ -61,11 +61,15 @@ script only if the font set changes (keep families in sync with `main.css`
## Languages (i18n) ## Languages (i18n)
Three locales, static, build-time — **English** (default, `/`), Three locales, static, build-time — **Spanish** (default, `/`),
**Spanish** (`/es/`), **Euskera** (`/eu/`). **English** (`/en/`), **Euskera** (`/eu/`).
- One template `src/pages/[...lang].astro` with `getStaticPaths` emits all three - One template `src/pages/[...lang].astro` with `getStaticPaths` emits all three
routes (`/`, `/es/`, `/eu/`). No runtime/JS routing. routes (`/`, `/en/`, `/eu/`). No runtime/JS routing. The default locale lives at
the bare root: its `getStaticPaths` entry is `{ lang: undefined }``locale:
"es"`; en/eu get an explicit prefix. The `LANG_*_PATH` maps in `ui.ts` mirror
this (es → no prefix). Changing the default = swap which locale is `undefined`
in all four pages' `getStaticPaths` + the `LANG_*_PATH` maps.
- Translatable copy lives in `src/i18n/ui.ts` (`ui[locale]`), keyed identically - Translatable copy lives in `src/i18n/ui.ts` (`ui[locale]`), keyed identically
across locales. `site.ts` holds only language-neutral config (URLs, theme, across locales. `site.ts` holds only language-neutral config (URLs, theme,
launcher files, stat/feature *structure*). launcher files, stat/feature *structure*).
@@ -103,6 +107,30 @@ Page links to fixed names so they survive launcher version bumps:
sync** between `site.ts` and the script. Mounted so they resolve at sync** between `site.ts` and the script. Mounted so they resolve at
`/launcher/latest/…` (see Caddy `10-static.caddy`). `/launcher/latest/…` (see Caddy `10-static.caddy`).
## Assets — where to drop images
All static images live under `landing/public/` and are referenced by an absolute
`/path` (Astro copies `public/` to the site root verbatim). After changing any,
rebuild the landing (`pnpm run build`).
- **Header / hero logo** — `landing/public/logo.png` (current 707×148). Referenced
as `/logo.png` in the hero (`[...lang].astro`, the `.hero-logo img`). Replace the
file; keep a wide transparent PNG. Update the `width`/`height` attrs if the
aspect changes.
- **Favicon** — `landing/public/favicon.png` (square, ~175×175). Referenced as
`/favicon.png` in the `<link rel="icon">` of all four pages
(`[...lang].astro`, `register`, `account`, `fjord`). Replace the file.
- **Server-status icon** — NOT a file you place here. The ServerCard pulls the
live server icon over the SLP query (`ServerCard.astro`, the favicon data-URI),
falling back to the `Creeper.astro` SVG. To change it, set `server-icon.png` on
the Minecraft server (64×64), not in the landing.
- **Features icons** — two options (`PixelIcon.astro`, fed by `site.ts` `features[]`):
1. *Procedural glyph* (default): pick `glyph ∈ shield|diamond|orb|heart` and
`gold` per feature in `site.ts`. Add a glyph by editing the 7×7 `GLYPHS` map.
2. *Custom image*: drop a file in `landing/public/icons/<name>.(png|svg)` and add
`img: "/icons/<name>.png"` to that feature's entry in `site.ts` `features[]`.
`PixelIcon` renders the image (`.picon-img`) instead of the glyph.
## Tasks ## Tasks
- [x] Vendor fonts (`tooling/fetch-fonts.sh`) → `public/fonts/` - [x] Vendor fonts (`tooling/fetch-fonts.sh`) → `public/fonts/`