перевод лендинга на vue, добавление поддержки inertia
This commit is contained in:
+118
@@ -16706,6 +16706,15 @@ namespace Illuminate\Support\Facades {
|
||||
return \Illuminate\Http\Request::hasValidRelativeSignatureWhileIgnoring($ignoreQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \Inertia\ServiceProvider::registerRequestMacro()
|
||||
* @static
|
||||
*/
|
||||
public static function inertia()
|
||||
{
|
||||
return \Illuminate\Http\Request::inertia();
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* @see \Illuminate\Routing\ResponseFactory
|
||||
@@ -18107,6 +18116,16 @@ namespace Illuminate\Support\Facades {
|
||||
return $instance->tap($callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $props
|
||||
* @see \Inertia\ServiceProvider::registerRouterMacro()
|
||||
* @static
|
||||
*/
|
||||
public static function inertia($uri, $component, $props = [])
|
||||
{
|
||||
return \Illuminate\Routing\Router::inertia($uri, $component, $props);
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
* @method static \Illuminate\Console\Scheduling\PendingEventAttributes withoutOverlapping(int $expiresAt = 1440)
|
||||
@@ -23386,6 +23405,105 @@ namespace Illuminate\Http {
|
||||
return \Illuminate\Http\Request::hasValidRelativeSignatureWhileIgnoring($ignoreQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \Inertia\ServiceProvider::registerRequestMacro()
|
||||
* @static
|
||||
*/
|
||||
public static function inertia()
|
||||
{
|
||||
return \Illuminate\Http\Request::inertia();
|
||||
}
|
||||
|
||||
}
|
||||
/**
|
||||
*/
|
||||
class RedirectResponse extends \Symfony\Component\HttpFoundation\RedirectResponse {
|
||||
/**
|
||||
* @see \Inertia\ServiceProvider::registerRedirectMacro()
|
||||
* @static
|
||||
*/
|
||||
public static function preserveFragment()
|
||||
{
|
||||
return \Illuminate\Http\RedirectResponse::preserveFragment();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
namespace Illuminate\Routing {
|
||||
/**
|
||||
* @mixin \Illuminate\Routing\RouteRegistrar
|
||||
*/
|
||||
class Router {
|
||||
/**
|
||||
* @param array<array-key, mixed> $props
|
||||
* @see \Inertia\ServiceProvider::registerRouterMacro()
|
||||
* @static
|
||||
*/
|
||||
public static function inertia($uri, $component, $props = [])
|
||||
{
|
||||
return \Illuminate\Routing\Router::inertia($uri, $component, $props);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
namespace Illuminate\Testing {
|
||||
/**
|
||||
* @template TResponse of \Symfony\Component\HttpFoundation\Response
|
||||
* @mixin \Illuminate\Http\Response
|
||||
*/
|
||||
class TestResponse {
|
||||
/**
|
||||
* @see \Inertia\Testing\TestResponseMacros::assertInertia()
|
||||
* @param \Closure|null $callback
|
||||
* @static
|
||||
*/
|
||||
public static function assertInertia($callback = null)
|
||||
{
|
||||
return \Illuminate\Testing\TestResponse::assertInertia($callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \Inertia\Testing\TestResponseMacros::inertiaPage()
|
||||
* @static
|
||||
*/
|
||||
public static function inertiaPage()
|
||||
{
|
||||
return \Illuminate\Testing\TestResponse::inertiaPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \Inertia\Testing\TestResponseMacros::inertiaProps()
|
||||
* @param string|null $propName
|
||||
* @static
|
||||
*/
|
||||
public static function inertiaProps($propName = null)
|
||||
{
|
||||
return \Illuminate\Testing\TestResponse::inertiaProps($propName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \Inertia\Testing\TestResponseMacros::assertInertiaFlash()
|
||||
* @param string $key
|
||||
* @param mixed|null $expected
|
||||
* @static
|
||||
*/
|
||||
public static function assertInertiaFlash($key, $expected = null)
|
||||
{
|
||||
return \Illuminate\Testing\TestResponse::assertInertiaFlash($key, $expected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see \Inertia\Testing\TestResponseMacros::assertInertiaFlashMissing()
|
||||
* @param string $key
|
||||
* @static
|
||||
*/
|
||||
public static function assertInertiaFlashMissing($key)
|
||||
{
|
||||
return \Illuminate\Testing\TestResponse::assertInertiaFlashMissing($key);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Throwable;
|
||||
|
||||
class LandingController extends Controller
|
||||
@@ -23,7 +24,7 @@ class LandingController extends Controller
|
||||
Request $request,
|
||||
AzerothCoreAccountService $accounts,
|
||||
NewsService $news,
|
||||
): View
|
||||
): Response
|
||||
{
|
||||
$realmConfig = config('moonwell.realm');
|
||||
$rates = $realmConfig['rates'] ?? [];
|
||||
@@ -34,6 +35,7 @@ class LandingController extends Controller
|
||||
? route('cabinet.client')
|
||||
: '#join';
|
||||
|
||||
try {
|
||||
$posts = $news->listForLanding()
|
||||
->values()
|
||||
->map(fn (News $item, int $index): array => [
|
||||
@@ -47,6 +49,11 @@ class LandingController extends Controller
|
||||
'featured' => $index === 0,
|
||||
])
|
||||
->all();
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
$posts = [];
|
||||
}
|
||||
|
||||
$bootstrap = [
|
||||
'realm' => [
|
||||
@@ -61,16 +68,17 @@ class LandingController extends Controller
|
||||
'rates' => $rates,
|
||||
'client_version' => $realmConfig['client_version'] ?? '3.3.5a',
|
||||
'tagline' => $realmConfig['tagline'] ?? null,
|
||||
'server_name' => $realmConfig['server_name'] ?? 'MoonWell',
|
||||
],
|
||||
'play_url' => $playUrl,
|
||||
'playUrl' => $playUrl,
|
||||
'posts' => $posts,
|
||||
'tweaks' => [
|
||||
'variant' => 'forest',
|
||||
'particles' => true,
|
||||
'moon' => true,
|
||||
],
|
||||
'register_endpoint' => route('game-account.store'),
|
||||
'csrf_token' => csrf_token(),
|
||||
'registerEndpoint' => route('game-account.store'),
|
||||
'csrfToken' => csrf_token(),
|
||||
'auth' => [
|
||||
'authenticated' => $user !== null,
|
||||
'is_admin' => $user?->canAccessAdminPanel() ?? false,
|
||||
@@ -92,10 +100,7 @@ class LandingController extends Controller
|
||||
],
|
||||
];
|
||||
|
||||
return view('landing', [
|
||||
'bootstrap' => $bootstrap,
|
||||
'realm' => $realmConfig,
|
||||
]);
|
||||
return Inertia::render('Landing/Main', $bootstrap);
|
||||
}
|
||||
|
||||
public function register(
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\News;
|
||||
use App\Services\AzerothCoreAccountService;
|
||||
use App\Services\NewsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Throwable;
|
||||
|
||||
class MainController extends Controller
|
||||
{
|
||||
public function index(
|
||||
Request $request,
|
||||
AzerothCoreAccountService $accounts,
|
||||
NewsService $news,
|
||||
): Response
|
||||
{
|
||||
$realmConfig = config('moonwell.realm');
|
||||
$rates = $realmConfig['rates'] ?? [];
|
||||
$user = Auth::user();
|
||||
$playersOnline = $accounts->onlinePlayerCount();
|
||||
|
||||
try {
|
||||
$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();
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
$posts = [];
|
||||
}
|
||||
|
||||
return Inertia::render('Landing/Main', [
|
||||
'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,
|
||||
'server_name' => $realmConfig['server_name'] ?? 'MoonWell',
|
||||
],
|
||||
'playUrl' => $user !== null ? route('cabinet.client') : '#join',
|
||||
'posts' => $posts,
|
||||
'tweaks' => [
|
||||
'variant' => 'forest',
|
||||
'particles' => true,
|
||||
'moon' => true,
|
||||
],
|
||||
'registerEndpoint' => route('game-account.store'),
|
||||
'csrfToken' => 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 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() : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
/**
|
||||
* The root template that's loaded on the first page visit.
|
||||
*
|
||||
* @see https://inertiajs.com/server-side-setup#root-template
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rootView = 'app';
|
||||
|
||||
/**
|
||||
* Determines the current asset version.
|
||||
*
|
||||
* @see https://inertiajs.com/asset-versioning
|
||||
*/
|
||||
public function version(Request $request): ?string
|
||||
{
|
||||
return parent::version($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the props that are shared by default.
|
||||
*
|
||||
* @see https://inertiajs.com/shared-data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function share(Request $request): array
|
||||
{
|
||||
return [
|
||||
...parent::share($request),
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -277,7 +277,7 @@ class AzerothCoreAccountService
|
||||
return (int) config('moonwell.registration.realm_id', -1);
|
||||
}
|
||||
|
||||
public function onlinePlayerCount(): ?int
|
||||
public function onlinePlayerCount(): int|null
|
||||
{
|
||||
try {
|
||||
return (int) $this->charactersConnection()
|
||||
|
||||
+10
-4
@@ -1,21 +1,27 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
web: __DIR__ . '/../routes/web.php',
|
||||
api: __DIR__ . '/../routes/api.php',
|
||||
commands: __DIR__ . '/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
$middleware->web(append: [
|
||||
HandleInertiaRequests::class,
|
||||
]);
|
||||
})
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->trustProxies(at: env('TRUSTED_PROXIES', '*'));
|
||||
|
||||
$middleware->api(prepend: [
|
||||
\Illuminate\Http\Middleware\SetCacheHeaders::class.':no_store',
|
||||
\Illuminate\Http\Middleware\SetCacheHeaders::class . ':no_store',
|
||||
\App\Http\Middleware\ForceJsonResponse::class,
|
||||
]);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"inertiajs/inertia-laravel": "^3.1",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/passport": "^13.6",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
|
||||
Generated
+74
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "cb49ff467452a9a586f2d6d30b1c2957",
|
||||
"content-hash": "9528534655052c637f0b4b19340a6c65",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -1334,6 +1334,79 @@
|
||||
],
|
||||
"time": "2025-08-22T14:27:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "inertiajs/inertia-laravel",
|
||||
"version": "v3.1.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/inertiajs/inertia-laravel.git",
|
||||
"reference": "f588ce4a5beb25d166f4e372bac0c7f46a4f52ac"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/inertiajs/inertia-laravel/zipball/f588ce4a5beb25d166f4e372bac0c7f46a4f52ac",
|
||||
"reference": "f588ce4a5beb25d166f4e372bac0c7f46a4f52ac",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"laravel/framework": "^11.0|^12.0|^13.0",
|
||||
"php": "^8.2.0",
|
||||
"symfony/console": "^7.0|^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"laravel/boost": "<2.2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"guzzlehttp/guzzle": "^7.2",
|
||||
"larastan/larastan": "^3.0",
|
||||
"laravel/pint": "^1.16",
|
||||
"mockery/mockery": "^1.3.3",
|
||||
"orchestra/testbench": "^9.2|^10.0|^11.0",
|
||||
"phpunit/phpunit": "^11.5|^12.0",
|
||||
"roave/security-advisories": "dev-master"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-pcntl": "Recommended when running the Inertia SSR server via the `inertia:start-ssr` artisan command."
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Inertia\\ServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"files": [
|
||||
"./helpers.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Inertia\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jonathan Reinink",
|
||||
"email": "jonathan@reinink.ca",
|
||||
"homepage": "https://reinink.ca"
|
||||
}
|
||||
],
|
||||
"description": "The Laravel adapter for Inertia.js.",
|
||||
"keywords": [
|
||||
"inertia",
|
||||
"laravel"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/inertiajs/inertia-laravel/issues",
|
||||
"source": "https://github.com/inertiajs/inertia-laravel/tree/v3.1.0"
|
||||
},
|
||||
"time": "2026-04-30T15:30:29+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v12.54.1",
|
||||
|
||||
Generated
+293
-131
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -16,7 +16,11 @@
|
||||
"vite": "^7.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inertiajs/vite": "^3.5.0",
|
||||
"@inertiajs/vue3": "^3.5.0",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"vue": "^3.5.39"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<section id="about" data-screen-label="02 About">
|
||||
<div class="wrap about-grid reveal">
|
||||
<div class="about-copy">
|
||||
<span class="eyebrow">Что такое MoonWell</span>
|
||||
<div style="height: 18px"></div>
|
||||
<h2 class="h-display">Ночной колодец, где эпохи ждут тебя.</h2>
|
||||
<div style="height: 28px"></div>
|
||||
<p>MoonWell - это не очередной «1x серверный клон». Мы переосмыслили Wrath of the Lich King как длинный путь: сначала Классика, затем Burning Crusade, потом Лич - но каждый герой идет по нему сам.</p>
|
||||
<p>Мы хотим вернуть ощущение, которое многие из нас помнят с 2005-го: когда незнакомый лес опасен, а первый рейд - событие года. И при этом добавить то, чего тогда не хватало - возможность играть, когда у тебя есть только час после работы.</p>
|
||||
<blockquote class="about-quote">
|
||||
«Мы строим мир, в котором ты можешь пройти все в одиночку - или собрать отряд из трех верных друзей и свергнуть короля-лича. Оба пути честные.»
|
||||
<cite>- команда MoonWell</cite>
|
||||
</blockquote>
|
||||
</div>
|
||||
<div class="about-visual corners">
|
||||
<div class="about-stats"><span>Est. 2026</span></div>
|
||||
<img :src="'/landing.png'" alt="концепт-арт: вход в Лунный Колодец, туман, силуэт ночного эльфа" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,101 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
const canvasRef = ref(null);
|
||||
let resizeHandler;
|
||||
let particleRaf;
|
||||
|
||||
onMounted(() => {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
const stars = [];
|
||||
const drift = [];
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let dpr = 1;
|
||||
|
||||
resizeHandler = () => {
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
width = canvas.width = window.innerWidth * dpr;
|
||||
height = canvas.height = window.innerHeight * dpr;
|
||||
canvas.style.width = `${window.innerWidth}px`;
|
||||
canvas.style.height = `${window.innerHeight}px`;
|
||||
stars.length = 0;
|
||||
drift.length = 0;
|
||||
|
||||
const density = Math.min(260, Math.floor((window.innerWidth * window.innerHeight) / 9000));
|
||||
for (let i = 0; i < density; i += 1) {
|
||||
stars.push({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
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',
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < 18; i += 1) {
|
||||
drift.push({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
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 = (time) => {
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
for (const star of stars) {
|
||||
const alpha = star.baseAlpha * (0.5 + 0.5 * Math.sin(time * star.twinkleSpeed + star.twinklePhase));
|
||||
context.beginPath();
|
||||
context.arc(star.x, star.y, star.r, 0, Math.PI * 2);
|
||||
context.fillStyle = star.hue === 'moon' ? `rgba(200, 230, 255, ${alpha})` : `rgba(244, 236, 207, ${alpha})`;
|
||||
context.fill();
|
||||
}
|
||||
|
||||
for (const particle of drift) {
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
if (particle.x < 0) particle.x = width;
|
||||
if (particle.x > width) particle.x = 0;
|
||||
if (particle.y < 0) particle.y = height;
|
||||
if (particle.y > height) particle.y = 0;
|
||||
|
||||
const gradient = context.createRadialGradient(particle.x, particle.y, 0, particle.x, particle.y, particle.r * 6);
|
||||
gradient.addColorStop(0, `rgba(176, 136, 238, ${particle.alpha})`);
|
||||
gradient.addColorStop(1, 'rgba(176, 136, 238, 0)');
|
||||
context.fillStyle = gradient;
|
||||
context.beginPath();
|
||||
context.arc(particle.x, particle.y, particle.r * 6, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
|
||||
particleRaf = window.requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
resizeHandler();
|
||||
draw(0);
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
if (particleRaf) window.cancelAnimationFrame(particleRaf);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg">
|
||||
<div class="bg-moon" aria-hidden="true"></div>
|
||||
<div class="bg-fog" aria-hidden="true"></div>
|
||||
</div>
|
||||
<canvas id="particles" ref="canvasRef" aria-hidden="true"></canvas>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import { features, iconPaths, steps } from './symbols';
|
||||
|
||||
function onMove(event) {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
event.currentTarget.style.setProperty('--mx', `${((event.clientX - rect.left) / rect.width) * 100}%`);
|
||||
event.currentTarget.style.setProperty('--my', `${((event.clientY - rect.top) / rect.height) * 100}%`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="features" data-screen-label="03 Features">
|
||||
<div class="wrap">
|
||||
<div class="section-head reveal">
|
||||
<span class="eyebrow">Особенности сервера</span>
|
||||
<div style="height: 20px"></div>
|
||||
<h2 class="h-display" style="font-size: clamp(36px, 5vw, 60px)">То, чего ты не найдешь у других</h2>
|
||||
<div class="divider-ornament">✦</div>
|
||||
<p class="lead">Восемь систем, которые делают MoonWell не клоном, а отдельной игрой поверх знакомого мира.</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<article v-for="feature in features" :key="feature.num" :class="['feature', feature.span, 'reveal']" @mousemove="onMove">
|
||||
<div class="feature-num"><span>{{ feature.num }}</span><span v-if="feature.tag" class="tag">{{ feature.tag }}</span></div>
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" v-html="iconPaths[feature.icon]"></svg>
|
||||
</div>
|
||||
<h3 class="feature-title">{{ feature.title }}</h3>
|
||||
<p class="feature-desc">{{ feature.desc }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div id="progression" class="progression reveal">
|
||||
<div class="progression-title">
|
||||
<h3>Путь через эпохи</h3>
|
||||
<span class="meta">Твой герой · Aranthel, Хранитель рощи · 47 ур.</span>
|
||||
</div>
|
||||
<div class="progression-track">
|
||||
<div v-for="step in steps" :key="step.name" :class="['progression-step', step.state]">
|
||||
<div class="name">{{ step.name }}<small>{{ step.lvl }}</small></div>
|
||||
<div class="status">{{ step.status }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { arrowSvg, moonMark } from './symbols';
|
||||
|
||||
defineProps({ playUrl: { type: String, required: true } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="footer" data-screen-label="05 Footer">
|
||||
<div class="wrap">
|
||||
<div class="footer-cta reveal">
|
||||
<span class="eyebrow">Свет лунного колодца зовет</span>
|
||||
<div style="height: 20px"></div>
|
||||
<h2 class="h-display">Один лаунчер - и ты у ворот Тельдрассила.</h2>
|
||||
<p>Наш лаунчер сам поставит игровой клиент, пропишет realmlist и будет держать все в актуальном состоянии. Без ручных патчей и копирования файлов.</p>
|
||||
<div class="launcher-steps">
|
||||
<div class="launcher-step"><span class="n">01</span><span class="t">Скачай лаунчер</span></div>
|
||||
<div class="launcher-step"><span class="n">02</span><span class="t">Войди или создай аккаунт</span></div>
|
||||
<div class="launcher-step"><span class="n">03</span><span class="t">Нажми «Играть» - остальное автоматически</span></div>
|
||||
</div>
|
||||
<div class="hero-cta" style="justify-content: center; margin-top: 40px">
|
||||
<a :href="playUrl" class="btn btn-primary">Скачать лаунчер для Windows <span v-html="arrowSvg"></span></a>
|
||||
<a href="#" class="btn btn-ghost is-disabled" aria-disabled="true">macOS - в разработке</a>
|
||||
</div>
|
||||
<div style="margin-top: 20px; font-family: var(--f-mono); font-size: 11px; letter-spacing: 0.18em; color: var(--text-mute); text-transform: uppercase">v 1.4.2 · ~180 MB · автообновление</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-grid">
|
||||
<div class="footer-col footer-brand">
|
||||
<div style="display: flex; align-items: center; gap: 12px; color: var(--moon)">
|
||||
<span v-html="moonMark(28)"></span>
|
||||
<span style="font-family: var(--f-display); letter-spacing: 0.18em; font-size: 16px; color: var(--ivory)">MOONWELL</span>
|
||||
</div>
|
||||
<p>Приватный сервер World of Warcraft 3.3.5a с индивидуальным прогрессом, соло-данжами и режимом «Предатель». Бесплатно и без pay-to-win.</p>
|
||||
</div>
|
||||
<div class="footer-col"><h4>Сервер</h4><a href="#">Статус реалмов</a><a href="#">Скачать клиент</a><a href="#">Правила</a><a href="#">Дорожная карта</a></div>
|
||||
<div class="footer-col"><h4>Сообщество</h4><a href="#">Discord</a><a href="#">Форум</a><a href="#">Поддержка</a><a href="#">Вакансии</a></div>
|
||||
<div class="footer-col"><h4>База знаний</h4><a href="#">Гайды</a><a href="#">База данных</a><a href="#">Рейтинги</a><a href="#">API</a></div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 MoonWell · fan project · не связан с Blizzard Entertainment</span>
|
||||
<span>Сделано с лунным светом</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import { arrowSvg } from './symbols';
|
||||
|
||||
defineProps({ playUrl: { type: String, required: true } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hero" data-screen-label="01 Hero">
|
||||
<div class="wrap hero-inner">
|
||||
<div class="hero-pretitle"><span class="dot"></span><span>Открытая бета · Wrath of the Lich King</span></div>
|
||||
<h1 class="h-display hero-title">MoonWell<span class="ornament">- Лунный Колодец -</span></h1>
|
||||
<p class="hero-tagline">Приватный сервер World of Warcraft, где ты проходишь эпоху за эпохой - от Классики до Короля-лича - в своем темпе, один или в отряде.</p>
|
||||
<div class="hero-meta">
|
||||
<span>Индивидуальный прогресс</span><span>Соло-данжи</span><span>Режим «Предатель»</span><span>Mythic+ без потолка</span>
|
||||
</div>
|
||||
<div class="hero-cta">
|
||||
<a :href="playUrl" class="btn btn-primary">Скачать лаунчер <span v-html="arrowSvg"></span></a>
|
||||
<a href="#about" class="btn btn-ghost">Узнать больше</a>
|
||||
</div>
|
||||
<div class="hero-launcher-note">
|
||||
<span class="os">Windows 10/11</span><span class="sep">·</span><span>macOS - в разработке</span><span class="sep">·</span><span>~180 MB · автообновление · клиент ставится сам</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-scroll">Прокрути вниз</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
|
||||
import { arrowSvg, iconPaths, moonMark } from './symbols';
|
||||
|
||||
const props = defineProps({
|
||||
registerEndpoint: { type: String, required: true },
|
||||
csrfToken: { type: String, required: true },
|
||||
auth: { type: Object, required: true },
|
||||
flash: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const hints = {
|
||||
username: 'Используй латиницу и цифры. Регистр символов при входе не имеет значения.',
|
||||
invite_code: 'Регистрация доступна только по действующему инвайт-коду.',
|
||||
};
|
||||
|
||||
const initialErrors = props.flash.errors || {};
|
||||
const submitted = ref(Boolean(props.flash.success));
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
username: props.flash.old?.username || '',
|
||||
email: props.flash.old?.email || '',
|
||||
invite_code: props.flash.old?.invite_code || '',
|
||||
password: '',
|
||||
password_confirmation: '',
|
||||
terms: false,
|
||||
});
|
||||
const touched = reactive(Object.fromEntries(Object.keys(initialErrors).map((key) => [key, true])));
|
||||
const serverErrors = reactive({ ...initialErrors });
|
||||
|
||||
const clientErrors = computed(() => {
|
||||
const errors = {};
|
||||
if (form.username && !/^[a-zA-Z0-9]{3,32}$/.test(form.username)) errors.username = 'Только латиница и цифры, 3-32 символа.';
|
||||
if (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) errors.email = 'Похоже на некорректный email.';
|
||||
if (form.invite_code && !/^[A-Za-z0-9-]{6,32}$/.test(form.invite_code)) errors.invite_code = 'Код из букв, цифр и тире, 6-32 символа.';
|
||||
if (form.password && form.password.length < 8) errors.password = 'Минимум 8 символов.';
|
||||
else if (form.password && !/[A-Za-z]/.test(form.password)) errors.password = 'Должна быть хотя бы одна буква.';
|
||||
else if (form.password && !/\d/.test(form.password)) errors.password = 'Должна быть хотя бы одна цифра.';
|
||||
if (form.password_confirmation && form.password_confirmation !== form.password) errors.password_confirmation = 'Пароли не совпадают.';
|
||||
return errors;
|
||||
});
|
||||
|
||||
const valid = computed(() => Boolean(
|
||||
form.username &&
|
||||
form.email &&
|
||||
form.invite_code &&
|
||||
form.password &&
|
||||
form.password_confirmation &&
|
||||
form.terms &&
|
||||
Object.keys(clientErrors.value).length === 0
|
||||
));
|
||||
|
||||
onMounted(() => {
|
||||
if (submitted.value || props.flash.error || Object.keys(initialErrors).length) {
|
||||
nextTick(() => document.getElementById('join')?.scrollIntoView({ behavior: 'smooth', block: 'start' }));
|
||||
}
|
||||
});
|
||||
|
||||
function firstError(field) {
|
||||
const value = serverErrors[field];
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
function errorFor(field) {
|
||||
return firstError(field) || (touched[field] && clientErrors.value[field]) || null;
|
||||
}
|
||||
|
||||
function updateField(field, value) {
|
||||
form[field] = value;
|
||||
delete serverErrors[field];
|
||||
}
|
||||
|
||||
function submit(event) {
|
||||
if (valid.value) {
|
||||
submitting.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
['username', 'email', 'invite_code', 'password', 'password_confirmation', 'terms'].forEach((field) => {
|
||||
touched[field] = true;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="join" data-screen-label="04 Join">
|
||||
<div class="wrap">
|
||||
<div v-if="submitted && flash.success" class="join-card join-success reveal is-visible">
|
||||
<div class="join-success-mark" v-html="moonMark(56)"></div>
|
||||
<span class="eyebrow" style="justify-content: center">Добро пожаловать</span>
|
||||
<h3 class="h-display" style="font-size: 36px; margin-top: 16px">Аккаунт создан</h3>
|
||||
<p class="lead" style="margin-top: 14px">
|
||||
Логин <span style="color: var(--moon); font-style: normal">{{ flash.success_username || form.username }}</span> готов к входу. Открывай лаунчер и жми «Играть».
|
||||
</p>
|
||||
<div class="hero-cta" style="justify-content: center; margin-top: 28px">
|
||||
<a :href="auth.cabinet_url || auth.login_url || '#'" class="btn btn-primary">Войти в личный кабинет <span v-html="arrowSvg"></span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="join-grid reveal">
|
||||
<div class="join-intro">
|
||||
<span class="eyebrow">Регистрация</span>
|
||||
<div style="height: 20px"></div>
|
||||
<h2 class="h-display" style="font-size: clamp(36px, 5vw, 56px)">Создай игровой аккаунт</h2>
|
||||
<div class="divider-ornament" style="justify-content: flex-start; margin: 22px 0"><span style="opacity: 0.6">✦</span></div>
|
||||
<p class="lead" style="font-style: italic">Укажи инвайт-код, логин, почту и пароль - аккаунт будет готов к входу в игру сразу после регистрации.</p>
|
||||
<div class="join-note">
|
||||
<span class="join-note-k">Закрытый бета-доступ</span>
|
||||
<span class="join-note-v">Пока идет закрытый этап: регистрация только по инвайт-кодам. Код можно получить у друзей-игроков или в нашем Discord.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="join-card" method="POST" :action="registerEndpoint" novalidate @submit="submit">
|
||||
<input type="hidden" name="_token" :value="csrfToken" />
|
||||
<div class="join-card-corner tl" aria-hidden="true"></div>
|
||||
<div class="join-card-corner tr" aria-hidden="true"></div>
|
||||
<div class="join-card-corner bl" aria-hidden="true"></div>
|
||||
<div class="join-card-corner br" aria-hidden="true"></div>
|
||||
<div v-if="flash.error" class="join-error" style="margin-bottom: 14px">{{ flash.error }}</div>
|
||||
|
||||
<div :class="['join-field', { 'has-error': errorFor('username') }]">
|
||||
<label class="join-label">Логин</label>
|
||||
<div class="join-input"><input type="text" name="username" autocomplete="username" placeholder="Moonwalker" :value="form.username" maxlength="32" @input="updateField('username', $event.target.value)" @blur="touched.username = true" /></div>
|
||||
<div v-if="errorFor('username')" class="join-error">{{ errorFor('username') }}</div>
|
||||
<div v-else class="join-hint">{{ hints.username }}</div>
|
||||
</div>
|
||||
|
||||
<div :class="['join-field', { 'has-error': errorFor('email') }]">
|
||||
<label class="join-label">Email</label>
|
||||
<div class="join-input"><input type="email" name="email" autocomplete="email" placeholder="player@example.com" :value="form.email" @input="updateField('email', $event.target.value)" @blur="touched.email = true" /></div>
|
||||
<div v-if="errorFor('email')" class="join-error">{{ errorFor('email') }}</div>
|
||||
</div>
|
||||
|
||||
<div :class="['join-field', { 'has-error': errorFor('invite_code') }]">
|
||||
<label class="join-label">Инвайт-код</label>
|
||||
<div class="join-input">
|
||||
<span class="join-input-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" v-html="iconPaths.mask"></svg></span>
|
||||
<input type="text" name="invite_code" placeholder="ABCD-EFGH-IJKL" :value="form.invite_code" maxlength="32" style="letter-spacing: 0.14em; font-family: var(--f-mono)" @input="updateField('invite_code', $event.target.value.toUpperCase())" @blur="touched.invite_code = true" />
|
||||
</div>
|
||||
<div v-if="errorFor('invite_code')" class="join-error">{{ errorFor('invite_code') }}</div>
|
||||
<div v-else class="join-hint">{{ hints.invite_code }}</div>
|
||||
</div>
|
||||
|
||||
<div class="join-row">
|
||||
<div :class="['join-field', { 'has-error': errorFor('password') }]">
|
||||
<label class="join-label">Пароль</label>
|
||||
<div class="join-input"><input type="password" name="password" autocomplete="new-password" placeholder="Минимум 8 символов" :value="form.password" maxlength="32" @input="updateField('password', $event.target.value)" @blur="touched.password = true" /></div>
|
||||
<div v-if="errorFor('password')" class="join-error">{{ errorFor('password') }}</div>
|
||||
</div>
|
||||
<div :class="['join-field', { 'has-error': errorFor('password_confirmation') }]">
|
||||
<label class="join-label">Повтор пароля</label>
|
||||
<div class="join-input"><input type="password" name="password_confirmation" autocomplete="new-password" placeholder="Повтори пароль" :value="form.password_confirmation" maxlength="32" @input="updateField('password_confirmation', $event.target.value)" @blur="touched.password_confirmation = true" /></div>
|
||||
<div v-if="errorFor('password_confirmation')" class="join-error">{{ errorFor('password_confirmation') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="join-check">
|
||||
<input type="checkbox" name="terms" value="1" :checked="form.terms" @change="updateField('terms', $event.target.checked)" />
|
||||
<span class="box" aria-hidden="true"></span>
|
||||
<span>Я принимаю <a href="#">правила сервера</a> и согласен на обработку данных для создания игрового аккаунта.</span>
|
||||
</label>
|
||||
<div v-if="errorFor('terms')" class="join-error">{{ errorFor('terms') }}</div>
|
||||
|
||||
<button type="submit" :class="['btn btn-primary join-submit', { 'is-disabled': !valid || submitting }]" :disabled="!valid || submitting">
|
||||
{{ submitting ? 'Создаем аккаунт...' : 'Зарегистрировать аккаунт' }} <span v-html="arrowSvg"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { arrowSvg, moonMark, navLinks } from './symbols';
|
||||
|
||||
const props = defineProps({
|
||||
auth: { type: Object, required: true },
|
||||
playUrl: { type: String, required: true },
|
||||
realm: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const scrolled = ref(false);
|
||||
const open = ref(false);
|
||||
const cabinetHref = computed(() => props.auth.authenticated ? props.auth.cabinet_url : (props.auth.login_url || '/login'));
|
||||
|
||||
const close = () => {
|
||||
open.value = false;
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
scrolled.value = window.scrollY > 40;
|
||||
};
|
||||
|
||||
onMounted(() => window.addEventListener('scroll', onScroll, { passive: true }));
|
||||
onBeforeUnmount(() => {
|
||||
document.body.style.overflow = '';
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
});
|
||||
|
||||
watch(open, (value) => {
|
||||
document.body.style.overflow = value ? 'hidden' : '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav :class="['nav', { scrolled }]">
|
||||
<div class="wrap nav-inner">
|
||||
<a href="#" class="nav-logo" @click="close">
|
||||
<span class="nav-logo-mark" style="color: var(--moon)" v-html="moonMark(32)"></span>
|
||||
<span>MoonWell</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-links nav-links-desktop">
|
||||
<a v-for="link in navLinks" :key="link.href" :href="link.href">{{ link.label }}</a>
|
||||
<a v-if="auth.is_admin" :href="auth.admin_url">Админка</a>
|
||||
<a :href="cabinetHref" class="nav-cta">
|
||||
{{ auth.authenticated ? 'Личный кабинет' : 'Войти в кабинет' }}
|
||||
<span style="color: var(--moon)" v-html="arrowSvg"></span>
|
||||
</a>
|
||||
<a :href="playUrl" class="nav-cta">Играть <span style="color: var(--moon)" v-html="arrowSvg"></span></a>
|
||||
</div>
|
||||
|
||||
<button :class="['nav-burger', { 'is-open': open }]" :aria-label="open ? 'Закрыть меню' : 'Открыть меню'" :aria-expanded="open" @click="open = !open">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div :class="['nav-drawer-backdrop', { 'is-open': open }]" aria-hidden="true" @click="close"></div>
|
||||
<aside :class="['nav-drawer', { 'is-open': open }]" :aria-hidden="!open">
|
||||
<div class="nav-drawer-head"><span class="eyebrow">Навигация</span></div>
|
||||
<div class="nav-drawer-links">
|
||||
<a v-for="(link, index) in navLinks" :key="link.href" :href="link.href" :style="{ transitionDelay: `${60 + index * 40}ms` }" @click="close">
|
||||
<span class="num">{{ String(index + 1).padStart(2, '0') }}</span>
|
||||
<span class="lbl">{{ link.label }}</span>
|
||||
<span class="arr" v-html="arrowSvg"></span>
|
||||
</a>
|
||||
<a :href="cabinetHref" @click="close">
|
||||
<span class="num">05</span>
|
||||
<span class="lbl">{{ auth.authenticated ? 'Личный кабинет' : 'Войти в кабинет' }}</span>
|
||||
<span class="arr" v-html="arrowSvg"></span>
|
||||
</a>
|
||||
<a v-if="auth.is_admin" :href="auth.admin_url" @click="close">
|
||||
<span class="num">06</span>
|
||||
<span class="lbl">Админка</span>
|
||||
<span class="arr" v-html="arrowSvg"></span>
|
||||
</a>
|
||||
</div>
|
||||
<a :href="playUrl" class="btn btn-primary nav-drawer-cta" @click="close">Играть <span v-html="arrowSvg"></span></a>
|
||||
<div class="nav-drawer-foot">
|
||||
<span class="eyebrow" style="color: var(--text-mute)">moonwell · {{ realm.client_version || '3.3.5a' }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup>
|
||||
defineProps({ posts: { type: Array, default: () => [] } });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="news" data-screen-label="04 News">
|
||||
<div class="wrap">
|
||||
<div class="section-head reveal">
|
||||
<span class="eyebrow">Хроники сервера</span>
|
||||
<div style="height: 20px"></div>
|
||||
<h2 class="h-display" style="font-size: clamp(36px, 5vw, 60px)">Новости и патчноуты</h2>
|
||||
<div class="divider-ornament">✦</div>
|
||||
</div>
|
||||
|
||||
<div v-if="posts.length" class="news-grid">
|
||||
<article v-for="post in posts" :key="post.id || post.title" :class="['news-card', 'reveal', { featured: post.featured }]">
|
||||
<div class="news-meta">
|
||||
<span :class="['news-tag', post.tagType || 'patch']">{{ post.tag }}</span>
|
||||
<span v-if="post.date">{{ post.date }}</span>
|
||||
</div>
|
||||
<div v-if="post.featured" class="news-placeholder" :style="post.image_url ? { backgroundImage: `url(${post.image_url})`, backgroundSize: 'cover', backgroundPosition: 'center', color: 'transparent' } : {}">
|
||||
{{ post.image_url ? post.title : '[ placeholder ] · 1600x800' }}
|
||||
</div>
|
||||
<h3 class="news-title">{{ post.title }}</h3>
|
||||
<p class="news-excerpt">{{ post.excerpt }}</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="news-empty reveal" style="text-align: center; padding: 80px 24px; border: 1px dashed var(--panel-border-strong); border-radius: var(--radius); background: rgba(127, 184, 212, 0.02)">
|
||||
<div style="font-size: 28px; color: var(--moon); margin-bottom: 14px">✦</div>
|
||||
<h3 class="h-display" style="font-size: 24px; margin-bottom: 10px">Пока новостей нет</h3>
|
||||
<p class="lead" style="font-style: italic; max-width: 520px; margin: 0 auto">Хроники сервера еще не открыли первую страницу. Загляни позже - здесь появятся патчноуты, события и девблоги.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
const props = defineProps({ realm: { type: Object, required: true } });
|
||||
const ping = ref(props.realm.ping ?? 38);
|
||||
const isOnline = computed(() => props.realm.online !== false);
|
||||
const players = computed(() => typeof props.realm.players === 'number' ? props.realm.players.toLocaleString('ru-RU') : '-');
|
||||
const factions = computed(() => props.realm.factions || { alliance: 50, horde: 50 });
|
||||
let timer;
|
||||
|
||||
onMounted(() => {
|
||||
if (!isOnline.value) return;
|
||||
timer = window.setInterval(() => {
|
||||
ping.value = Math.max(20, Math.min(80, ping.value + Math.round((Math.random() - 0.5) * 6)));
|
||||
}, 2200);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => window.clearInterval(timer));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap realm reveal">
|
||||
<div class="realm-panel">
|
||||
<div class="realm-status"><span class="orb"></span><span class="label">{{ isOnline ? 'Онлайн' : 'Оффлайн' }}</span></div>
|
||||
<div class="realm-name">{{ realm.name || "Elune's Grace" }}<small>{{ realm.mode || 'PvE · x1 rate · RU' }}</small></div>
|
||||
<div class="realm-stat"><span class="k">Игроков</span><span class="v">{{ players }}</span></div>
|
||||
<div class="realm-stat"><span class="k">Аптайм</span><span class="v small">{{ realm.uptime || '99.94%' }}</span></div>
|
||||
<div class="realm-stat"><span class="k">Пинг</span><span class="v small">{{ isOnline ? `${ping} ms` : '-' }}</span></div>
|
||||
<div class="realm-stat"><span class="k">Фракции</span><span class="v small">{{ factions.alliance }} / {{ factions.horde }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps({ tweaks: { type: Object, default: () => ({}) } });
|
||||
const open = ref(false);
|
||||
const state = reactive({
|
||||
variant: 'forest',
|
||||
particles: true,
|
||||
moon: true,
|
||||
...props.tweaks,
|
||||
...readStoredTweaks(),
|
||||
});
|
||||
|
||||
function readStoredTweaks() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('moonwell.tweaks') || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function applyTweaks() {
|
||||
document.body.dataset.variant = state.variant;
|
||||
document.body.dataset.particles = state.particles ? 'true' : 'false';
|
||||
document.body.dataset.moon = state.moon ? 'true' : 'false';
|
||||
}
|
||||
|
||||
const onKey = (event) => {
|
||||
if (event.key === '`' || (event.altKey && event.key.toLowerCase() === 't')) {
|
||||
open.value = !open.value;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
applyTweaks();
|
||||
window.addEventListener('keydown', onKey);
|
||||
});
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onKey));
|
||||
|
||||
watch(state, () => {
|
||||
applyTweaks();
|
||||
try {
|
||||
localStorage.setItem('moonwell.tweaks', JSON.stringify(state));
|
||||
} catch {
|
||||
// Appearance preferences are non-critical.
|
||||
}
|
||||
}, { deep: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="tweaks-fab" aria-label="Tweaks" title="Настройки внешнего вида (alt+T)" @click="open = !open">✦</button>
|
||||
<div :class="['tweaks', { open }]">
|
||||
<div class="tweaks-title">
|
||||
<span>Tweaks</span>
|
||||
<button aria-label="Close" @click="open = false">×</button>
|
||||
</div>
|
||||
<div class="tweak-row">
|
||||
<label>Настроение</label>
|
||||
<div class="tweak-seg">
|
||||
<button :class="{ active: state.variant === 'forest' }" @click="state.variant = 'forest'">Лес</button>
|
||||
<button :class="{ active: state.variant === 'temple' }" @click="state.variant = 'temple'">Храм</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tweak-row">
|
||||
<label>Частицы и анимации</label>
|
||||
<div :class="['tweak-switch', { on: state.particles }]" role="switch" :aria-checked="state.particles" @click="state.particles = !state.particles"></div>
|
||||
</div>
|
||||
<div class="tweak-row">
|
||||
<label>Луна на фоне</label>
|
||||
<div :class="['tweak-switch', { on: state.moon }]" role="switch" :aria-checked="state.moon" @click="state.moon = !state.moon"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
export const navLinks = [
|
||||
{ href: '#about', label: 'О сервере' },
|
||||
{ href: '#features', label: 'Особенности' },
|
||||
{ href: '#join', label: 'Регистрация' },
|
||||
{ href: '#news', label: 'Новости' },
|
||||
];
|
||||
|
||||
export const features = [
|
||||
['I', 'Сигнатурная фича', 'Индивидуальный прогресс по эпохам', 'Пока твой герой не увидел финал Классики - Outland закрыт. Пока не закрыт Illidan - Нортренд в тумане. Каждый идет в своем темпе, без скипов.', 'span-6', 'path'],
|
||||
['II', '', 'Соло-данжи', 'Любое подземелье проходимо в одиночку: мы скейлим боссов и механики под размер отряда - от 1 до 5.', 'span-3', 'solo'],
|
||||
['III', '', 'Режим «Предатель»', 'При создании персонаж Альянса может встать на сторону Орды (и наоборот). Отдельные сюжеты, NPC, репутации.', 'span-3', 'mask'],
|
||||
['IV', 'В разработке', 'Ликантропия', 'Воргены как болезнь: заражайся, сопротивляйся, или прими форму. Твой выбор - твой путь.', 'span-4', 'wolf'],
|
||||
['V', '', 'Mythic+ без потолка', 'Бесконечное масштабирование уровня ключа. Каждую неделю - новые аффиксы.', 'span-4', 'mythic'],
|
||||
['VI', 'В разработке', 'Боевой пропуск · магазин наград', 'Ежедневные и еженедельные задания, ежедневные награды за вход, косметика в магазине.', 'span-4', 'pass'],
|
||||
['VII', 'В разработке', 'Коллекции', 'Питомцы, ездовые животные, трансмогрификация - все в одном журнале, с фильтрами и прогрессом.', 'span-6', 'collection'],
|
||||
['VIII', 'Side Project', 'MoonWell Karts', 'Отдельная мини-игра в нашей инфраструктуре - аркадные гонки с персонажами сервера. В разработке.', 'span-6', 'kart'],
|
||||
].map(([num, tag, title, desc, span, icon]) => ({ num, tag, title, desc, span, icon }));
|
||||
|
||||
export 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' },
|
||||
];
|
||||
|
||||
export const iconPaths = {
|
||||
path: '<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" />',
|
||||
solo: '<circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /><circle cx="12" cy="12" r=".8" fill="currentColor" />',
|
||||
mask: '<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" />',
|
||||
wolf: '<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" />',
|
||||
mythic: '<polygon points="12 3 15 9 22 10 17 15 18 22 12 19 6 22 7 15 2 10 9 9" />',
|
||||
pass: '<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" />',
|
||||
collection: '<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" />',
|
||||
kart: '<path d="M3 15l2-5h8l4 3h4v2" /><circle cx="7" cy="17" r="2" /><circle cx="17" cy="17" r="2" /><path d="M9 15h4" />',
|
||||
};
|
||||
|
||||
export const arrowSvg = '<svg class="btn-arrow" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 5h12M9 1l4 4-4 4" /></svg>';
|
||||
|
||||
export const moonMark = (size = 32) => `<svg viewBox="0 0 32 32" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="1"><circle cx="16" cy="16" r="11" stroke-opacity="0.3" /><path d="M20 7 A11 11 0 1 0 20 25 A8 8 0 1 1 20 7 Z" fill="currentColor" fill-opacity="0.7" /><circle cx="16" cy="16" r="15" stroke-opacity="0.15" stroke-dasharray="1 3" /><path d="M16 1 L16 4 M16 28 L16 31 M1 16 L4 16 M28 16 L31 16" stroke-opacity="0.4" /></svg>`;
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue';
|
||||
import About from './Components/About.vue';
|
||||
import Background from './Components/Background.vue';
|
||||
import Features from './Components/Features.vue';
|
||||
import Footer from './Components/Footer.vue';
|
||||
import Hero from './Components/Hero.vue';
|
||||
import Join from './Components/Join.vue';
|
||||
import Nav from './Components/Nav.vue';
|
||||
import News from './Components/News.vue';
|
||||
import Realm from './Components/Realm.vue';
|
||||
import Tweaks from './Components/Tweaks.vue';
|
||||
|
||||
const props = defineProps({
|
||||
realm: { type: Object, required: true },
|
||||
playUrl: { type: String, required: true },
|
||||
posts: { type: Array, default: () => [] },
|
||||
tweaks: { type: Object, default: () => ({}) },
|
||||
registerEndpoint: { type: String, required: true },
|
||||
csrfToken: { type: String, required: true },
|
||||
auth: { type: Object, default: () => ({}) },
|
||||
flash: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => `${props.realm.server_name || 'MoonWell'} - Лунный Колодец`);
|
||||
let revealObserver;
|
||||
|
||||
onMounted(() => {
|
||||
document.documentElement.lang = 'ru';
|
||||
revealObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-visible');
|
||||
revealObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.12, rootMargin: '0px 0px -8% 0px' });
|
||||
|
||||
document.querySelectorAll('.reveal').forEach((el) => revealObserver.observe(el));
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => revealObserver?.disconnect());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head>
|
||||
<title>{{ pageTitle }}</title>
|
||||
<meta name="description" :content="realm.tagline || 'MoonWell - приватный сервер World of Warcraft 3.3.5a'" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<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" />
|
||||
</Head>
|
||||
|
||||
<Background />
|
||||
<Nav :auth="auth" :play-url="playUrl" :realm="realm" />
|
||||
<Hero :play-url="playUrl" />
|
||||
<Realm :realm="realm" />
|
||||
<About />
|
||||
<Features />
|
||||
<Join :register-endpoint="registerEndpoint" :csrf-token="csrfToken" :auth="auth" :flash="flash" />
|
||||
<News :posts="posts" />
|
||||
<Footer :play-url="playUrl" />
|
||||
<Tweaks :tweaks="tweaks" />
|
||||
</template>
|
||||
+17
-1
@@ -1 +1,17 @@
|
||||
import './bootstrap';
|
||||
import '../css/moonwell.css';
|
||||
|
||||
import { createInertiaApp } from '@inertiajs/vue3';
|
||||
import { createApp, h } from 'vue';
|
||||
|
||||
createInertiaApp({
|
||||
resolve: (name) => {
|
||||
const pages = import.meta.glob('./Pages/**/*.vue', { eager: true });
|
||||
|
||||
return pages[`./Pages/${name}.vue`];
|
||||
},
|
||||
setup({ el, App, props, plugin }) {
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
.mount(el);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
@vite('resources/js/app.js')
|
||||
<x-inertia::head />
|
||||
</head>
|
||||
<body>
|
||||
<x-inertia::app />
|
||||
</body>
|
||||
</html>
|
||||
+2
-1
@@ -2,6 +2,7 @@ import { defineConfig } from 'vite';
|
||||
import laravel from 'laravel-vite-plugin';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
@@ -9,12 +10,12 @@ export default defineConfig({
|
||||
input: [
|
||||
'resources/css/app.css',
|
||||
'resources/js/app.js',
|
||||
'resources/js/moonwell/main.jsx',
|
||||
],
|
||||
refresh: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
react(),
|
||||
vue(),
|
||||
],
|
||||
server: {
|
||||
watch: {
|
||||
|
||||
Reference in New Issue
Block a user