Files
Oier Bravo Urtasun 3d52951355 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>
2026-06-08 18:18:36 +02:00

435 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 />);