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:
434
landing/design/app.jsx
Normal file
434
landing/design/app.jsx
Normal 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 & Bedrock
|
||||
Edition from minecraft.net. Already own Java Edition? Skip straight to step 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 & 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 4–6 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 & 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 />);
|
||||
Reference in New Issue
Block a user