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

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

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

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

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

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

View File

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

View File

@@ -0,0 +1,84 @@
---
// Installed-pack mod list (the "Mods" section). Build-time baked from the
// auto-generated catalogue (tooling/build-modlist.py → src/data/mods.json),
// loaded via ./data/mods.ts (resilient: missing file → empty list, build never
// fails). Flat alphabetical grid: extracted logo (or a pixel placeholder) +
// pretty name + version. description is exposed as a tooltip. No categories, no
// external links. Tiles share the MC-GUI bevel treatment used by .feat/.tile.
import { MODS } from "../data/mods";
interface Props {
emptyLabel?: string;
}
const { emptyLabel = "Mod list not generated yet." } = Astro.props;
const mods = MODS.mods;
---
{mods.length === 0 ? (
<p class="mods-empty">{emptyLabel}</p>
) : (
<div class="mod-grid">
{mods.map((m) => (
<div class="mod" title={m.description || m.name}>
<div class="mod-logo">
{m.logo ? (
<img src={m.logo} alt="" width="40" height="40" loading="lazy" />
) : (
<span class="mod-noicon" aria-hidden="true">{m.name.slice(0, 1).toUpperCase()}</span>
)}
</div>
<div class="mod-meta">
<span class="mod-name">{m.name}</span>
{m.version && <span class="mod-ver">{m.version}</span>}
</div>
</div>
))}
</div>
)}
<style>
.mod-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.mod {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--surface);
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
transition: transform .14s ease, filter .14s ease;
}
.mod:hover { transform: translateY(-2px); filter: brightness(1.06); }
.mod-logo {
width: 40px; height: 40px; flex-shrink: 0;
display: grid; place-items: center;
background: var(--bg-2);
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
}
.mod-logo img { width: 40px; height: 40px; image-rendering: pixelated; }
.mod-noicon {
font-family: var(--font-head);
font-weight: 600;
font-size: 20px;
color: var(--accent);
line-height: 1;
}
.mod-meta { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.mod-name {
font-size: 15px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mod-ver {
font-family: var(--font-mono);
font-size: 12px;
color: var(--dim);
font-variant-numeric: tabular-nums;
}
.mods-empty { margin: 0; color: var(--dim); font-size: 14px; }
</style>

View File

@@ -0,0 +1,88 @@
---
// Registered-players roster (the "Members" section). Progressive enhancement,
// same shape as ServerCard:
// SSG → renders an empty skeleton (a single .roster grid + an empty-state
// line) so the page is valid with JS off.
// Client → fetches OUR mc-status roster endpoint (site.status.rosterApi,
// same-origin via caddy /api/mcstatus/* → internal /players). It
// returns a sanitized [{uuid,name}] of every Drasl player (admin login
// is server-side in mc-status — no admin token reaches the page). Each
// player renders as a SHARED avatar tile (.roster-tile, styled in
// main.css alongside ServerCard's online tiles).
//
// Empty/error → hide the grid and show the "no members yet" line (data-empty).
import { site } from "../data/site";
interface Props {
// Empty-state copy (passed from the page for i18n; English default).
emptyLabel?: string;
}
const { emptyLabel = "No members yet." } = Astro.props;
const { status, avatarUrl, avatarMode } = site;
---
<div
class="roster-block"
data-api={status.rosterApi}
data-avatar={avatarUrl}
data-mode={avatarMode}
data-empty={emptyLabel}
>
<div class="roster" aria-label="Registered players"></div>
<p class="roster-empty" hidden></p>
</div>
<script>
async function hydrate(el: HTMLElement) {
const { api, avatar, mode, empty } = el.dataset;
if (!api || !avatar || !mode) return;
const grid = el.querySelector<HTMLElement>(".roster");
const emptyEl = el.querySelector<HTMLElement>(".roster-empty");
if (!grid) return;
const showEmpty = () => {
grid.hidden = true;
if (emptyEl) {
emptyEl.textContent = empty ?? "No members yet.";
emptyEl.hidden = false;
}
};
let data: Array<{ uuid?: string; name?: string }>;
try {
const r = await fetch(api, { headers: { Accept: "application/json" } });
if (!r.ok) return showEmpty();
data = await r.json();
} catch {
return showEmpty();
}
const players = Array.isArray(data) ? data.filter((p) => p && p.uuid) : [];
if (players.length === 0) return showEmpty();
grid.innerHTML = "";
for (const p of players) {
grid.appendChild(rosterTile(p.uuid!, p.name ?? "", avatar, mode));
}
}
// Shared avatar tile (avatar + name) — identical markup to ServerCard's
// online tiles, so .roster / .roster-tile (global, main.css) style both.
function rosterTile(uuid: string, name: string, avatar: string, mode: string) {
const tile = document.createElement("div");
tile.className = "roster-tile";
const img = document.createElement("img");
img.loading = "lazy";
img.alt = name;
img.src = `${avatar}/${mode}/${uuid}?size=64`;
const label = document.createElement("span");
label.className = "roster-name";
label.textContent = name;
label.title = name;
tile.append(img, label);
return tile;
}
document.querySelectorAll<HTMLElement>(".roster-block").forEach(hydrate);
</script>

View File

@@ -33,14 +33,13 @@ const {
emptyLabel = "Nobody online — be the first.",
} = Astro.props;
const { status, avatarUrl } = site;
const HEAD_MODE = "headiso"; // NMSR render mode for the head row
const { status, avatarUrl, avatarMode } = site;
---
<div
class="servercard"
data-api={status.statusApi}
data-avatar={avatarUrl}
data-mode={HEAD_MODE}
data-mode={avatarMode}
data-slots={status.slots}
data-online={onlineLabel}
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. -->
<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-heads" aria-label="Players online"></div>
<div class="roster" aria-label="Players online"></div>
<p class="sc-empty" hidden></p>
</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-fill { display: block; height: 100%; width: 0; background: var(--accent); transition: width 0.6s ease; }
.sc-heads { display: flex; flex-wrap: wrap; gap: 6px; }
.sc-heads img {
width: 34px; height: 34px; image-rendering: pixelated;
background: var(--bg-2);
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
}
/* Online avatar tiles use the shared .roster / .roster-tile styles in
main.css (also used by PlayerRoster.astro). */
.sc-empty { margin: 0; color: var(--dim); font-size: 13px; }
</style>
@@ -160,7 +155,7 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row
const bar = q(".sc-bar");
const fill = q<HTMLElement>(".sc-bar-fill");
const players = q(".sc-players");
const heads = q(".sc-heads");
const roster = q(".roster");
const emptyEl = q(".sc-empty");
let data: any;
@@ -198,7 +193,9 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row
// Color MOTD (mcstatus ships inline-styled spans for our own server).
if (motdEl && data.motd?.html) motdEl.innerHTML = data.motd.html;
// Player heads from OUR NMSR (Drasl skins). Filter promo/placeholder rows.
// Labeled avatar tiles from OUR NMSR (Drasl skins). Filter promo/placeholder
// rows (all-zero UUID) and dedupe. Tiles share .roster styles with the
// Members grid (PlayerRoster.astro) — see rosterTile() below.
const list: Array<{ uuid?: string; name_clean?: string }> = data.players?.list ?? [];
const seen = new Set<string>();
const real = list.filter((p) => {
@@ -207,7 +204,7 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row
seen.add(u);
return true;
});
if (players && heads) {
if (players && roster) {
players.hidden = false;
if (real.length === 0) {
if (emptyEl) {
@@ -215,18 +212,30 @@ const HEAD_MODE = "headiso"; // NMSR render mode for the head row
emptyEl.hidden = false;
}
} else {
heads.innerHTML = "";
roster.innerHTML = "";
for (const p of real.slice(0, 16)) {
const img = document.createElement("img");
img.loading = "lazy";
img.alt = p.name_clean ?? "";
img.title = p.name_clean ?? "";
img.src = `${avatar}/${mode}/${p.uuid}?size=64`;
heads.appendChild(img);
roster.appendChild(rosterTile(p.uuid!, p.name_clean ?? "", avatar, mode));
}
}
}
}
// Build one shared avatar tile (avatar + name). Same markup PlayerRoster
// builds, so .roster / .roster-tile (global, in main.css) style both.
function rosterTile(uuid: string, name: string, avatar: string, mode: string) {
const tile = document.createElement("div");
tile.className = "roster-tile";
const img = document.createElement("img");
img.loading = "lazy";
img.alt = name;
img.src = `${avatar}/${mode}/${uuid}?size=64`;
const label = document.createElement("span");
label.className = "roster-name";
label.textContent = name;
label.title = name;
tile.append(img, label);
return tile;
}
document.querySelectorAll<HTMLElement>(".servercard").forEach(hydrate);
</script>

View File

@@ -0,0 +1,41 @@
{
"schemaVersion": 1,
"product": "UlicraftLauncher",
"version": "1.0.0",
"channel": "stable",
"releasedAt": "2026-06-01T12:00:00Z",
"minecraft": "1.21.1",
"neoforge": "21.1.233",
"builds": [
{
"os": "windows",
"arch": "x64",
"format": "exe",
"filename": "UlicraftLauncher-1.0.0-setup.exe",
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-setup.exe",
"size": 78643200,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
},
{
"os": "macos",
"arch": "universal",
"format": "dmg",
"filename": "UlicraftLauncher-1.0.0-universal.dmg",
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-universal.dmg",
"size": 92274688
},
{
"os": "linux",
"arch": "x64",
"format": "appimage",
"filename": "UlicraftLauncher-1.0.0-x86_64.AppImage",
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-x86_64.AppImage",
"size": 85983232
}
],
"fjordPack": {
"filename": "ulicraft-1.21.1.mrpack",
"url": "https://distribution.ulicraft.net/launcher/ulicraft-1.21.1.mrpack",
"size": 12582912
}
}

View File

@@ -0,0 +1,119 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ulicraft.net/schemas/launcher-manifest.schema.json",
"title": "Ulicraft launcher download manifest",
"description": "Produced by the custom-launcher release flow, consumed by the landing build to render per-OS download buttons. One file describes the current UlicraftLauncher release plus the optional Fjord pack.",
"type": "object",
"additionalProperties": false,
"required": ["schemaVersion", "product", "version", "builds"],
"properties": {
"schemaVersion": {
"description": "Bump on breaking shape changes. Landing pins the major it understands.",
"type": "integer",
"const": 1
},
"product": {
"description": "Display name of the launcher.",
"type": "string",
"examples": ["UlicraftLauncher"]
},
"version": {
"description": "Launcher release version (semver, no leading v).",
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+(?:[-+].+)?$"
},
"channel": {
"description": "Release channel.",
"type": "string",
"enum": ["stable", "beta"],
"default": "stable"
},
"releasedAt": {
"description": "Release timestamp (ISO 8601 / RFC 3339).",
"type": "string",
"format": "date-time"
},
"minecraft": {
"description": "Target Minecraft version the launcher installs.",
"type": "string",
"examples": ["1.21.1"]
},
"neoforge": {
"description": "NeoForge version the distribution pins.",
"type": "string",
"examples": ["21.1.233"]
},
"builds": {
"description": "Downloadable launcher binaries, one entry per OS/arch/format.",
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/asset" }
},
"fjordPack": {
"description": "Optional importable pack for the Fjord (Prism) alternative path. Lives on its own landing page, not the homepage.",
"$ref": "#/$defs/file"
}
},
"$defs": {
"asset": {
"type": "object",
"additionalProperties": false,
"required": ["os", "arch", "format", "filename", "url"],
"properties": {
"os": {
"type": "string",
"enum": ["windows", "macos", "linux"]
},
"arch": {
"description": "CPU arch; 'universal' for a fat/any binary.",
"type": "string",
"enum": ["x64", "arm64", "universal"]
},
"format": {
"description": "Installer/package kind, drives the button hint.",
"type": "string",
"enum": ["exe", "msi", "dmg", "pkg", "appimage", "deb", "rpm", "tar.gz", "zip"]
},
"label": {
"description": "Optional human hint override (else derived from os+arch+format).",
"type": "string"
},
"filename": {
"type": "string",
"minLength": 1
},
"url": {
"description": "Absolute download URL.",
"type": "string",
"format": "uri",
"pattern": "^https://"
},
"size": {
"description": "Bytes, for showing a size next to the button.",
"type": "integer",
"minimum": 0
},
"sha256": {
"description": "Lowercase hex SHA-256 for integrity display/verify.",
"type": "string",
"pattern": "^[a-f0-9]{64}$"
}
}
},
"file": {
"type": "object",
"additionalProperties": false,
"required": ["filename", "url"],
"properties": {
"filename": { "type": "string", "minLength": 1 },
"url": {
"type": "string",
"format": "uri",
"pattern": "^https://"
},
"size": { "type": "integer", "minimum": 0 },
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
}
}
}
}

View File

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

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

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

View File

@@ -2,8 +2,88 @@
// domains, URLs, theme, launcher files, and the structural shape of the
// stat/feature lists. Translatable copy lives in src/i18n/ui.ts.
// BASE_DOMAIN is read at build time; falls back to ulicraft.local.
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
// `process` below is untyped). Used only at build time to read the manifest.
import { readFileSync } from "node:fs";
import { MODS } from "./mods";
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? "ulicraft.local";
// Installed-pack mod count, fed by tooling/build-modlist.py via mods.json (see
// ./mods.ts). Feeds the homepage "Mods" stat tile. When the generated catalogue
// is absent/empty (fresh checkout), fall back to the honest "50+" estimate.
const MOD_COUNT_KEY = MODS.count > 0 ? `${MODS.count}` : "50+";
// NMSR render mode for avatar tiles (online roster + members grid). Read at
// build time, same as BASE_DOMAIN. e.g. "headiso" | "head" | "fullbody".
// @ts-ignore — `process` is untyped here (no @types/node), same as BASE_DOMAIN.
const AVATAR_MODE = process.env.AVATAR_MODE ?? "headiso";
// Launcher download manifest (per launcher-manifest.schema.json). The
// custom-launcher release flow syncs launcher-manifest.json into this dir
// (gitignored, generated) — same pattern as sync-server-mods.sh. It MAY NOT
// EXIST at build time, so we read it at runtime with fs (a static `import`
// would hard-fail the build when absent). Fall back to the committed
// .example.json (the documented shape); if even that is unreadable, expose an
// empty-builds manifest so the homepage simply renders no download buttons.
type LauncherBuild = {
os: "windows" | "macos" | "linux";
arch: "x64" | "arm64" | "universal";
format: "exe" | "msi" | "dmg" | "pkg" | "appimage" | "deb" | "rpm" | "tar.gz" | "zip";
label?: string;
filename: string;
url: string;
size?: number;
sha256?: string;
};
type LauncherFile = { filename: string; url: string; size?: number; sha256?: string };
type LauncherManifest = {
schemaVersion: number;
product: string;
version: string;
channel?: string;
releasedAt?: string;
minecraft?: string;
neoforge?: string;
builds: LauncherBuild[];
fjordPack?: LauncherFile;
};
function loadLauncherManifest(): LauncherManifest {
const empty: LauncherManifest = {
schemaVersion: 1,
product: "UlicraftLauncher",
version: "0.0.0",
builds: [],
};
// Resolve relative to this module first, then fall back to a cwd-relative path:
// Astro bundles this module so import.meta.url may not point at src/data/ at
// build time, but the build runs from the landing root, so <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
// render-config.sh. "invite" => the register form must collect an invite code.
const REGISTRATION_MODE = process.env.REGISTRATION_MODE ?? "invite";
@@ -43,19 +123,17 @@ export const site = {
// Requires CORSAllowOrigins to include the apex in drasl config.toml.
draslApiUrl: `https://auth.${BASE_DOMAIN}/drasl/api/v2`,
// TODO: packwiz removed — the `pack.` vhost no longer exists, so this URL 404s.
// The modpack is now distributed to clients by HeliosLauncher via the
// distribution.json (server loads a filtered subset from ./server-mods/; see
// plan/04-mods.md). The join flow still references this in the launcher step
// and needs to be redesigned around the launcher/distribution flow. Kept
// (non-empty) only so the Astro build + the CopyChip in [...lang].astro don't
// break; do not treat as live.
packwizUrl: `https://pack.${BASE_DOMAIN}/pack.toml`,
// NMSR avatar renderer root. Player heads come from OUR Drasl skins, not
// Mojang/Crafatar — head URL is `${avatarUrl}/headiso/<uuid>?size=64`.
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
// before/without JS; live data comes from our SELF-HOSTED mc-status service
// (docker/mc-status, mcutil wrapper) reverse-proxied same-origin at the apex
@@ -65,12 +143,17 @@ export const site = {
status: {
slots: 10,
statusApi: `/api/mcstatus/v2/status/java/${BASE_DOMAIN}`,
// Registered-players roster (PlayerRoster.astro). Same mc-status service,
// same-origin via caddy /api/mcstatus/* → internal /players. Returns a
// sanitized [{uuid,name}] of every Drasl player (admin login is server-side
// in mc-status; no admin token in the page).
rosterApi: `/api/mcstatus/players`,
},
// Honest stat tiles. Keys (numbers/versions) are language-neutral; labels
// come from ui.ts (same order).
stats: [
{ key: "50+" },
{ key: MOD_COUNT_KEY },
{ key: MC_VERSION },
{ key: "10" },
{ key: "6h" },
@@ -85,10 +168,16 @@ export const site = {
{ glyph: "heart", gold: true },
],
// Launcher downloads. Stable filenames are symlinks created by
// UlicraftLauncher download manifest — feeds the homepage join step 2
// per-OS buttons from `builds[]` (and the Fjord page's .mrpack via
// `fjordPack`). Synced from the custom-launcher repo; see loader above.
launcherManifest: LAUNCHER_MANIFEST,
// Fjord Launcher downloads (the alternative manual path on /fjord, OFF the
// homepage flow). Stable filenames are symlinks created by
// tooling/fetch-launcher.sh — keep these in sync with that script. OS names
// and hints are technical, kept language-neutral here.
launcher: {
fjord: {
name: "Fjord Launcher",
base: "/launcher/latest",
downloads: [

View File

@@ -2,7 +2,7 @@
// locales; the page (src/pages/[...lang].astro) renders against it.
//
// Conventions:
// - "{name}" in step1 is replaced with the launcher name at render time.
// - "{name}" is replaced with the launcher name at render time.
// - Sentences containing links/kbd/literals are split into parts (a/b/pre/
// post/link) so the markup stays in the template, not in the strings.
// - Product / in-game literals (authlib-injector, Multiplayer, Add Server)
@@ -19,11 +19,23 @@ export const LANG_REGISTER_PATH: Record<Lang, string> = {
es: "/es/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 {
htmlLang: string;
copied: string; // transient copy-button feedback
nav: { status: string; features: string; join: string; play: string };
nav: { status: string; members: string; features: string; mods: string; join: string; play: string };
hero: {
eyebrow: string; // "Modded LAN · NeoForge" — version appended at render
tagline: string;
@@ -42,13 +54,46 @@ export interface UI {
upTo: string;
};
statLabels: [string, string, string, string];
members: {
eyebrow: string;
title: string;
lead: string;
empty: string; // shown when the roster is empty or unreachable
};
features: {
eyebrow: string;
title: string;
lead: string;
items: { h: string; p: string }[];
};
mods: {
eyebrow: string;
title: string;
lead: string;
empty: string; // shown when the catalogue is absent/empty (not yet generated)
};
join: {
eyebrow: string;
title: string;
lead: string;
s1: {
kicker: string;
h: string;
pa: string; // before the register link
pb: string; // after it
registerLink: string;
};
s2: { kicker: string; h: string; p: string };
s3: {
kicker: string;
h: string;
pa: string; // before the Multiplayer/Add Server kbds
pb: string; // after them
copy: string;
};
};
fjord: {
navHome: string; // "← Home" link in nav
eyebrow: string;
title: string;
lead: string;
@@ -59,7 +104,6 @@ export interface UI {
pa: string; // before the authlib-injector literal
pb: string; // after it
copy: string;
registerPre: string;
};
s3: { kicker: string; h: string; p: string };
s4: {
@@ -69,8 +113,9 @@ export interface UI {
pb: string; // after them
copy: string;
};
noPack: string; // shown when fjordPack is missing from the manifest
};
footer: { join: string; disc: string };
footer: { join: string; status: string; disc: string };
register: {
navHome: string; // "← Home" link in nav
eyebrow: string;
@@ -86,7 +131,7 @@ export interface UI {
submit: string;
submitting: string;
haveAccount: string; // "Already have an account?"
loginLink: string; // links to the Drasl web UI
loginLink: string; // links to the account page
successTitle: string;
successBody: string; // {name} = player name
uuidLabel: string;
@@ -104,13 +149,47 @@ export interface UI {
errSkinType: string;
errNetwork: string;
};
account: {
navHome: string; // "← Home" link in nav
eyebrow: string;
title: string;
lead: string;
// Login form
usernameLabel: string;
usernamePlaceholder: string;
passwordLabel: string;
passwordPlaceholder: string;
submit: string;
submitting: string;
noAccount: string; // "No account yet?"
registerLink: string; // links to /register
// Skin panel
playerLabel: string; // "Logged in as"
skinTitle: string;
skinLead: string; // reuses register.skinLead concept
skinChoose: string;
modelLabel: string;
modelClassic: string;
modelSlim: string;
skinUpload: string;
skinUploading: string;
skinOk: string;
skinPreviewNote: string; // "NMSR takes ~15 min to update" hint
resetSkin: string;
resetConfirm: string; // window.confirm text
resetOk: string;
// Errors
errFields: string;
errSkinType: string;
errNetwork: string;
};
}
export const ui: Record<Lang, UI> = {
en: {
htmlLang: "en",
copied: "Copied!",
nav: { status: "Status", features: "Features", join: "How to Join", play: "Play Now" },
nav: { status: "Status", members: "Members", features: "Features", mods: "Mods", join: "How to Join", play: "Play Now" },
hero: {
eyebrow: "Modded LAN · NeoForge",
tagline: "Kitchen-sink survival, built to outlast the weekend.",
@@ -132,6 +211,12 @@ export const ui: Record<Lang, UI> = {
upTo: "up to",
},
statLabels: ["Mods", "NeoForge", "Player slots", "World backups"],
members: {
eyebrow: "The crew",
title: "Who's on the server.",
lead: "Everyone registered on Ulicraft, with their own self-hosted skin.",
empty: "No members yet — be the first to register.",
},
features: {
eyebrow: "What makes it Ulicraft",
title: "Built for the crew.",
@@ -149,7 +234,7 @@ export const ui: Record<Lang, UI> = {
},
{
h: "One-click setup",
p: "packwiz installs and updates the whole modpack inside your launcher, so everyone stays in sync.",
p: "The Ulicraft Launcher installs and updates the whole modpack from our distribution, so everyone stays in sync.",
},
{
h: "Built to last",
@@ -157,27 +242,57 @@ export const ui: Record<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: {
eyebrow: "How to Join · 4 Steps",
eyebrow: "How to Join · 3 Steps",
title: "From zero to spawning in.",
lead: "One-time setup. After this, the launcher keeps you in sync.",
s1: {
kicker: "Account",
h: "Register",
pa: "Create your Ulicraft account at",
pb: "— one username and password for the server and your skin.",
registerLink: "Register",
},
s2: {
kicker: "Launcher",
h: "Download {name}",
p: "Grab {name} for your system — a Prism-based launcher with authlib-injector support built in.",
p: "Grab {name} for your system and log in with your Ulicraft credentials — it installs the modpack and everything else automatically.",
},
s3: {
kicker: "Connect",
h: "Join the server",
pa: "Launch the launcher, open",
pb: ", paste the address, and join.",
copy: "Copy IP",
},
},
fjord: {
navHome: "← Home",
eyebrow: "Alternative · Fjord Launcher",
title: "Play with Fjord Launcher.",
lead: "Prefer a vanilla Prism-based launcher? Set it up manually with Fjord and import the Ulicraft pack.",
s1: {
kicker: "Launcher",
h: "Download Fjord",
p: "Grab Fjord Launcher for your system — a Prism-based launcher with authlib-injector support built in.",
},
s2: {
kicker: "Account",
h: "Add your account",
pa: "In the launcher, add an",
pa: "In Fjord, add an",
pb: "account using this URL, then log in with your Ulicraft credentials.",
copy: "Copy",
registerPre: "Need credentials? Register at",
},
s3: {
kicker: "Modpack",
h: "Import the modpack",
p: "Add a new instance from this packwiz URL — it installs and updates the whole pack.",
h: "Import the Ulicraft pack",
p: "Download the Ulicraft modpack and import it into Fjord as a new instance — it installs the whole pack.",
},
s4: {
kicker: "Connect",
@@ -186,9 +301,11 @@ export const ui: Record<Lang, UI> = {
pb: ", paste the address, and join.",
copy: "Copy IP",
},
noPack: "The pack download isn't available yet — check back soon.",
},
footer: {
join: "How to Join",
status: "Server status",
disc: "Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.",
},
register: {
@@ -224,12 +341,43 @@ export const ui: Record<Lang, UI> = {
errSkinType: "Pick a PNG image.",
errNetwork: "Network error — try again.",
},
account: {
navHome: "← Home",
eyebrow: "Account",
title: "Manage your skin.",
lead: "Log in to upload or reset your Minecraft skin.",
usernameLabel: "Username",
usernamePlaceholder: "your in-game name",
passwordLabel: "Password",
passwordPlaceholder: "your password",
submit: "Log in",
submitting: "Logging in…",
noAccount: "No account yet?",
registerLink: "Register here",
playerLabel: "Logged in as",
skinTitle: "Your skin",
skinLead: "Upload a Minecraft skin PNG. The 3D preview below updates instantly; the in-game avatar takes ~15 minutes.",
skinChoose: "Choose PNG…",
modelLabel: "Model",
modelClassic: "Classic",
modelSlim: "Slim",
skinUpload: "Upload skin",
skinUploading: "Uploading…",
skinOk: "Skin updated!",
skinPreviewNote: "Avatar preview updates in ~15 min.",
resetSkin: "Reset to default",
resetConfirm: "Reset your skin to the default? This cannot be undone.",
resetOk: "Skin reset to default.",
errFields: "Fill in every field.",
errSkinType: "Pick a PNG image.",
errNetwork: "Network error — try again.",
},
},
es: {
htmlLang: "es",
copied: "¡Copiado!",
nav: { status: "Estado", features: "Características", join: "Cómo entrar", play: "Jugar ahora" },
nav: { status: "Estado", members: "Miembros", features: "Características", mods: "Mods", join: "Cómo entrar", play: "Jugar ahora" },
hero: {
eyebrow: "LAN con mods · NeoForge",
tagline: "Supervivencia todo incluido, para durar más que el finde.",
@@ -251,6 +399,12 @@ export const ui: Record<Lang, UI> = {
upTo: "hasta",
},
statLabels: ["Mods", "NeoForge", "Plazas", "Copias del mundo"],
members: {
eyebrow: "La cuadrilla",
title: "Quién está en el servidor.",
lead: "Todos los registrados en Ulicraft, cada uno con su skin autoalojada.",
empty: "Aún no hay miembros — sé el primero en registrarte.",
},
features: {
eyebrow: "Qué hace especial a Ulicraft",
title: "Hecho para la cuadrilla.",
@@ -268,7 +422,7 @@ export const ui: Record<Lang, UI> = {
},
{
h: "Configuración en un clic",
p: "packwiz instala y actualiza todo el modpack dentro de tu launcher, así todos vais sincronizados.",
p: "El Ulicraft Launcher instala y actualiza todo el modpack desde nuestra distribución, así todos vais sincronizados.",
},
{
h: "Hecho para durar",
@@ -276,27 +430,57 @@ export const ui: Record<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: {
eyebrow: "Cómo entrar · 4 pasos",
eyebrow: "Cómo entrar · 3 pasos",
title: "De cero a aparecer en el mundo.",
lead: "Configuración única. Después, el launcher te mantiene sincronizado.",
s1: {
kicker: "Cuenta",
h: "Regístrate",
pa: "Crea tu cuenta de Ulicraft en",
pb: "— un usuario y contraseña para el servidor y tu skin.",
registerLink: "Registro",
},
s2: {
kicker: "Launcher",
h: "Descarga {name}",
p: "Coge {name} para tu sistema: un launcher basado en Prism con soporte authlib-injector integrado.",
p: "Coge {name} para tu sistema e inicia sesión con tus credenciales de Ulicraft: instala el modpack y todo lo demás automáticamente.",
},
s3: {
kicker: "Conectar",
h: "Entra al servidor",
pa: "Lanza el launcher, abre",
pb: ", pega la dirección y entra.",
copy: "Copiar IP",
},
},
fjord: {
navHome: "← Inicio",
eyebrow: "Alternativa · Fjord Launcher",
title: "Juega con Fjord Launcher.",
lead: "¿Prefieres un launcher vanilla basado en Prism? Configúralo a mano con Fjord e importa el pack de Ulicraft.",
s1: {
kicker: "Launcher",
h: "Descarga Fjord",
p: "Coge Fjord Launcher para tu sistema: un launcher basado en Prism con soporte authlib-injector integrado.",
},
s2: {
kicker: "Cuenta",
h: "Añade tu cuenta",
pa: "En el launcher, añade una cuenta",
pa: "En Fjord, añade una cuenta",
pb: "con esta URL y luego inicia sesión con tus credenciales de Ulicraft.",
copy: "Copiar",
registerPre: "¿Sin credenciales? Regístrate en",
},
s3: {
kicker: "Modpack",
h: "Importa el modpack",
p: "Añade una instancia nueva desde esta URL de packwiz: instala y actualiza todo el pack.",
h: "Importa el pack de Ulicraft",
p: "Descarga el modpack de Ulicraft e impórtalo en Fjord como instancia nueva: instala todo el pack.",
},
s4: {
kicker: "Conectar",
@@ -305,9 +489,11 @@ export const ui: Record<Lang, UI> = {
pb: ", pega la dirección y entra.",
copy: "Copiar IP",
},
noPack: "La descarga del pack aún no está disponible — vuelve pronto.",
},
footer: {
join: "Cómo entrar",
status: "Estado del servidor",
disc: "No afiliado a Mojang ni Microsoft. Minecraft es una marca de Mojang AB.",
},
register: {
@@ -343,12 +529,43 @@ export const ui: Record<Lang, UI> = {
errSkinType: "Elige una imagen PNG.",
errNetwork: "Error de red: inténtalo de nuevo.",
},
account: {
navHome: "← Inicio",
eyebrow: "Cuenta",
title: "Gestiona tu skin.",
lead: "Inicia sesión para subir o restablecer tu skin de Minecraft.",
usernameLabel: "Usuario",
usernamePlaceholder: "tu nombre en el juego",
passwordLabel: "Contraseña",
passwordPlaceholder: "tu contraseña",
submit: "Iniciar sesión",
submitting: "Entrando…",
noAccount: "¿Aún no tienes cuenta?",
registerLink: "Regístrate aquí",
playerLabel: "Sesión iniciada como",
skinTitle: "Tu skin",
skinLead: "Sube un PNG de skin de Minecraft. La vista previa 3D se actualiza al instante; el avatar en el juego tarda ~15 minutos.",
skinChoose: "Elegir PNG…",
modelLabel: "Modelo",
modelClassic: "Clásico",
modelSlim: "Fino",
skinUpload: "Subir skin",
skinUploading: "Subiendo…",
skinOk: "¡Skin actualizada!",
skinPreviewNote: "La vista previa del avatar se actualiza en ~15 min.",
resetSkin: "Restablecer skin",
resetConfirm: "¿Restablecer tu skin a la predeterminada? Esta acción no se puede deshacer.",
resetOk: "Skin restablecida.",
errFields: "Rellena todos los campos.",
errSkinType: "Elige una imagen PNG.",
errNetwork: "Error de red: inténtalo de nuevo.",
},
},
eu: {
htmlLang: "eu",
copied: "Kopiatuta!",
nav: { status: "Egoera", features: "Ezaugarriak", join: "Nola sartu", play: "Jokatu orain" },
nav: { status: "Egoera", members: "Kideak", features: "Ezaugarriak", mods: "Mods", join: "Nola sartu", play: "Jokatu orain" },
hero: {
eyebrow: "Mod-dun LANa · NeoForge",
tagline: "Mod ugariko biziraupena, irauteko egina.",
@@ -370,6 +587,12 @@ export const ui: Record<Lang, UI> = {
upTo: "gehienez",
},
statLabels: ["Modak", "NeoForge", "Plazak", "Munduaren babeskopiak"],
members: {
eyebrow: "Koadrila",
title: "Nor dago zerbitzarian.",
lead: "Ulicraft-en erregistratutako guztiak, bakoitza bere azal auto-ostatatuarekin.",
empty: "Oraindik ez dago kiderik — izan zaitez lehena erregistratzen.",
},
features: {
eyebrow: "Zerk egiten du Ulicraft berezi",
title: "Koadrilarentzat eginda.",
@@ -387,7 +610,7 @@ export const ui: Record<Lang, UI> = {
},
{
h: "Konfigurazioa klik batean",
p: "packwiz-ek modpack osoa instalatu eta eguneratzen du zure launcherrean, denok sinkronizatuta egoteko.",
p: "Ulicraft Launcher-ek modpack osoa instalatu eta eguneratzen du gure banaketatik, denok sinkronizatuta egoteko.",
},
{
h: "Irauteko eginda",
@@ -395,27 +618,57 @@ export const ui: Record<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: {
eyebrow: "Nola sartu · 4 urrats",
eyebrow: "Nola sartu · 3 urrats",
title: "Zerotik mundura agertzera.",
lead: "Behin bakarrik konfiguratu. Gero, launcherrak sinkronizatuta mantenduko zaitu.",
s1: {
kicker: "Kontua",
h: "Erregistratu",
pa: "Sortu zure Ulicraft kontua hemen:",
pb: "— erabiltzaile eta pasahitz bat zerbitzarirako eta zure azalerako.",
registerLink: "Erregistroa",
},
s2: {
kicker: "Launcherra",
h: "Deskargatu {name}",
p: "Hartu {name} zure sistemarako: Prism-en oinarritutako launcherra, authlib-injector euskarriarekin.",
p: "Hartu {name} zure sistemarako eta hasi saioa zure Ulicraft kredentzialekin: modpacka eta gainerako guztia automatikoki instalatzen du.",
},
s3: {
kicker: "Konektatu",
h: "Sartu zerbitzarira",
pa: "Abiarazi launcherra, ireki",
pb: ", itsatsi helbidea eta sartu.",
copy: "Kopiatu IPa",
},
},
fjord: {
navHome: "← Hasiera",
eyebrow: "Alternatiba · Fjord Launcher",
title: "Jokatu Fjord Launcher-ekin.",
lead: "Prism-en oinarritutako launcher vanilla nahiago duzu? Konfiguratu eskuz Fjord-ekin eta inportatu Ulicraft packa.",
s1: {
kicker: "Launcherra",
h: "Deskargatu Fjord",
p: "Hartu Fjord Launcher zure sistemarako: Prism-en oinarritutako launcherra, authlib-injector euskarriarekin.",
},
s2: {
kicker: "Kontua",
h: "Gehitu zure kontua",
pa: "Launcherrean, gehitu",
pa: "Fjord-en, gehitu",
pb: "kontu bat URL honekin, eta gero hasi saioa zure Ulicraft kredentzialekin.",
copy: "Kopiatu",
registerPre: "Kredentzialik ez? Erregistratu hemen:",
},
s3: {
kicker: "Modpacka",
h: "Inportatu modpacka",
p: "Gehitu instantzia berri bat packwiz URL honetatik: pack osoa instalatu eta eguneratzen du.",
h: "Inportatu Ulicraft packa",
p: "Deskargatu Ulicraft modpacka eta inportatu Fjord-en instantzia berri gisa: pack osoa instalatzen du.",
},
s4: {
kicker: "Konektatu",
@@ -424,9 +677,11 @@ export const ui: Record<Lang, UI> = {
pb: ", itsatsi helbidea eta sartu.",
copy: "Kopiatu IPa",
},
noPack: "Packaren deskarga ez dago oraindik eskuragarri — itzuli laster.",
},
footer: {
join: "Nola sartu",
status: "Zerbitzariaren egoera",
disc: "Ez dago Mojang edo Microsoft-ekin lotuta. Minecraft Mojang AB-ren marka da.",
},
register: {
@@ -462,5 +717,36 @@ export const ui: Record<Lang, UI> = {
errSkinType: "Aukeratu PNG irudi bat.",
errNetwork: "Sare-errorea: saiatu berriro.",
},
account: {
navHome: "← Hasiera",
eyebrow: "Kontua",
title: "Kudeatu zure azala.",
lead: "Hasi saioa zure Minecraft azala igotzeko edo berrezartzeko.",
usernameLabel: "Erabiltzailea",
usernamePlaceholder: "zure jokoko izena",
passwordLabel: "Pasahitza",
passwordPlaceholder: "zure pasahitza",
submit: "Hasi saioa",
submitting: "Sartzen…",
noAccount: "Oraindik konturik ez?",
registerLink: "Erregistratu hemen",
playerLabel: "Saioa hasita:",
skinTitle: "Zure azala",
skinLead: "Igo Minecraft azal PNG bat. 3D aurrebista berehala eguneratzen da; jokoko avatarra ~15 minutuan.",
skinChoose: "Aukeratu PNGa…",
modelLabel: "Eredua",
modelClassic: "Klasikoa",
modelSlim: "Mehea",
skinUpload: "Igo azala",
skinUploading: "Igotzen…",
skinOk: "Azala eguneratuta!",
skinPreviewNote: "Avatararen aurrebista ~15 min barru eguneratzen da.",
resetSkin: "Berrezarri azala",
resetConfirm: "Zure azala lehenetsi nahi duzu? Ezin da desegin.",
resetOk: "Azala berrezarrita.",
errFields: "Bete eremu guztiak.",
errSkinType: "Aukeratu PNG irudi bat.",
errNetwork: "Sare-errorea: saiatu berriro.",
},
},
};

View File

@@ -1,10 +1,12 @@
---
import { site, LITERALS, HEAD_FONTS } from "../data/site";
import { ui, LANGS, LANG_LABEL, LANG_PATH, type Lang } from "../i18n/ui";
import { ui, LANGS, LANG_LABEL, LANG_PATH, LANG_REGISTER_PATH, type Lang } from "../i18n/ui";
import Creeper from "../components/Creeper.astro";
import PixelIcon from "../components/PixelIcon.astro";
import CopyChip from "../components/CopyChip.astro";
import ServerCard from "../components/ServerCard.astro";
import PlayerRoster from "../components/PlayerRoster.astro";
import ModList from "../components/ModList.astro";
import "../styles/main.css";
// One static page per locale: en at "/", es at "/es/", eu at "/eu/".
@@ -18,9 +20,19 @@ export function getStaticPaths() {
const { locale } = Astro.props;
const t = ui[locale];
const { theme, launcher } = site;
const { theme, launcherManifest } = site;
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
const launcherName = launcher.name;
const launcherName = launcherManifest.product;
const registerPath = LANG_REGISTER_PATH[locale];
// Per-OS download buttons (join step 2) from the synced manifest. Missing
// manifest → empty builds → no buttons (the build never fails on it).
const OS_LABELS: Record<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).
const dustBits = Array.from({ length: 16 }, (_, i) => {
@@ -62,7 +74,9 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
</a>
<nav class="nav-links">
<a href="#status">{t.nav.status}</a>
<a href="#members">{t.nav.members}</a>
<a href="#features">{t.nav.features}</a>
<a href="#mods">{t.nav.mods}</a>
<a href="#join">{t.nav.join}</a>
</nav>
<span class="nav-spacer"></span>
@@ -143,8 +157,22 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
</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" style="background: var(--bg-2)">
<section id="features" class="pad">
<div class="wrap">
<div class="sec-head reveal">
<span class="eyebrow">{t.features.eyebrow}</span>
@@ -165,6 +193,20 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
</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 -->
<section id="join" class="pad">
<div class="wrap">
@@ -180,15 +222,10 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
<div class="num">1</div>
<div class="body">
<span class="kicker">{t.join.s1.kicker}</span>
<h3>{t.join.s1.h.replace("{name}", launcherName)}</h3>
<p>{t.join.s1.p.replace(/\{name\}/g, launcherName)}</p>
<div class="actions">
{launcher.downloads.map((d) => (
<a class="mc-btn sm" href={`${launcher.base}/${d.file}`}>
{d.os} <span class="hint">· {d.hint}</span>
</a>
))}
</div>
<h3>{t.join.s1.h}</h3>
<p>
{t.join.s1.pa} <a href={registerPath}>{t.join.s1.registerLink}</a> {t.join.s1.pb}
</p>
</div>
</div>
@@ -197,16 +234,17 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
<div class="num">2</div>
<div class="body">
<span class="kicker">{t.join.s2.kicker}</span>
<h3>{t.join.s2.h}</h3>
<p>
{t.join.s2.pa} <strong style="color:var(--text)">{LITERALS.authlib}</strong> {t.join.s2.pb}
</p>
<div class="actions" style="margin-bottom:14px">
<CopyChip value={site.authlibUrl} variant="url" label={t.join.s2.copy} copiedLabel={t.copied} />
</div>
<p>
{t.join.s2.registerPre} <a href={site.authUrl}>{site.authUrl}</a>.
</p>
<h3>{t.join.s2.h.replace("{name}", launcherName)}</h3>
<p>{t.join.s2.p.replace(/\{name\}/g, launcherName)}</p>
{launcherBuilds.length > 0 && (
<div class="actions">
{launcherBuilds.map((b) => (
<a class="mc-btn sm" href={b.url}>
{b.label} <span class="hint">· {b.hint}</span>
</a>
))}
</div>
)}
</div>
</div>
@@ -216,24 +254,11 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
<div class="body">
<span class="kicker">{t.join.s3.kicker}</span>
<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>
{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>
<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>
@@ -251,6 +276,7 @@ const dustBits = Array.from({ length: 16 }, (_, i) => {
</a>
<div class="footer-links">
<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>
<p class="disc">
{t.footer.disc} {site.name} · {site.baseDomain}

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
LANG_LABEL,
LANG_PATH,
LANG_REGISTER_PATH,
LANG_ACCOUNT_PATH,
type Lang,
} from "../../i18n/ui";
import Creeper from "../../components/Creeper.astro";
@@ -118,7 +119,7 @@ const clientCfg = {
</form>
<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>
<!-- SUCCESS + SKIN -->
@@ -167,6 +168,9 @@ const clientCfg = {
<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>

View File

@@ -245,7 +245,7 @@ section { position: relative; z-index: 1; }
.ip-chip button:hover { filter: brightness(1.1); }
.ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); }
/* url variant: long auth/packwiz URLs — smaller, wraps instead of overflowing */
/* url variant: long auth URLs — smaller, wraps instead of overflowing */
.ip-chip.url { max-width: 100%; }
.ip-chip.url .ip-val {
font-family: var(--font-mono);
@@ -467,6 +467,41 @@ section { position: relative; z-index: 1; }
.tile .k .u { color: var(--accent); font-size: 22px; }
.tile .l { margin-top: 8px; color: var(--dim); font-size: 13px; letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-pixel); font-size: 9px; line-height: 1.8; }
/* ============================================================
PLAYER ROSTER (shared avatar tiles)
Used by ServerCard.astro (online row) and PlayerRoster.astro
(Members grid). A tile is an NMSR avatar + the player name.
============================================================ */
.roster {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 12px;
}
.roster-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px 8px;
background: var(--bg-2);
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
}
.roster-tile img {
width: 48px;
height: 48px;
image-rendering: pixelated;
}
.roster-name {
max-width: 100%;
color: var(--muted);
font-size: 13px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.roster-empty { margin: 0; color: var(--dim); font-size: 14px; }
/* ============================================================
FEATURES
============================================================ */