feat(landing): pixel design ported to static Astro + en/es/eu i18n
Adopt the "Carved from stone" pixel design (landing/design/) as the apex onboarding page, ported from its React/Babel-via-CDN prototype to static Astro so it works on an offline LAN. Real modded-LAN content replaces the prototype's fictional public-SMP copy. - Vendor web fonts locally (tooling/fetch-fonts.sh -> public/fonts/) so the page renders without the Google Fonts CDN at party time. - Port the design system (main.css: mood/hero/bevels) and split markup into Astro components (Creeper, PixelIcon, CopyChip, ServerListPanel). - site.ts holds language-neutral config + theme knobs (mood/hero/headFont/ dust) that replace the design's in-browser TweaksPanel; LITERALS holds never-translated product/in-game terms. - i18n: English (/), Spanish (/es/), Euskera (/eu/) generated from one [...lang].astro via getStaticPaths; copy lives in src/i18n/ui.ts. Nav has an EN/ES/EU switcher; html lang + hreflang set per page. - Status panel shows a static server-list row + honest stat tiles (no fake live count). Copy + scroll-reveal are the only client JS. Build emits to ../www (gitignored). Euskera strings pending a native review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
317
landing/src/pages/[...lang].astro
Normal file
317
landing/src/pages/[...lang].astro
Normal file
@@ -0,0 +1,317 @@
|
||||
---
|
||||
import { site, LITERALS, HEAD_FONTS } from "../data/site";
|
||||
import { ui, LANGS, LANG_LABEL, LANG_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 ServerListPanel from "../components/ServerListPanel.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: "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 { theme, launcher } = site;
|
||||
const headFont = HEAD_FONTS[theme.headFont] ?? HEAD_FONTS.pixelify;
|
||||
const launcherName = launcher.name;
|
||||
|
||||
// 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="/logo.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">
|
||||
<Creeper />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#status">{t.nav.status}</a>
|
||||
<a href="#features">{t.nav.features}</a>
|
||||
<a href="#join">{t.nav.join}</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 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">
|
||||
<CopyChip value={site.serverAddress} variant="ip" label={t.hero.copyIp} copiedLabel={t.copied} />
|
||||
<a class="mc-btn" href="#join">{t.hero.howToJoin}</a>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span>Java {site.mcVersion}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{t.hero.metaModpack}</span>
|
||||
<span class="dot"></span>
|
||||
<span>{t.hero.metaLan}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-status">
|
||||
<ServerListPanel
|
||||
modded={t.status.modded}
|
||||
motd={t.status.motd}
|
||||
upTo={t.status.upTo}
|
||||
/>
|
||||
</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">
|
||||
<ServerListPanel
|
||||
modded={t.status.modded}
|
||||
motd={t.status.motd}
|
||||
upTo={t.status.upTo}
|
||||
/>
|
||||
</div>
|
||||
<div class="stat-grid">
|
||||
{site.stats.map((s, i) => (
|
||||
<div class="tile reveal" style={`transition-delay:${i * 60}ms`}>
|
||||
<div class="k">{s.key}</div>
|
||||
<div class="l">{t.statLabels[i]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section id="features" class="pad" style="background: var(--bg-2)">
|
||||
<div class="wrap">
|
||||
<div class="sec-head reveal">
|
||||
<span class="eyebrow">{t.features.eyebrow}</span>
|
||||
<h2 class="section-title">{t.features.title}</h2>
|
||||
<p class="lead">{t.features.lead}</p>
|
||||
</div>
|
||||
<div class="feat-grid">
|
||||
{site.features.map((f, i) => (
|
||||
<div class="feat reveal" style={`transition-delay:${(i % 2) * 80}ms`}>
|
||||
<PixelIcon glyph={f.glyph} gold={f.gold} />
|
||||
<div>
|
||||
<h3>{t.features.items[i].h}</h3>
|
||||
<p>{t.features.items[i].p}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 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.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>
|
||||
</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}</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>
|
||||
<span class="hint">
|
||||
{t.join.s2.caPre} <a href={site.caCertUrl}>{t.join.s2.caLink}</a> {t.join.s2.caPost}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 3 -->
|
||||
<div class="step reveal">
|
||||
<div class="num">3</div>
|
||||
<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}
|
||||
</p>
|
||||
<div class="actions">
|
||||
<CopyChip value={site.serverAddress} variant="ip" label={t.join.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="#top">
|
||||
<Creeper />
|
||||
<span class="name">ULICRAFT</span>
|
||||
</a>
|
||||
<div class="footer-links">
|
||||
<a class="mc-btn primary sm" href="#join">{t.footer.join}</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));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,199 +0,0 @@
|
||||
---
|
||||
import { site } from "../data/site";
|
||||
const { launcher } = site;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{site.name} — join the server</title>
|
||||
<meta name="description" content={`${site.name}: ${site.tagline}`} />
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header class="hero">
|
||||
<img class="logo" src="/logo.png" alt={`${site.name} logo`} width="128" height="128" />
|
||||
<h1>{site.name}</h1>
|
||||
<p class="tagline">{site.tagline}</p>
|
||||
<p class="addr">
|
||||
<span class="addr-label">Server</span>
|
||||
<code id="server-addr">{site.serverAddress}</code>
|
||||
<button class="copy" data-copy={site.serverAddress} type="button">Copy</button>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ol class="steps">
|
||||
<li>
|
||||
<h2><span class="num">1</span> Download the launcher</h2>
|
||||
<p>Grab <strong>{launcher.name}</strong> for your system.</p>
|
||||
<div class="downloads">
|
||||
{launcher.downloads.map((d) => (
|
||||
<a class="dl" href={`${launcher.base}/${d.file}`}>
|
||||
<span class="dl-os">{d.os}</span>
|
||||
<span class="dl-hint">{d.hint}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2><span class="num">2</span> Add your account</h2>
|
||||
<p>In the launcher, add an <strong>authlib-injector</strong> account using this URL:</p>
|
||||
<p>
|
||||
<code>{site.authlibUrl}</code>
|
||||
<button class="copy" data-copy={site.authlibUrl} type="button">Copy</button>
|
||||
</p>
|
||||
<p>
|
||||
Need credentials? Register at <a href={site.authUrl}>{site.authUrl}</a>, then log in
|
||||
with that username and password.
|
||||
</p>
|
||||
<p class="note">
|
||||
Offline party? Download and trust the
|
||||
<a href={site.caCertUrl}>local CA certificate</a> so the game-file
|
||||
mirror works over HTTPS.
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2><span class="num">3</span> Import the modpack</h2>
|
||||
<p>Add a new instance from this packwiz URL:</p>
|
||||
<p>
|
||||
<code>{site.packwizUrl}</code>
|
||||
<button class="copy" data-copy={site.packwizUrl} type="button">Copy</button>
|
||||
</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<h2><span class="num">4</span> Connect</h2>
|
||||
<p>Launch the instance, add a server, and join:</p>
|
||||
<p>
|
||||
<code>{site.serverAddress}</code>
|
||||
<button class="copy" data-copy={site.serverAddress} type="button">Copy</button>
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<footer>
|
||||
<p>{site.name} · {site.baseDomain}</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Copy-to-clipboard for every [data-copy] button.
|
||||
document.querySelectorAll<HTMLButtonElement>("button.copy").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const text = btn.dataset.copy ?? "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const prev = btn.textContent;
|
||||
btn.textContent = "Copied";
|
||||
setTimeout(() => (btn.textContent = prev), 1200);
|
||||
} catch {
|
||||
btn.textContent = "Copy failed";
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--stone: #7a7a7a;
|
||||
--bg: #f4f4f2;
|
||||
--card: #ffffff;
|
||||
--ink: #1c1c1c;
|
||||
--muted: #6b6b6b;
|
||||
--line: #e2e2df;
|
||||
--accent: #4f7942;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
line-height: 1.55;
|
||||
}
|
||||
main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.25rem 4rem;
|
||||
}
|
||||
.hero { text-align: center; margin-bottom: 2.5rem; }
|
||||
.logo {
|
||||
image-rendering: pixelated;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
h1 { font-size: 2.4rem; margin: 0.25rem 0 0; letter-spacing: -0.5px; }
|
||||
.tagline { color: var(--muted); margin: 0.25rem 0 1.25rem; }
|
||||
.addr {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.addr-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
}
|
||||
code {
|
||||
background: #ededea;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.copy {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
border-radius: 6px;
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.copy:hover { border-color: var(--stone); }
|
||||
.steps { list-style: none; padding: 0; margin: 0; display: grid; gap: 1rem; }
|
||||
.steps > li {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.steps h2 { font-size: 1.2rem; margin: 0 0 0.5rem; display: flex; align-items: center; gap: 0.6rem; }
|
||||
.num {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.steps p { margin: 0.4rem 0; }
|
||||
.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-top: 0.75rem; }
|
||||
.dl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-decoration: none;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.8rem 1rem;
|
||||
}
|
||||
.dl:hover { border-color: var(--accent); }
|
||||
.dl-os { font-weight: 600; }
|
||||
.dl-hint { font-size: 0.8rem; color: var(--muted); }
|
||||
footer { text-align: center; color: var(--muted); font-size: 0.85rem; margin-top: 2.5rem; }
|
||||
a { color: var(--accent); }
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user