новости в админке для логин скрина
This commit is contained in:
@@ -12,6 +12,7 @@ Laravel 12-приложение с WoW-лендингом и публичной
|
|||||||
- Добавлены личный кабинет и админка поверх игровых аккаунтов из auth-базы.
|
- Добавлены личный кабинет и админка поверх игровых аккаунтов из auth-базы.
|
||||||
- В кабинете доступны смена пароля, список персонажей и ссылка на клиент игры из S3.
|
- В кабинете доступны смена пароля, список персонажей и ссылка на клиент игры из S3.
|
||||||
- В админке доступны просмотр всех аккаунтов, их персонажей, смена `gmlevel` и выпуск инвайт-кодов.
|
- В админке доступны просмотр всех аккаунтов, их персонажей, смена `gmlevel` и выпуск инвайт-кодов.
|
||||||
|
- В админке доступны новости для логин-экрана WoW 3.3.5a с публичным `SERVERALERT`-фидом для клиента.
|
||||||
- Проект подготовлен под Laravel Sail: приложение работает в контейнере, а AzerothCore auth DB берётся с хоста через `host.docker.internal:3306`.
|
- Проект подготовлен под Laravel Sail: приложение работает в контейнере, а AzerothCore auth DB берётся с хоста через `host.docker.internal:3306`.
|
||||||
|
|
||||||
## Как запускать через Sail
|
## Как запускать через Sail
|
||||||
@@ -108,9 +109,16 @@ chmod +x scripts/deploy-ubuntu.sh
|
|||||||
- `app/Auth/AzerothCoreUserProvider.php` — Laravel-auth поверх игровых аккаунтов.
|
- `app/Auth/AzerothCoreUserProvider.php` — Laravel-auth поверх игровых аккаунтов.
|
||||||
- `app/Services/AzerothCoreAccountService.php` — работа с аккаунтами, персонажами, паролями и `gmlevel`.
|
- `app/Services/AzerothCoreAccountService.php` — работа с аккаунтами, персонажами, паролями и `gmlevel`.
|
||||||
- `app/Services/GameClientDownloadService.php` — генерация ссылки на клиент из S3.
|
- `app/Services/GameClientDownloadService.php` — генерация ссылки на клиент из S3.
|
||||||
|
- `app/Services/LoginScreenNewsService.php` — сборка plain-text фида для логин-экрана WoW.
|
||||||
- `app/Services/AzerothCoreAccountRegistrar.php` — SRP6-совместимое создание учётной записи AzerothCore.
|
- `app/Services/AzerothCoreAccountRegistrar.php` — SRP6-совместимое создание учётной записи AzerothCore.
|
||||||
- `app/Http/Controllers/LandingController.php` — данные лендинга и обработка формы.
|
- `app/Http/Controllers/LandingController.php` — данные лендинга и обработка формы.
|
||||||
|
|
||||||
|
## Новости логин-экрана WoW
|
||||||
|
|
||||||
|
- Админский раздел: `/admin/login-screen-news`
|
||||||
|
- Публичный фид для клиента: `/launcher/login-news.txt`
|
||||||
|
- Сайт сам добавляет префикс `SERVERALERT:` и склеивает опубликованные записи по `sort_order`.
|
||||||
|
|
||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
Вне Sail я уже проверил:
|
Вне Sail я уже проверил:
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\SaveLoginScreenNewsRequest;
|
||||||
|
use App\Models\LoginScreenNews;
|
||||||
|
use App\Services\LoginScreenNewsService;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class LoginScreenNewsController extends Controller
|
||||||
|
{
|
||||||
|
public function index(LoginScreenNewsService $news): View
|
||||||
|
{
|
||||||
|
return view('admin.login-screen-news.index', [
|
||||||
|
'newsItems' => $news->listForAdmin(),
|
||||||
|
'newsStats' => $news->stats(),
|
||||||
|
'feedPreview' => $news->renderFeed(),
|
||||||
|
'feedUrl' => route('launcher.login-news.show'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(SaveLoginScreenNewsRequest $request, LoginScreenNewsService $news): RedirectResponse
|
||||||
|
{
|
||||||
|
$news->create($request->validated(), $request->user());
|
||||||
|
|
||||||
|
return back()->with('status', 'Новость для логин-экрана добавлена.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(
|
||||||
|
LoginScreenNews $newsItem,
|
||||||
|
SaveLoginScreenNewsRequest $request,
|
||||||
|
LoginScreenNewsService $news,
|
||||||
|
): RedirectResponse {
|
||||||
|
$news->update($newsItem, $request->validated(), $request->user());
|
||||||
|
|
||||||
|
return back()->with('status', 'Новость для логин-экрана обновлена.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(LoginScreenNews $newsItem, LoginScreenNewsService $news): RedirectResponse
|
||||||
|
{
|
||||||
|
$news->delete($newsItem);
|
||||||
|
|
||||||
|
return back()->with('status', 'Новость для логин-экрана удалена.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\LoginScreenNewsService;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class WowLoginNewsFeedController extends Controller
|
||||||
|
{
|
||||||
|
public function show(LoginScreenNewsService $news): Response
|
||||||
|
{
|
||||||
|
return response($news->renderFeed(), 200, [
|
||||||
|
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||||
|
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SaveLoginScreenNewsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'is_published' => $this->boolean('is_published'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => ['required', 'string', 'max:120'],
|
||||||
|
'body' => ['required', 'string', 'max:20000'],
|
||||||
|
'sort_order' => ['required', 'integer', 'min:0', 'max:9999'],
|
||||||
|
'is_published' => ['nullable', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title.required' => 'Укажи название новости для админки.',
|
||||||
|
'title.max' => 'Название новости не должно быть длиннее 120 символов.',
|
||||||
|
'body.required' => 'Заполни текст новости для логин-экрана.',
|
||||||
|
'body.max' => 'Текст новости слишком длинный.',
|
||||||
|
'sort_order.required' => 'Укажи порядок вывода.',
|
||||||
|
'sort_order.integer' => 'Порядок вывода должен быть числом.',
|
||||||
|
'sort_order.min' => 'Порядок вывода не может быть отрицательным.',
|
||||||
|
'sort_order.max' => 'Порядок вывода слишком большой.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class LoginScreenNews extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'body',
|
||||||
|
'sort_order',
|
||||||
|
'is_published',
|
||||||
|
'created_by_account_id',
|
||||||
|
'created_by_username',
|
||||||
|
'updated_by_account_id',
|
||||||
|
'updated_by_username',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_published' => 'bool',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOrdered(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->orderBy('sort_order')
|
||||||
|
->orderByDesc('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopePublished(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_published', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\GameAccountUser;
|
||||||
|
use App\Models\LoginScreenNews;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class LoginScreenNewsService
|
||||||
|
{
|
||||||
|
public const FEED_HEADER = 'SERVERALERT:';
|
||||||
|
|
||||||
|
public const SECTION_SEPARATOR = '----------------------------------------------------------';
|
||||||
|
|
||||||
|
public const EMPTY_MESSAGE = '|cFFB7C1CFСкоро здесь появятся новости сервера.|r';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \Illuminate\Support\Collection<int, \App\Models\LoginScreenNews>
|
||||||
|
*/
|
||||||
|
public function listForAdmin(): Collection
|
||||||
|
{
|
||||||
|
return LoginScreenNews::query()
|
||||||
|
->ordered()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{total: int, published: int, draft: int}
|
||||||
|
*/
|
||||||
|
public function stats(): array
|
||||||
|
{
|
||||||
|
$total = LoginScreenNews::query()->count();
|
||||||
|
$published = LoginScreenNews::query()->published()->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => $total,
|
||||||
|
'published' => $published,
|
||||||
|
'draft' => $total - $published,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{title: string, body: string, sort_order: int, is_published?: bool} $attributes
|
||||||
|
*/
|
||||||
|
public function create(array $attributes, ?GameAccountUser $actor): LoginScreenNews
|
||||||
|
{
|
||||||
|
return LoginScreenNews::query()->create([
|
||||||
|
...$this->normalizeAttributes($attributes),
|
||||||
|
'created_by_account_id' => $actor?->id,
|
||||||
|
'created_by_username' => $actor?->username,
|
||||||
|
'updated_by_account_id' => $actor?->id,
|
||||||
|
'updated_by_username' => $actor?->username,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{title: string, body: string, sort_order: int, is_published?: bool} $attributes
|
||||||
|
*/
|
||||||
|
public function update(LoginScreenNews $newsItem, array $attributes, ?GameAccountUser $actor): LoginScreenNews
|
||||||
|
{
|
||||||
|
$newsItem->forceFill([
|
||||||
|
...$this->normalizeAttributes($attributes),
|
||||||
|
'updated_by_account_id' => $actor?->id,
|
||||||
|
'updated_by_username' => $actor?->username,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $newsItem->refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(LoginScreenNews $newsItem): void
|
||||||
|
{
|
||||||
|
$newsItem->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function renderFeed(): string
|
||||||
|
{
|
||||||
|
$sections = LoginScreenNews::query()
|
||||||
|
->published()
|
||||||
|
->ordered()
|
||||||
|
->pluck('body')
|
||||||
|
->map(fn (mixed $body): string => $this->normalizeBody((string) $body))
|
||||||
|
->filter(fn (string $body): bool => $body !== '')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$payload = $sections->isNotEmpty()
|
||||||
|
? $sections->implode("\n".self::SECTION_SEPARATOR."\n")
|
||||||
|
: self::EMPTY_MESSAGE;
|
||||||
|
|
||||||
|
return self::FEED_HEADER."\n\n".$payload."\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{title: string, body: string, sort_order: int, is_published?: bool} $attributes
|
||||||
|
* @return array{title: string, body: string, sort_order: int, is_published: bool}
|
||||||
|
*/
|
||||||
|
private function normalizeAttributes(array $attributes): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => trim($attributes['title']),
|
||||||
|
'body' => $this->normalizeBody($attributes['body']),
|
||||||
|
'sort_order' => (int) $attributes['sort_order'],
|
||||||
|
'is_published' => (bool) ($attributes['is_published'] ?? false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeBody(string $body): string
|
||||||
|
{
|
||||||
|
$normalized = trim(str_replace(["\r\n", "\r"], "\n", $body));
|
||||||
|
|
||||||
|
if (Str::startsWith($normalized, self::FEED_HEADER)) {
|
||||||
|
$normalized = trim(Str::after($normalized, self::FEED_HEADER));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('login_screen_news', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('title', 120);
|
||||||
|
$table->text('body');
|
||||||
|
$table->unsignedSmallInteger('sort_order')->default(0)->index();
|
||||||
|
$table->boolean('is_published')->default(true)->index();
|
||||||
|
$table->unsignedInteger('created_by_account_id')->nullable()->index();
|
||||||
|
$table->string('created_by_username', 32)->nullable();
|
||||||
|
$table->unsignedInteger('updated_by_account_id')->nullable()->index();
|
||||||
|
$table->string('updated_by_username', 32)->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('login_screen_news');
|
||||||
|
}
|
||||||
|
};
|
||||||
+2
-1
@@ -23,7 +23,8 @@
|
|||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
<env name="DB_DATABASE" value="testing"/>
|
<env name="DB_CONNECTION" value="sqlite"/>
|
||||||
|
<env name="DB_DATABASE" value=":memory:"/>
|
||||||
<env name="DB_URL" value=""/>
|
<env name="DB_URL" value=""/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
|
|||||||
@@ -343,6 +343,14 @@ pre {
|
|||||||
box-shadow: 0 0 0 3px rgba(241, 200, 121, 0.1);
|
box-shadow: 0 0 0 3px rgba(241, 200, 121, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-control--textarea {
|
||||||
|
min-height: 220px;
|
||||||
|
padding: 16px;
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-family: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.is-invalid {
|
.is-invalid {
|
||||||
border-color: rgba(255, 143, 143, 0.6);
|
border-color: rgba(255, 143, 143, 0.6);
|
||||||
}
|
}
|
||||||
@@ -540,11 +548,51 @@ pre {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stack-form,
|
||||||
|
.news-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-inline input {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.muted-note {
|
.muted-note {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.code-block,
|
||||||
|
.feed-preview {
|
||||||
|
margin: 0;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-preview--compact {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.character-grid {
|
.character-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -626,6 +674,12 @@ pre {
|
|||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.inline-form .form-field {
|
.inline-form .form-field {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
@extends('layouts.portal')
|
||||||
|
|
||||||
|
@section('portal-content')
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Админка</span>
|
||||||
|
<h2>Новости логин-экрана WoW</h2>
|
||||||
|
<p>Публикуй raw-текст с WoW-разметкой, а сайт сам отдаст готовый `SERVERALERT`-фид по открытой ссылке для клиента 3.3.5a.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="portal-grid">
|
||||||
|
<article class="content-card">
|
||||||
|
<h3>Публичная ссылка для клиента</h3>
|
||||||
|
<p>Эту ссылку можно вставить в логин-скрин клиента. Ответ возвращается как plain text и доступен без авторизации.</p>
|
||||||
|
|
||||||
|
<div class="action-stack">
|
||||||
|
<a class="button button--gold" href="{{ $feedUrl }}" target="_blank" rel="noreferrer">Открыть фид</a>
|
||||||
|
<code class="code-block">{{ $feedUrl }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="stats-list stats-list--compact">
|
||||||
|
<div>
|
||||||
|
<dt>Всего записей</dt>
|
||||||
|
<dd>{{ $newsStats['total'] }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Опубликовано</dt>
|
||||||
|
<dd>{{ $newsStats['published'] }}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Черновиков</dt>
|
||||||
|
<dd>{{ $newsStats['draft'] }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="content-card">
|
||||||
|
<h3>Итоговый ответ сервера</h3>
|
||||||
|
<p>Это превью полного ответа, который получит клиент по публичному URL.</p>
|
||||||
|
|
||||||
|
<pre class="feed-preview">{{ $feedPreview }}</pre>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section section--split">
|
||||||
|
<article class="content-card">
|
||||||
|
<span class="eyebrow">Новая запись</span>
|
||||||
|
<h2>Добавить новость</h2>
|
||||||
|
<p>Каждая запись становится отдельным блоком в общем фиде. Префикс `SERVERALERT:` добавляется автоматически.</p>
|
||||||
|
|
||||||
|
<form class="stack-form" method="POST" action="{{ route('admin.login-screen-news.store') }}">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Название в админке</span>
|
||||||
|
<input class="form-control @error('title') is-invalid @enderror" type="text" name="title" value="{{ old('title') }}" maxlength="120" required>
|
||||||
|
@error('title')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Порядок вывода</span>
|
||||||
|
<input class="form-control @error('sort_order') is-invalid @enderror" type="number" name="sort_order" value="{{ old('sort_order', 0) }}" min="0" max="9999" required>
|
||||||
|
@error('sort_order')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="checkbox-inline">
|
||||||
|
<input type="checkbox" name="is_published" value="1" @checked(old('is_published', true))>
|
||||||
|
<span>Сразу публиковать эту запись</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Текст для клиента</span>
|
||||||
|
<textarea class="form-control form-control--textarea @error('body') is-invalid @enderror" name="body" required>{{ old('body') }}</textarea>
|
||||||
|
<small>Поддерживаются WoW-коды цветов `|cFF...|r`, иконки `|T...|t`, переносы строк и обычный текст.</small>
|
||||||
|
@error('body')<span class="form-error">{{ $message }}</span>@enderror
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button class="button button--gold" type="submit">Добавить запись</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="content-card">
|
||||||
|
<span class="eyebrow">Шаблон</span>
|
||||||
|
<h2>Пример WoW-блока</h2>
|
||||||
|
<p>Если нужно, можно вставлять уже готовые строки почти в том же виде, как у тебя в примере с GitHub raw.</p>
|
||||||
|
|
||||||
|
<pre class="feed-preview feed-preview--compact">|TInterface\ICONS\achievement_boss_lichking:25:25:0:-5|t |cFF00D1FFWrath of the Lich King|r
|
||||||
|
|TInterface\ICONS\achievement_zone_icecrown_01:18:18:0:-8|t |cFFA6D8FF- Артас пробуждается как Король-лич|r
|
||||||
|
|TInterface\ICONS\spell_deathknight_iceboundfortitude:18:18:0:-8|t |cFFA6D8FF- Рыцари смерти вторгаются в Азерот|r</pre>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="eyebrow">Текущие записи</span>
|
||||||
|
<h2>Редактирование и публикация</h2>
|
||||||
|
<p>Опубликованные записи попадают в фид по порядку `sort_order`. Черновики остаются только в админке.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="account-list">
|
||||||
|
@forelse ($newsItems as $newsItem)
|
||||||
|
<article class="content-card news-card">
|
||||||
|
<div class="account-card__header">
|
||||||
|
<div>
|
||||||
|
<h3>{{ $newsItem->title }}</h3>
|
||||||
|
<p>
|
||||||
|
Порядок: {{ $newsItem->sort_order }}
|
||||||
|
@if ($newsItem->updated_by_username)
|
||||||
|
· Обновил: {{ $newsItem->updated_by_username }}
|
||||||
|
@endif
|
||||||
|
@if ($newsItem->updated_at)
|
||||||
|
· {{ $newsItem->updated_at->format('d.m.Y H:i') }}
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="tag">{{ $newsItem->is_published ? 'Опубликовано' : 'Черновик' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="stack-form" method="POST" action="{{ route('admin.login-screen-news.update', $newsItem) }}">
|
||||||
|
@csrf
|
||||||
|
@method('PATCH')
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Название</span>
|
||||||
|
<input class="form-control" type="text" name="title" value="{{ $newsItem->title }}" maxlength="120" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Порядок вывода</span>
|
||||||
|
<input class="form-control" type="number" name="sort_order" value="{{ $newsItem->sort_order }}" min="0" max="9999" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="checkbox-inline">
|
||||||
|
<input type="checkbox" name="is_published" value="1" @checked($newsItem->is_published)>
|
||||||
|
<span>Показывать в публичном фиде</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-field">
|
||||||
|
<span>Текст блока</span>
|
||||||
|
<textarea class="form-control form-control--textarea" name="body" required>{{ $newsItem->body }}</textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="button button--gold" type="submit">Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('admin.login-screen-news.destroy', $newsItem) }}">
|
||||||
|
@csrf
|
||||||
|
@method('DELETE')
|
||||||
|
|
||||||
|
<button class="button button--ghost" type="submit" onclick="return confirm('Удалить эту запись из фида?')">Удалить запись</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
@empty
|
||||||
|
<article class="content-card empty-state">
|
||||||
|
<h3>Записей пока нет</h3>
|
||||||
|
<p>Добавь первую новость, и публичный фид сразу начнёт отдавать её клиенту.</p>
|
||||||
|
</article>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endsection
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
@if ($portalUser)
|
@if ($portalUser)
|
||||||
<a href="{{ route('cabinet.index') }}">Личный кабинет</a>
|
<a href="{{ route('cabinet.index') }}">Личный кабинет</a>
|
||||||
@if ($portalUser->canAccessAdminPanel())
|
@if ($portalUser->canAccessAdminPanel())
|
||||||
<a href="{{ route('admin.accounts.index') }}">Админка</a>
|
<a href="{{ route('admin.accounts.index') }}">Аккаунты</a>
|
||||||
|
<a href="{{ route('admin.login-screen-news.index') }}">Новости WoW</a>
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Admin\AccountController;
|
use App\Http\Controllers\Admin\AccountController;
|
||||||
use App\Http\Controllers\Admin\InviteCodeController;
|
use App\Http\Controllers\Admin\InviteCodeController;
|
||||||
|
use App\Http\Controllers\Admin\LoginScreenNewsController;
|
||||||
use App\Http\Controllers\Auth\LoginController;
|
use App\Http\Controllers\Auth\LoginController;
|
||||||
use App\Http\Controllers\CabinetController;
|
use App\Http\Controllers\CabinetController;
|
||||||
use App\Http\Controllers\LandingController;
|
use App\Http\Controllers\LandingController;
|
||||||
|
use App\Http\Controllers\WowLoginNewsFeedController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/', [LandingController::class, 'index'])->name('landing.index');
|
Route::get('/', [LandingController::class, 'index'])->name('landing.index');
|
||||||
|
Route::get('/launcher/login-news.txt', [WowLoginNewsFeedController::class, 'show'])->name('launcher.login-news.show');
|
||||||
Route::post('/register', [LandingController::class, 'register'])
|
Route::post('/register', [LandingController::class, 'register'])
|
||||||
->middleware('throttle:8,1')
|
->middleware('throttle:8,1')
|
||||||
->name('game-account.store');
|
->name('game-account.store');
|
||||||
@@ -33,4 +36,8 @@ Route::middleware(['auth', 'gamemaster'])
|
|||||||
Route::get('/accounts', [AccountController::class, 'index'])->name('accounts.index');
|
Route::get('/accounts', [AccountController::class, 'index'])->name('accounts.index');
|
||||||
Route::patch('/accounts/{accountId}/access', [AccountController::class, 'updateAccess'])->name('accounts.access.update');
|
Route::patch('/accounts/{accountId}/access', [AccountController::class, 'updateAccess'])->name('accounts.access.update');
|
||||||
Route::post('/invite-codes', [InviteCodeController::class, 'store'])->name('invite-codes.store');
|
Route::post('/invite-codes', [InviteCodeController::class, 'store'])->name('invite-codes.store');
|
||||||
|
Route::get('/login-screen-news', [LoginScreenNewsController::class, 'index'])->name('login-screen-news.index');
|
||||||
|
Route::post('/login-screen-news', [LoginScreenNewsController::class, 'store'])->name('login-screen-news.store');
|
||||||
|
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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\GameAccountUser;
|
||||||
|
use App\Models\LoginScreenNews;
|
||||||
|
use App\Services\LoginScreenNewsService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class LoginScreenNewsFeatureTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_public_feed_returns_published_news_in_sort_order(): void
|
||||||
|
{
|
||||||
|
LoginScreenNews::query()->create([
|
||||||
|
'title' => 'Draft',
|
||||||
|
'body' => 'HIDDEN',
|
||||||
|
'sort_order' => 1,
|
||||||
|
'is_published' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
LoginScreenNews::query()->create([
|
||||||
|
'title' => 'Late',
|
||||||
|
'body' => "SERVERALERT:\n\n|cFF111111Late|r",
|
||||||
|
'sort_order' => 20,
|
||||||
|
'is_published' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
LoginScreenNews::query()->create([
|
||||||
|
'title' => 'Early',
|
||||||
|
'body' => '|cFF222222Early|r',
|
||||||
|
'sort_order' => 10,
|
||||||
|
'is_published' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('launcher.login-news.show'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertHeader('Content-Type', 'text/plain; charset=UTF-8');
|
||||||
|
$response->assertContent(
|
||||||
|
LoginScreenNewsService::FEED_HEADER
|
||||||
|
."\n\n|cFF222222Early|r\n"
|
||||||
|
.LoginScreenNewsService::SECTION_SEPARATOR
|
||||||
|
."\n|cFF111111Late|r\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_public_feed_returns_fallback_message_when_no_news_is_published(): void
|
||||||
|
{
|
||||||
|
LoginScreenNews::query()->create([
|
||||||
|
'title' => 'Draft',
|
||||||
|
'body' => 'HIDDEN',
|
||||||
|
'sort_order' => 0,
|
||||||
|
'is_published' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->get(route('launcher.login-news.show'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertContent(
|
||||||
|
LoginScreenNewsService::FEED_HEADER
|
||||||
|
."\n\n"
|
||||||
|
.LoginScreenNewsService::EMPTY_MESSAGE
|
||||||
|
."\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_regular_player_cannot_open_admin_news_page(): void
|
||||||
|
{
|
||||||
|
$response = $this->actingAs($this->makeUser(0))->get(route('admin.login-screen-news.index'));
|
||||||
|
|
||||||
|
$response->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_open_admin_news_page(): void
|
||||||
|
{
|
||||||
|
LoginScreenNews::query()->create([
|
||||||
|
'title' => 'Wrath Update',
|
||||||
|
'body' => '|cFF00D1FFWrath update|r',
|
||||||
|
'sort_order' => 0,
|
||||||
|
'is_published' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->makeUser(3))->get(route('admin.login-screen-news.index'));
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('Новости логин-экрана WoW');
|
||||||
|
$response->assertSee('Wrath Update');
|
||||||
|
$response->assertSee(route('launcher.login-news.show'), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_create_update_and_delete_news_item(): void
|
||||||
|
{
|
||||||
|
$admin = $this->makeUser(3);
|
||||||
|
|
||||||
|
$createResponse = $this->from(route('admin.login-screen-news.index'))
|
||||||
|
->actingAs($admin)
|
||||||
|
->post(route('admin.login-screen-news.store'), [
|
||||||
|
'title' => 'Launch Notice',
|
||||||
|
'body' => '|cFFFFD700Server launch tonight|r',
|
||||||
|
'sort_order' => 5,
|
||||||
|
'is_published' => '1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$createResponse->assertRedirect(route('admin.login-screen-news.index'));
|
||||||
|
$createResponse->assertSessionHas('status', 'Новость для логин-экрана добавлена.');
|
||||||
|
$this->assertDatabaseHas('login_screen_news', [
|
||||||
|
'title' => 'Launch Notice',
|
||||||
|
'sort_order' => 5,
|
||||||
|
'is_published' => 1,
|
||||||
|
'created_by_username' => 'ADMIN',
|
||||||
|
'updated_by_username' => 'ADMIN',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$newsItem = LoginScreenNews::query()->firstOrFail();
|
||||||
|
|
||||||
|
$updateResponse = $this->from(route('admin.login-screen-news.index'))
|
||||||
|
->actingAs($admin)
|
||||||
|
->patch(route('admin.login-screen-news.update', $newsItem), [
|
||||||
|
'title' => 'Launch Notice Updated',
|
||||||
|
'body' => '|cFFFFD700Server launch moved to Friday|r',
|
||||||
|
'sort_order' => 8,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updateResponse->assertRedirect(route('admin.login-screen-news.index'));
|
||||||
|
$updateResponse->assertSessionHas('status', 'Новость для логин-экрана обновлена.');
|
||||||
|
$this->assertDatabaseHas('login_screen_news', [
|
||||||
|
'id' => $newsItem->id,
|
||||||
|
'title' => 'Launch Notice Updated',
|
||||||
|
'sort_order' => 8,
|
||||||
|
'is_published' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$deleteResponse = $this->from(route('admin.login-screen-news.index'))
|
||||||
|
->actingAs($admin)
|
||||||
|
->delete(route('admin.login-screen-news.destroy', $newsItem));
|
||||||
|
|
||||||
|
$deleteResponse->assertRedirect(route('admin.login-screen-news.index'));
|
||||||
|
$deleteResponse->assertSessionHas('status', 'Новость для логин-экрана удалена.');
|
||||||
|
$this->assertDatabaseMissing('login_screen_news', [
|
||||||
|
'id' => $newsItem->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeUser(int $gmLevel): GameAccountUser
|
||||||
|
{
|
||||||
|
return new GameAccountUser(
|
||||||
|
id: 1,
|
||||||
|
username: 'ADMIN',
|
||||||
|
email: 'ADMIN@EXAMPLE.COM',
|
||||||
|
regMail: 'ADMIN@EXAMPLE.COM',
|
||||||
|
gmLevel: $gmLevel,
|
||||||
|
locked: false,
|
||||||
|
lastLoginAt: '2026-03-13 18:00:00',
|
||||||
|
joinedAt: '2026-03-12 12:00:00',
|
||||||
|
saltHex: str_repeat('AA', 32),
|
||||||
|
verifierHex: str_repeat('BB', 32),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user