Блог на Laravel 7, часть 9. Панель управления — создание, публикация, удаление комментариев

29.12.2020

Теги: LaravelMySQLPHPWeb-разработкаБазаДанныхБлогПанельУправленияПрактикаФормаФреймворкШаблонСайта

Продолжаем работать над панелью управления сайтом. Добавим для пользователей возможность добавлять комментарии к постам в публичной части, а для администратора — редактировать, публиковать и удалять комментарии в панели управления. Для этого потребуется создать ресурсный контроллер и несколько шаблонов.

CRUD-операции над комментариями

Новые маршруты:

/*
 * Панель управления: CRUD-операции над постами, категориями, тегами
 */
Route::group([
    'as' => 'admin.', // имя маршрута, например admin.index
    'prefix' => 'admin', // префикс маршрута, например admin/index
    'namespace' => 'Admin', // пространство имен контроллеров
    'middleware' => ['auth'] // один или несколько посредников
], function () {

    /* ... */

    /*
     * CRUD-операции над комментариями
     */
    Route::resource('comment', 'CommentController', ['except' => ['create', 'store']]);
    // доп.маршрут, чтобы разрешить публикацию комментария
    Route::get('comment/enable/{comment}', 'CommentController@enable')
        ->name('comment.enable');
    // доп.маршрут, чтобы запретить публикацию комментария
    Route::get('comment/disable/{comment}', 'CommentController@disable')
        ->name('comment.disable');
});

Контроллер CommentController:

> php artisan make:controller Admin/CommentController --resource --model=Comment
namespace App\Http\Controllers\Admin;

use App\Comment;
use App\Http\Controllers\Controller;
use App\Http\Requests\CommentRequest;
use Illuminate\Http\Request;

class CommentController extends Controller {

    public function __construct() {
        $this->middleware('perm:manage-comments')->only(['index', 'show']);
        $this->middleware('perm:edit-comment')->only('update');
        $this->middleware('perm:publish-comment')->only(['enable', 'disable']);
        $this->middleware('perm:delete-comment')->only('destroy');
    }

    /**
     * Показывает список всех комментариев
     */
    public function index() {
        $comments = Comment::orderBy('created_at', 'desc')->paginate();
        return view('admin.comment.index', compact('comments'));
    }

    /**
     * Просмотр комментария к посту блога
     */
    public function show(Comment $comment) {
        // сигнализирует о том, что это режим пред.просмотра
        session()->flash('preview', 'yes');
        // это тот пост блога, к которому оставлен комментарий
        $post = $comment->post;
        // коллекция всех комментариев к этому посту блога
        $comments = $post->comments()->orderBy('created_at')->paginate();
        // используем шаблон предварительного просмотра поста
        return view('admin.post.show', compact('post', 'comments'));
    }

    /**
     * Показывает форму редактирования комментария
     */
    public function edit(Comment $comment) {
        // нужно сохранить flash-переменную, которая сигнализирует о том,
        // что кнопка редактирования была нажата в режиме пред.просмотра
        session()->keep('preview');
        return view('admin.comment.edit', compact('comment'));
    }

    /**
     * Обновляет комментарий в базе данных
     */
    public function update(CommentRequest $request, Comment $comment) {
        $comment->update($request->all());
        return $this->redirectAfterUpdate($comment);
    }

    /**
     * Разрешить публикацию комментария
     */
    public function enable(Comment $comment) {
        $comment->enable();
        $redirect = back();
        if (session('preview')) {
            $redirect = $redirect->withFragment('comment-list');
        }
        return $redirect->with('success', 'Комментарий был опубликован');
    }

    /**
     * Запретить публикацию комментария
     */
    public function disable(Comment $comment) {
        $comment->disable();
        $redirect = back();
        if (session('preview')) {
            $redirect = $redirect->withFragment('comment-list');
        }
        return $redirect->with('success', 'Комментарий снят с публикации');
    }

    /**
     * Удаляет комментарий из базы данных
     */
    public function destroy(Comment $comment) {
        $comment->delete();
        $redirect = back();
        if (session('preview')) {
            $redirect = $redirect->withFragment('comment-list');
        }
        return $redirect->with('success', 'Комментарий успешно удален');
    }

    /**
     * Выполянет редирект после обновления
     */
    private function redirectAfterUpdate(Comment $comment) {
        // кнопка редактирования может быть нажата в режиме пред.просмотра
        // или в панели управления блогом, поэтому и редирект будет разный
        $redirect = redirect();
        if (session('preview')) {
            $redirect = $redirect->route(
                'admin.comment.show',
                ['comment' => $comment->id, 'page' => $comment->adminPageNumber()]
            )->withFragment('comment-list');
        } else {
            $redirect = $redirect->route('admin.comment.index');
        }
        return $redirect->with('success', 'Комментарий был успешно исправлен');
    }
}
Для валидации данных мы используем класс CommentRequest, который создадим чуть позже, когда будем писать код для добавления нового комментария.

Шаблон resource/views/admin/comment/index.blade.php для просмотра списка всех комментариев:

@extends('layout.admin', ['title' => 'Все комментарии'])

@section('content')
    <h1>Все комментарии</h1>
    <table class="table table-bordered">
        <tr>
            <th>#</th>
            <th width="12%">Дата и время</th>
            <th width="47%">Текст комментария</th>
            <th width="17%">Автор комментария</th>
            <th width="20%">Разрешил публикацию</th>
            <th><i class="fas fa-eye"></i></th>
            <th><i class="fas fa-toggle-on"></i></th>
            <th><i class="fas fa-edit"></i></th>
            <th><i class="fas fa-trash-alt"></i></th>
        </tr>
        @foreach ($comments as $comment)
        <tr>
            <td>{{ $comment->id }}</td>
            <td>{{ $comment->created_at }}</td>
            <td>{{ iconv_substr($comment->content, 0, 100) }}</td>
            <td>{{ $comment->user->name }}</td>
            <td>
                @if ($comment->editor)
                    {{ $comment->editor->name }}
                @endif
            </td>
            <td>
                @php($params = ['comment' => $comment->id, 'page' => $comment->adminPageNumber()])
                <a href="{{ route('admin.comment.show', $params) }}#comment-list"
                   title="Предварительный просмотр">
                    <i class="far fa-eye"></i>
                </a>
            </td>
            <td>
                @perm('publish-comment')
                    @if ($comment->isVisible())
                        <a href="{{ route('admin.comment.disable', ['comment' => $comment->id]) }}"
                           title="Запретить комментарий">
                            <i class="far fa-toggle-on"></i>
                        </a>
                    @else
                        <a href="{{ route('admin.comment.enable', ['comment' => $comment->id]) }}"
                           title="Разрешить комментарий">
                            <i class="far fa-toggle-off"></i>
                        </a>
                    @endif
                @endperm
            </td>
            <td>
                @perm('edit-comment')
                    <a href="{{ route('admin.comment.edit', ['comment' => $comment->id]) }}">
                        <i class="far fa-edit"></i>
                    </a>
                @endperm
            </td>
            <td>
                @perm('delete-comment')
                    <form action="{{ route('admin.comment.destroy', ['comment' => $comment->id]) }}"
                          method="post" onsubmit="return confirm('Удалить этот комментарий?')">
                        @csrf
                        @method('DELETE')
                        <button type="submit" class="m-0 p-0 border-0 bg-transparent">
                            <i class="far fa-trash-alt text-danger"></i>
                        </button>
                    </form>
                @endperm
            </td>
        </tr>
        @endforeach
    </table>
    {{ $comments->links() }}
@endsection

Мы здесь используем метод adminPageNumber() модели Comment — комментариев может быть много, поэтому используется постраничная навигация. Для предварительного просмотра надо перейти на ту страницу, где расположен комментарий.

class Comment extends Model {
    /**
     * Номер страницы пагинации, на которой расположен комментарий;
     * учитываются все комментарии, в том числе не опубликованные
     */
    public function adminPageNumber() {
        $comments = $this->post->comments()->orderBy('created_at')->get();
        if ($comments->count() == 0) {
            return 1;
        }
        if ($comments->count() <= $this->getPerPage()) {
            return 1;
        }
        foreach ($comments as $i => $comment) {
            if ($this->id == $comment->id) {
                break;
            }
        }
        return (int) ceil(($i+1) / $this->getPerPage());
    }
}

Шаблон resource/views/admin/comment/edit.blade.php для редактирования комментария:

@extends('layout.admin', ['title' => 'Редактирование комментария'])

@section('content')
    <h1>Редактирование комментария</h1>
    <form method="post"
          action="{{ route('admin.comment.update', ['comment' => $comment->id]) }}">
        @csrf
        @method('PUT')
        <div class="form-group">
            <textarea class="form-control" name="content"
                      placeholder="Текст комментария" maxlength="500"
                      rows="5">{{ old('content') ?? $comment->content }}</textarea>
        </div>
        <div class="form-group">
            <button type="submit" class="btn btn-primary">Сохранить</button>
        </div>
    </form>
@endsection

Для предварительного просмотра комментария используется тот же шаблон resource/views/admin/post/show.blade.php, который мы уже использовали ранее. Но тогда мы не рассмотрели шаблон resource/views/admin/post/comments.blade.php, так что сделаем это сейчас. В режиме предварительного просмотра поста показывается только сам пост без комментариев. В режиме предварительного просмотра комментария показывается сам пост (или анонс) + все комментарии.

<h3 id="comment-list">Все комментарии</h3>
@if ($comments->count())
    @foreach ($comments as $comment)
        <div class="card mb-3" id="comment-{{ $comment->id }}">
            <div class="card-header p-2">
                @if ( ! $comment->isVisible())
                    <i class="far fa-eye-slash text-danger" title="Предварительный просмотр"></i>
                @else
                    <i class="far fa-eye text-success" title="Комментарий опубликован"></i>
                @endif
                {{ $comment->user->name }}
            </div>
            <div class="card-body p-2">
                {{ $comment->content }}
            </div>
            <div class="card-footer p-2 d-flex justify-content-between">
                <span>{{ $comment->created_at }}</span>
                <span>
                    @perm('publish-post')
                        @if ($comment->isVisible())
                            <a href="{{ route('admin.comment.disable', ['comment' => $comment->id]) }}"
                               class="btn btn-outline-success btn-sm" title="Запретить публикацию">
                                <i class="fas fa-toggle-on"></i>
                            </a>
                        @else
                            <a href="{{ route('admin.comment.enable', ['comment' => $comment->id]) }}"
                               class="btn btn-outline-dark btn-sm" title="Разрешить публикацию">
                                <i class="fas fa-toggle-off"></i>
                            </a>
                        @endif
                    @endperm
                    @perm('edit-comment')
                        <a href="{{ route('admin.comment.edit', ['comment' => $comment->id]) }}"
                           class="btn btn-outline-primary btn-sm" title="Редактировать комментарий">
                            <i class="far fa-edit"></i>
                        </a>
                    @endperm
                    @perm('delete-comment')
                        <form action="{{ route('admin.comment.destroy', ['comment' => $comment->id]) }}"
                              method="post" class="d-inline"
                              onsubmit="return confirm('Удалить этот комментарий?')">
                            @csrf
                            @method('DELETE')
                            <button type="submit" class="btn btn-outline-danger btn-sm" title="Удалить комментарий">
                                <i class="far fa-trash-alt"></i>
                            </button>
                        </form>
                    @endperm
                </span>
            </div>
        </div>
    @endforeach
    {{ $comments->fragment('comment-list')->links() }}
@else
    <p>К этому посту еще нет комментариев</p>
@endif

С режимом предварительного просмотра довольно много хлопот, потому что требуется всегда помнить, где была нажата кнопка редактирования. Если в режиме пред.просмотра — то после редактирования надо вернуться в режим пред.просмотра, если в панели управления — надо вернуться в панель управления. В этом нам помогает flash-переменная сессии preview, по аналогии с редактированием поста.

Поскольку в контроллере мы используем «mass assignment» — добавляем свойство $fillable в модель Comment:

class Comment extends Model {
    protected $fillable = [
        'user_id',
        'post_id',
        'published_by',
        'content',
    ];
    /* ... */
}

Добавление нового комментария

Для этого добавим новый маршрут, создадим шаблон с формой и реализуем новый метод comment() в контроллер BlogController.

/*
 * Блог: все посты, посты категории, посты тега, страница поста
 */
Route::group([
    'as' => 'blog.', // имя маршрута, например blog.index
    'prefix' => 'blog', // префикс маршрута, например blog/index
], function () {
    /* ... */
    // добавление комментария к посту
    Route::post('post/{post}/comment', [BlogController::class, 'comment'])
        ->name('comment');
});
class BlogController extends Controller {
    /**
     * Сохраняет новый комментарий в базу данных
     */
    public function comment(CommentRequest $request) {
        $request->merge(['user_id' => auth()->user()->id]);
        $message = 'Комментарий добавлен, будет доступен после проверки';
        if (auth()->user()->hasPermAnyWay('publish-comment')) {
            $request->merge(['published_by' => auth()->user()->id]);
            $message = 'Комментарий добавлен и уже доступен для просмотра';
        }
        $comment = Comment::create($request->all());
        // комментариев может быть много, поэтому есть пагинация; надо
        // перейти на последнюю страницу — новый комментарий будет там
        $page = $comment->post->comments()->published()->paginate()->lastPage();
        return redirect()
            ->route('blog.post', ['post' => $comment->post->slug, 'page' => $page])
            ->withFragment('comment-list')
            ->with('success', $message);
    }
}

Шаблон resources/views/blog/part/form.blade.php с формой комментария:

<h3 id="comment-form">Ваш комментарий</h3>
<form method="post" action="{{ route('blog.comment', ['post' => $post->id]) }}">
    @csrf
    <input type="hidden" name="post_id" value="{{ $post->id }}">
    <div class="form-group">
        <textarea class="form-control" name="content" placeholder="Текст комментария"
                  maxlength="500" rows="4">{{ old('content') ?? '' }}</textarea>
    </div>
    <div class="form-group">
        <button type="submit" class="btn btn-primary">Отправить</button>
    </div>
</form>

Подключим его в шаблоне resources/views/blog/part/comments.blade.php:

@perm('create-comment')
    @include('blog.part.form')
@endperm

<h3 id="comment-list">Все комментарии</h3>
@if ($comments->count())
    @foreach ($comments as $comment)
        <div class="card mb-3" id="comment-{{ $comment->id }}">
            <div class="card-header p-2">
                {{ $comment->user->name }}
            </div>
            <div class="card-body p-2">
                {{ $comment->content }}
            </div>
            <div class="card-footer p-2">
                {{ $comment->created_at }}
            </div>
        </div>
    @endforeach
    {{ $comments->fragment('comment-list')->links() }}
@else
    <p>К этому посту еще нет комментариев</p>
@endif

Для валидации данных формы создадим отдельный класс CommentRequest:

> php artisan make:request CommentRequest
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CommentRequest extends FormRequest {
    /**
     * Определяет, есть ли права у пользователя на этот запрос
     *
     * @return bool
     */
    public function authorize() {
        return true;
    }

    /**
     * Возвращает массив правил для проверки полей формы
     *
     * @return array
     */
    public function rules() {
        $rules = [
            'content' => [
                'required',
                'string',
                'max:500',
            ],
        ];
        // при добавлении нового комментария есть скрытое поле post_id
        if ($this->route()->getName() == 'blog.comment') {
            $rules['post_id'] = [
                'required',
                'numeric',
                'min:1',
                'exists:posts,id'
            ];
        }
        return $rules;
    }

    /**
     * Возвращает массив сообщений об ошибках для заданных правил
     *
     * @return array
     */
    public function messages() {
        return [
            'required' => 'Поле «:attribute» обязательно для заполнения',
            'max' => 'Поле «:attribute» должно быть не больше :max символов',
            'numeric' => 'Идентификатор публикации должен быть числом',
            'min' => 'Идентификатор публикации должен быть :min или больше',
            'exists' => 'Публикации с таким идентификатором не существует',
        ];
    }

    /**
     * Возвращает массив дружественных пользователю названий полей
     *
     * @return array
     */
    public function attributes() {
        return [
            'content' => 'Текст комментария'
        ];
    }
}

Главная страница панели управления

У нас уже есть страница управления постами, комментариями, категориями и тегами. Но нет главной страницы панели управления, куда администратор переходит для управления блогом. Подразумевается, что после аутентификации пользователь изначально попадает в личный кабинет — а оттуда, если есть соответствующие права, может попасть в панель управления. Немного позже в личном кабинете для некоторых пользователей сделаем ссылку для перехода на главную страницу панели управления.

Добавляем новый маршрут:

/*
 * Панель управления: CRUD-операции над постами, категориями, тегами
 */
Route::group([
    'as' => 'admin.', // имя маршрута, например admin.index
    'prefix' => 'admin', // префикс маршрута, например admin/index
    'namespace' => 'Admin', // пространство имен контроллеров
    'middleware' => ['auth'] // один или несколько посредников
], function () {
    /*
     * Главная страница панели управления
     */
    Route::get('index', 'IndexController')->name('index');
    /* ... */
});

Создаем контроллер:

> php artisan make:controller Admin/IndexController --invokable
namespace App\Http\Controllers\Admin;

use App\Comment;
use App\Http\Controllers\Controller;
use App\Post;
use Illuminate\Http\Request;

class IndexController extends Controller {
    /**
     * Handle the incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return \Illuminate\Http\Response
     */
    public function __invoke(Request $request) {
        $posts = Post::whereNull('published_by')->orderBy('created_at')->limit(5)->get();
        $comments = Comment::whereNull('published_by')->orderBy('created_at')->limit(5)->get();
        return view('admin.index', compact('posts', 'comments'));
    }
}

Шаблон resources/views/admin/index.blade.php для показа новых публикаций и комментариев:

@extends('layout.admin')

@section('content')
    <h1>Панель управления</h1>

    @perm('manage-posts')
        <h3>Новые публикации</h3>
        @if ($posts->count())
            <table class="table table-bordered">
                <tr>
                    <th>#</th>
                    <th width="25%">Дата и время</th>
                    <th width="40%">Наименование</th>
                    <th width="25%">Автор публикации</th>
                    <th><i class="fas fa-eye"></i></th>
                    <th><i class="fas fa-toggle-on"></i></th>
                </tr>
                @foreach ($posts as $item)
                <tr>
                    <td>{{ $item->id }}</td>
                    <td>{{ $item->created_at }}</td>
                    <td>{{ $item->name }}</td>
                    <td>{{ $item->user->name }}</td>
                    <td>
                        <a href="{{ route('admin.post.show', ['post' => $item->id]) }}"
                           title="Предварительный просмотр">
                            <i class="far fa-eye"></i>
                        </a>
                    </td>
                    <td>
                        @perm('publish-post')
                            <a href="{{ route('admin.post.enable', ['post' => $item->id]) }}"
                               title="Разрешить публикацию">
                                <i class="far fa-toggle-off"></i>
                            </a>
                        @endperm
                    </td>
                </tr>
                @endforeach
            </table>
        @else
            <p>Нет новых публикаций</p>
        @endif
    @endperm

    @perm('manage-comments')
        <h3>Новые комментарии</h3>
        @if ($comments->count())
            <table class="table table-bordered">
                <tr>
                    <th>#</th>
                    <th width="25%">Дата и время</th>
                    <th width="40%">Текст комментария</th>
                    <th width="25%">Автор комментария</th>
                    <th><i class="fas fa-eye"></i></th>
                    <th><i class="fas fa-toggle-on"></i></th>
                </tr>
                @foreach ($comments as $item)
                <tr>
                    <td>{{ $item->id }}</td>
                    <td>{{ $item->created_at }}</td>
                    <td>{{ iconv_substr($item->content, 0, 50) }}</td>
                    <td>{{ $item->user->name }}</td>
                    <td>
                        @php
                        $params = ['comment' => $item->id, 'page' => $item->adminPageNumber()];
                        @endphp
                        <a href="{{ route('admin.comment.show', $params) }}#comment-list"
                           title="Предварительный просмотр">
                            <i class="far fa-eye"></i>
                        </a>
                    </td>
                    <td>
                        @perm('publish-comment')
                            <a href="{{ route('admin.comment.enable', ['comment' => $item->id]) }}"
                               title="Разрешить комментарий">
                                <i class="far fa-toggle-off"></i>
                            </a>
                        @endperm
                    </td>
                </tr>
                @endforeach
            </table>
        @else
            <p>Нет новых комментариев</p>
        @endif
    @endperm
@endsection

На главной странице администратор сразу видит, есть ли новые публикации и/или комментарии. И переходит в нужный раздел панели управления, чтобы проверить их и опубликовать.

Верхнее меню панели управления

Верхнее меню панели управления содержит ссылки для перехода к управлению публикациями, комментариями, категориями блога и так далее. Нам надо показывать эти ссылки только тем пользователям, у которых есть соответствующие права. Поэтому редактироуем шаблон resources/views/layout/admin.blade.php.

<nav class="navbar navbar-expand-lg navbar-dark bg-danger mb-4">
    <!-- Логотип и кнопка «Гамбургер» -->
    <a class="navbar-brand" href="{{ route('admin.index') }}">Панель управления</a>
    <button class="navbar-toggler" type="button" data-toggle="collapse"
            data-target="#navbar-blog" aria-controls="navbar-blog"
            aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
    </button>
    <!-- Основная часть меню (может содержать ссылки, формы и прочее) -->
    <div class="collapse navbar-collapse" id="navbar-blog">
        <!-- Этот блок расположен слева -->
        <ul class="navbar-nav mr-auto">
            @perm('manage-posts')
                <li class="nav-item">
                    <a class="nav-link" href="{{ route('admin.post.index') }}">
                        Посты
                    </a>
                </li>
            @endperm
            @perm('manage-comments')
                <li class="nav-item">
                    <a class="nav-link" href="{{ route('admin.comment.index') }}">
                        Комментарии
                    </a>
                </li>
            @endperm
            @perm('manage-categories')
                <li class="nav-item">
                    <a class="nav-link" href="{{ route('admin.category.index') }}">
                        Категории
                    </a>
                </li>
            @endperm
            @perm('manage-tags')
                <li class="nav-item">
                    <a class="nav-link" href="{{ route('admin.tag.index') }}">
                        Теги
                    </a>
                </li>
            @endperm
            @perm('manage-users')
                <li class="nav-item">
                    <a class="nav-link" href="{{ route('admin.user.index') }}">
                        Пользователи
                    </a>
                </li>
            @endperm
            <li class="nav-item">
                <a class="nav-link" href="#">Страницы</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" href="#">Корзина</a>
            </li>
        </ul>
        <!-- Этот блок расположен справа -->
        <ul class="navbar-nav ml-auto">
            <li class="nav-item">
                <a class="nav-link" href="{{ route('user.index') }}">Личный кабинет</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" href="{{ route('auth.logout') }}">Выйти</a>
            </li>
        </ul>
    </div>
</nav>

Поиск: Laravel • MySQL • PHP • Web-разработка • База данных • Блог • Практика • Форма • Фреймворк • Шаблон сайта • Панель управления

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.