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:
2026-06-08 18:18:36 +02:00
parent 5cec2993d7
commit 3d52951355
60 changed files with 3388 additions and 228 deletions

434
landing/design/app.jsx Normal file
View File

@@ -0,0 +1,434 @@
// Ulicraft landing — pixel components, live status, copy-IP, scroll reveals, tweaks
const { useState, useEffect, useRef, useCallback } = React;
/* ---------- pixel art helpers ---------- */
// 8x8 creeper face
const CREEPER = [
"00000000",
"01100110",
"01100110",
"00011000",
"00111100",
"00111100",
"00100100",
"00000000",
];
function Creeper() {
const cells = CREEPER.join("").split("");
return (
<div className="creeper" aria-hidden="true">
{cells.map((c, i) => <i key={i} className={c === "1" ? "f" : ""} />)}
</div>
);
}
// 7x7 feature glyphs
const GLYPHS = {
shield: ["0111110","1111111","1111111","1111111","0111110","0011100","0001000"],
diamond:["0001000","0011100","0111110","1111111","0111110","0011100","0001000"],
orb: ["0011100","0111110","1111111","1111111","1111111","0111110","0011100"],
heart: ["0110110","1111111","1111111","1111111","0111110","0011100","0001000"],
};
function PixelIcon({ glyph, gold }) {
const cells = GLYPHS[glyph].join("").split("");
return (
<div className="picon" aria-hidden="true">
{cells.map((c, i) => (
<i key={i} className={c === "1" ? (gold ? "go" : "on") : ""} />
))}
</div>
);
}
/* ---------- copy to clipboard ---------- */
function copyText(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text);
return;
}
} catch (e) {}
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 (e) {}
document.body.removeChild(ta);
}
function IpChip({ ip }) {
const [copied, setCopied] = useState(false);
const onCopy = () => {
copyText(ip);
setCopied(true);
clearTimeout(onCopy._t);
onCopy._t = setTimeout(() => setCopied(false), 1600);
};
return (
<div className="ip-chip">
<span className="ip-val"><span className="pin"></span>{ip}</span>
<button className={copied ? "copied" : ""} onClick={onCopy}>
{copied ? "Copied!" : "Copy IP"}
</button>
</div>
);
}
/* ---------- live player count ---------- */
function useLivePlayers(base = 37, max = 100) {
const [n, setN] = useState(base);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const id = setInterval(() => {
setN((p) => {
const step = Math.floor(Math.random() * 5) - 2;
return Math.max(base - 6, Math.min(max - 4, p + step));
});
}, 2600);
return () => clearInterval(id);
}, [base, max]);
return n;
}
/* ---------- floating pixel dust ---------- */
function Dust({ on }) {
if (!on) return null;
const bits = Array.from({ length: 16 }, (_, i) => i);
return (
<div className="dust" aria-hidden="true">
{bits.map((i) => {
const size = 3 + (i % 3) * 2;
const st = {
position: "absolute",
left: ((i * 61) % 100) + "%",
bottom: "-20px",
width: size + "px",
height: size + "px",
background: i % 4 === 0 ? "var(--gold)" : "var(--accent)",
opacity: 0.18 + (i % 3) * 0.06,
imageRendering: "pixelated",
animation: `rise ${13 + (i % 7) * 3}s linear ${i * 0.7}s infinite`,
};
return <i key={i} style={st} />;
})}
</div>
);
}
/* ---------- server-list status panel ---------- */
function ServerListPanel({ players, max, version }) {
return (
<div className="serverlist">
<div className="icon"><Creeper /></div>
<div className="meta">
<div className="row1">
<span className="title">Ulicraft</span>
<span className="ver">Java {version}</span>
</div>
<p className="motd">
<span className="a"> Season 3</span> · Survival SMP · <span className="g">whitelist open</span>
</p>
</div>
<div className="stat">
<div className="players">
<span className="bars" aria-hidden="true"><i /><i /><i /><i /><i /></span>
<span><b>{players}</b>/{max}</span>
</div>
</div>
</div>
);
}
/* ---------- scroll reveal (fail-safe: visible at rest, .anim added when in view) ---------- */
function useReveal() {
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let els = [...document.querySelectorAll(".reveal")];
let stop = false;
const tick = () => {
if (stop) return;
const vh = window.innerHeight;
for (let i = els.length - 1; i >= 0; i--) {
const r = els[i].getBoundingClientRect();
if (r.top < vh * 0.9 && r.bottom > 0) {
els[i].classList.add("anim");
els.splice(i, 1);
}
}
if (els.length) requestAnimationFrame(tick);
else stop = true;
};
requestAnimationFrame(tick);
return () => { stop = true; };
}, []);
}
/* ============================================================ */
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"heroLayout": "centered",
"mood": "grass",
"headFont": "pixelify",
"serverIp": "play.ulicraft.net",
"tagline": "Hardcore survival, built to last.",
"particles": true
}/*EDITMODE-END*/;
const HEAD_FONTS = {
pixelify: "'Pixelify Sans', system-ui, sans-serif",
"8bit": "'Press Start 2P', monospace",
silkscreen: "'Silkscreen', system-ui, sans-serif",
};
const FEATURES = [
{ glyph: "shield", gold: false, h: "Grief-proof claims",
p: "Lock down your base with golden-shovel land claims. Your builds stay yours — even while you're offline." },
{ glyph: "diamond", gold: true, h: "Zero pay-to-win",
p: "The store sells cosmetics and nothing else. Every diamond is earned in-game, never bought." },
{ glyph: "orb", gold: false, h: "A living world",
p: "Seasonal map resets, custom bosses, and community events keep the overworld worth logging into." },
{ glyph: "heart", gold: true, h: "Real community",
p: "A whitelisted, moderated server with an active Discord. Toxicity meets the ban hammer, fast." },
];
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const players = useLivePlayers(37, 100);
const peak = 84;
const version = "1.21.4";
// guarantee pixel head fonts are downloaded before first paint settles
useEffect(() => {
if (!document.fonts || !document.fonts.load) return;
["600 32px 'Pixelify Sans'", "700 32px 'Pixelify Sans'",
"400 32px 'Press Start 2P'", "400 32px 'Silkscreen'", "700 32px 'Silkscreen'"]
.forEach((f) => document.fonts.load(f).catch(() => {}));
}, []);
// apply mood / hero / font to <html>
useEffect(() => {
const r = document.documentElement;
r.setAttribute("data-mood", t.mood);
r.setAttribute("data-hero", t.heroLayout);
r.setAttribute("data-head", t.headFont);
r.style.setProperty("--font-head", HEAD_FONTS[t.headFont] || HEAD_FONTS.pixelify);
}, [t.mood, t.heroLayout, t.headFont]);
useReveal();
return (
<>
{/* NAV */}
<header className="nav">
<div className="wrap nav-in">
<a className="brand" href="#top">
<Creeper />
<span className="name">ULICRAFT</span>
</a>
<nav className="nav-links">
<a href="#status">Status</a>
<a href="#features">Features</a>
<a href="#join">How to Join</a>
</nav>
<span className="nav-spacer" />
<a className="mc-btn primary sm" href="#join">Play Now</a>
</div>
</header>
{/* HERO */}
<main id="top">
<section className="hero" data-screen-label="Hero">
<Dust on={t.particles} />
<div className="wrap hero-grid">
<div className="hero-logo">
<img src="assets/ulicraft-logo.png" alt="Ulicraft" width="707" height="148" />
</div>
<div className="hero-copy">
<span className="eyebrow">Java Edition · Survival SMP · Season 3</span>
<h1 className="hero-tagline">{t.tagline}</h1>
<p className="hero-sub">
A whitelisted Java SMP with land claims, seasonal events, and zero pay-to-win.
Grab the IP, fire up your launcher, and stake your claim.
</p>
<div className="hero-ip-row">
<IpChip ip={t.serverIp} />
<a className="mc-btn" href="#join">How to Join</a>
</div>
<div className="hero-meta">
<span className="live-pill">
<span className="live-dot" />
<span><b>{players}</b> playing now</span>
</span>
<span>Java {version}</span>
<span className="dot" />
<span>No mods required</span>
</div>
</div>
<div className="hero-status">
<ServerListPanel players={players} max={100} version={version} />
</div>
</div>
</section>
{/* STATUS */}
<section id="status" className="pad" data-screen-label="Status">
<div className="wrap">
<div className="sec-head reveal">
<span className="eyebrow">Server Status</span>
<h2 className="section-title">Live, and waiting for you.</h2>
<p className="lead">This is exactly what you'll see in your multiplayer list.</p>
</div>
<div className="reveal">
<ServerListPanel players={players} max={100} version={version} />
</div>
<div className="stat-grid">
{[
{ k: <>{players}<span className="u">/100</span></>, l: "Players Online" },
{ k: <>{peak}</>, l: "Peak Today" },
{ k: <>99.9<span className="u">%</span></>, l: "Uptime" },
{ k: <>20.0<span className="u"> tps</span></>, l: "Performance" },
].map((s, i) => (
<div className="tile reveal" key={i} style={{ transitionDelay: i * 60 + "ms" }}>
<div className="k">{s.k}</div>
<div className="l">{s.l}</div>
</div>
))}
</div>
</div>
</section>
{/* FEATURES */}
<section id="features" className="pad" data-screen-label="Features" style={{ background: "var(--bg-2)" }}>
<div className="wrap">
<div className="sec-head reveal">
<span className="eyebrow">What makes it Ulicraft</span>
<h2 className="section-title">Built for players who stay.</h2>
<p className="lead">No reset roulette, no whales buying god gear. Just a server that respects your time.</p>
</div>
<div className="feat-grid">
{FEATURES.map((f, i) => (
<div className="feat reveal" key={i} style={{ transitionDelay: (i % 2) * 80 + "ms" }}>
<PixelIcon glyph={f.glyph} gold={f.gold} />
<div>
<h3>{f.h}</h3>
<p>{f.p}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* HOW TO JOIN */}
<section id="join" className="pad" data-screen-label="How to Join">
<div className="wrap">
<div className="sec-head reveal">
<span className="eyebrow">How to Join · 3 Steps</span>
<h2 className="section-title">From zero to spawning in.</h2>
<p className="lead">Ulicraft runs on Minecraft: Java Edition. Here's the whole path, start to finish.</p>
</div>
<div className="steps">
{/* STEP 1 */}
<div className="step reveal">
<div className="num">1</div>
<div className="body">
<span className="kicker">Account</span>
<h3>Get Minecraft: Java Edition</h3>
<p>
Create a free Microsoft account, then buy and download Minecraft: Java &amp; Bedrock
Edition from minecraft.net. Already own Java Edition? Skip straight to step&nbsp;2.
</p>
<div className="actions">
<a className="mc-btn gold sm" href="https://www.minecraft.net/get-minecraft" target="_blank" rel="noopener">Get Java Edition </a>
<span className="hint">Microsoft account required</span>
</div>
</div>
</div>
{/* STEP 2 */}
<div className="step reveal">
<div className="num">2</div>
<div className="body">
<span className="kicker">Launcher</span>
<h3>Download &amp; set up the launcher</h3>
<p>
Install the official Minecraft Launcher, sign in with your Microsoft account, and select
the <strong style={{ color: "var(--text)" }}>Latest Release ({version})</strong> profile.
</p>
<ul>
<li>Download the launcher for Windows, macOS, or Linux.</li>
<li>Sign in pick <strong style={{ color: "var(--text)" }}>Latest Release</strong> press <strong style={{ color: "var(--text)" }}>Play</strong> once to finish installing.</li>
<li>Optional: allocate 46&nbsp;GB RAM under Installations More Options for smoother play.</li>
</ul>
<div className="actions">
<a className="mc-btn sm" href="https://www.minecraft.net/download" target="_blank" rel="noopener">Download launcher </a>
<span className="hint">No mods or modpack needed</span>
</div>
</div>
</div>
{/* STEP 3 */}
<div className="step reveal">
<div className="num">3</div>
<div className="body">
<span className="kicker">Connect</span>
<h3>Join the Ulicraft server</h3>
<p>
In Minecraft, open <kbd>Multiplayer</kbd> <kbd>Add Server</kbd>. Name it Ulicraft,
paste the address below, hit <kbd>Done</kbd>, then double-click the server to join.
</p>
<div className="actions" style={{ marginBottom: "14px" }}>
<IpChip ip={t.serverIp} />
</div>
<span className="hint">Whitelisted server apply in our Discord first to get added.</span>
</div>
</div>
</div>
</div>
</section>
</main>
{/* FOOTER */}
<footer className="footer">
<div className="wrap footer-in">
<a className="brand" href="#top">
<Creeper />
<span className="name">ULICRAFT</span>
</a>
<div className="footer-links">
<a className="mc-btn primary sm" href="#join">Copy IP &amp; Play</a>
<a className="mc-btn sm" href="#" onClick={(e) => e.preventDefault()}>Discord</a>
</div>
<p className="disc">
Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.
© 2026 Ulicraft.
</p>
</div>
</footer>
{/* TWEAKS */}
<TweaksPanel title="Tweaks">
<TweakSection label="Layout" />
<TweakRadio label="Hero" value={t.heroLayout}
options={[{ value: "centered", label: "Centered" }, { value: "split", label: "Split" }, { value: "spotlight", label: "Spotlight" }]}
onChange={(v) => setTweak("heroLayout", v)} />
<TweakSection label="Mood" />
<TweakRadio label="Palette" value={t.mood}
options={[{ value: "grass", label: "Grass" }, { value: "nether", label: "Nether" }, { value: "end", label: "End" }]}
onChange={(v) => setTweak("mood", v)} />
<TweakSection label="Type" />
<TweakSelect label="Headline font" value={t.headFont}
options={[{ value: "pixelify", label: "Pixelify (readable)" }, { value: "8bit", label: "Press Start (8-bit)" }, { value: "silkscreen", label: "Silkscreen" }]}
onChange={(v) => setTweak("headFont", v)} />
<TweakSection label="Content" />
<TweakText label="Tagline" value={t.tagline} onChange={(v) => setTweak("tagline", v)} />
<TweakText label="Server IP" value={t.serverIp} onChange={(v) => setTweak("serverIp", v)} />
<TweakToggle label="Floating dust" value={t.particles} onChange={(v) => setTweak("particles", v)} />
</TweaksPanel>
</>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);