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:
@@ -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";
|
||||
|
||||
84
landing/src/components/ModList.astro
Normal file
84
landing/src/components/ModList.astro
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
// Installed-pack mod list (the "Mods" section). Build-time baked from the
|
||||
// auto-generated catalogue (tooling/build-modlist.py → src/data/mods.json),
|
||||
// loaded via ./data/mods.ts (resilient: missing file → empty list, build never
|
||||
// fails). Flat alphabetical grid: extracted logo (or a pixel placeholder) +
|
||||
// pretty name + version. description is exposed as a tooltip. No categories, no
|
||||
// external links. Tiles share the MC-GUI bevel treatment used by .feat/.tile.
|
||||
import { MODS } from "../data/mods";
|
||||
|
||||
interface Props {
|
||||
emptyLabel?: string;
|
||||
}
|
||||
const { emptyLabel = "Mod list not generated yet." } = Astro.props;
|
||||
|
||||
const mods = MODS.mods;
|
||||
---
|
||||
{mods.length === 0 ? (
|
||||
<p class="mods-empty">{emptyLabel}</p>
|
||||
) : (
|
||||
<div class="mod-grid">
|
||||
{mods.map((m) => (
|
||||
<div class="mod" title={m.description || m.name}>
|
||||
<div class="mod-logo">
|
||||
{m.logo ? (
|
||||
<img src={m.logo} alt="" width="40" height="40" loading="lazy" />
|
||||
) : (
|
||||
<span class="mod-noicon" aria-hidden="true">{m.name.slice(0, 1).toUpperCase()}</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="mod-meta">
|
||||
<span class="mod-name">{m.name}</span>
|
||||
{m.version && <span class="mod-ver">{m.version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.mod-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.mod {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: var(--surface);
|
||||
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
|
||||
transition: transform .14s ease, filter .14s ease;
|
||||
}
|
||||
.mod:hover { transform: translateY(-2px); filter: brightness(1.06); }
|
||||
.mod-logo {
|
||||
width: 40px; height: 40px; flex-shrink: 0;
|
||||
display: grid; place-items: center;
|
||||
background: var(--bg-2);
|
||||
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
|
||||
}
|
||||
.mod-logo img { width: 40px; height: 40px; image-rendering: pixelated; }
|
||||
.mod-noicon {
|
||||
font-family: var(--font-head);
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
color: var(--accent);
|
||||
line-height: 1;
|
||||
}
|
||||
.mod-meta { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.mod-name {
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.mod-ver {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.mods-empty { margin: 0; color: var(--dim); font-size: 14px; }
|
||||
</style>
|
||||
88
landing/src/components/PlayerRoster.astro
Normal file
88
landing/src/components/PlayerRoster.astro
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
// Registered-players roster (the "Members" section). Progressive enhancement,
|
||||
// same shape as ServerCard:
|
||||
// SSG → renders an empty skeleton (a single .roster grid + an empty-state
|
||||
// line) so the page is valid with JS off.
|
||||
// Client → fetches OUR mc-status roster endpoint (site.status.rosterApi,
|
||||
// same-origin via caddy /api/mcstatus/* → internal /players). It
|
||||
// returns a sanitized [{uuid,name}] of every Drasl player (admin login
|
||||
// is server-side in mc-status — no admin token reaches the page). Each
|
||||
// player renders as a SHARED avatar tile (.roster-tile, styled in
|
||||
// main.css alongside ServerCard's online tiles).
|
||||
//
|
||||
// Empty/error → hide the grid and show the "no members yet" line (data-empty).
|
||||
import { site } from "../data/site";
|
||||
|
||||
interface Props {
|
||||
// Empty-state copy (passed from the page for i18n; English default).
|
||||
emptyLabel?: string;
|
||||
}
|
||||
const { emptyLabel = "No members yet." } = Astro.props;
|
||||
|
||||
const { status, avatarUrl, avatarMode } = site;
|
||||
---
|
||||
<div
|
||||
class="roster-block"
|
||||
data-api={status.rosterApi}
|
||||
data-avatar={avatarUrl}
|
||||
data-mode={avatarMode}
|
||||
data-empty={emptyLabel}
|
||||
>
|
||||
<div class="roster" aria-label="Registered players"></div>
|
||||
<p class="roster-empty" hidden></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function hydrate(el: HTMLElement) {
|
||||
const { api, avatar, mode, empty } = el.dataset;
|
||||
if (!api || !avatar || !mode) return;
|
||||
|
||||
const grid = el.querySelector<HTMLElement>(".roster");
|
||||
const emptyEl = el.querySelector<HTMLElement>(".roster-empty");
|
||||
if (!grid) return;
|
||||
|
||||
const showEmpty = () => {
|
||||
grid.hidden = true;
|
||||
if (emptyEl) {
|
||||
emptyEl.textContent = empty ?? "No members yet.";
|
||||
emptyEl.hidden = false;
|
||||
}
|
||||
};
|
||||
|
||||
let data: Array<{ uuid?: string; name?: string }>;
|
||||
try {
|
||||
const r = await fetch(api, { headers: { Accept: "application/json" } });
|
||||
if (!r.ok) return showEmpty();
|
||||
data = await r.json();
|
||||
} catch {
|
||||
return showEmpty();
|
||||
}
|
||||
|
||||
const players = Array.isArray(data) ? data.filter((p) => p && p.uuid) : [];
|
||||
if (players.length === 0) return showEmpty();
|
||||
|
||||
grid.innerHTML = "";
|
||||
for (const p of players) {
|
||||
grid.appendChild(rosterTile(p.uuid!, p.name ?? "", avatar, mode));
|
||||
}
|
||||
}
|
||||
|
||||
// Shared avatar tile (avatar + name) — identical markup to ServerCard's
|
||||
// online tiles, so .roster / .roster-tile (global, main.css) style both.
|
||||
function rosterTile(uuid: string, name: string, avatar: string, mode: string) {
|
||||
const tile = document.createElement("div");
|
||||
tile.className = "roster-tile";
|
||||
const img = document.createElement("img");
|
||||
img.loading = "lazy";
|
||||
img.alt = name;
|
||||
img.src = `${avatar}/${mode}/${uuid}?size=64`;
|
||||
const label = document.createElement("span");
|
||||
label.className = "roster-name";
|
||||
label.textContent = name;
|
||||
label.title = name;
|
||||
tile.append(img, label);
|
||||
return tile;
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>(".roster-block").forEach(hydrate);
|
||||
</script>
|
||||
@@ -33,14 +33,13 @@ const {
|
||||
emptyLabel = "Nobody online — be the first.",
|
||||
} = 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>
|
||||
|
||||
41
landing/src/data/launcher-manifest.example.json
Normal file
41
landing/src/data/launcher-manifest.example.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"product": "UlicraftLauncher",
|
||||
"version": "1.0.0",
|
||||
"channel": "stable",
|
||||
"releasedAt": "2026-06-01T12:00:00Z",
|
||||
"minecraft": "1.21.1",
|
||||
"neoforge": "21.1.233",
|
||||
"builds": [
|
||||
{
|
||||
"os": "windows",
|
||||
"arch": "x64",
|
||||
"format": "exe",
|
||||
"filename": "UlicraftLauncher-1.0.0-setup.exe",
|
||||
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-setup.exe",
|
||||
"size": 78643200,
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
},
|
||||
{
|
||||
"os": "macos",
|
||||
"arch": "universal",
|
||||
"format": "dmg",
|
||||
"filename": "UlicraftLauncher-1.0.0-universal.dmg",
|
||||
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-universal.dmg",
|
||||
"size": 92274688
|
||||
},
|
||||
{
|
||||
"os": "linux",
|
||||
"arch": "x64",
|
||||
"format": "appimage",
|
||||
"filename": "UlicraftLauncher-1.0.0-x86_64.AppImage",
|
||||
"url": "https://distribution.ulicraft.net/launcher/UlicraftLauncher-1.0.0-x86_64.AppImage",
|
||||
"size": 85983232
|
||||
}
|
||||
],
|
||||
"fjordPack": {
|
||||
"filename": "ulicraft-1.21.1.mrpack",
|
||||
"url": "https://distribution.ulicraft.net/launcher/ulicraft-1.21.1.mrpack",
|
||||
"size": 12582912
|
||||
}
|
||||
}
|
||||
119
landing/src/data/launcher-manifest.schema.json
Normal file
119
landing/src/data/launcher-manifest.schema.json
Normal file
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://ulicraft.net/schemas/launcher-manifest.schema.json",
|
||||
"title": "Ulicraft launcher download manifest",
|
||||
"description": "Produced by the custom-launcher release flow, consumed by the landing build to render per-OS download buttons. One file describes the current UlicraftLauncher release plus the optional Fjord pack.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schemaVersion", "product", "version", "builds"],
|
||||
"properties": {
|
||||
"schemaVersion": {
|
||||
"description": "Bump on breaking shape changes. Landing pins the major it understands.",
|
||||
"type": "integer",
|
||||
"const": 1
|
||||
},
|
||||
"product": {
|
||||
"description": "Display name of the launcher.",
|
||||
"type": "string",
|
||||
"examples": ["UlicraftLauncher"]
|
||||
},
|
||||
"version": {
|
||||
"description": "Launcher release version (semver, no leading v).",
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+\\.\\d+(?:[-+].+)?$"
|
||||
},
|
||||
"channel": {
|
||||
"description": "Release channel.",
|
||||
"type": "string",
|
||||
"enum": ["stable", "beta"],
|
||||
"default": "stable"
|
||||
},
|
||||
"releasedAt": {
|
||||
"description": "Release timestamp (ISO 8601 / RFC 3339).",
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"minecraft": {
|
||||
"description": "Target Minecraft version the launcher installs.",
|
||||
"type": "string",
|
||||
"examples": ["1.21.1"]
|
||||
},
|
||||
"neoforge": {
|
||||
"description": "NeoForge version the distribution pins.",
|
||||
"type": "string",
|
||||
"examples": ["21.1.233"]
|
||||
},
|
||||
"builds": {
|
||||
"description": "Downloadable launcher binaries, one entry per OS/arch/format.",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/asset" }
|
||||
},
|
||||
"fjordPack": {
|
||||
"description": "Optional importable pack for the Fjord (Prism) alternative path. Lives on its own landing page, not the homepage.",
|
||||
"$ref": "#/$defs/file"
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"asset": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["os", "arch", "format", "filename", "url"],
|
||||
"properties": {
|
||||
"os": {
|
||||
"type": "string",
|
||||
"enum": ["windows", "macos", "linux"]
|
||||
},
|
||||
"arch": {
|
||||
"description": "CPU arch; 'universal' for a fat/any binary.",
|
||||
"type": "string",
|
||||
"enum": ["x64", "arm64", "universal"]
|
||||
},
|
||||
"format": {
|
||||
"description": "Installer/package kind, drives the button hint.",
|
||||
"type": "string",
|
||||
"enum": ["exe", "msi", "dmg", "pkg", "appimage", "deb", "rpm", "tar.gz", "zip"]
|
||||
},
|
||||
"label": {
|
||||
"description": "Optional human hint override (else derived from os+arch+format).",
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"url": {
|
||||
"description": "Absolute download URL.",
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^https://"
|
||||
},
|
||||
"size": {
|
||||
"description": "Bytes, for showing a size next to the button.",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"sha256": {
|
||||
"description": "Lowercase hex SHA-256 for integrity display/verify.",
|
||||
"type": "string",
|
||||
"pattern": "^[a-f0-9]{64}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"file": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["filename", "url"],
|
||||
"properties": {
|
||||
"filename": { "type": "string", "minLength": 1 },
|
||||
"url": {
|
||||
"type": "string",
|
||||
"format": "uri",
|
||||
"pattern": "^https://"
|
||||
},
|
||||
"size": { "type": "integer", "minimum": 0 },
|
||||
"sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
landing/src/data/mods.example.json
Normal file
5
landing/src/data/mods.example.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"count": 0,
|
||||
"mods": []
|
||||
}
|
||||
56
landing/src/data/mods.ts
Normal file
56
landing/src/data/mods.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Build-time loader for the auto-generated mod catalogue. tooling/build-modlist.py
|
||||
// writes src/data/mods.json (gitignored, generated) from the distribution's full
|
||||
// client modset; it MAY NOT EXIST on a fresh checkout, so we read it at runtime
|
||||
// with fs (a static `import` would hard-fail the build when absent). Fall back to
|
||||
// the committed .example.json (an empty catalogue); if even that is unreadable,
|
||||
// expose an empty catalogue so the page simply renders no mods and the stat tile
|
||||
// keeps its default. Same pattern as loadLauncherManifest in site.ts.
|
||||
// @ts-ignore — node builtins (no @types/node in this Astro SSG; same reason
|
||||
// `process`/`import.meta.url` reads work at build time, as in site.ts).
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export type Mod = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
authors: string;
|
||||
description: string;
|
||||
logo: string | null;
|
||||
};
|
||||
|
||||
export type ModCatalogue = {
|
||||
schemaVersion: number;
|
||||
count: number;
|
||||
mods: Mod[];
|
||||
};
|
||||
|
||||
export function loadMods(): ModCatalogue {
|
||||
const empty: ModCatalogue = { schemaVersion: 1, count: 0, mods: [] };
|
||||
// Resolve relative to this module first (works when import.meta.url points at
|
||||
// the source dir), then fall back to a cwd-relative path (Astro runs the build
|
||||
// from the landing root, so src/data/* resolves there even when the module has
|
||||
// been bundled to a different location). Prefer the generated mods.json, then
|
||||
// the committed empty mods.example.json.
|
||||
// @ts-ignore — `process` is untyped here (no @types/node), same as site.ts.
|
||||
const cwd: string = typeof process !== "undefined" ? process.cwd() : ".";
|
||||
const candidates: Array<string | URL> = [];
|
||||
for (const name of ["mods.json", "mods.example.json"]) {
|
||||
try {
|
||||
candidates.push(new URL(name, import.meta.url));
|
||||
} catch {
|
||||
// import.meta.url unavailable — skip the module-relative candidate.
|
||||
}
|
||||
candidates.push(`${cwd}/src/data/${name}`);
|
||||
}
|
||||
for (const c of candidates) {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(c, "utf8")) as ModCatalogue;
|
||||
if (parsed && Array.isArray(parsed.mods)) return parsed;
|
||||
} catch {
|
||||
// try the next candidate (file missing/unreadable → fall back)
|
||||
}
|
||||
}
|
||||
return empty;
|
||||
}
|
||||
|
||||
export const MODS = loadMods();
|
||||
@@ -2,8 +2,88 @@
|
||||
// domains, URLs, theme, launcher files, and the structural shape of the
|
||||
// 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: [
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
357
landing/src/pages/[...lang]/account.astro
Normal file
357
landing/src/pages/[...lang]/account.astro
Normal file
@@ -0,0 +1,357 @@
|
||||
---
|
||||
import { site, HEAD_FONTS } from "../../data/site";
|
||||
import {
|
||||
ui,
|
||||
LANGS,
|
||||
LANG_LABEL,
|
||||
LANG_PATH,
|
||||
LANG_ACCOUNT_PATH,
|
||||
LANG_REGISTER_PATH,
|
||||
type Lang,
|
||||
} from "../../i18n/ui";
|
||||
import Creeper from "../../components/Creeper.astro";
|
||||
import "../../styles/main.css";
|
||||
|
||||
// One static account page per locale: /account, /es/account, /eu/account.
|
||||
export function getStaticPaths() {
|
||||
return [
|
||||
{ params: { lang: undefined }, props: { locale: "en" as Lang } },
|
||||
{ params: { lang: "es" }, props: { locale: "es" as Lang } },
|
||||
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||
];
|
||||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const t = ui[locale];
|
||||
const a = t.account;
|
||||
const { theme } = site;
|
||||
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||
const homePath = LANG_PATH[locale];
|
||||
const registerPath = LANG_REGISTER_PATH[locale];
|
||||
|
||||
// Strings the client script needs at runtime (kept minimal, passed via define:vars).
|
||||
const clientCfg = {
|
||||
apiUrl: site.draslApiUrl,
|
||||
avatarUrl: site.avatarUrl,
|
||||
avatarMode: site.avatarMode,
|
||||
submit: a.submit,
|
||||
submitting: a.submitting,
|
||||
skinUpload: a.skinUpload,
|
||||
skinUploading: a.skinUploading,
|
||||
skinOk: a.skinOk,
|
||||
skinPreviewNote: a.skinPreviewNote,
|
||||
resetSkin: a.resetSkin,
|
||||
resetConfirm: a.resetConfirm,
|
||||
resetOk: a.resetOk,
|
||||
errFields: a.errFields,
|
||||
errSkinType: a.errSkinType,
|
||||
errNetwork: a.errNetwork,
|
||||
};
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html
|
||||
lang={t.htmlLang}
|
||||
data-mood={theme.mood}
|
||||
data-head={theme.headFont}
|
||||
style={`--font-head: ${headFont}`}
|
||||
>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{site.name} — {a.title}</title>
|
||||
<meta name="description" content={a.lead} />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_ACCOUNT_PATH[l]} />)}
|
||||
</head>
|
||||
<body>
|
||||
<!-- NAV -->
|
||||
<header class="nav">
|
||||
<div class="wrap nav-in">
|
||||
<a class="brand" href={homePath}>
|
||||
<Creeper />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href={homePath}>{a.navHome}</a>
|
||||
</nav>
|
||||
<span class="nav-spacer"></span>
|
||||
<div class="lang-switch" aria-label="Language">
|
||||
{LANGS.map((l) => (
|
||||
<a
|
||||
href={LANG_ACCOUNT_PATH[l]}
|
||||
class:list={[l === locale && "on"]}
|
||||
aria-current={l === locale ? "true" : undefined}
|
||||
>{LANG_LABEL[l]}</a>
|
||||
))}
|
||||
</div>
|
||||
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="pad">
|
||||
<div class="reg-wrap">
|
||||
<div class="reg-card">
|
||||
<span class="eyebrow">{a.eyebrow}</span>
|
||||
<h1>{a.title}</h1>
|
||||
<p class="lead">{a.lead}</p>
|
||||
|
||||
<!-- LOGIN FORM -->
|
||||
<form class="reg-form" id="acc-form" novalidate>
|
||||
<div class="reg-field">
|
||||
<label for="acc-username">{a.usernameLabel}</label>
|
||||
<input class="reg-input" id="acc-username" name="username"
|
||||
type="text" autocomplete="username" placeholder={a.usernamePlaceholder} required />
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label for="acc-password">{a.passwordLabel}</label>
|
||||
<input class="reg-input" id="acc-password" name="password"
|
||||
type="password" autocomplete="current-password" placeholder={a.passwordPlaceholder} required />
|
||||
</div>
|
||||
<div class="reg-msg err" id="acc-error" role="alert" hidden></div>
|
||||
<button class="mc-btn primary" id="acc-submit" type="submit">{a.submit}</button>
|
||||
</form>
|
||||
|
||||
<p class="reg-foot">
|
||||
{a.noAccount} <a href={registerPath}>{a.registerLink}</a>
|
||||
</p>
|
||||
|
||||
<!-- SKIN PANEL (revealed after login) -->
|
||||
<div class="reg-success" id="acc-panel" hidden>
|
||||
<div>
|
||||
<span class="eyebrow" style="color:var(--accent)">{a.playerLabel}</span>
|
||||
<p class="lead" id="acc-player-name" style="margin-top:6px;font-size:20px;color:var(--text)"></p>
|
||||
</div>
|
||||
|
||||
<!-- Current avatar preview (NMSR) -->
|
||||
<div id="acc-avatar-wrap" style="display:flex;align-items:flex-start;gap:20px;flex-wrap:wrap">
|
||||
<img id="acc-avatar" src="" alt="avatar" width="96" height="96"
|
||||
style="image-rendering:pixelated;width:96px;height:96px;background:var(--slot);box-shadow:inset 2px 2px 0 var(--bevel-lo),inset -2px -2px 0 var(--bevel-hi)" />
|
||||
<div style="flex:1;min-width:0">
|
||||
<p class="reg-skin" style="border:none;padding-top:0;margin:0;color:var(--dim);font-size:13px" id="acc-preview-note"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reg-skin" id="acc-skin">
|
||||
<div>
|
||||
<h3>{a.skinTitle}</h3>
|
||||
<p class="lead">{a.skinLead}</p>
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label class="mc-btn sm" for="acc-skin-file" style="align-self:flex-start">{a.skinChoose}</label>
|
||||
<input id="acc-skin-file" type="file" accept="image/png" hidden />
|
||||
<span class="reg-file-name" id="acc-skin-name"></span>
|
||||
</div>
|
||||
<div class="reg-field">
|
||||
<label>{a.modelLabel}</label>
|
||||
<div class="reg-models">
|
||||
<label><input type="radio" name="skin-model" value="classic" checked /> {a.modelClassic}</label>
|
||||
<label><input type="radio" name="skin-model" value="slim" /> {a.modelSlim}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reg-msg" id="acc-skin-msg" role="status" hidden></div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center">
|
||||
<button class="mc-btn primary" id="acc-skin-upload" type="button">{a.skinUpload}</button>
|
||||
<button class="mc-btn" id="acc-skin-reset" type="button" style="color:var(--dim)">{a.resetSkin}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="mc-btn sm" href={homePath}>{a.navHome}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="footer">
|
||||
<div class="wrap footer-in">
|
||||
<a class="brand" href={homePath}>
|
||||
<Creeper />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<div class="footer-links">
|
||||
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||
</div>
|
||||
<p class="disc">
|
||||
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script define:vars={{ cfg: clientCfg }}>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const form = $("acc-form");
|
||||
const errBox = $("acc-error");
|
||||
const submitBtn = $("acc-submit");
|
||||
const panel = $("acc-panel");
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
|
||||
// Token + player kept in memory only — never stored in localStorage.
|
||||
let apiToken = null;
|
||||
let player = null;
|
||||
|
||||
const showMsg = (box, msg) => {
|
||||
box.textContent = msg;
|
||||
box.hidden = false;
|
||||
};
|
||||
const apiMessage = async (res, fallback) => {
|
||||
try {
|
||||
const data = await res.json();
|
||||
return data && data.message ? data.message : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Login ----
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errBox.hidden = true;
|
||||
const username = $("acc-username").value.trim();
|
||||
const password = $("acc-password").value;
|
||||
if (!username || !password) {
|
||||
errBox.classList.add("err");
|
||||
showMsg(errBox, cfg.errFields);
|
||||
return;
|
||||
}
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = cfg.submitting;
|
||||
try {
|
||||
const res = await fetch(`${cfg.apiUrl}/login`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
errBox.classList.add("err");
|
||||
showMsg(errBox, await apiMessage(res, cfg.errNetwork));
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
apiToken = data.apiToken;
|
||||
player = data.user.players[0];
|
||||
|
||||
// Show panel
|
||||
form.hidden = true;
|
||||
$("acc-player-name").textContent = player.name;
|
||||
// Set NMSR avatar
|
||||
const avatarImg = $("acc-avatar");
|
||||
avatarImg.src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96`;
|
||||
avatarImg.alt = player.name;
|
||||
// Show NMSR lag hint
|
||||
$("acc-preview-note").textContent = cfg.skinPreviewNote;
|
||||
panel.hidden = false;
|
||||
} catch {
|
||||
errBox.classList.add("err");
|
||||
showMsg(errBox, cfg.errNetwork);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = cfg.submit;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Skin upload ----
|
||||
const fileInput = $("acc-skin-file");
|
||||
const fileName = $("acc-skin-name");
|
||||
const skinMsg = $("acc-skin-msg");
|
||||
const skinBtn = $("acc-skin-upload");
|
||||
const resetBtn = $("acc-skin-reset");
|
||||
|
||||
fileInput.addEventListener("change", () => {
|
||||
skinMsg.hidden = true;
|
||||
skinMsg.classList.remove("ok", "err");
|
||||
fileName.textContent = fileInput.files && fileInput.files[0] ? fileInput.files[0].name : "";
|
||||
});
|
||||
|
||||
const readBase64 = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const fr = new FileReader();
|
||||
fr.onload = () => resolve(String(fr.result).split(",")[1]);
|
||||
fr.onerror = reject;
|
||||
fr.readAsDataURL(file);
|
||||
});
|
||||
|
||||
skinBtn.addEventListener("click", async () => {
|
||||
skinMsg.hidden = true;
|
||||
skinMsg.classList.remove("ok", "err");
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
if (!file) {
|
||||
skinMsg.classList.add("err");
|
||||
showMsg(skinMsg, cfg.errSkinType);
|
||||
return;
|
||||
}
|
||||
if (file.type !== "image/png") {
|
||||
skinMsg.classList.add("err");
|
||||
showMsg(skinMsg, cfg.errSkinType);
|
||||
return;
|
||||
}
|
||||
const model = document.querySelector('input[name="skin-model"]:checked').value;
|
||||
skinBtn.disabled = true;
|
||||
resetBtn.disabled = true;
|
||||
skinBtn.textContent = cfg.skinUploading;
|
||||
try {
|
||||
const skinBase64 = await readBase64(file);
|
||||
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
|
||||
body: JSON.stringify({ skinBase64, skinModel: model }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
skinMsg.classList.add("err");
|
||||
showMsg(skinMsg, await apiMessage(res, cfg.errNetwork));
|
||||
return;
|
||||
}
|
||||
// Show the local file immediately as instant confirmation (NMSR lags ~15m).
|
||||
const dataUrl = URL.createObjectURL(file);
|
||||
$("acc-avatar").src = dataUrl;
|
||||
skinMsg.classList.add("ok");
|
||||
showMsg(skinMsg, cfg.skinOk);
|
||||
// Clear file picker
|
||||
fileInput.value = "";
|
||||
fileName.textContent = "";
|
||||
} catch {
|
||||
skinMsg.classList.add("err");
|
||||
showMsg(skinMsg, cfg.errNetwork);
|
||||
} finally {
|
||||
skinBtn.disabled = false;
|
||||
resetBtn.disabled = false;
|
||||
skinBtn.textContent = cfg.skinUpload;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Reset to default ----
|
||||
resetBtn.addEventListener("click", async () => {
|
||||
if (!window.confirm(cfg.resetConfirm)) return;
|
||||
skinMsg.hidden = true;
|
||||
skinMsg.classList.remove("ok", "err");
|
||||
skinBtn.disabled = true;
|
||||
resetBtn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`${cfg.apiUrl}/players/${player.uuid}`, {
|
||||
method: "PATCH",
|
||||
headers: { ...headers, Authorization: `Bearer ${apiToken}` },
|
||||
body: JSON.stringify({ deleteSkin: true }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
skinMsg.classList.add("err");
|
||||
showMsg(skinMsg, await apiMessage(res, cfg.errNetwork));
|
||||
return;
|
||||
}
|
||||
// Reset preview back to NMSR (will eventually show the default skin).
|
||||
$("acc-avatar").src = `${cfg.avatarUrl}/${cfg.avatarMode}/${player.uuid}?size=96&t=${Date.now()}`;
|
||||
skinMsg.classList.add("ok");
|
||||
showMsg(skinMsg, cfg.resetOk);
|
||||
} catch {
|
||||
skinMsg.classList.add("err");
|
||||
showMsg(skinMsg, cfg.errNetwork);
|
||||
} finally {
|
||||
skinBtn.disabled = false;
|
||||
resetBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
226
landing/src/pages/[...lang]/fjord.astro
Normal file
226
landing/src/pages/[...lang]/fjord.astro
Normal file
@@ -0,0 +1,226 @@
|
||||
---
|
||||
import { site, LITERALS, HEAD_FONTS } from "../../data/site";
|
||||
import {
|
||||
ui,
|
||||
LANGS,
|
||||
LANG_LABEL,
|
||||
LANG_PATH,
|
||||
LANG_FJORD_PATH,
|
||||
type Lang,
|
||||
} from "../../i18n/ui";
|
||||
import Creeper from "../../components/Creeper.astro";
|
||||
import CopyChip from "../../components/CopyChip.astro";
|
||||
import "../../styles/main.css";
|
||||
|
||||
// One static Fjord page per locale: /fjord, /es/fjord, /eu/fjord. This is the
|
||||
// alternative manual path — intentionally NOT linked from the homepage nav.
|
||||
export function getStaticPaths() {
|
||||
return [
|
||||
{ params: { lang: undefined }, props: { locale: "en" as Lang } },
|
||||
{ params: { lang: "es" }, props: { locale: "es" as Lang } },
|
||||
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
||||
];
|
||||
}
|
||||
|
||||
const { locale } = Astro.props;
|
||||
const t = ui[locale];
|
||||
const f = t.fjord;
|
||||
const { theme, fjord } = site;
|
||||
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||
const homePath = LANG_PATH[locale];
|
||||
const fjordPack = site.launcherManifest.fjordPack;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html
|
||||
lang={t.htmlLang}
|
||||
data-mood={theme.mood}
|
||||
data-head={theme.headFont}
|
||||
style={`--font-head: ${headFont}`}
|
||||
>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{site.name} — {f.title}</title>
|
||||
<meta name="description" content={f.lead} />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
<link rel="stylesheet" href="/fonts/fonts.css" />
|
||||
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_FJORD_PATH[l]} />)}
|
||||
</head>
|
||||
<body>
|
||||
<!-- NAV -->
|
||||
<header class="nav">
|
||||
<div class="wrap nav-in">
|
||||
<a class="brand" href={homePath}>
|
||||
<Creeper />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href={homePath}>{f.navHome}</a>
|
||||
</nav>
|
||||
<span class="nav-spacer"></span>
|
||||
<div class="lang-switch" aria-label="Language">
|
||||
{LANGS.map((l) => (
|
||||
<a
|
||||
href={LANG_FJORD_PATH[l]}
|
||||
class:list={[l === locale && "on"]}
|
||||
aria-current={l === locale ? "true" : undefined}
|
||||
>{LANG_LABEL[l]}</a>
|
||||
))}
|
||||
</div>
|
||||
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.nav.play}</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section id="join" class="pad">
|
||||
<div class="wrap">
|
||||
<div class="sec-head reveal">
|
||||
<span class="eyebrow">{f.eyebrow}</span>
|
||||
<h2 class="section-title">{f.title}</h2>
|
||||
<p class="lead">{f.lead}</p>
|
||||
</div>
|
||||
|
||||
<div class="steps">
|
||||
<!-- STEP 1 -->
|
||||
<div class="step reveal">
|
||||
<div class="num">1</div>
|
||||
<div class="body">
|
||||
<span class="kicker">{f.s1.kicker}</span>
|
||||
<h3>{f.s1.h}</h3>
|
||||
<p>{f.s1.p}</p>
|
||||
<div class="actions">
|
||||
{fjord.downloads.map((d) => (
|
||||
<a class="mc-btn sm" href={`${fjord.base}/${d.file}`}>
|
||||
{d.os} <span class="hint">· {d.hint}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 2 -->
|
||||
<div class="step reveal">
|
||||
<div class="num">2</div>
|
||||
<div class="body">
|
||||
<span class="kicker">{f.s2.kicker}</span>
|
||||
<h3>{f.s2.h}</h3>
|
||||
<p>
|
||||
{f.s2.pa} <strong style="color:var(--text)">{LITERALS.authlib}</strong> {f.s2.pb}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<CopyChip value={site.authlibUrl} variant="url" label={f.s2.copy} copiedLabel={t.copied} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 3 -->
|
||||
<div class="step reveal">
|
||||
<div class="num">3</div>
|
||||
<div class="body">
|
||||
<span class="kicker">{f.s3.kicker}</span>
|
||||
<h3>{f.s3.h}</h3>
|
||||
<p>{f.s3.p}</p>
|
||||
<div class="actions">
|
||||
{fjordPack ? (
|
||||
<a class="mc-btn sm" href={fjordPack.url}>
|
||||
{fjordPack.filename} <span class="hint">· .mrpack</span>
|
||||
</a>
|
||||
) : (
|
||||
<p class="hint">{f.noPack}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 4 -->
|
||||
<div class="step reveal">
|
||||
<div class="num">4</div>
|
||||
<div class="body">
|
||||
<span class="kicker">{f.s4.kicker}</span>
|
||||
<h3>{f.s4.h}</h3>
|
||||
<p>
|
||||
{f.s4.pa} <kbd>{LITERALS.multiplayer}</kbd> → <kbd>{LITERALS.addServer}</kbd>{f.s4.pb}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<CopyChip value={site.serverAddress} variant="ip" label={f.s4.copy} copiedLabel={t.copied} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="footer">
|
||||
<div class="wrap footer-in">
|
||||
<a class="brand" href={homePath}>
|
||||
<Creeper />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<div class="footer-links">
|
||||
<a class="mc-btn primary sm" href={`${homePath}#join`}>{t.footer.join}</a>
|
||||
<a class="mc-btn sm" href={site.statusUrl} target="_blank" rel="noopener">{t.footer.status}</a>
|
||||
</div>
|
||||
<p class="disc">
|
||||
{t.footer.disc} {site.name} · {site.baseDomain}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Copy-to-clipboard for every [data-copy] button (HTTP-LAN safe fallback).
|
||||
// Mirrors the global handler in [...lang].astro (CopyChip depends on it).
|
||||
function copyText(text: string) {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
document.querySelectorAll<HTMLButtonElement>("button[data-copy]").forEach((btn) => {
|
||||
let timer: number | undefined;
|
||||
btn.addEventListener("click", () => {
|
||||
copyText(btn.dataset.copy ?? "");
|
||||
const label = btn.dataset.label ?? "Copy";
|
||||
btn.textContent = btn.dataset.copied ?? "Copied!";
|
||||
btn.classList.add("copied");
|
||||
clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
btn.textContent = label;
|
||||
btn.classList.remove("copied");
|
||||
}, 1600);
|
||||
});
|
||||
});
|
||||
|
||||
// Scroll reveal — visible at rest; .anim added when scrolled into view.
|
||||
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
const els = [...document.querySelectorAll<HTMLElement>(".reveal")];
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const e of entries) {
|
||||
if (e.isIntersecting) {
|
||||
e.target.classList.add("anim");
|
||||
io.unobserve(e.target);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -10% 0px" }
|
||||
);
|
||||
els.forEach((el) => io.observe(el));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LANG_LABEL,
|
||||
LANG_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>
|
||||
|
||||
@@ -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
|
||||
============================================================ */
|
||||
|
||||
Reference in New Issue
Block a user