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 parent
096ef82420
commit
6c7e259fcb
24 files changed
+2319
-117
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user