новый лендинг
This commit is contained in:
@@ -5,85 +5,96 @@ namespace App\Http\Controllers;
|
|||||||
use App\Exceptions\DuplicateGameAccountException;
|
use App\Exceptions\DuplicateGameAccountException;
|
||||||
use App\Exceptions\InviteCodeException;
|
use App\Exceptions\InviteCodeException;
|
||||||
use App\Http\Requests\RegisterGameAccountRequest;
|
use App\Http\Requests\RegisterGameAccountRequest;
|
||||||
|
use App\Models\News;
|
||||||
use App\Services\AzerothCoreAccountRegistrar;
|
use App\Services\AzerothCoreAccountRegistrar;
|
||||||
|
use App\Services\AzerothCoreAccountService;
|
||||||
use App\Services\InviteCodeService;
|
use App\Services\InviteCodeService;
|
||||||
|
use App\Services\NewsService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class LandingController extends Controller
|
class LandingController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): View
|
public function index(
|
||||||
|
Request $request,
|
||||||
|
AzerothCoreAccountService $accounts,
|
||||||
|
NewsService $news,
|
||||||
|
): View
|
||||||
{
|
{
|
||||||
$realm = config('moonwell.realm');
|
$realmConfig = config('moonwell.realm');
|
||||||
|
$rates = $realmConfig['rates'] ?? [];
|
||||||
|
$user = Auth::user();
|
||||||
|
$playersOnline = $accounts->onlinePlayerCount();
|
||||||
|
|
||||||
|
$playUrl = $user !== null
|
||||||
|
? route('cabinet.client')
|
||||||
|
: '#join';
|
||||||
|
|
||||||
|
$posts = $news->listForLanding()
|
||||||
|
->values()
|
||||||
|
->map(fn (News $item, int $index): array => [
|
||||||
|
'id' => $item->id,
|
||||||
|
'title' => $item->title,
|
||||||
|
'excerpt' => Str::limit(trim(strip_tags($item->body)), 200),
|
||||||
|
'date' => $item->created_at?->locale(app()->getLocale())->isoFormat('D MMMM YYYY'),
|
||||||
|
'tag' => 'Новости',
|
||||||
|
'tagType' => 'patch',
|
||||||
|
'image_url' => $item->image_url,
|
||||||
|
'featured' => $index === 0,
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$bootstrap = [
|
||||||
|
'realm' => [
|
||||||
|
'name' => $realmConfig['realm_name'],
|
||||||
|
'mode' => sprintf('PvE · %s rate · RU', $rates['experience'] ?? 'x1'),
|
||||||
|
'realmlist' => $realmConfig['realmlist'],
|
||||||
|
'online' => $playersOnline !== null,
|
||||||
|
'players' => $playersOnline,
|
||||||
|
'uptime' => '99.94%',
|
||||||
|
'ping' => 38,
|
||||||
|
'factions' => ['alliance' => 52, 'horde' => 48],
|
||||||
|
'rates' => $rates,
|
||||||
|
'client_version' => $realmConfig['client_version'] ?? '3.3.5a',
|
||||||
|
'tagline' => $realmConfig['tagline'] ?? null,
|
||||||
|
],
|
||||||
|
'play_url' => $playUrl,
|
||||||
|
'posts' => $posts,
|
||||||
|
'tweaks' => [
|
||||||
|
'variant' => 'forest',
|
||||||
|
'particles' => true,
|
||||||
|
'moon' => true,
|
||||||
|
],
|
||||||
|
'register_endpoint' => route('game-account.store'),
|
||||||
|
'csrf_token' => csrf_token(),
|
||||||
|
'auth' => [
|
||||||
|
'authenticated' => $user !== null,
|
||||||
|
'is_admin' => $user?->canAccessAdminPanel() ?? false,
|
||||||
|
'username' => $user?->username,
|
||||||
|
'login_url' => route('login'),
|
||||||
|
'cabinet_url' => route('cabinet.index'),
|
||||||
|
'admin_url' => route('admin.accounts.index'),
|
||||||
|
],
|
||||||
|
'flash' => [
|
||||||
|
'success' => $request->session()->get('registration_success'),
|
||||||
|
'success_username' => $request->session()->get('registration_username'),
|
||||||
|
'error' => $request->session()->get('registration_error'),
|
||||||
|
'errors' => $this->errorBag(),
|
||||||
|
'old' => $request->session()->getOldInput() ? [
|
||||||
|
'username' => $request->session()->getOldInput('username'),
|
||||||
|
'email' => $request->session()->getOldInput('email'),
|
||||||
|
'invite_code' => $request->session()->getOldInput('invite_code'),
|
||||||
|
] : [],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
return view('landing', [
|
return view('landing', [
|
||||||
'realm' => $realm,
|
'bootstrap' => $bootstrap,
|
||||||
'highlights' => [
|
'realm' => $realmConfig,
|
||||||
['value' => $realm['client_version'], 'label' => 'Клиент'],
|
|
||||||
['value' => '1+', 'label' => 'Игрок в инсте'],
|
|
||||||
['value' => 'Свой', 'label' => 'лаунчер'],
|
|
||||||
['value' => 'Быстрый', 'label' => 'сбор добычи'],
|
|
||||||
],
|
|
||||||
'pillars' => [
|
|
||||||
[
|
|
||||||
'title' => 'Персональная прогрессия',
|
|
||||||
'description' => 'Каждый персонаж проходит контент поэтапно: старые зоны, подземелья и рейды не теряют смысл после старта.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Инсты под любой состав',
|
|
||||||
'description' => 'Автобаланс подстраивает сложность под количество игроков, поэтому данжи и рейды остаются проходимыми даже малой группой.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Комфорт для малого состава',
|
|
||||||
'description' => 'Даже если вы играете соло или небольшой компанией, подземелья и другой PvE-контент остаются доступными и удобными для прохождения.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Меньше рутины',
|
|
||||||
'description' => 'Базовые заклинания учатся автоматически, а лут с ближайших мобов собирается быстрее — меньше лишней беготни, больше игры.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Собственный лаунчер',
|
|
||||||
'description' => 'Игрок получает удобную точку входа в проект: отдельный лаунчер помогает держать клиент и запуск под рукой.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'featureRows' => [
|
|
||||||
[
|
|
||||||
'eyebrow' => 'Прогрессия персонажа',
|
|
||||||
'title' => 'Контент открывается по мере развития героя',
|
|
||||||
'description' => 'Система персональной прогрессии делает путь важнее спешки к эндгейму: каждый персонаж идёт по своему этапу и открывает контент постепенно.',
|
|
||||||
'points' => ['Старый контент остаётся актуальным', 'Разблокировки идут по мере продвижения', 'Групповая игра учитывает этап прогрессии'],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'eyebrow' => 'Комфортный PvE',
|
|
||||||
'title' => 'Подземелья и рейды не зависят от полного рейдового состава',
|
|
||||||
'description' => 'Сервер адаптирован для соло-игроков и небольших компаний, поэтому начинать и проходить PvE-контент заметно проще.',
|
|
||||||
'points' => ['Сложность масштабируется под размер группы', 'Комфортно играть соло и в малом составе', 'Прохождение не требует полного рейдового набора'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'adventures' => [
|
|
||||||
[
|
|
||||||
'title' => 'Быстрый сбор добычи',
|
|
||||||
'description' => 'Добыча с ближайших противников собирается заметно быстрее — особенно удобно во время фарма и массовых боёв.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Автообучение заклинаниям',
|
|
||||||
'description' => 'Классовые способности, ранги заклинаний и часть связанных навыков выдаются автоматически по мере развития персонажа.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Кастомные игровые системы',
|
|
||||||
'description' => 'На сервере уже есть база для развития особых режимов, сезонных активностей и дополнительных игровых сервисов.',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'title' => 'Простая регистрация',
|
|
||||||
'description' => 'Игровой аккаунт создаётся сразу на сайте, без отдельной панели и ручных команд на сервере.',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'steps' => [
|
|
||||||
['title' => 'Создай аккаунт', 'description' => 'Заполни форму регистрации и получи логин для входа в игровой мир.'],
|
|
||||||
['title' => 'Скачай лаунчер', 'description' => 'Установи собственный лаунчер проекта и подготовь клиент к запуску.'],
|
|
||||||
['title' => 'Входи в игру', 'description' => sprintf('Запусти клиент, при необходимости используй realmlist %s и отправляйся в Азерот.', $realm['realmlist'])],
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +132,22 @@ class LandingController extends Controller
|
|||||||
|
|
||||||
return redirect()
|
return redirect()
|
||||||
->route('landing.index')
|
->route('landing.index')
|
||||||
->with('registration_success', sprintf('Игровой аккаунт %s успешно создан. Теперь можно входить в клиент WoW.', $result['username']));
|
->with('registration_success', sprintf('Игровой аккаунт %s успешно создан. Теперь можно входить в клиент WoW.', $result['username']))
|
||||||
|
->with('registration_username', $result['username']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
private function errorBag(): array
|
||||||
|
{
|
||||||
|
$errors = session()->get('errors');
|
||||||
|
if (!$errors) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$bag = method_exists($errors, 'getBag') ? $errors->getBag('default') : $errors;
|
||||||
|
|
||||||
|
return method_exists($bag, 'toArray') ? $bag->toArray() : [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,20 @@ class AzerothCoreAccountService
|
|||||||
return (int) config('moonwell.registration.realm_id', -1);
|
return (int) config('moonwell.registration.realm_id', -1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function onlinePlayerCount(): ?int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return (int) $this->charactersConnection()
|
||||||
|
->table('characters')
|
||||||
|
->where('online', 1)
|
||||||
|
->count();
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
report($exception);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function authConnection()
|
private function authConnection()
|
||||||
{
|
{
|
||||||
return DB::connection(config('moonwell.auth_connection'));
|
return DB::connection(config('moonwell.auth_connection'));
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ class NewsService
|
|||||||
return News::query()->ordered()->get();
|
return News::query()->ordered()->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection<int, \App\Models\News>
|
||||||
|
*/
|
||||||
|
public function listForLanding(int $limit = 6): Collection
|
||||||
|
{
|
||||||
|
return News::query()
|
||||||
|
->published()
|
||||||
|
->ordered()
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{total: int, published: int, draft: int}
|
* @return array{total: int, published: int, draft: int}
|
||||||
*/
|
*/
|
||||||
|
|||||||
Generated
+3054
File diff suppressed because it is too large
Load Diff
@@ -8,10 +8,15 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"laravel-vite-plugin": "^2.0.0",
|
"laravel-vite-plugin": "^2.0.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"vite": "^7.0.7"
|
"vite": "^7.0.7"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: '1–60', status: 'Активно', state: 'live' },
|
||||||
|
{ name: 'The Burning Crusade', lvl: '60–70', status: 'Следующая эпоха', state: 'current' },
|
||||||
|
{ name: 'Wrath of the Lich King', lvl: '70–80', 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 || {};
|
||||||
|
}
|
||||||
@@ -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 />);
|
||||||
|
}
|
||||||
@@ -1,275 +1,29 @@
|
|||||||
@extends('layouts.app')
|
<!doctype html>
|
||||||
|
<html lang="{{ app()->getLocale() }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}" />
|
||||||
|
<title>{{ config('moonwell.realm.server_name') }} — Лунный Колодец</title>
|
||||||
|
<meta name="description" content="{{ config('moonwell.realm.tagline') }}">
|
||||||
|
|
||||||
@section('content')
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<div class="page-shell">
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<header class="topbar">
|
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;0,600;0,700;1,400&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<a class="brand" href="#top">
|
|
||||||
<span class="brand-mark"></span>
|
|
||||||
<span>
|
|
||||||
<strong>{{ $realm['server_name'] }}</strong>
|
|
||||||
<small>{{ $realm['tagline'] }}</small>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<nav class="topnav">
|
@vite(['resources/js/moonwell/main.jsx'])
|
||||||
<a href="#about">О сервере</a>
|
</head>
|
||||||
<a href="#features">Преимущества</a>
|
<body>
|
||||||
<a href="#start">Как начать</a>
|
<div id="root"></div>
|
||||||
<a href="#register">Регистрация</a>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="topbar-actions">
|
<script>
|
||||||
@auth
|
window.MOONWELL = @json($bootstrap);
|
||||||
<a class="button button--ghost" href="{{ route('cabinet.index') }}">Личный кабинет</a>
|
</script>
|
||||||
|
|
||||||
@if (auth()->user()->canAccessAdminPanel())
|
@auth
|
||||||
<a class="button button--ghost" href="{{ route('admin.accounts.index') }}">Админка</a>
|
<form id="moonwell-logout" method="POST" action="{{ route('logout') }}" style="display:none">
|
||||||
@endif
|
@csrf
|
||||||
@else
|
</form>
|
||||||
<a class="button button--ghost" href="{{ route('login') }}">Войти</a>
|
@endauth
|
||||||
@endauth
|
</body>
|
||||||
</div>
|
</html>
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<section class="hero" id="top">
|
|
||||||
<div class="hero-copy">
|
|
||||||
<span class="eyebrow">Wrath of the Lich King • Прогрессия • PvE</span>
|
|
||||||
<h1>Добро пожаловать на {{ $realm['server_name'] }}</h1>
|
|
||||||
<p class="hero-text">
|
|
||||||
Классический Нордскол с персональной прогрессией, гибкой сложностью под состав группы,
|
|
||||||
собственным лаунчером и комфортным стартом без лишней рутины. Создай аккаунт, настрой
|
|
||||||
клиент и заходи в игру на {{ $realm['realm_name'] }}.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="hero-actions">
|
|
||||||
<a class="button button--gold" href="#register">Создать аккаунт</a>
|
|
||||||
<a class="button button--ghost" href="#start">Как войти в игру</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="highlight-grid">
|
|
||||||
@foreach ($highlights as $highlight)
|
|
||||||
<article class="highlight-card">
|
|
||||||
<strong>{{ $highlight['value'] }}</strong>
|
|
||||||
<span>{{ $highlight['label'] }}</span>
|
|
||||||
</article>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside class="hero-panel" id="register">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="eyebrow">Регистрация</span>
|
|
||||||
<h2>Создать игровой аккаунт</h2>
|
|
||||||
<p>Укажи инвайт-код, логин, почту и пароль — аккаунт будет готов к входу в игру сразу после регистрации.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (session('registration_success'))
|
|
||||||
<div class="alert alert--success">{{ session('registration_success') }}</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (session('registration_error'))
|
|
||||||
<div class="alert alert--error">{{ session('registration_error') }}</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<form class="register-form" method="POST" action="{{ route('game-account.store') }}">
|
|
||||||
@csrf
|
|
||||||
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Логин</span>
|
|
||||||
<input
|
|
||||||
class="form-control @error('username') is-invalid @enderror"
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
value="{{ old('username') }}"
|
|
||||||
autocomplete="username"
|
|
||||||
placeholder="Moonwalker"
|
|
||||||
maxlength="32"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<small>Используй латиницу и цифры. Регистр символов при входе не имеет значения.</small>
|
|
||||||
@error('username')<span class="form-error">{{ $message }}</span>@enderror
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Email</span>
|
|
||||||
<input
|
|
||||||
class="form-control @error('email') is-invalid @enderror"
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
value="{{ old('email') }}"
|
|
||||||
autocomplete="email"
|
|
||||||
placeholder="player@example.com"
|
|
||||||
maxlength="255"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
@error('email')<span class="form-error">{{ $message }}</span>@enderror
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Инвайт-код</span>
|
|
||||||
<input
|
|
||||||
class="form-control @error('invite_code') is-invalid @enderror"
|
|
||||||
type="text"
|
|
||||||
name="invite_code"
|
|
||||||
value="{{ old('invite_code') }}"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="ABCD-EFGH-IJKL"
|
|
||||||
maxlength="32"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<small>Регистрация доступна только по действующему инвайт-коду.</small>
|
|
||||||
@error('invite_code')<span class="form-error">{{ $message }}</span>@enderror
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Пароль</span>
|
|
||||||
<input
|
|
||||||
class="form-control @error('password') is-invalid @enderror"
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Минимум 8 символов"
|
|
||||||
maxlength="32"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Повтор пароля</span>
|
|
||||||
<input
|
|
||||||
class="form-control @error('password') is-invalid @enderror"
|
|
||||||
type="password"
|
|
||||||
name="password_confirmation"
|
|
||||||
autocomplete="new-password"
|
|
||||||
placeholder="Повтори пароль"
|
|
||||||
maxlength="32"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@error('password')<span class="form-error form-error--stack">{{ $message }}</span>@enderror
|
|
||||||
|
|
||||||
<label class="checkbox-field @error('terms') is-invalid @enderror">
|
|
||||||
<input type="checkbox" name="terms" value="1" {{ old('terms') ? 'checked' : '' }}>
|
|
||||||
<span>Я принимаю правила сервера и согласен на обработку данных для создания игрового аккаунта.</span>
|
|
||||||
</label>
|
|
||||||
@error('terms')<span class="form-error form-error--stack">{{ $message }}</span>@enderror
|
|
||||||
|
|
||||||
<button class="button button--gold button--full" type="submit">Зарегистрировать аккаунт</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="server-card">
|
|
||||||
<div>
|
|
||||||
<span class="eyebrow">Информация о мире</span>
|
|
||||||
<h3>{{ $realm['realm_name'] }}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl>
|
|
||||||
<div>
|
|
||||||
<dt>Клиент</dt>
|
|
||||||
<dd>{{ $realm['client_version'] }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Опыт</dt>
|
|
||||||
<dd>{{ $realm['rates']['experience'] }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Золото</dt>
|
|
||||||
<dd>{{ $realm['rates']['gold'] }}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Профессии</dt>
|
|
||||||
<dd>{{ $realm['rates']['profession'] }}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section" id="about">
|
|
||||||
<div class="section-heading">
|
|
||||||
<span class="eyebrow">О сервере</span>
|
|
||||||
<h2>Wrath of the Lich King, где важен путь персонажа</h2>
|
|
||||||
<p>Сервер сочетает поэтапную прогрессию, комфортный PvE для малого состава и удобные игровые мелочи, которые экономят время.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pillar-grid">
|
|
||||||
@foreach ($pillars as $pillar)
|
|
||||||
<article class="content-card">
|
|
||||||
<h3>{{ $pillar['title'] }}</h3>
|
|
||||||
<p>{{ $pillar['description'] }}</p>
|
|
||||||
</article>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section section--split" id="features">
|
|
||||||
@foreach ($featureRows as $feature)
|
|
||||||
<article class="feature-card">
|
|
||||||
<span class="eyebrow">{{ $feature['eyebrow'] }}</span>
|
|
||||||
<h2>{{ $feature['title'] }}</h2>
|
|
||||||
<p>{{ $feature['description'] }}</p>
|
|
||||||
|
|
||||||
<ul class="feature-points">
|
|
||||||
@foreach ($feature['points'] as $point)
|
|
||||||
<li>{{ $point }}</li>
|
|
||||||
@endforeach
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
@endforeach
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section">
|
|
||||||
<div class="section-heading">
|
|
||||||
<span class="eyebrow">Игровые особенности</span>
|
|
||||||
<h2>Сервер собран вокруг комфорта, но не теряет дух классики</h2>
|
|
||||||
<p>Основные системы уже настроены под комфортную игру: прогрессия по персонажу, гибкая сложность инстов и быстрый сбор добычи.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="adventure-grid">
|
|
||||||
@foreach ($adventures as $adventure)
|
|
||||||
<article class="adventure-card">
|
|
||||||
<h3>{{ $adventure['title'] }}</h3>
|
|
||||||
<p>{{ $adventure['description'] }}</p>
|
|
||||||
</article>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section start-section" id="start">
|
|
||||||
<div class="section-heading">
|
|
||||||
<span class="eyebrow">С чего начать</span>
|
|
||||||
<h2>Как начать играть</h2>
|
|
||||||
<p>Аккаунт, собственный лаунчер и несколько простых действий — после этого можно заходить в игру.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="steps-grid">
|
|
||||||
@foreach ($steps as $index => $step)
|
|
||||||
<article class="step-card">
|
|
||||||
<span class="step-index">0{{ $index + 1 }}</span>
|
|
||||||
<h3>{{ $step['title'] }}</h3>
|
|
||||||
<p>{{ $step['description'] }}</p>
|
|
||||||
</article>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="realmlist-box">
|
|
||||||
<span class="eyebrow">Адрес сервера</span>
|
|
||||||
<pre>{{ $realm['realmlist'] }}</pre>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="footer">
|
|
||||||
<div>
|
|
||||||
<strong>{{ $realm['server_name'] }}</strong>
|
|
||||||
<p>Русскоязычный сервер Wrath of the Lich King с акцентом на комфортный PvE</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a class="button button--ghost" href="#register">Перейти к регистрации</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
@endsection
|
|
||||||
|
|||||||
Executable
+300
@@ -0,0 +1,300 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
ENV_FILE="${ENV_FILE:-${ROOT_DIR}/.env}"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-${ROOT_DIR}/storage/backups}"
|
||||||
|
MYSQL_CLIENT_IMAGE="${MYSQL_CLIENT_IMAGE:-mysql:8.4}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Использование:
|
||||||
|
./scripts/db-backup.sh export [--target=all|app|auth|characters] [--out=DIR]
|
||||||
|
./scripts/db-backup.sh import <файл.sql[.gz]> --target=app|auth|characters
|
||||||
|
./scripts/db-backup.sh list
|
||||||
|
|
||||||
|
Опции:
|
||||||
|
--env=PATH путь к .env (по умолчанию ./.env)
|
||||||
|
--target=NAME какая БД: app (laravel), auth (acore_auth),
|
||||||
|
characters (acore_characters), all (по умолчанию для export)
|
||||||
|
--out=DIR куда писать дампы (по умолчанию storage/backups)
|
||||||
|
--gzip сжимать дамп gzip-ом (по умолчанию включено для export)
|
||||||
|
--no-gzip не сжимать
|
||||||
|
--force для import: не спрашивать подтверждение
|
||||||
|
--create-db перед импортом создать БД, если её нет
|
||||||
|
-h, --help показать справку
|
||||||
|
|
||||||
|
Переменные окружения:
|
||||||
|
ENV_FILE, BACKUP_DIR — переопределяют значения по умолчанию
|
||||||
|
MYSQL_CLIENT_IMAGE — образ для фолбэка (по умолчанию mysql:8.4)
|
||||||
|
|
||||||
|
Зависимости:
|
||||||
|
Если на хосте установлены mysqldump/mysql — используются они.
|
||||||
|
Иначе скрипт запускает их в одноразовом контейнере MYSQL_CLIENT_IMAGE.
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
./scripts/db-backup.sh export
|
||||||
|
./scripts/db-backup.sh export --target=auth --out=/tmp
|
||||||
|
./scripts/db-backup.sh import /tmp/acore_auth_2026-04-26.sql.gz --target=auth
|
||||||
|
./scripts/db-backup.sh import dump.sql.gz --target=app --create-db --force
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
log() { printf '\033[1;34m[db-backup]\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m[db-backup]\033[0m %s\n' "$*" >&2; }
|
||||||
|
err() { printf '\033[1;31m[db-backup]\033[0m %s\n' "$*" >&2; }
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
command -v "$1" >/dev/null 2>&1 || { err "Не найдена команда: $1"; exit 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
# Безопасное чтение значения из .env (поддержка кавычек)
|
||||||
|
env_get() {
|
||||||
|
local key="$1" file="$2"
|
||||||
|
local line value
|
||||||
|
line="$(grep -E "^${key}=" "$file" | tail -n1 || true)"
|
||||||
|
[[ -z "$line" ]] && return 0
|
||||||
|
value="${line#${key}=}"
|
||||||
|
# снять обрамляющие кавычки
|
||||||
|
if [[ "$value" =~ ^\".*\"$ ]] || [[ "$value" =~ ^\'.*\'$ ]]; then
|
||||||
|
value="${value:1:${#value}-2}"
|
||||||
|
fi
|
||||||
|
printf '%s' "$value"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_target() {
|
||||||
|
local target="$1"
|
||||||
|
case "$target" in
|
||||||
|
app)
|
||||||
|
DB_HOST="$(env_get DB_HOST "$ENV_FILE")"
|
||||||
|
DB_PORT="$(env_get DB_PORT "$ENV_FILE")"
|
||||||
|
DB_NAME="$(env_get DB_DATABASE "$ENV_FILE")"
|
||||||
|
DB_USER="$(env_get DB_USERNAME "$ENV_FILE")"
|
||||||
|
DB_PASS="$(env_get DB_PASSWORD "$ENV_FILE")"
|
||||||
|
;;
|
||||||
|
auth)
|
||||||
|
DB_HOST="$(env_get AZEROTHCORE_AUTH_DB_HOST "$ENV_FILE")"
|
||||||
|
DB_PORT="$(env_get AZEROTHCORE_AUTH_DB_PORT "$ENV_FILE")"
|
||||||
|
DB_NAME="$(env_get AZEROTHCORE_AUTH_DB_DATABASE "$ENV_FILE")"
|
||||||
|
DB_USER="$(env_get AZEROTHCORE_AUTH_DB_USERNAME "$ENV_FILE")"
|
||||||
|
DB_PASS="$(env_get AZEROTHCORE_AUTH_DB_PASSWORD "$ENV_FILE")"
|
||||||
|
;;
|
||||||
|
characters)
|
||||||
|
DB_HOST="$(env_get AZEROTHCORE_CHARACTERS_DB_HOST "$ENV_FILE")"
|
||||||
|
DB_PORT="$(env_get AZEROTHCORE_CHARACTERS_DB_PORT "$ENV_FILE")"
|
||||||
|
DB_NAME="$(env_get AZEROTHCORE_CHARACTERS_DB_DATABASE "$ENV_FILE")"
|
||||||
|
DB_USER="$(env_get AZEROTHCORE_CHARACTERS_DB_USERNAME "$ENV_FILE")"
|
||||||
|
DB_PASS="$(env_get AZEROTHCORE_CHARACTERS_DB_PASSWORD "$ENV_FILE")"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
err "Неизвестная цель: $target"; exit 2;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# docker-only хосты заменяем на localhost при запуске вне контейнера
|
||||||
|
if [[ "$DB_HOST" == "mysql" || "$DB_HOST" == "host.docker.internal" ]]; then
|
||||||
|
local fwd
|
||||||
|
fwd="$(env_get FORWARD_DB_PORT "$ENV_FILE")"
|
||||||
|
if [[ -n "$fwd" && "$target" == "app" ]]; then
|
||||||
|
DB_PORT="$fwd"
|
||||||
|
fi
|
||||||
|
DB_HOST="127.0.0.1"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$DB_NAME" || -z "$DB_USER" ]]; then
|
||||||
|
err "Не удалось определить параметры подключения для target=$target"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
have_native_mysql() {
|
||||||
|
command -v mysqldump >/dev/null 2>&1 && command -v mysql >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Запуск mysqldump/mysql либо на хосте, либо в одноразовом docker-контейнере.
|
||||||
|
# Стдин/стдаут пробрасываются — годится и для дампа, и для импорта по pipe.
|
||||||
|
# $1 — "mysqldump" или "mysql"; остальные — доп.аргументы тулзе.
|
||||||
|
# Подключение читается из глобалей DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_NAME.
|
||||||
|
run_mysql_tool() {
|
||||||
|
local tool="$1"; shift
|
||||||
|
|
||||||
|
if have_native_mysql; then
|
||||||
|
local defaults
|
||||||
|
defaults="$(mktemp)"
|
||||||
|
chmod 600 "$defaults"
|
||||||
|
{
|
||||||
|
printf '[client]\n'
|
||||||
|
printf 'host=%s\n' "$DB_HOST"
|
||||||
|
printf 'port=%s\n' "${DB_PORT:-3306}"
|
||||||
|
printf 'user=%s\n' "$DB_USER"
|
||||||
|
if [[ -n "${DB_PASS:-}" && "$DB_PASS" != "null" ]]; then
|
||||||
|
printf 'password=%s\n' "$DB_PASS"
|
||||||
|
fi
|
||||||
|
} > "$defaults"
|
||||||
|
local rc=0
|
||||||
|
"$tool" --defaults-extra-file="$defaults" "$@" || rc=$?
|
||||||
|
rm -f "$defaults"
|
||||||
|
return $rc
|
||||||
|
fi
|
||||||
|
|
||||||
|
require_cmd docker
|
||||||
|
|
||||||
|
local target_host="$DB_HOST"
|
||||||
|
if [[ "$target_host" == "127.0.0.1" || "$target_host" == "localhost" ]]; then
|
||||||
|
target_host="host.docker.internal"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local pwd_value=""
|
||||||
|
if [[ -n "${DB_PASS:-}" && "$DB_PASS" != "null" ]]; then
|
||||||
|
pwd_value="$DB_PASS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker run --rm -i \
|
||||||
|
--add-host=host.docker.internal:host-gateway \
|
||||||
|
-e MYSQL_PWD="$pwd_value" \
|
||||||
|
"$MYSQL_CLIENT_IMAGE" \
|
||||||
|
"$tool" \
|
||||||
|
-h"$target_host" \
|
||||||
|
-P"${DB_PORT:-3306}" \
|
||||||
|
-u"$DB_USER" \
|
||||||
|
"$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_export_one() {
|
||||||
|
local target="$1" out_dir="$2" use_gzip="$3"
|
||||||
|
load_target "$target"
|
||||||
|
|
||||||
|
local ts dump_path
|
||||||
|
ts="$(date +%Y-%m-%d_%H-%M-%S)"
|
||||||
|
dump_path="${out_dir}/${DB_NAME}_${ts}.sql"
|
||||||
|
|
||||||
|
log "Экспорт ${target} (${DB_NAME}@${DB_HOST}:${DB_PORT:-3306}) → ${dump_path}$([[ $use_gzip -eq 1 ]] && echo .gz)"
|
||||||
|
|
||||||
|
local -a dump_args=(
|
||||||
|
--single-transaction
|
||||||
|
--quick
|
||||||
|
--routines
|
||||||
|
--triggers
|
||||||
|
--events
|
||||||
|
--set-gtid-purged=OFF
|
||||||
|
--hex-blob
|
||||||
|
--default-character-set=utf8mb4
|
||||||
|
--no-tablespaces
|
||||||
|
"$DB_NAME"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$use_gzip" -eq 1 ]]; then
|
||||||
|
require_cmd gzip
|
||||||
|
run_mysql_tool mysqldump "${dump_args[@]}" | gzip -c > "${dump_path}.gz"
|
||||||
|
log "Готово: ${dump_path}.gz ($(du -h "${dump_path}.gz" | cut -f1))"
|
||||||
|
else
|
||||||
|
run_mysql_tool mysqldump "${dump_args[@]}" > "$dump_path"
|
||||||
|
log "Готово: ${dump_path} ($(du -h "$dump_path" | cut -f1))"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
do_export() {
|
||||||
|
local target="$1" out_dir="$2" use_gzip="$3"
|
||||||
|
mkdir -p "$out_dir"
|
||||||
|
if [[ "$target" == "all" ]]; then
|
||||||
|
for t in app auth characters; do
|
||||||
|
do_export_one "$t" "$out_dir" "$use_gzip"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
do_export_one "$target" "$out_dir" "$use_gzip"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
do_import() {
|
||||||
|
local file="$1" target="$2" force="$3" create_db="$4"
|
||||||
|
[[ -f "$file" ]] || { err "Файл не найден: $file"; exit 1; }
|
||||||
|
[[ -z "$target" ]] && { err "Для import обязателен --target"; exit 2; }
|
||||||
|
|
||||||
|
load_target "$target"
|
||||||
|
|
||||||
|
if [[ "$force" -ne 1 ]]; then
|
||||||
|
printf '\033[1;33m[!] Импорт перезапишет данные в %s@%s:%s. Продолжить? [y/N]: \033[0m' \
|
||||||
|
"$DB_NAME" "$DB_HOST" "${DB_PORT:-3306}"
|
||||||
|
read -r answer
|
||||||
|
[[ "$answer" =~ ^[yYдД]$ ]] || { warn "Отменено"; exit 0; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$create_db" -eq 1 ]]; then
|
||||||
|
log "Создаю БД ${DB_NAME} при отсутствии"
|
||||||
|
local saved_name="$DB_NAME"
|
||||||
|
DB_NAME=""
|
||||||
|
run_mysql_tool mysql -e \
|
||||||
|
"CREATE DATABASE IF NOT EXISTS \`$saved_name\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
|
||||||
|
DB_NAME="$saved_name"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Импорт ${file} → ${target} (${DB_NAME}@${DB_HOST}:${DB_PORT:-3306})"
|
||||||
|
|
||||||
|
if [[ "$file" == *.gz ]]; then
|
||||||
|
require_cmd gzip
|
||||||
|
gzip -dc "$file" | run_mysql_tool mysql "$DB_NAME"
|
||||||
|
else
|
||||||
|
run_mysql_tool mysql "$DB_NAME" < "$file"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Импорт завершён"
|
||||||
|
}
|
||||||
|
|
||||||
|
do_list() {
|
||||||
|
if [[ ! -d "$BACKUP_DIR" ]]; then
|
||||||
|
warn "Директория бэкапов отсутствует: $BACKUP_DIR"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
log "Бэкапы в $BACKUP_DIR:"
|
||||||
|
ls -lh "$BACKUP_DIR" 2>/dev/null | awk 'NR>1 {print " " $9 " (" $5 ", " $6, $7, $8 ")"}'
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
[[ $# -lt 1 ]] && { usage; exit 2; }
|
||||||
|
local cmd="$1"; shift
|
||||||
|
|
||||||
|
local target="" out_dir="$BACKUP_DIR" use_gzip=1 force=0 create_db=0 import_file=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--env=*) ENV_FILE="${1#*=}";;
|
||||||
|
--target=*) target="${1#*=}";;
|
||||||
|
--out=*) out_dir="${1#*=}";;
|
||||||
|
--gzip) use_gzip=1;;
|
||||||
|
--no-gzip) use_gzip=0;;
|
||||||
|
--force) force=1;;
|
||||||
|
--create-db) create_db=1;;
|
||||||
|
-h|--help) usage; exit 0;;
|
||||||
|
-*) err "Неизвестная опция: $1"; usage; exit 2;;
|
||||||
|
*) import_file="$1";;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -f "$ENV_FILE" ]] || { err ".env не найден: $ENV_FILE"; exit 1; }
|
||||||
|
|
||||||
|
case "$cmd" in
|
||||||
|
export)
|
||||||
|
[[ -z "$target" ]] && target="all"
|
||||||
|
do_export "$target" "$out_dir" "$use_gzip"
|
||||||
|
;;
|
||||||
|
import)
|
||||||
|
[[ -z "$import_file" ]] && { err "Укажите путь к дампу"; usage; exit 2; }
|
||||||
|
do_import "$import_file" "$target" "$force" "$create_db"
|
||||||
|
;;
|
||||||
|
list)
|
||||||
|
do_list
|
||||||
|
;;
|
||||||
|
-h|--help|help)
|
||||||
|
usage
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
err "Неизвестная команда: $cmd"
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Binary file not shown.
Binary file not shown.
+7
-1
@@ -1,14 +1,20 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import laravel from 'laravel-vite-plugin';
|
import laravel from 'laravel-vite-plugin';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
laravel({
|
laravel({
|
||||||
input: ['resources/css/app.css', 'resources/js/app.js'],
|
input: [
|
||||||
|
'resources/css/app.css',
|
||||||
|
'resources/js/app.js',
|
||||||
|
'resources/js/moonwell/main.jsx',
|
||||||
|
],
|
||||||
refresh: true,
|
refresh: true,
|
||||||
}),
|
}),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
react(),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
watch: {
|
watch: {
|
||||||
|
|||||||
Reference in New Issue
Block a user