новый лендинг

This commit is contained in:
2026-04-26 16:09:08 +04:00
parent 103e2bc4cf
commit 7701aa16c0
24 changed files with 6368 additions and 341 deletions
+41
View File
@@ -0,0 +1,41 @@
import { useReveal } from './atoms.jsx';
export default function About() {
const ref = useReveal();
return (
<section id="about" data-screen-label="02 About">
<div className="wrap about-grid reveal" ref={ref}>
<div className="about-copy">
<span className="eyebrow">Что такое MoonWell</span>
<div style={{ height: 18 }} />
<h2 className="h-display">Ночной колодец, где эпохи ждут тебя.</h2>
<div style={{ height: 28 }} />
<p>
MoonWell это не очередной «1x серверный клон». Мы переосмыслили Wrath of the Lich King как
длинный путь: сначала Классика, затем Burning Crusade, потом Лич но каждый герой идёт по нему
сам. Никаких скачков на 80-й уровень и автоматических буст-паков.
</p>
<p>
Мы хотим вернуть ощущение, которое многие из нас помнят с 2005-го: когда незнакомый лес опасен,
а первый рейд событие года. И при этом добавить то, чего тогда не хватало возможность
играть, когда у тебя есть только час после работы.
</p>
<blockquote className="about-quote">
«Мы строим мир, в котором ты можешь пройти всё в одиночку или собрать отряд из трёх верных друзей и свергнуть короля-лича. Оба пути честные.»
<cite> команда MoonWell</cite>
</blockquote>
</div>
<div className="about-visual corners">
<div className="about-stats">
<span>WoW 3.3.5a</span>
<span>Est. 2026</span>
</div>
<div className="placeholder-note">
[ placeholder ] концепт-арт: вход в Лунный Колодец, туман, силуэт ночного эльфа · 1200×1600
</div>
</div>
</div>
</section>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { useEffect, useState } from 'react';
import Background from './Background.jsx';
import Nav from './Nav.jsx';
import Hero from './Hero.jsx';
import Realm from './Realm.jsx';
import About from './About.jsx';
import Features from './Features.jsx';
import Join from './Join.jsx';
import News from './News.jsx';
import Footer from './Footer.jsx';
import Tweaks from './Tweaks.jsx';
export default function App() {
const [state, setState] = useState(() => {
const fromBootstrap = (window.MOONWELL && window.MOONWELL.tweaks) || {};
let stored = {};
try {
const raw = localStorage.getItem('moonwell.tweaks');
if (raw) stored = JSON.parse(raw);
} catch (e) {
// ignore
}
return { variant: 'forest', particles: true, moon: true, ...fromBootstrap, ...stored };
});
useEffect(() => {
document.body.dataset.variant = state.variant;
document.body.dataset.particles = state.particles ? 'true' : 'false';
document.body.dataset.moon = state.moon ? 'true' : 'false';
}, [state]);
return (
<>
<Background />
<Nav />
<Hero />
<Realm />
<About />
<Features />
<Join />
<News />
<Footer />
<Tweaks state={state} setState={setState} />
</>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useRef } from 'react';
export default function Background() {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
let raf;
let w, h, dpr;
const stars = [];
const drift = [];
const resize = () => {
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = canvas.width = window.innerWidth * dpr;
h = canvas.height = window.innerHeight * dpr;
canvas.style.width = window.innerWidth + 'px';
canvas.style.height = window.innerHeight + 'px';
stars.length = 0;
const density = Math.min(260, Math.floor((window.innerWidth * window.innerHeight) / 9000));
for (let i = 0; i < density; i++) {
stars.push({
x: Math.random() * w,
y: Math.random() * h,
r: Math.random() * 1.3 * dpr + 0.3 * dpr,
baseAlpha: Math.random() * 0.6 + 0.2,
twinkleSpeed: Math.random() * 0.003 + 0.0005,
twinklePhase: Math.random() * Math.PI * 2,
hue: Math.random() < 0.2 ? 'moon' : 'white',
});
}
drift.length = 0;
for (let i = 0; i < 18; i++) {
drift.push({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.15,
vy: (Math.random() - 0.5) * 0.1 - 0.02,
r: Math.random() * 2 * dpr + 0.5 * dpr,
alpha: Math.random() * 0.3 + 0.05,
});
}
};
const draw = (t) => {
ctx.clearRect(0, 0, w, h);
for (const s of stars) {
const a = s.baseAlpha * (0.5 + 0.5 * Math.sin(t * s.twinkleSpeed + s.twinklePhase));
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fillStyle = s.hue === 'moon'
? `rgba(200, 230, 255, ${a})`
: `rgba(244, 236, 207, ${a})`;
ctx.fill();
}
for (const d of drift) {
d.x += d.vx;
d.y += d.vy;
if (d.x < 0) d.x = w; if (d.x > w) d.x = 0;
if (d.y < 0) d.y = h; if (d.y > h) d.y = 0;
const grad = ctx.createRadialGradient(d.x, d.y, 0, d.x, d.y, d.r * 6);
grad.addColorStop(0, `rgba(176, 136, 238, ${d.alpha})`);
grad.addColorStop(1, 'rgba(176, 136, 238, 0)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(d.x, d.y, d.r * 6, 0, Math.PI * 2);
ctx.fill();
}
raf = requestAnimationFrame(draw);
};
resize();
draw(0);
window.addEventListener('resize', resize);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', resize);
};
}, []);
return (
<>
<div className="bg">
<div className="bg-moon" aria-hidden="true"></div>
<div className="bg-fog" aria-hidden="true"></div>
</div>
<canvas id="particles" ref={canvasRef} aria-hidden="true" />
</>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { useReveal, Icons } from './atoms.jsx';
const items = [
{
icon: Icons.path,
num: 'I',
tag: 'Сигнатурная фича',
title: 'Индивидуальный прогресс по эпохам',
desc: 'Пока твой герой не увидел финал Классики — Outland закрыт. Пока не закрыт Illidan — Нортренд в тумане. Каждый идёт в своём темпе, без скипов.',
span: 'span-6',
},
{
icon: Icons.solo,
num: 'II',
tag: '',
title: 'Соло-данжи',
desc: 'Любое подземелье проходимо в одиночку: мы скейлим боссов и механики под размер отряда — от 1 до 5.',
span: 'span-3',
},
{
icon: Icons.mask,
num: 'III',
tag: '',
title: 'Режим «Предатель»',
desc: 'При создании персонаж Альянса может встать на сторону Орды (и наоборот). Отдельные сюжеты, NPC, репутации.',
span: 'span-3',
},
{
icon: Icons.wolf,
num: 'IV',
tag: 'В разработке',
title: 'Ликантропия',
desc: 'Воргены как болезнь: заражайся, сопротивляйся, или прими форму. Твой выбор — твой путь.',
span: 'span-4',
},
{
icon: Icons.mythic,
num: 'V',
tag: '',
title: 'Mythic+ без потолка',
desc: 'Бесконечное масштабирование уровня ключа. Каждую неделю — новые аффиксы.',
span: 'span-4',
},
{
icon: Icons.pass,
num: 'VI',
tag: 'В разработке',
title: 'Боевой пропуск · магазин наград',
desc: 'Ежедневные и еженедельные задания, ежедневные награды за вход, косметика в магазине.',
span: 'span-4',
},
{
icon: Icons.collection,
num: 'VII',
tag: 'В разработке',
title: 'Коллекции',
desc: 'Питомцы, ездовые животные, трансмогрификация — всё в одном журнале, с фильтрами и прогрессом.',
span: 'span-6',
},
{
icon: Icons.kart,
num: 'VIII',
tag: 'Side Project',
title: 'MoonWell Karts',
desc: 'Отдельная мини-игра в нашей инфраструктуре — аркадные гонки с персонажами сервера. В разработке.',
span: 'span-6',
},
];
const onMove = (e) => {
const r = e.currentTarget.getBoundingClientRect();
e.currentTarget.style.setProperty('--mx', `${((e.clientX - r.left) / r.width) * 100}%`);
e.currentTarget.style.setProperty('--my', `${((e.clientY - r.top) / r.height) * 100}%`);
};
function FeatureCard({ icon, num, tag, title, desc, span }) {
const ref = useReveal();
return (
<article
className={`feature ${span} reveal`}
ref={ref}
onMouseMove={onMove}
style={{ transitionDelay: '0ms' }}
>
<div className="feature-num">
<span>{num}</span>
{tag && <span className="tag">{tag}</span>}
</div>
<div className="feature-icon">{icon}</div>
<h3 className="feature-title">{title}</h3>
<p className="feature-desc">{desc}</p>
</article>
);
}
function Progression() {
const ref = useReveal();
const steps = [
{ name: 'Classic', lvl: '160', status: 'Активно', state: 'live' },
{ name: 'The Burning Crusade', lvl: '6070', status: 'Следующая эпоха', state: 'current' },
{ name: 'Wrath of the Lich King', lvl: '7080', status: 'Закрыто за туманом', state: 'locked' },
];
return (
<div id="progression" className="progression reveal" ref={ref}>
<div className="progression-title">
<h3>Путь через эпохи</h3>
<span className="meta">Твой герой · Aranthel, Хранитель рощи · 47 ур.</span>
</div>
<div className="progression-track">
{steps.map((s, i) => (
<div key={i} className={`progression-step ${s.state}`}>
<div className="name">
{s.name}
<small>{s.lvl}</small>
</div>
<div className="status">{s.status}</div>
</div>
))}
</div>
</div>
);
}
export default function Features() {
const ref = useReveal();
return (
<section id="features" data-screen-label="03 Features">
<div className="wrap">
<div className="section-head reveal" ref={ref}>
<span className="eyebrow">Особенности сервера</span>
<div style={{ height: 20 }} />
<h2 className="h-display" style={{ fontSize: 'clamp(36px, 5vw, 60px)' }}>
То, чего ты не найдёшь у других
</h2>
<div className="divider-ornament"></div>
<p className="lead">
Восемь систем, которые делают MoonWell не клоном, а отдельной игрой поверх знакомого мира.
</p>
</div>
<div className="features-grid">
{items.map((f, i) => (
<FeatureCard key={i} {...f} />
))}
</div>
<Progression />
</div>
</section>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useReveal, Icons, MoonMark, getBootstrap } from './atoms.jsx';
export default function Footer() {
const ref = useReveal();
const playUrl = getBootstrap().play_url || '#join';
return (
<footer className="footer" data-screen-label="05 Footer">
<div className="wrap">
<div className="footer-cta reveal" ref={ref}>
<span className="eyebrow">Свет лунного колодца зовёт</span>
<div style={{ height: 20 }} />
<h2 className="h-display">Один лаунчер и ты у ворот Тельдрассила.</h2>
<p>
Наш лаунчер сам поставит игровой клиент, пропишет realmlist и будет держать всё в актуальном
состоянии. Без ручных патчей и копирования файлов.
</p>
<div className="launcher-steps">
<div className="launcher-step">
<span className="n">01</span>
<span className="t">Скачай лаунчер</span>
</div>
<div className="launcher-step">
<span className="n">02</span>
<span className="t">Войди или создай аккаунт</span>
</div>
<div className="launcher-step">
<span className="n">03</span>
<span className="t">Нажми «Играть» остальное автоматически</span>
</div>
</div>
<div className="hero-cta" style={{ justifyContent: 'center', marginTop: 40 }}>
<a href={playUrl} className="btn btn-primary">
Скачать лаунчер для Windows {Icons.arrow}
</a>
<a href="#" className="btn btn-ghost is-disabled" aria-disabled="true">
macOS в разработке
</a>
</div>
<div style={{ marginTop: 20, fontFamily: 'var(--f-mono)', fontSize: 11, letterSpacing: '0.18em', color: 'var(--text-mute)', textTransform: 'uppercase' }}>
v 1.4.2 · ~180 MB · автообновление
</div>
</div>
<div className="footer-grid">
<div className="footer-col footer-brand">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, color: 'var(--moon)' }}>
<MoonMark size={28} />
<span style={{ fontFamily: 'var(--f-display)', letterSpacing: '0.18em', fontSize: 16, color: 'var(--ivory)' }}>
MOONWELL
</span>
</div>
<p>
Приватный сервер World of Warcraft 3.3.5a с индивидуальным прогрессом, соло-данжами и
режимом «Предатель». Бесплатно и без pay-to-win.
</p>
</div>
<div className="footer-col">
<h4>Сервер</h4>
<a href="#">Статус реалмов</a>
<a href="#">Скачать клиент</a>
<a href="#">Правила</a>
<a href="#">Дорожная карта</a>
</div>
<div className="footer-col">
<h4>Сообщество</h4>
<a href="#">Discord</a>
<a href="#">Форум</a>
<a href="#">Поддержка</a>
<a href="#">Вакансии</a>
</div>
<div className="footer-col">
<h4>База знаний</h4>
<a href="#">Гайды</a>
<a href="#">База данных</a>
<a href="#">Рейтинги</a>
<a href="#">API</a>
</div>
</div>
<div className="footer-bottom">
<span>© 2026 MoonWell · fan project · не связан с Blizzard Entertainment</span>
<span>Сделано с лунным светом</span>
</div>
</div>
</footer>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { Icons, getBootstrap } from './atoms.jsx';
export default function Hero() {
const playUrl = getBootstrap().play_url || '#join';
return (
<section className="hero" data-screen-label="01 Hero">
<div className="wrap hero-inner">
<div className="hero-pretitle">
<span className="dot"></span>
<span>Открытая бета · Wrath of the Lich King</span>
</div>
<h1 className="h-display hero-title">
MoonWell
<span className="ornament"> Лунный Колодец </span>
</h1>
<p className="hero-tagline">
Приватный сервер World of Warcraft, где ты проходишь эпоху за эпохой
от Классики до Короля-лича в своём темпе, один или в отряде.
</p>
<div className="hero-meta">
<span>Индивидуальный прогресс</span>
<span>Соло-данжи</span>
<span>Режим «Предатель»</span>
<span>Mythic+ без потолка</span>
</div>
<div className="hero-cta">
<a href={playUrl} className="btn btn-primary">
Скачать лаунчер {Icons.arrow}
</a>
<a href="#about" className="btn btn-ghost">
Узнать больше
</a>
</div>
<div className="hero-launcher-note">
<span className="os">Windows 10/11</span>
<span className="sep">·</span>
<span>macOS в разработке</span>
<span className="sep">·</span>
<span>~180 MB · автообновление · клиент ставится сам</span>
</div>
</div>
<div className="hero-scroll">Прокрути вниз</div>
</section>
);
}
+325
View File
@@ -0,0 +1,325 @@
import { useEffect, useMemo, useState } from 'react';
import { useReveal, Icons, MoonMark, getBootstrap } from './atoms.jsx';
const FIELD_HINTS = {
username: 'Используй латиницу и цифры. Регистр символов при входе не имеет значения.',
invite_code: 'Регистрация доступна только по действующему инвайт-коду.',
};
function firstError(errors, field) {
if (!errors || !errors[field]) return null;
const value = errors[field];
return Array.isArray(value) ? value[0] : value;
}
export default function Join() {
const ref = useReveal();
const bootstrap = getBootstrap();
const flash = bootstrap.flash || {};
const old = flash.old || {};
const initialErrors = flash.errors || {};
const [form, setForm] = useState({
username: old.username || '',
email: old.email || '',
invite_code: old.invite_code || '',
password: '',
password_confirmation: '',
terms: false,
});
const [touched, setTouched] = useState(() => {
const t = {};
Object.keys(initialErrors).forEach((k) => { t[k] = true; });
return t;
});
const [serverErrors, setServerErrors] = useState(initialErrors);
const [serverMessage, setServerMessage] = useState(flash.error || null);
const [submitted, setSubmitted] = useState(Boolean(flash.success));
const [successUsername, setSuccessUsername] = useState(
flash.success_username || old.username || ''
);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!submitted) return;
const target = document.getElementById('join');
if (target && (flash.success || flash.error || Object.keys(initialErrors).length)) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, [submitted]);
const set = (k) => (e) => {
const v = e.target.type === 'checkbox' ? e.target.checked : e.target.value;
setForm((f) => ({ ...f, [k]: v }));
if (serverErrors[k]) {
setServerErrors((errs) => {
const next = { ...errs };
delete next[k];
return next;
});
}
};
const blur = (k) => () => setTouched((t) => ({ ...t, [k]: true }));
const clientErrors = useMemo(() => {
const e = {};
if (form.username && !/^[a-zA-Z0-9]{3,32}$/.test(form.username)) {
e.username = 'Только латиница и цифры, 3–32 символа.';
}
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
e.email = 'Похоже на некорректный email.';
}
if (form.invite_code && !/^[A-Za-z0-9-]{6,32}$/.test(form.invite_code)) {
e.invite_code = 'Код из букв, цифр и тире, 6–32 символа.';
}
if (form.password && form.password.length < 8) {
e.password = 'Минимум 8 символов.';
} else if (form.password && !/[A-Za-z]/.test(form.password)) {
e.password = 'Должна быть хотя бы одна буква.';
} else if (form.password && !/\d/.test(form.password)) {
e.password = 'Должна быть хотя бы одна цифра.';
}
if (form.password_confirmation && form.password_confirmation !== form.password) {
e.password_confirmation = 'Пароли не совпадают.';
}
return e;
}, [form]);
const errorFor = (field) =>
firstError(serverErrors, field) || (touched[field] && clientErrors[field]) || null;
const valid =
form.username &&
form.email &&
form.invite_code &&
form.password &&
form.password_confirmation &&
form.terms &&
Object.keys(clientErrors).length === 0;
const onSubmit = (e) => {
if (!valid) {
e.preventDefault();
setTouched({
username: true,
email: true,
invite_code: true,
password: true,
password_confirmation: true,
terms: true,
});
return;
}
setSubmitting(true);
};
if (submitted && flash.success) {
return (
<section id="join" data-screen-label="04 Join">
<div className="wrap">
<div className="join-card join-success reveal is-visible">
<div className="join-success-mark">
<MoonMark size={56} />
</div>
<span className="eyebrow" style={{ justifyContent: 'center' }}>Добро пожаловать</span>
<h3 className="h-display" style={{ fontSize: 36, marginTop: 16 }}>
Аккаунт создан
</h3>
<p className="lead" style={{ marginTop: 14 }}>
{successUsername ? (
<>
Логин <span style={{ color: 'var(--moon)', fontStyle: 'normal' }}>{successUsername}</span> готов к входу.
</>
) : (
'Аккаунт готов к входу. '
)}
{' '}Открывай лаунчер и жми «Играть».
</p>
<div className="hero-cta" style={{ justifyContent: 'center', marginTop: 28 }}>
<a
href={bootstrap.auth?.cabinet_url || bootstrap.auth?.login_url || '#'}
className="btn btn-primary"
>
Войти в личный кабинет {Icons.arrow}
</a>
</div>
</div>
</div>
</section>
);
}
const endpoint = bootstrap.register_endpoint || '/register';
const csrfToken = bootstrap.csrf_token || '';
return (
<section id="join" data-screen-label="04 Join">
<div className="wrap">
<div className="join-grid reveal" ref={ref}>
<div className="join-intro">
<span className="eyebrow">Регистрация</span>
<div style={{ height: 20 }} />
<h2 className="h-display" style={{ fontSize: 'clamp(36px, 5vw, 56px)' }}>
Создай игровой аккаунт
</h2>
<div className="divider-ornament" style={{ justifyContent: 'flex-start', margin: '22px 0' }}>
<span style={{ opacity: 0.6 }}></span>
</div>
<p className="lead" style={{ fontStyle: 'italic' }}>
Укажи инвайт-код, логин, почту и пароль аккаунт будет готов к входу в игру сразу после
регистрации.
</p>
<div className="join-note">
<span className="join-note-k">Закрытый бета-доступ</span>
<span className="join-note-v">
Пока идёт закрытый этап: регистрация только по инвайт-кодам. Код можно получить у
друзей-игроков или в нашем Discord.
</span>
</div>
</div>
<form
className="join-card"
method="POST"
action={endpoint}
onSubmit={onSubmit}
noValidate
>
<input type="hidden" name="_token" value={csrfToken} />
<div className="join-card-corner tl" aria-hidden="true"></div>
<div className="join-card-corner tr" aria-hidden="true"></div>
<div className="join-card-corner bl" aria-hidden="true"></div>
<div className="join-card-corner br" aria-hidden="true"></div>
{serverMessage && (
<div className="join-error" style={{ marginBottom: 14 }}>{serverMessage}</div>
)}
<Field
label="Логин"
hint={FIELD_HINTS.username}
error={errorFor('username')}
>
<input
type="text"
name="username"
autoComplete="username"
placeholder="Moonwalker"
value={form.username}
onChange={set('username')}
onBlur={blur('username')}
maxLength={32}
/>
</Field>
<Field label="Email" error={errorFor('email')}>
<input
type="email"
name="email"
autoComplete="email"
placeholder="player@example.com"
value={form.email}
onChange={set('email')}
onBlur={blur('email')}
/>
</Field>
<Field
label="Инвайт-код"
hint={FIELD_HINTS.invite_code}
error={errorFor('invite_code')}
icon={Icons.mask}
>
<input
type="text"
name="invite_code"
placeholder="ABCD-EFGH-IJKL"
value={form.invite_code}
onChange={(e) =>
setForm((f) => ({ ...f, invite_code: e.target.value.toUpperCase() }))
}
onBlur={blur('invite_code')}
maxLength={32}
style={{ letterSpacing: '0.14em', fontFamily: 'var(--f-mono)' }}
/>
</Field>
<div className="join-row">
<Field label="Пароль" error={errorFor('password')}>
<input
type="password"
name="password"
autoComplete="new-password"
placeholder="Минимум 8 символов"
value={form.password}
onChange={set('password')}
onBlur={blur('password')}
maxLength={32}
/>
</Field>
<Field
label="Повтор пароля"
error={errorFor('password_confirmation')}
>
<input
type="password"
name="password_confirmation"
autoComplete="new-password"
placeholder="Повтори пароль"
value={form.password_confirmation}
onChange={set('password_confirmation')}
onBlur={blur('password_confirmation')}
maxLength={32}
/>
</Field>
</div>
<label className="join-check">
<input
type="checkbox"
name="terms"
value="1"
checked={form.terms}
onChange={set('terms')}
/>
<span className="box" aria-hidden="true"></span>
<span>
Я принимаю <a href="#">правила сервера</a> и согласен на обработку данных для создания игрового
аккаунта.
</span>
</label>
{errorFor('terms') && (
<div className="join-error">{errorFor('terms')}</div>
)}
<button
type="submit"
className={`btn btn-primary join-submit ${(!valid || submitting) ? 'is-disabled' : ''}`}
disabled={!valid || submitting}
>
{submitting ? 'Создаём аккаунт…' : 'Зарегистрировать аккаунт'} {Icons.arrow}
</button>
</form>
</div>
</div>
</section>
);
}
function Field({ label, hint, error, icon, children }) {
return (
<div className={`join-field ${error ? 'has-error' : ''}`}>
<label className="join-label">{label}</label>
<div className="join-input">
{icon && <span className="join-input-icon">{icon}</span>}
{children}
</div>
{error ? (
<div className="join-error">{error}</div>
) : hint ? (
<div className="join-hint">{hint}</div>
) : null}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { Icons, MoonMark, getBootstrap } from './atoms.jsx';
export default function Nav() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 40);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
useEffect(() => {
document.body.style.overflow = open ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [open]);
const close = () => setOpen(false);
const links = [
{ href: '#about', label: 'О сервере' },
{ href: '#features', label: 'Особенности' },
{ href: '#join', label: 'Регистрация' },
{ href: '#news', label: 'Новости' },
];
const bootstrap = getBootstrap();
const auth = bootstrap.auth || {};
const playUrl = bootstrap.play_url || '#join';
return (
<nav className={`nav ${scrolled ? 'scrolled' : ''}`}>
<div className="wrap nav-inner">
<a href="#" className="nav-logo" onClick={close}>
<span className="nav-logo-mark" style={{ color: 'var(--moon)' }}>
<MoonMark size={32} />
</span>
<span>MoonWell</span>
</a>
<div className="nav-links nav-links-desktop">
{links.map((l) => (
<a key={l.href} href={l.href}>{l.label}</a>
))}
{auth.is_admin && <a href={auth.admin_url}>Админка</a>}
<a
href={auth.authenticated ? auth.cabinet_url : (auth.login_url || '/login')}
className="nav-cta"
>
{auth.authenticated ? 'Личный кабинет' : 'Войти в кабинет'}
<span style={{ color: 'var(--moon)' }}>{Icons.arrow}</span>
</a>
<a href={playUrl} className="nav-cta">
Играть <span style={{ color: 'var(--moon)' }}>{Icons.arrow}</span>
</a>
</div>
<button
className={`nav-burger ${open ? 'is-open' : ''}`}
onClick={() => setOpen((v) => !v)}
aria-label={open ? 'Закрыть меню' : 'Открыть меню'}
aria-expanded={open}
>
<span></span>
<span></span>
<span></span>
</button>
</div>
<div
className={`nav-drawer-backdrop ${open ? 'is-open' : ''}`}
onClick={close}
aria-hidden="true"
/>
<aside className={`nav-drawer ${open ? 'is-open' : ''}`} aria-hidden={!open}>
<div className="nav-drawer-head">
<span className="eyebrow">Навигация</span>
</div>
<div className="nav-drawer-links">
{links.map((l, i) => (
<a
key={l.href}
href={l.href}
onClick={close}
style={{ transitionDelay: `${60 + i * 40}ms` }}
>
<span className="num">{String(i + 1).padStart(2, '0')}</span>
<span className="lbl">{l.label}</span>
<span className="arr">{Icons.arrow}</span>
</a>
))}
<a
href={auth.authenticated ? auth.cabinet_url : (auth.login_url || '/login')}
onClick={close}
>
<span className="num">{String(links.length + 1).padStart(2, '0')}</span>
<span className="lbl">{auth.authenticated ? 'Личный кабинет' : 'Войти в кабинет'}</span>
<span className="arr">{Icons.arrow}</span>
</a>
{auth.is_admin && (
<a href={auth.admin_url} onClick={close}>
<span className="num">{String(links.length + 2).padStart(2, '0')}</span>
<span className="lbl">Админка</span>
<span className="arr">{Icons.arrow}</span>
</a>
)}
</div>
<a href={playUrl} className="btn btn-primary nav-drawer-cta" onClick={close}>
Играть {Icons.arrow}
</a>
<div className="nav-drawer-foot">
<span className="eyebrow" style={{ color: 'var(--text-mute)' }}>moonwell · 3.3.5a</span>
</div>
</aside>
</nav>
);
}
+90
View File
@@ -0,0 +1,90 @@
import { useReveal, Icons, getBootstrap } from './atoms.jsx';
function NewsCard({ tag, tagType, date, title, excerpt, featured, image_url }) {
const ref = useReveal();
return (
<article className={`news-card reveal ${featured ? 'featured' : ''}`} ref={ref}>
<div className="news-meta">
<span className={`news-tag ${tagType || 'patch'}`}>{tag}</span>
{date && <span>{date}</span>}
</div>
{featured && image_url && (
<div
className="news-placeholder"
style={{
backgroundImage: `url(${image_url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
color: 'transparent',
}}
>
{title}
</div>
)}
{featured && !image_url && (
<div className="news-placeholder">[ placeholder ] · 1600×800</div>
)}
<h3 className="news-title">{title}</h3>
<p className="news-excerpt">{excerpt}</p>
</article>
);
}
export default function News() {
const ref = useReveal();
const posts = getBootstrap().posts || [];
return (
<section id="news" data-screen-label="04 News">
<div className="wrap">
<div className="section-head reveal" ref={ref}>
<span className="eyebrow">Хроники сервера</span>
<div style={{ height: 20 }} />
<h2 className="h-display" style={{ fontSize: 'clamp(36px, 5vw, 60px)' }}>
Новости и патчноуты
</h2>
<div className="divider-ornament"></div>
</div>
{posts.length === 0 ? (
<NewsEmpty />
) : (
<div className="news-grid">
{posts.map((p) => (
<NewsCard key={p.id ?? p.title} {...p} />
))}
</div>
)}
</div>
</section>
);
}
function NewsEmpty() {
const ref = useReveal();
return (
<div
className="news-empty reveal"
ref={ref}
style={{
textAlign: 'center',
padding: '80px 24px',
border: '1px dashed var(--panel-border-strong)',
borderRadius: 'var(--radius)',
background: 'rgba(127, 184, 212, 0.02)',
}}
>
<div style={{ fontSize: 28, color: 'var(--moon)', marginBottom: 14 }}></div>
<h3
className="h-display"
style={{ fontSize: 24, marginBottom: 10 }}
>
Пока новостей нет
</h3>
<p className="lead" style={{ fontStyle: 'italic', maxWidth: 520, margin: '0 auto' }}>
Хроники сервера ещё не открыли первую страницу. Загляни позже здесь
появятся патчноуты, события и девблоги.
</p>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from 'react';
import { useReveal, getBootstrap } from './atoms.jsx';
export default function Realm() {
const seed = (getBootstrap().realm) || {};
const ref = useReveal();
const isOnline = seed.online !== false;
const realPlayers = typeof seed.players === 'number' ? seed.players : null;
// Лёгкий "живой" дрейф пинга, чтобы число не было статичным.
const [ping, setPing] = useState(seed.ping ?? 38);
useEffect(() => {
if (!isOnline) return;
const t = setInterval(() => {
setPing((v) => Math.max(20, Math.min(80, v + Math.round((Math.random() - 0.5) * 6))));
}, 2200);
return () => clearInterval(t);
}, [isOnline]);
const name = seed.name || "Elune's Grace";
const mode = seed.mode || 'PvE · x1 rate · RU';
const uptime = seed.uptime || '99.94%';
const factions = seed.factions || { alliance: 50, horde: 50 };
return (
<div className="wrap realm reveal" ref={ref}>
<div className="realm-panel">
<div className="realm-status">
<span className="orb"></span>
<span className="label">{isOnline ? 'Онлайн' : 'Оффлайн'}</span>
</div>
<div className="realm-name">
{name}
<small>{mode}</small>
</div>
<div className="realm-stat">
<span className="k">Игроков</span>
<span className="v">
{realPlayers !== null ? realPlayers.toLocaleString('ru-RU') : '—'}
</span>
</div>
<div className="realm-stat">
<span className="k">Аптайм</span>
<span className="v small">{uptime}</span>
</div>
<div className="realm-stat">
<span className="k">Пинг</span>
<span className="v small">{isOnline ? `${ping} ms` : '—'}</span>
</div>
<div className="realm-stat">
<span className="k">Фракции</span>
<span className="v small">{factions.alliance} / {factions.horde}</span>
</div>
</div>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react';
export default function Tweaks({ state, setState }) {
const [open, setOpen] = useState(false);
useEffect(() => {
const onKey = (e) => {
if (e.key === '`' || (e.altKey && e.key.toLowerCase() === 't')) {
setOpen((v) => !v);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
const set = (k, v) => {
setState((s) => {
const next = { ...s, [k]: v };
try {
localStorage.setItem('moonwell.tweaks', JSON.stringify(next));
} catch (e) {
// ignore
}
return next;
});
};
return (
<>
<button
className="tweaks-fab"
onClick={() => setOpen((v) => !v)}
aria-label="Tweaks"
title="Настройки внешнего вида (alt+T)"
>
</button>
<div className={`tweaks ${open ? 'open' : ''}`}>
<div className="tweaks-title">
<span>Tweaks</span>
<button onClick={() => setOpen(false)} aria-label="Close">×</button>
</div>
<div className="tweak-row">
<label>Настроение</label>
<div className="tweak-seg">
<button
className={state.variant === 'forest' ? 'active' : ''}
onClick={() => set('variant', 'forest')}
>
Лес
</button>
<button
className={state.variant === 'temple' ? 'active' : ''}
onClick={() => set('variant', 'temple')}
>
Храм
</button>
</div>
</div>
<div className="tweak-row">
<label>Частицы и анимации</label>
<div
className={`tweak-switch ${state.particles ? 'on' : ''}`}
onClick={() => set('particles', !state.particles)}
role="switch"
aria-checked={state.particles}
/>
</div>
<div className="tweak-row">
<label>Луна на фоне</label>
<div
className={`tweak-switch ${state.moon ? 'on' : ''}`}
onClick={() => set('moon', !state.moon)}
role="switch"
aria-checked={state.moon}
/>
</div>
</div>
</>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useEffect, useRef } from 'react';
export function useReveal() {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.add('is-visible');
io.unobserve(e.target);
}
});
},
{ threshold: 0.12, rootMargin: '0px 0px -8% 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
return ref;
}
export const Icons = {
moon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z" />
<circle cx="14" cy="9" r=".6" fill="currentColor" />
<circle cx="17" cy="12" r=".4" fill="currentColor" />
</svg>
),
path: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 20 L8 12 L12 16 L16 6 L21 14" />
<circle cx="8" cy="12" r="1.2" fill="currentColor" />
<circle cx="12" cy="16" r="1.2" fill="currentColor" />
<circle cx="16" cy="6" r="1.2" fill="currentColor" />
</svg>
),
solo: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
<circle cx="12" cy="12" r=".8" fill="currentColor" />
</svg>
),
mask: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 8c2-2 5-3 8-3s6 1 8 3v5c-1 4-4 7-8 7s-7-3-8-7z" />
<path d="M8 11l2 2" />
<path d="M16 11l-2 2" />
</svg>
),
wolf: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 9l3-5 3 4h6l3-4 3 5-2 3v5l-4 3h-6l-4-3V12z" />
<circle cx="9" cy="13" r=".8" fill="currentColor" />
<circle cx="15" cy="13" r=".8" fill="currentColor" />
</svg>
),
mythic: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 3 15 9 22 10 17 15 18 22 12 19 6 22 7 15 2 10 9 9" />
</svg>
),
pass: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="6" width="18" height="14" rx="1" />
<path d="M3 10h18" />
<path d="M7 14h6" />
<circle cx="17" cy="15" r="1.2" />
</svg>
),
collection: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="8" height="8" />
<rect x="13" y="3" width="8" height="8" />
<rect x="3" y="13" width="8" height="8" />
<circle cx="17" cy="17" r="4" />
</svg>
),
kart: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 15l2-5h8l4 3h4v2" />
<circle cx="7" cy="17" r="2" />
<circle cx="17" cy="17" r="2" />
<path d="M9 15h4" />
</svg>
),
arrow: (
<svg className="btn-arrow" viewBox="0 0 14 10" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 5h12M9 1l4 4-4 4" />
</svg>
),
};
export function MoonMark({ size = 32 }) {
return (
<svg viewBox="0 0 32 32" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1">
<circle cx="16" cy="16" r="11" stroke="currentColor" strokeOpacity="0.3" />
<path d="M20 7 A11 11 0 1 0 20 25 A8 8 0 1 1 20 7 Z" fill="currentColor" fillOpacity="0.7" />
<circle cx="16" cy="16" r="15" stroke="currentColor" strokeOpacity="0.15" strokeDasharray="1 3" />
<path d="M16 1 L16 4 M16 28 L16 31 M1 16 L4 16 M28 16 L31 16" stroke="currentColor" strokeOpacity="0.4" />
</svg>
);
}
export function getBootstrap() {
return window.MOONWELL || {};
}
+8
View File
@@ -0,0 +1,8 @@
import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import '../../css/moonwell.css';
const mount = document.getElementById('root');
if (mount) {
createRoot(mount).render(<App />);
}