Read-only file browse was only reachable by typing the URL. Add
site.filesUrl (files.${BASE_DOMAIN}) plus nav/hero labels in en/es/eu,
and surface it as a nav link and a secondary hero CTA beside "Cómo entrar".
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
344 lines
13 KiB
Plaintext
344 lines
13 KiB
Plaintext
---
|
|
import { site, HEAD_FONTS } from "../data/site";
|
|
import { ui, LANGS, LANG_LABEL, LANG_PATH, LANG_REGISTER_PATH, LANG_ACCOUNT_PATH, type Lang } from "../i18n/ui";
|
|
import PixelIcon from "../components/PixelIcon.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/".
|
|
export function getStaticPaths() {
|
|
return [
|
|
{ params: { lang: undefined }, props: { locale: "es" as Lang } },
|
|
{ params: { lang: "en" }, props: { locale: "en" as Lang } },
|
|
{ params: { lang: "eu" }, props: { locale: "eu" as Lang } },
|
|
];
|
|
}
|
|
|
|
const { locale } = Astro.props;
|
|
const t = ui[locale];
|
|
const { theme, launcherManifest } = site;
|
|
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
|
const launcherName = launcherManifest.product;
|
|
const registerPath = LANG_REGISTER_PATH[locale];
|
|
const accountPath = LANG_ACCOUNT_PATH[locale];
|
|
|
|
// Per-OS download buttons (join step 2) are fetched client-side from the live
|
|
// launcher.json on page load (no build-time baking, no polling) — see the
|
|
// script below. We only pass the URL; buttons render into the #launcher-dl
|
|
// skeleton. URL derives from the distribution subdomain.
|
|
const launcherJsonUrl = `https://distribution.${site.baseDomain}/launcher/launcher.json`;
|
|
|
|
// Build-time floating dust bits (index-derived, no runtime RNG).
|
|
const dustBits = Array.from({ length: 16 }, (_, i) => {
|
|
const size = 3 + (i % 3) * 2;
|
|
return {
|
|
left: `${(i * 61) % 100}%`,
|
|
size: `${size}px`,
|
|
bg: i % 4 === 0 ? "var(--gold)" : "var(--accent)",
|
|
opacity: 0.18 + (i % 3) * 0.06,
|
|
anim: `rise ${13 + (i % 7) * 3}s linear ${i * 0.7}s infinite`,
|
|
};
|
|
});
|
|
---
|
|
|
|
<!doctype html>
|
|
<html
|
|
lang={t.htmlLang}
|
|
data-mood={theme.mood}
|
|
data-hero={theme.hero}
|
|
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} — {t.hero.howToJoin}</title>
|
|
<meta name="description" content={`${site.name}: ${t.hero.tagline}`} />
|
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
|
<link rel="stylesheet" href="/fonts/fonts.css" />
|
|
{LANGS.map((l) => <link rel="alternate" hreflang={l} href={LANG_PATH[l]} />)}
|
|
</head>
|
|
<body>
|
|
<!-- NAV -->
|
|
<header class="nav">
|
|
<div class="wrap nav-in">
|
|
<a class="brand" href="#top">
|
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
|
<span class="name">ULICRAFT</span>
|
|
</a>
|
|
<nav class="nav-links">
|
|
<a href="#join">{t.nav.join}</a>
|
|
<a href={site.filesUrl} target="_blank" rel="noopener">{t.nav.files}</a>
|
|
<a href="#status">{t.nav.status}</a>
|
|
</nav>
|
|
<span class="nav-spacer"></span>
|
|
<div class="lang-switch" aria-label="Language">
|
|
{LANGS.map((l) => (
|
|
<a href={LANG_PATH[l]} class:list={[l === locale && "on"]} aria-current={l === locale ? "true" : undefined}>{LANG_LABEL[l]}</a>
|
|
))}
|
|
</div>
|
|
<a class="mc-btn sm" href={registerPath}>{t.nav.register}</a>
|
|
<a class="mc-btn sm" href={accountPath}>{t.nav.login}</a>
|
|
<a class="mc-btn primary sm" href="#join">{t.nav.play}</a>
|
|
</div>
|
|
</header>
|
|
|
|
<main id="top">
|
|
<!-- HERO -->
|
|
<section class="hero">
|
|
{theme.dust && (
|
|
<div class="dust" aria-hidden="true">
|
|
{dustBits.map((b) => (
|
|
<i
|
|
style={`position:absolute;left:${b.left};bottom:-20px;width:${b.size};height:${b.size};background:${b.bg};opacity:${b.opacity};image-rendering:pixelated;animation:${b.anim}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div class="wrap hero-grid">
|
|
<div class="hero-logo">
|
|
<img src="/logo.png" alt={site.name} width="707" height="148" />
|
|
</div>
|
|
<div class="hero-copy">
|
|
<span class="eyebrow">{t.hero.eyebrow} {site.mcVersion}</span>
|
|
<h1 class="hero-tagline">{t.hero.tagline}</h1>
|
|
<p class="hero-sub">{t.hero.sub}</p>
|
|
<div class="hero-ip-row">
|
|
<a class="mc-btn primary" href="#join">{t.hero.howToJoin}</a>
|
|
<a class="mc-btn" href={site.filesUrl} target="_blank" rel="noopener">{t.hero.files}</a>
|
|
</div>
|
|
<div class="hero-meta">
|
|
<span>Java {site.mcVersion}</span>
|
|
<span class="dot"></span>
|
|
<span>{t.hero.metaModpack}</span>
|
|
</div>
|
|
</div>
|
|
<div class="hero-status">
|
|
<ServerCard
|
|
modded={t.status.modded}
|
|
motd={t.status.motd}
|
|
upTo={t.status.upTo}
|
|
emptyLabel={t.status.empty}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- HOW TO JOIN -->
|
|
<section id="join" class="pad">
|
|
<div class="wrap">
|
|
<div class="sec-head reveal">
|
|
<span class="eyebrow">{t.join.eyebrow}</span>
|
|
<h2 class="section-title">{t.join.title}</h2>
|
|
<p class="lead">{t.join.lead}</p>
|
|
</div>
|
|
|
|
<div class="steps">
|
|
<!-- STEP 1 -->
|
|
<div class="step reveal">
|
|
<div class="num">1</div>
|
|
<div class="body">
|
|
<span class="kicker">{t.join.s1.kicker}</span>
|
|
<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>
|
|
|
|
<!-- STEP 2 -->
|
|
<div class="step reveal">
|
|
<div class="num">2</div>
|
|
<div class="body">
|
|
<span class="kicker">{t.join.s2.kicker}</span>
|
|
<h3>{t.join.s2.h.replace("{name}", launcherName)}</h3>
|
|
<p>{t.join.s2.p.replace(/\{name\}/g, launcherName)}</p>
|
|
<div class="actions" id="launcher-dl" data-src={launcherJsonUrl}>
|
|
<span class="mc-btn sm dl-skeleton" aria-hidden="true"></span>
|
|
<span class="mc-btn sm dl-skeleton" aria-hidden="true"></span>
|
|
<span class="mc-btn sm dl-skeleton" aria-hidden="true"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- STATUS -->
|
|
<section id="status" class="pad">
|
|
<div class="wrap">
|
|
<div class="sec-head reveal">
|
|
<span class="eyebrow">{t.status.eyebrow}</span>
|
|
<h2 class="section-title">{t.status.title}</h2>
|
|
<p class="lead">{t.status.lead}</p>
|
|
</div>
|
|
<div class="reveal">
|
|
<ServerCard
|
|
modded={t.status.modded}
|
|
motd={t.status.motd}
|
|
upTo={t.status.upTo}
|
|
emptyLabel={t.status.empty}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- MEMBERS (registered players roster) -->
|
|
<section id="members" class="pad" style="background: var(--bg-2)">
|
|
<div class="wrap">
|
|
<div class="sec-head reveal">
|
|
<span class="eyebrow">{t.members.eyebrow}</span>
|
|
<h2 class="section-title">{t.members.title}</h2>
|
|
<p class="lead">{t.members.lead}</p>
|
|
</div>
|
|
<div class="reveal">
|
|
<PlayerRoster emptyLabel={t.members.empty} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- MODS (installed-pack list, auto-generated) -->
|
|
<section id="mods" class="pad" style="background: var(--bg-2)">
|
|
<div class="wrap">
|
|
<div class="sec-head reveal">
|
|
<span class="eyebrow">{t.mods.eyebrow}</span>
|
|
<h2 class="section-title">{t.mods.title}</h2>
|
|
<p class="lead">{t.mods.lead}</p>
|
|
</div>
|
|
<div class="reveal">
|
|
<ModList emptyLabel={t.mods.empty} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- FEATURES -->
|
|
<section id="features" class="pad">
|
|
<div class="wrap">
|
|
<div class="sec-head reveal">
|
|
<span class="eyebrow">{t.features.eyebrow}</span>
|
|
<h2 class="section-title">{t.features.title}</h2>
|
|
<p class="lead">{t.features.lead}</p>
|
|
</div>
|
|
<div class="feat-split">
|
|
<div class="feat-grid">
|
|
{site.features.map((f, i) => (
|
|
<div class="feat reveal" style={`transition-delay:${(i % 2) * 80}ms`}>
|
|
<PixelIcon glyph={f.glyph} gold={f.gold} />
|
|
<div>
|
|
<h3>{t.features.items[i].h}</h3>
|
|
<p>{t.features.items[i].p}</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div class="feat-aside reveal">
|
|
<img src="/Goat_JE1_BE1.webp" alt="" width="1200" height="1200" loading="lazy" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<!-- FOOTER -->
|
|
<footer class="footer">
|
|
<div class="wrap footer-in">
|
|
<a class="brand" href="#top">
|
|
<img class="brand-logo" src="/ulicraft-logo-mini.svg" alt="" width="34" height="34" />
|
|
<span class="name">ULICRAFT</span>
|
|
</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}
|
|
</p>
|
|
</div>
|
|
</footer>
|
|
|
|
<script>
|
|
// Copy-to-clipboard for every [data-copy] button (HTTP-LAN safe fallback).
|
|
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));
|
|
}
|
|
|
|
// Launcher downloads — fetch the live launcher.json once on load (no
|
|
// polling) and render per-OS buttons into the #launcher-dl skeleton.
|
|
// launcher.json shape: { files: [{ name, os, arch, ext, url }] }.
|
|
const dl = document.getElementById("launcher-dl");
|
|
const src = dl?.dataset.src;
|
|
if (dl && src) {
|
|
const OS: Record<string, string> = { windows: "Windows", macos: "macOS", linux: "Linux" };
|
|
fetch(src, { cache: "no-store" })
|
|
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
.then((data) => {
|
|
const files = Array.isArray(data?.files) ? data.files : [];
|
|
const frag = document.createDocumentFragment();
|
|
for (const f of files) {
|
|
if (!f?.url) continue;
|
|
const a = document.createElement("a");
|
|
a.className = "mc-btn sm";
|
|
a.href = f.url;
|
|
a.append(`${OS[f.os] ?? f.os ?? "Download"} `);
|
|
const hint = document.createElement("span");
|
|
hint.className = "hint";
|
|
hint.textContent = `· ${[f.arch, f.ext].filter(Boolean).join(" · ")}`;
|
|
a.append(hint);
|
|
frag.append(a);
|
|
}
|
|
dl.replaceChildren(frag); // empty list → no buttons (skeleton cleared)
|
|
})
|
|
.catch(() => dl.replaceChildren()); // unreachable/error → clear skeleton
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|