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>
435 lines
17 KiB
JavaScript
435 lines
17 KiB
JavaScript
// 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 />);
|