новости + переработка текстов
This commit is contained in:
+4
-4
@@ -1,4 +1,4 @@
|
||||
APP_NAME=Moonwell
|
||||
APP_NAME=MoonWell
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
@@ -110,9 +110,9 @@ MOONWELL_ADMIN_MIN_GMLEVEL=3
|
||||
MOONWELL_ADMIN_ACCESS_COMMENT="updated via moonwell-web admin"
|
||||
MOONWELL_BOT_ACCOUNT_PREFIX=RNDBOT
|
||||
|
||||
MOONWELL_SERVER_NAME=Moonwell
|
||||
MOONWELL_REALM_NAME=Moonwell
|
||||
MOONWELL_SERVER_TAGLINE="Русскоязычный WotLK-сервер с прогрессией и комфортным PvE"
|
||||
MOONWELL_SERVER_NAME=MoonWell
|
||||
MOONWELL_REALM_NAME=MoonWell
|
||||
MOONWELL_SERVER_TAGLINE="Пройди историю Азерота — от Классики до Короля-лича — в своём темпе, в одиночку или с небольшим отрядом."
|
||||
MOONWELL_CLIENT_VERSION=3.3.5a
|
||||
MOONWELL_REALMLIST="set realmlist strictlypragmatic.ru"
|
||||
MOONWELL_RATE_EXPERIENCE=x3
|
||||
|
||||
@@ -26,6 +26,7 @@ trait BuildsPortalProps
|
||||
],
|
||||
'urls' => [
|
||||
'home' => route('landing.index'),
|
||||
'news' => route('news.index'),
|
||||
'login' => route('login'),
|
||||
'cabinet' => route('cabinet.index'),
|
||||
'admin' => route('admin.accounts.index'),
|
||||
|
||||
@@ -29,6 +29,8 @@ class LandingController extends Controller
|
||||
$rates = $realmConfig['rates'] ?? [];
|
||||
$user = Auth::user();
|
||||
$playersOnline = $accounts->onlinePlayerCount();
|
||||
$realmName = $this->canonicalMoonWellName($realmConfig['realm_name'] ?? null);
|
||||
$serverName = $this->canonicalMoonWellName($realmConfig['server_name'] ?? null);
|
||||
|
||||
$playUrl = $user !== null
|
||||
? route('cabinet.client')
|
||||
@@ -46,6 +48,7 @@ class LandingController extends Controller
|
||||
'tagType' => 'patch',
|
||||
'image_url' => $item->image_url,
|
||||
'featured' => $index === 0,
|
||||
'url' => route('news.show', $item),
|
||||
])
|
||||
->all();
|
||||
} catch (Throwable $exception) {
|
||||
@@ -56,7 +59,7 @@ class LandingController extends Controller
|
||||
|
||||
$bootstrap = [
|
||||
'realm' => [
|
||||
'name' => $realmConfig['realm_name'],
|
||||
'name' => $realmName,
|
||||
'mode' => sprintf('PvE · %s rate · RU', $rates['experience'] ?? 'x1'),
|
||||
'realmlist' => $realmConfig['realmlist'],
|
||||
'online' => $playersOnline !== null,
|
||||
@@ -67,10 +70,11 @@ class LandingController extends Controller
|
||||
'rates' => $rates,
|
||||
'client_version' => $realmConfig['client_version'] ?? '3.3.5a',
|
||||
'tagline' => $realmConfig['tagline'] ?? null,
|
||||
'server_name' => $realmConfig['server_name'] ?? 'MoonWell',
|
||||
'server_name' => $serverName,
|
||||
],
|
||||
'playUrl' => $playUrl,
|
||||
'posts' => $posts,
|
||||
'newsIndexUrl' => route('news.index'),
|
||||
'tweaks' => [
|
||||
'variant' => 'forest',
|
||||
'particles' => true,
|
||||
@@ -163,4 +167,13 @@ class LandingController extends Controller
|
||||
|
||||
return method_exists($bag, 'toArray') ? $bag->toArray() : [];
|
||||
}
|
||||
|
||||
private function canonicalMoonWellName(?string $name): string
|
||||
{
|
||||
if ($name === null || strcasecmp($name, 'MoonWell') === 0) {
|
||||
return 'MoonWell';
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Concerns\BuildsPortalProps;
|
||||
use App\Models\News;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class NewsController extends Controller
|
||||
{
|
||||
use BuildsPortalProps;
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$articles = News::query()
|
||||
->published()
|
||||
->latest('created_at')
|
||||
->latest('id')
|
||||
->paginate(9)
|
||||
->withQueryString()
|
||||
->through(fn (News $item): array => $this->summary($item));
|
||||
|
||||
return Inertia::render('News/Index', [
|
||||
...$this->portalProps($request),
|
||||
'articles' => $articles,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, News $news): Response
|
||||
{
|
||||
abort_unless($news->is_published, 404);
|
||||
|
||||
$moreNews = News::query()
|
||||
->published()
|
||||
->where('id', '!=', $news->id)
|
||||
->latest('created_at')
|
||||
->latest('id')
|
||||
->limit(3)
|
||||
->get()
|
||||
->map(fn (News $item): array => $this->summary($item))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return Inertia::render('News/Show', [
|
||||
...$this->portalProps($request),
|
||||
'article' => [
|
||||
...$this->summary($news),
|
||||
'body' => $news->body,
|
||||
'canonical_url' => route('news.show', $news),
|
||||
'back_url' => route('news.index'),
|
||||
],
|
||||
'moreNews' => $moreNews,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function summary(News $news): array
|
||||
{
|
||||
return [
|
||||
'id' => $news->id,
|
||||
'title' => $news->title,
|
||||
'excerpt' => Str::limit(trim(strip_tags($news->body)), 200),
|
||||
'date' => $news->created_at?->locale(app()->getLocale())->isoFormat('D MMMM YYYY'),
|
||||
'image_url' => $news->image_url,
|
||||
'url' => route('news.show', $news),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,12 @@ class NewsService
|
||||
/**
|
||||
* @return \Illuminate\Support\Collection<int, \App\Models\News>
|
||||
*/
|
||||
public function listForLanding(int $limit = 6): Collection
|
||||
public function listForLanding(int $limit = 3): Collection
|
||||
{
|
||||
return News::query()
|
||||
->published()
|
||||
->ordered()
|
||||
->latest('created_at')
|
||||
->latest('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
+3
-3
@@ -40,9 +40,9 @@ return [
|
||||
],
|
||||
|
||||
'realm' => [
|
||||
'server_name' => env('MOONWELL_SERVER_NAME', 'Moonwell'),
|
||||
'realm_name' => env('MOONWELL_REALM_NAME', 'Moonwell'),
|
||||
'tagline' => env('MOONWELL_SERVER_TAGLINE', 'Русскоязычный WotLK-сервер с прогрессией и комфортным PvE'),
|
||||
'server_name' => env('MOONWELL_SERVER_NAME', 'MoonWell'),
|
||||
'realm_name' => env('MOONWELL_REALM_NAME', 'MoonWell'),
|
||||
'tagline' => env('MOONWELL_SERVER_TAGLINE', 'Пройди историю Азерота — от Классики до Короля-лича — в своём темпе, в одиночку или с небольшим отрядом.'),
|
||||
'client_version' => env('MOONWELL_CLIENT_VERSION', '3.3.5a'),
|
||||
'realmlist' => env('MOONWELL_REALMLIST', 'set realmlist 127.0.0.1'),
|
||||
'rates' => [
|
||||
|
||||
Generated
-578
@@ -8,13 +8,10 @@
|
||||
"@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",
|
||||
"vue": "^3.5.39"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"axios": "^1.11.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
@@ -22,148 +19,6 @@
|
||||
"vite": "^7.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
||||
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
"json5": "^2.2.3",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/babel"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-plugin-utils": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
||||
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
@@ -182,30 +37,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
@@ -221,72 +52,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
|
||||
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
|
||||
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
@@ -809,13 +574,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.60.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
|
||||
@@ -1413,78 +1171,12 @@
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.20.7",
|
||||
"@babel/types": "^7.20.7",
|
||||
"@types/babel__generator": "*",
|
||||
"@types/babel__template": "*",
|
||||
"@types/babel__traverse": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__generator": {
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
|
||||
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__template": {
|
||||
"version": "7.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
|
||||
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.1.0",
|
||||
"@babel/types": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__traverse": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
|
||||
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
|
||||
@@ -1652,53 +1344,6 @@
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.22",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.22.tgz",
|
||||
"integrity": "sha512-6qruVrb5rse6WylFkU0FhBKKGuecWseqdpQfhkawn6ztyk2QlfwSRjsDxMCLJrkfmfN21qvhl9ABgaMeRkuwww==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
|
||||
"integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
"electron-to-chromium": "^1.5.328",
|
||||
"node-releases": "^2.0.36",
|
||||
"update-browserslist-db": "^1.2.3"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -1713,27 +1358,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001791",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
|
||||
"integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -1837,37 +1461,12 @@
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -1903,13 +1502,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.344",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
|
||||
"integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -2138,16 +1730,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/gensync": {
|
||||
"version": "1.0.0-beta.2",
|
||||
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
|
||||
"integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
@@ -2289,38 +1871,6 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
"integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"jsesc": "bin/jsesc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/laravel-precognition": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/laravel-precognition/-/laravel-precognition-2.0.0.tgz",
|
||||
@@ -2608,28 +2158,6 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2672,13 +2200,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
@@ -2697,13 +2218,6 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.38",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
|
||||
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2760,41 +2274,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -2859,25 +2338,6 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
@@ -2998,37 +2458,6 @@
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"escalade": "^3.2.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"update-browserslist-db": "cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
@@ -3176,13 +2605,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"axios": "^1.11.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0.0",
|
||||
@@ -19,8 +18,6 @@
|
||||
"@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",
|
||||
"vue": "^3.5.39"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,6 +1110,10 @@ section {
|
||||
border-color: var(--panel-border-strong);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.news-card:focus-visible {
|
||||
outline: 2px solid var(--moon);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
.news-card.featured {
|
||||
grid-row: span 2;
|
||||
padding: 40px;
|
||||
@@ -1180,12 +1184,357 @@ section {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.news-all-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
padding: 11px 16px;
|
||||
border: 1px solid var(--panel-border-strong);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--moon);
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
|
||||
.news-all-link:hover {
|
||||
border-color: var(--moon);
|
||||
color: var(--ivory);
|
||||
background: rgba(127, 184, 212, 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.news-grid { grid-template-columns: 1fr; }
|
||||
.news-card.featured { grid-row: auto; padding: 28px; }
|
||||
.news-card.featured .news-title { font-size: 24px; }
|
||||
}
|
||||
|
||||
/* ------------------------- public news index ------------------------- */
|
||||
.portal-news-index {
|
||||
padding-top: clamp(64px, 8vw, 110px);
|
||||
padding-bottom: clamp(80px, 10vw, 140px);
|
||||
}
|
||||
|
||||
.portal-news-index__hero {
|
||||
max-width: 830px;
|
||||
margin-bottom: clamp(44px, 6vw, 74px);
|
||||
}
|
||||
|
||||
.portal-news-index__hero h1 {
|
||||
margin: 20px 0 22px;
|
||||
font-size: clamp(44px, 7vw, 86px);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.portal-news-index__hero .lead {
|
||||
max-width: 720px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.portal-news-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
.portal-news-list__card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 470px;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
radial-gradient(ellipse at 80% -10%, rgba(91, 58, 138, 0.15), transparent 50%),
|
||||
linear-gradient(180deg, rgba(16, 20, 42, 0.66), rgba(10, 13, 31, 0.42));
|
||||
transition: border-color var(--t-fast), transform var(--t-fast), box-shadow var(--t-fast);
|
||||
}
|
||||
|
||||
.portal-news-list__card:hover {
|
||||
border-color: var(--panel-border-strong);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.portal-news-list__card:focus-visible {
|
||||
outline: 2px solid var(--moon);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.portal-news-list__cover {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.portal-news-list__cover.empty {
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, rgba(127, 184, 212, 0.12), transparent 38%),
|
||||
repeating-linear-gradient(45deg, transparent 0 16px, rgba(127, 184, 212, 0.025) 16px 17px);
|
||||
}
|
||||
|
||||
.portal-news-list__cover.empty span {
|
||||
font-family: var(--f-display);
|
||||
font-size: 34px;
|
||||
color: var(--moon);
|
||||
filter: drop-shadow(0 0 18px rgba(127, 184, 212, 0.42));
|
||||
}
|
||||
|
||||
.portal-news-list__content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
padding: 26px;
|
||||
}
|
||||
|
||||
.portal-news-list__content h2 {
|
||||
font-size: clamp(22px, 2.2vw, 30px);
|
||||
line-height: 1.22;
|
||||
}
|
||||
|
||||
.portal-news-list__content > p {
|
||||
margin: 0;
|
||||
font-family: var(--f-serif);
|
||||
font-size: 17px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.portal-news-index__empty {
|
||||
padding: clamp(60px, 10vw, 110px) 30px;
|
||||
border: 1px dashed var(--panel-border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
background: rgba(127, 184, 212, 0.025);
|
||||
}
|
||||
|
||||
.portal-news-index__empty > span {
|
||||
display: block;
|
||||
margin-bottom: 18px;
|
||||
font-size: 32px;
|
||||
color: var(--moon);
|
||||
}
|
||||
|
||||
.portal-news-index__empty h2 { font-size: clamp(28px, 4vw, 42px); }
|
||||
.portal-news-index__empty p {
|
||||
margin: 14px 0 28px;
|
||||
font-family: var(--f-serif);
|
||||
font-size: 19px;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.portal-pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 52px;
|
||||
}
|
||||
|
||||
.portal-pagination a,
|
||||
.portal-pagination span {
|
||||
display: grid;
|
||||
min-width: 42px;
|
||||
height: 42px;
|
||||
padding: 0 13px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-mute);
|
||||
}
|
||||
|
||||
.portal-pagination a {
|
||||
transition: border-color var(--t-fast), color var(--t-fast), background var(--t-fast);
|
||||
}
|
||||
|
||||
.portal-pagination a:hover,
|
||||
.portal-pagination a.active {
|
||||
border-color: var(--moon);
|
||||
color: var(--ivory);
|
||||
background: rgba(127, 184, 212, 0.08);
|
||||
}
|
||||
|
||||
.portal-pagination .disabled { opacity: 0.36; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.portal-news-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.portal-news-list { grid-template-columns: 1fr; }
|
||||
.portal-news-list__card { min-height: auto; }
|
||||
.portal-news-list__content { padding: 22px; }
|
||||
.portal-pagination a:first-of-type,
|
||||
.portal-pagination a:last-of-type,
|
||||
.portal-pagination span:first-child,
|
||||
.portal-pagination span:last-child { min-width: 74px; }
|
||||
}
|
||||
|
||||
/* ------------------------- public news article ------------------------- */
|
||||
.portal-news-page {
|
||||
max-width: 1040px;
|
||||
padding-top: clamp(54px, 8vw, 110px);
|
||||
padding-bottom: clamp(68px, 9vw, 120px);
|
||||
}
|
||||
|
||||
.portal-news-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: clamp(38px, 6vw, 70px);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-mute);
|
||||
transition: color var(--t-fast);
|
||||
}
|
||||
|
||||
.portal-news-back:hover { color: var(--moon); }
|
||||
|
||||
.portal-news-hero {
|
||||
max-width: 880px;
|
||||
}
|
||||
|
||||
.portal-news-hero h1 {
|
||||
margin: 24px 0;
|
||||
font-size: clamp(42px, 7vw, 82px);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.portal-news-hero .lead {
|
||||
max-width: 760px;
|
||||
margin: 0;
|
||||
font-size: clamp(20px, 2vw, 26px);
|
||||
}
|
||||
|
||||
.portal-news-cover {
|
||||
width: 100%;
|
||||
max-height: 620px;
|
||||
margin: clamp(42px, 6vw, 72px) 0;
|
||||
border: 1px solid var(--panel-border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
object-fit: cover;
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
.portal-news-body {
|
||||
max-width: 780px;
|
||||
margin: clamp(44px, 6vw, 76px) auto 0;
|
||||
}
|
||||
|
||||
.portal-news-body p {
|
||||
margin: 0 0 1.45em;
|
||||
white-space: pre-line;
|
||||
font-family: var(--f-serif);
|
||||
font-size: clamp(20px, 2.1vw, 25px);
|
||||
line-height: 1.72;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
.portal-news-body p:first-child::first-letter {
|
||||
float: left;
|
||||
margin: 0.08em 0.12em 0 0;
|
||||
font-family: var(--f-display);
|
||||
font-size: 3.2em;
|
||||
line-height: 0.78;
|
||||
color: var(--moon);
|
||||
}
|
||||
|
||||
.portal-news-end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
max-width: 780px;
|
||||
margin: 56px auto 0;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid var(--panel-border);
|
||||
font-family: var(--f-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--moon);
|
||||
}
|
||||
|
||||
.portal-news-end a { transition: color var(--t-fast); }
|
||||
.portal-news-end a:hover { color: var(--ivory); }
|
||||
|
||||
.portal-news-more {
|
||||
padding-bottom: clamp(80px, 10vw, 140px);
|
||||
}
|
||||
|
||||
.portal-news-more__head {
|
||||
padding-top: 50px;
|
||||
border-top: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.portal-news-more__head h2 {
|
||||
margin: 16px 0 34px;
|
||||
font-size: clamp(32px, 4vw, 50px);
|
||||
}
|
||||
|
||||
.portal-news-more__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.portal-news-more__card {
|
||||
display: flex;
|
||||
min-height: 280px;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(180deg, rgba(16, 20, 42, 0.58), rgba(10, 13, 31, 0.36));
|
||||
transition: border-color var(--t-fast), transform var(--t-fast);
|
||||
}
|
||||
|
||||
.portal-news-more__card:hover {
|
||||
border-color: var(--panel-border-strong);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.portal-news-more__card img {
|
||||
width: calc(100% + 48px);
|
||||
height: 150px;
|
||||
margin: -24px -24px 6px;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.portal-news-more__card h3 {
|
||||
font-size: 23px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.portal-news-more__card > p {
|
||||
margin: 0;
|
||||
font-family: var(--f-serif);
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.portal-news-more__grid { grid-template-columns: 1fr; }
|
||||
.portal-news-more__card { min-height: auto; }
|
||||
.portal-news-body p { font-size: 20px; }
|
||||
}
|
||||
|
||||
/* ------------------------- footer ------------------------- */
|
||||
.footer {
|
||||
padding: clamp(80px, 10vw, 140px) 0 48px;
|
||||
|
||||
@@ -16,6 +16,7 @@ defineProps({
|
||||
<span>{{ site.name }}</span>
|
||||
</Link>
|
||||
<nav aria-label="Юридическая информация">
|
||||
<Link v-if="urls.news" :href="urls.news">Новости</Link>
|
||||
<Link v-if="urls.offer" :href="urls.offer">Публичная оферта</Link>
|
||||
<Link v-if="urls.privacy" :href="urls.privacy">Политика конфиденциальности</Link>
|
||||
</nav>
|
||||
|
||||
@@ -14,6 +14,7 @@ const menuOpen = ref(false);
|
||||
const currentPath = computed(() => page.url.split('?')[0]);
|
||||
const isCabinet = computed(() => currentPath.value.startsWith('/cabinet'));
|
||||
const isAdmin = computed(() => currentPath.value.startsWith('/admin'));
|
||||
const isNews = computed(() => currentPath.value.startsWith('/news'));
|
||||
const cabinetUrl = computed(() => (
|
||||
props.auth.authenticated
|
||||
? props.urls.cabinet
|
||||
@@ -47,6 +48,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<nav class="portal-nav portal-nav--desktop" aria-label="Основная навигация">
|
||||
<Link :href="urls.home">Главная</Link>
|
||||
<Link v-if="urls.news" :class="{ active: isNews }" :href="urls.news">Новости</Link>
|
||||
<Link v-if="cabinetUrl" :class="{ active: isCabinet }" :href="cabinetUrl">
|
||||
{{ auth.authenticated ? 'Кабинет' : 'Войти' }}
|
||||
</Link>
|
||||
@@ -77,6 +79,7 @@ onBeforeUnmount(() => {
|
||||
<div :class="['portal-mobile-menu', { open: menuOpen }]">
|
||||
<nav class="wrap" aria-label="Мобильная навигация">
|
||||
<Link :href="urls.home" @click="menuOpen = false">Главная</Link>
|
||||
<Link v-if="urls.news" :href="urls.news" @click="menuOpen = false">Новости</Link>
|
||||
<Link v-if="cabinetUrl" :href="cabinetUrl" @click="menuOpen = false">
|
||||
{{ auth.authenticated ? 'Личный кабинет' : 'Войти в кабинет' }}
|
||||
</Link>
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
<div class="about-copy">
|
||||
<span class="eyebrow">Что такое MoonWell</span>
|
||||
<div style="height: 18px"></div>
|
||||
<h2 class="h-display">Ночной колодец, где эпохи ждут тебя.</h2>
|
||||
<h2 class="h-display">Эпоха за эпохой</h2>
|
||||
<div style="height: 28px"></div>
|
||||
<p>MoonWell - это не очередной «1x серверный клон». Мы переосмыслили Wrath of the Lich King как длинный путь: сначала Классика, затем Burning Crusade, потом Лич - но каждый герой идет по нему сам.</p>
|
||||
<p>Мы хотим вернуть ощущение, которое многие из нас помнят с 2005-го: когда незнакомый лес опасен, а первый рейд - событие года. И при этом добавить то, чего тогда не хватало - возможность играть, когда у тебя есть только час после работы.</p>
|
||||
<p>MoonWell превращает знакомый мир в длинный путь: начни с Классики, пройди Запределье и постепенно открой Нордскол. Каждый герой продвигается в своём темпе.</p>
|
||||
<p>Исследуй опасные земли, готовься к первым рейдам и возвращайся к приключению тогда, когда тебе удобно. Здесь не нужно подстраиваться под общий прогресс сервера.</p>
|
||||
<blockquote class="about-quote">
|
||||
«Мы строим мир, в котором ты можешь пройти все в одиночку - или собрать отряд из трех верных друзей и свергнуть короля-лича. Оба пути честные.»
|
||||
<cite>- команда MoonWell</cite>
|
||||
«Пройди историю Азерота в одиночку или собери небольшой отряд. Оба пути открывают всё путешествие до Короля-лича».
|
||||
<cite>— команда MoonWell</cite>
|
||||
</blockquote>
|
||||
</div>
|
||||
<div class="about-visual corners">
|
||||
<div class="about-stats"><span>Est. 2026</span></div>
|
||||
<img :src="'/landing.png'" alt="концепт-арт: вход в Лунный Колодец, туман, силуэт ночного эльфа" />
|
||||
<img :src="'/landing.png'" alt="Концепт-арт: вход в Лунный колодец, туман и силуэт ночного эльфа" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -14,9 +14,9 @@ function onMove(event) {
|
||||
<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>
|
||||
<h2 class="h-display" style="font-size: clamp(36px, 5vw, 60px)">Системы MoonWell</h2>
|
||||
<div class="divider-ornament">✦</div>
|
||||
<p class="lead">Восемь систем, которые делают MoonWell не клоном, а отдельной игрой поверх знакомого мира.</p>
|
||||
<p class="lead">Восемь систем для личного прогресса, одиночной игры и новых приключений в знакомом мире.</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
@@ -32,7 +32,7 @@ function onMove(event) {
|
||||
|
||||
<div id="progression" class="progression reveal">
|
||||
<div class="progression-title">
|
||||
<h3>Путь через эпохи</h3>
|
||||
<h3>Эпоха за эпохой</h3>
|
||||
<span class="meta">Твой герой · Aranthel, Хранитель рощи · 47 ур.</span>
|
||||
</div>
|
||||
<div class="progression-track">
|
||||
|
||||
@@ -11,20 +11,20 @@ defineProps({
|
||||
<footer class="footer" data-screen-label="05 Footer">
|
||||
<div class="wrap">
|
||||
<div class="footer-cta reveal">
|
||||
<span class="eyebrow">Свет лунного колодца зовет</span>
|
||||
<span class="eyebrow">Простая установка</span>
|
||||
<div style="height: 20px"></div>
|
||||
<h2 class="h-display">Один лаунчер - и ты у ворот Тельдрассила.</h2>
|
||||
<p>Наш лаунчер сам поставит игровой клиент, пропишет realmlist и будет держать все в актуальном состоянии. Без ручных патчей и копирования файлов.</p>
|
||||
<h2 class="h-display">Твоя история начинается здесь</h2>
|
||||
<p>Лаунчер сам скачает, установит и обновит игровой клиент. Тебе не придётся вручную ставить патчи или менять файлы.</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 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>
|
||||
<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 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 · автоматическая установка и обновления</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-grid">
|
||||
@@ -33,7 +33,7 @@ defineProps({
|
||||
<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>
|
||||
<p>Приватный сервер World of Warcraft 3.3.5a с личным прогрессом, одиночными подземельями и режимом «Предатель». Играй бесплатно — покупки не дают боевого преимущества.</p>
|
||||
</div>
|
||||
<div class="footer-col"><h4>Сервер</h4><a href="#">Статус реалмов</a><a :href="playUrl">Скачать клиент</a><a v-if="legalUrls.published" :href="legalUrls.offer">Публичная оферта</a><a v-if="legalUrls.published" :href="legalUrls.privacy">Конфиденциальность</a></div>
|
||||
<div class="footer-col"><h4>Сообщество</h4><a href="#">Discord</a><a href="#">Форум</a><a href="#">Поддержка</a><a href="#">Вакансии</a></div>
|
||||
@@ -41,7 +41,7 @@ defineProps({
|
||||
</div>
|
||||
|
||||
<div class="footer-bottom">
|
||||
<span>© 2026 MoonWell · fan project · не связан с Blizzard Entertainment</span>
|
||||
<span>© 2026 MoonWell · фанатский проект · не связан с Blizzard Entertainment</span>
|
||||
<span v-if="legalUrls.published"><a :href="legalUrls.offer">Оферта</a> · <a :href="legalUrls.privacy">Политика конфиденциальности</a></span>
|
||||
<span v-else>Сделано с лунным светом</span>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
<script setup>
|
||||
import { arrowSvg } from './symbols';
|
||||
|
||||
defineProps({ playUrl: { type: String, required: true } });
|
||||
defineProps({
|
||||
playUrl: { type: String, required: true },
|
||||
clientVersion: { type: String, default: '3.3.5a' },
|
||||
});
|
||||
</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-pretitle"><span class="dot"></span><span>MoonWell · клиент World of Warcraft {{ clientVersion }}</span></div>
|
||||
<h1 class="h-display hero-title">История Азерота в твоём темпе<span class="ornament">— Лунный колодец —</span></h1>
|
||||
<p class="hero-tagline">Пройди путь от Классики до Короля-лича — в одиночку или с небольшим отрядом.</p>
|
||||
<div class="hero-meta">
|
||||
<span>Индивидуальный прогресс</span><span>Соло-данжи</span><span>Режим «Предатель»</span><span>Mythic+ без потолка</span>
|
||||
<span>Личный прогресс</span><span>Одиночные подземелья</span><span>Режим «Предатель»</span><span>Мифик+ без предела</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>
|
||||
<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>
|
||||
<span class="os">Windows 10/11</span><span class="sep">·</span><span>macOS — в разработке</span><span class="sep">·</span><span>Автоматическая установка и обновления</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-scroll">Прокрути вниз</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ function submit(event) {
|
||||
Логин <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>
|
||||
<a :href="auth.cabinet_url || auth.login_url || '#'" class="btn btn-primary">Войди в личный кабинет <span v-html="arrowSvg"></span></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@ function submit(event) {
|
||||
<p class="lead" style="font-style: italic">{{ inviteRequired ? 'Укажи инвайт-код, логин, почту и пароль' : 'Укажи логин, почту и пароль' }} — аккаунт будет готов к входу в игру сразу после регистрации.</p>
|
||||
<div v-if="inviteRequired" class="join-note">
|
||||
<span class="join-note-k">Закрытый бета-доступ</span>
|
||||
<span class="join-note-v">Пока идет закрытый этап: регистрация только по инвайт-кодам. Код можно получить у друзей-игроков или в нашем Discord.</span>
|
||||
<span class="join-note-v">Пока идёт закрытый этап: регистрация только по инвайт-кодам. Код можно получить у друзей-игроков или в нашем Discord.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -169,7 +169,7 @@ function submit(event) {
|
||||
<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>
|
||||
{{ submitting ? 'Создаём аккаунт…' : 'Создай аккаунт' }} <span v-html="arrowSvg"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -43,10 +43,10 @@ watch(open, (value) => {
|
||||
<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 ? 'Личный кабинет' : 'Войти в кабинет' }}
|
||||
{{ 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>
|
||||
<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">
|
||||
@@ -65,7 +65,7 @@ watch(open, (value) => {
|
||||
</a>
|
||||
<a :href="cabinetHref" @click="close">
|
||||
<span class="num">05</span>
|
||||
<span class="lbl">{{ auth.authenticated ? 'Личный кабинет' : 'Войти в кабинет' }}</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">
|
||||
@@ -74,9 +74,9 @@ watch(open, (value) => {
|
||||
<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>
|
||||
<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>
|
||||
<span class="eyebrow" style="color: var(--text-mute)">MoonWell · {{ realm.client_version || '3.3.5a' }}</span>
|
||||
</div>
|
||||
</aside>
|
||||
</nav>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script setup>
|
||||
defineProps({ posts: { type: Array, default: () => [] } });
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
|
||||
defineProps({
|
||||
posts: { type: Array, default: () => [] },
|
||||
indexUrl: { type: String, required: true },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -10,10 +15,17 @@ defineProps({ posts: { type: Array, default: () => [] } });
|
||||
<div style="height: 20px"></div>
|
||||
<h2 class="h-display" style="font-size: clamp(36px, 5vw, 60px)">Новости и патчноуты</h2>
|
||||
<div class="divider-ornament">✦</div>
|
||||
<Link class="news-all-link" :href="indexUrl">Все новости <span aria-hidden="true">→</span></Link>
|
||||
</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 }]">
|
||||
<Link
|
||||
v-for="post in posts"
|
||||
:key="post.id || post.title"
|
||||
:href="post.url"
|
||||
:aria-label="`Прочитать новость «${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>
|
||||
@@ -23,13 +35,14 @@ defineProps({ posts: { type: Array, default: () => [] } });
|
||||
</div>
|
||||
<h3 class="news-title">{{ post.title }}</h3>
|
||||
<p class="news-excerpt">{{ post.excerpt }}</p>
|
||||
</article>
|
||||
<span class="news-read">Читать полностью <span aria-hidden="true">→</span></span>
|
||||
</Link>
|
||||
</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>
|
||||
<p class="lead" style="font-style: italic; max-width: 520px; margin: 0 auto">Хроники сервера ещё не открыли первую страницу. Загляни позже — здесь появятся заметки об обновлениях, события и дневники разработки.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -6,20 +6,20 @@ export const navLinks = [
|
||||
];
|
||||
|
||||
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'],
|
||||
['I', 'Главная особенность', 'Личный прогресс', 'Заверши путь в Классике, чтобы открыть Запределье, затем одолей Иллидана и отправляйся в Нортренд. Продвигайся в своём темпе.', 'span-6', 'path'],
|
||||
['II', '', 'Одиночные подземелья', 'Проходи подземелья самостоятельно или с отрядом до пяти игроков: боссы и механики подстраиваются под размер группы.', 'span-3', 'solo'],
|
||||
['III', '', 'Режим «Предатель»', 'Создай персонажа Альянса на стороне Орды или персонажа Орды на стороне Альянса. Тебя ждут отдельные сюжеты, персонажи и репутации.', 'span-3', 'mask'],
|
||||
['IV', 'В разработке', 'Ликантропия', 'Заразись ликантропией, сопротивляйся ей или прими новую форму. Выбор определит твой путь.', 'span-4', 'wolf'],
|
||||
['V', '', 'Мифик+ без предела', 'Уровень ключей не ограничен: сложность продолжает расти вместе с твоим прогрессом, а набор модификаторов меняется каждую неделю.', 'span-4', 'mythic'],
|
||||
['VI', 'В разработке', 'Боевой пропуск · магазин наград', 'Выполняй ежедневные и еженедельные задания, получай награды за вход и открывай косметические предметы.', 'span-4', 'pass'],
|
||||
['VII', 'В разработке', 'Коллекции', 'Собирай питомцев, ездовых животных и облики для трансмогрификации в одном журнале с фильтрами и отметками прогресса.', 'span-6', 'collection'],
|
||||
['VIII', 'Отдельный проект', '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' },
|
||||
{ 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 = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { Head } from '@inertiajs/vue3';
|
||||
import { computed, onBeforeUnmount, onMounted } from 'vue';
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import About from './Components/About.vue';
|
||||
import Background from './Components/Background.vue';
|
||||
import Features from './Components/Features.vue';
|
||||
@@ -16,6 +16,7 @@ const props = defineProps({
|
||||
realm: { type: Object, required: true },
|
||||
playUrl: { type: String, required: true },
|
||||
posts: { type: Array, default: () => [] },
|
||||
newsIndexUrl: { type: String, required: true },
|
||||
tweaks: { type: Object, default: () => ({}) },
|
||||
registerEndpoint: { type: String, required: true },
|
||||
legalUrls: { type: Object, required: true },
|
||||
@@ -25,7 +26,8 @@ const props = defineProps({
|
||||
flash: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => `${props.realm.server_name || 'MoonWell'} - Лунный Колодец`);
|
||||
const pageTitle = 'MoonWell — Лунный колодец';
|
||||
const pageDescription = 'Пройди историю Азерота — от Классики до Короля-лича — в своём темпе, в одиночку или с небольшим отрядом.';
|
||||
let revealObserver;
|
||||
|
||||
onMounted(() => {
|
||||
@@ -48,7 +50,7 @@ onBeforeUnmount(() => revealObserver?.disconnect());
|
||||
<template>
|
||||
<Head>
|
||||
<title>{{ pageTitle }}</title>
|
||||
<meta name="description" :content="realm.tagline || 'MoonWell - приватный сервер World of Warcraft 3.3.5a'" />
|
||||
<meta name="description" :content="pageDescription" />
|
||||
<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" />
|
||||
@@ -56,12 +58,12 @@ onBeforeUnmount(() => revealObserver?.disconnect());
|
||||
|
||||
<Background />
|
||||
<Nav :auth="auth" :play-url="playUrl" :realm="realm" />
|
||||
<Hero :play-url="playUrl" />
|
||||
<Hero :play-url="playUrl" :client-version="realm.client_version" />
|
||||
<Realm :realm="realm" />
|
||||
<About />
|
||||
<Features />
|
||||
<Join :register-endpoint="registerEndpoint" :csrf-token="csrfToken" :invite-required="inviteRequired" :auth="auth" :flash="flash" :legal-urls="legalUrls" />
|
||||
<News :posts="posts" />
|
||||
<News :posts="posts" :index-url="newsIndexUrl" />
|
||||
<Footer :play-url="playUrl" :legal-urls="legalUrls" />
|
||||
<Tweaks :tweaks="tweaks" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup>
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import PortalLayout from '../../Layouts/PortalLayout.vue';
|
||||
|
||||
const props = defineProps({
|
||||
site: { type: Object, required: true },
|
||||
auth: { type: Object, required: true },
|
||||
urls: { type: Object, required: true },
|
||||
flash: { type: Object, default: () => ({}) },
|
||||
articles: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const paginationLinks = computed(() => props.articles.links.map((link, index, links) => ({
|
||||
...link,
|
||||
label: index === 0 ? 'Назад' : (index === links.length - 1 ? 'Вперёд' : link.label),
|
||||
})));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head>
|
||||
<title>Новости — {{ site.name }}</title>
|
||||
<meta name="description" content="Новости, обновления и события игрового проекта MoonWell." />
|
||||
<link rel="canonical" :href="urls.news" />
|
||||
</Head>
|
||||
|
||||
<PortalLayout :site="site" :auth="auth" :urls="urls" :flash="flash">
|
||||
<main class="wrap portal-news-index">
|
||||
<header class="portal-news-index__hero">
|
||||
<span class="eyebrow">Хроники сервера</span>
|
||||
<h1 class="h-display">Новости и обновления</h1>
|
||||
<p class="lead">События MoonWell, патчноуты, дневники разработки и важные объявления проекта.</p>
|
||||
</header>
|
||||
|
||||
<div v-if="articles.data.length" class="portal-news-list">
|
||||
<Link
|
||||
v-for="article in articles.data"
|
||||
:key="article.id"
|
||||
class="portal-news-list__card"
|
||||
:href="article.url"
|
||||
:aria-label="`Прочитать новость «${article.title}»`"
|
||||
>
|
||||
<div
|
||||
class="portal-news-list__cover"
|
||||
:class="{ empty: !article.image_url }"
|
||||
:style="article.image_url ? { backgroundImage: `url(${article.image_url})` } : {}"
|
||||
>
|
||||
<span v-if="!article.image_url" aria-hidden="true">✦</span>
|
||||
</div>
|
||||
<div class="portal-news-list__content">
|
||||
<div class="news-meta">
|
||||
<span class="news-tag patch">Новости</span>
|
||||
<time v-if="article.date">{{ article.date }}</time>
|
||||
</div>
|
||||
<h2 class="h-display">{{ article.title }}</h2>
|
||||
<p>{{ article.excerpt }}</p>
|
||||
<span class="news-read">Читать полностью <span aria-hidden="true">→</span></span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div v-else class="portal-news-index__empty">
|
||||
<span aria-hidden="true">✦</span>
|
||||
<h2 class="h-display">Хроники пока пусты</h2>
|
||||
<p>Первая новость появится здесь после публикации.</p>
|
||||
<Link class="btn btn-ghost" :href="urls.home">Вернуться на главную</Link>
|
||||
</div>
|
||||
|
||||
<nav v-if="articles.last_page > 1" class="portal-pagination" aria-label="Страницы новостей">
|
||||
<template v-for="(link, index) in paginationLinks" :key="`${link.label}-${index}`">
|
||||
<Link
|
||||
v-if="link.url"
|
||||
:class="{ active: link.active }"
|
||||
:href="link.url"
|
||||
preserve-scroll
|
||||
>
|
||||
{{ link.label }}
|
||||
</Link>
|
||||
<span v-else class="disabled">{{ link.label }}</span>
|
||||
</template>
|
||||
</nav>
|
||||
</main>
|
||||
</PortalLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup>
|
||||
import { Head, Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import PortalLayout from '../../Layouts/PortalLayout.vue';
|
||||
|
||||
const props = defineProps({
|
||||
site: { type: Object, required: true },
|
||||
auth: { type: Object, required: true },
|
||||
urls: { type: Object, required: true },
|
||||
flash: { type: Object, default: () => ({}) },
|
||||
article: { type: Object, required: true },
|
||||
moreNews: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const paragraphs = computed(() => props.article.body
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split(/\n{2,}/)
|
||||
.map((paragraph) => paragraph.trim())
|
||||
.filter(Boolean));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Head>
|
||||
<title>{{ article.title }} — {{ site.name }}</title>
|
||||
<meta name="description" :content="article.excerpt" />
|
||||
<link rel="canonical" :href="article.canonical_url" />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="og:title" :content="article.title" />
|
||||
<meta property="og:description" :content="article.excerpt" />
|
||||
<meta property="og:url" :content="article.canonical_url" />
|
||||
<meta v-if="article.image_url" property="og:image" :content="article.image_url" />
|
||||
</Head>
|
||||
|
||||
<PortalLayout :site="site" :auth="auth" :urls="urls" :flash="flash">
|
||||
<article class="wrap portal-news-page">
|
||||
<Link class="portal-news-back" :href="article.back_url">
|
||||
<span aria-hidden="true">←</span>
|
||||
Все новости
|
||||
</Link>
|
||||
|
||||
<header class="portal-news-hero">
|
||||
<div class="news-meta">
|
||||
<span class="news-tag patch">Новости</span>
|
||||
<time v-if="article.date">{{ article.date }}</time>
|
||||
</div>
|
||||
<h1 class="h-display">{{ article.title }}</h1>
|
||||
<p class="lead">{{ article.excerpt }}</p>
|
||||
</header>
|
||||
|
||||
<img
|
||||
v-if="article.image_url"
|
||||
class="portal-news-cover"
|
||||
:src="article.image_url"
|
||||
:alt="article.title"
|
||||
/>
|
||||
|
||||
<div class="portal-news-body">
|
||||
<p v-for="(paragraph, index) in paragraphs" :key="index">{{ paragraph }}</p>
|
||||
</div>
|
||||
|
||||
<footer class="portal-news-end">
|
||||
<span aria-hidden="true">✦</span>
|
||||
<Link :href="article.back_url">Вернуться к хроникам сервера</Link>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<section v-if="moreNews.length" class="wrap portal-news-more">
|
||||
<div class="portal-news-more__head">
|
||||
<span class="eyebrow">Продолжить чтение</span>
|
||||
<h2 class="h-display">Другие новости</h2>
|
||||
</div>
|
||||
<div class="portal-news-more__grid">
|
||||
<Link v-for="item in moreNews" :key="item.id" class="portal-news-more__card" :href="item.url">
|
||||
<img v-if="item.image_url" :src="item.image_url" :alt="item.title" />
|
||||
<div class="news-meta"><span>{{ item.date }}</span></div>
|
||||
<h3 class="h-display">{{ item.title }}</h3>
|
||||
<p>{{ item.excerpt }}</p>
|
||||
<span class="news-read">Читать <span aria-hidden="true">→</span></span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</PortalLayout>
|
||||
</template>
|
||||
@@ -1,38 +0,0 @@
|
||||
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>Est. 2026</span>
|
||||
</div>
|
||||
<img src="landing.png" alt="концепт-арт: вход в Лунный Колодец, туман, силуэт ночного эльфа" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
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 || {};
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
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 />);
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/x-icon" href="{{ asset('moonwell-icon-512.ico') }}">
|
||||
@if (request()->routeIs('landing.index'))
|
||||
<title>MoonWell — Лунный колодец</title>
|
||||
<meta name="description" content="Пройди историю Азерота — от Классики до Короля-лича — в своём темпе, в одиночку или с небольшим отрядом.">
|
||||
@endif
|
||||
@vite('resources/js/app.js')
|
||||
<x-inertia::head />
|
||||
</head>
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<!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() }}" />
|
||||
<link rel="icon" type="image/x-icon" href="{{ asset('moonwell-icon-512.ico') }}">
|
||||
<title>{{ config('moonwell.realm.server_name') }} — Лунный Колодец</title>
|
||||
<meta name="description" content="{{ config('moonwell.realm.tagline') }}">
|
||||
|
||||
<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">
|
||||
|
||||
@vite(['resources/js/moonwell/main.jsx'])
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script>
|
||||
window.MOONWELL = @json($bootstrap);
|
||||
</script>
|
||||
|
||||
@auth
|
||||
<form id="moonwell-logout" method="POST" action="{{ route('logout') }}" style="display:none">
|
||||
@csrf
|
||||
</form>
|
||||
@endauth
|
||||
|
||||
@include('partials.yandex-metrika')
|
||||
</body>
|
||||
</html>
|
||||
+10
-5
@@ -4,17 +4,22 @@ use App\Http\Controllers\Admin\AccountController;
|
||||
use App\Http\Controllers\Admin\BalanceController;
|
||||
use App\Http\Controllers\Admin\InviteCodeController;
|
||||
use App\Http\Controllers\Admin\LoginScreenNewsController;
|
||||
use App\Http\Controllers\Admin\NewsController;
|
||||
use App\Http\Controllers\Admin\NewsController as AdminNewsController;
|
||||
use App\Http\Controllers\Admin\ShopController;
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
use App\Http\Controllers\CabinetController;
|
||||
use App\Http\Controllers\LandingController;
|
||||
use App\Http\Controllers\LegalController;
|
||||
use App\Http\Controllers\NewsController;
|
||||
use App\Http\Controllers\PaymentController;
|
||||
use App\Http\Controllers\WowLoginNewsFeedController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', [LandingController::class, 'index'])->name('landing.index');
|
||||
Route::get('/news', [NewsController::class, 'index'])->name('news.index');
|
||||
Route::get('/news/{news}', [NewsController::class, 'show'])
|
||||
->whereNumber('news')
|
||||
->name('news.show');
|
||||
Route::get('/public-offer', [LegalController::class, 'offer'])->name('legal.offer');
|
||||
Route::get('/privacy-policy', [LegalController::class, 'privacy'])->name('legal.privacy');
|
||||
Route::get('/launcher/login-news.txt', [WowLoginNewsFeedController::class, 'show'])->name('launcher.login-news.show');
|
||||
@@ -59,10 +64,10 @@ Route::middleware(['auth', 'gamemaster'])
|
||||
Route::patch('/login-screen-news/{newsItem}', [LoginScreenNewsController::class, 'update'])->name('login-screen-news.update');
|
||||
Route::delete('/login-screen-news/{newsItem}', [LoginScreenNewsController::class, 'destroy'])->name('login-screen-news.destroy');
|
||||
|
||||
Route::get('/news', [NewsController::class, 'index'])->name('news.index');
|
||||
Route::post('/news', [NewsController::class, 'store'])->name('news.store');
|
||||
Route::patch('/news/{newsItem}', [NewsController::class, 'update'])->name('news.update');
|
||||
Route::delete('/news/{newsItem}', [NewsController::class, 'destroy'])->name('news.destroy');
|
||||
Route::get('/news', [AdminNewsController::class, 'index'])->name('news.index');
|
||||
Route::post('/news', [AdminNewsController::class, 'store'])->name('news.store');
|
||||
Route::patch('/news/{newsItem}', [AdminNewsController::class, 'update'])->name('news.update');
|
||||
Route::delete('/news/{newsItem}', [AdminNewsController::class, 'destroy'])->name('news.destroy');
|
||||
|
||||
Route::get('/shop', [ShopController::class, 'index'])->name('shop.index');
|
||||
Route::post('/shop/categories', [ShopController::class, 'storeCategory'])->name('shop.categories.store');
|
||||
|
||||
@@ -12,9 +12,11 @@ class LandingPageTest extends TestCase
|
||||
$response = $this->get(route('landing.index'));
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertSee('<title>MoonWell — Лунный колодец</title>', false);
|
||||
$response->assertSee('<meta name="description" content="Пройди историю Азерота — от Классики до Короля-лича — в своём темпе, в одиночку или с небольшим отрядом.">', false);
|
||||
$response->assertInertia(fn (Assert $page) => $page
|
||||
->component('Landing/Main', false)
|
||||
->where('realm.server_name', config('moonwell.realm.server_name'))
|
||||
->where('realm.server_name', 'MoonWell')
|
||||
->where('legalUrls.published', false)
|
||||
->where('legalUrls.offer', null)
|
||||
->where('legalUrls.privacy', null)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\News;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PublicNewsFeatureTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_news_index_lists_only_published_articles_with_pagination(): void
|
||||
{
|
||||
foreach (range(1, 10) as $number) {
|
||||
News::query()->create([
|
||||
'title' => "Новость {$number}",
|
||||
'body' => "Полный текст новости {$number}.",
|
||||
'sort_order' => 0,
|
||||
'is_published' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
News::query()->create([
|
||||
'title' => 'Скрытый черновик',
|
||||
'body' => 'Не должен попасть в список.',
|
||||
'sort_order' => 0,
|
||||
'is_published' => false,
|
||||
]);
|
||||
|
||||
$this->get(route('news.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('News/Index', false)
|
||||
->has('articles.data', 9)
|
||||
->where('articles.data.0.title', 'Новость 10')
|
||||
->where('articles.total', 10)
|
||||
->where('articles.last_page', 2)
|
||||
->where('urls.news', route('news.index'))
|
||||
->etc()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_published_news_has_public_inertia_page(): void
|
||||
{
|
||||
$news = News::query()->create([
|
||||
'title' => 'Открытие новой эпохи',
|
||||
'body' => "Герои завершили путь в Классике.\n\nТеперь открыты врата Запределья.",
|
||||
'sort_order' => 1,
|
||||
'is_published' => true,
|
||||
]);
|
||||
|
||||
$this->get(route('news.show', $news))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('News/Show', false)
|
||||
->where('article.id', $news->id)
|
||||
->where('article.title', 'Открытие новой эпохи')
|
||||
->where('article.body', $news->body)
|
||||
->where('article.url', route('news.show', $news))
|
||||
->where('article.canonical_url', route('news.show', $news))
|
||||
->where('article.back_url', route('news.index'))
|
||||
->etc()
|
||||
);
|
||||
}
|
||||
|
||||
public function test_unpublished_news_is_not_publicly_available(): void
|
||||
{
|
||||
$news = News::query()->create([
|
||||
'title' => 'Черновик',
|
||||
'body' => 'Эта новость ещё не опубликована.',
|
||||
'sort_order' => 1,
|
||||
'is_published' => false,
|
||||
]);
|
||||
|
||||
$this->get(route('news.show', $news))->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_landing_news_card_links_to_full_article(): void
|
||||
{
|
||||
$news = collect(range(1, 4))->map(fn (int $number): News => News::query()->create([
|
||||
'title' => "Обновление {$number}",
|
||||
'body' => "Полный текст обновления {$number}.",
|
||||
'sort_order' => 10 - $number,
|
||||
'is_published' => true,
|
||||
]));
|
||||
|
||||
$this->get(route('landing.index'))
|
||||
->assertOk()
|
||||
->assertInertia(fn (Assert $page) => $page
|
||||
->component('Landing/Main', false)
|
||||
->has('posts', 3)
|
||||
->where('posts.0.id', $news[3]->id)
|
||||
->where('posts.0.url', route('news.show', $news[3]))
|
||||
->where('posts.2.id', $news[1]->id)
|
||||
->where('newsIndexUrl', route('news.index'))
|
||||
->etc()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
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({
|
||||
@@ -14,7 +13,6 @@ export default defineConfig({
|
||||
refresh: true,
|
||||
}),
|
||||
tailwindcss(),
|
||||
react(),
|
||||
vue(),
|
||||
],
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user