Блог на Laravel 7, часть 15. Восстановление постов, slug для категории, поста и страницы

12.02.2021

Теги: LaravelMySQLPHPWeb-разработкаБлогКорзинаПанельУправленияПользовательПраваДоступаПрактика

Soft Deletes для постов блога

Продолжаем работать с корзиной, которая позволяет восстановить посты блога, удаленные по ошибке. Нам надо создать контроллер, который позволит работать с удаленными постами, добавить несколько маршрутов и создать шаблон для просмотра списка удаленных постов. Кроме того, надо защитить созданные маршруты, чтобы доступ к списку удаленных постов имели только администраторы с правом manage-posts.

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

    /* ... */

    /*
     * Восстановление постов блога
     */
    Route::get('trash/index', 'TrashController@index')
        ->name('trash.index');
    Route::get('trash/restore/{post}', 'TrashController@restore')
        ->name('trash.restore');
    Route::delete('trash/destroy/{post}', 'TrashController@destroy')
        ->name('trash.destroy');
});
Для восстановления поста блога следовало бы использовать http-метод PUT или PATCH, но не хотелось возиться с еще одной формой, поэтому использовал метод GET.
> php artisan make:controller Admin/TrashController
namespace App\Http\Controllers\Admin;

use App\Helpers\ImageUploader;
use App\Http\Controllers\Controller;
use App\Post;
use Illuminate\Http\Request;

class TrashController extends Controller {

    public function __construct() {
        $this->middleware('perm:manage-posts')->only('index');
        $this->middleware('perm:delete-post')->only(['restore', 'destroy']);
    }

    /**
     * Список всех удаленных постов блога
     */
    public function index() {
        $posts = Post::onlyTrashed()->orderBy('deleted_at', 'desc')->paginate();
        return view('admin.trash.index', compact('posts'));
    }

    /**
     * Восстанавливает удаленный пост блога
     */
    public function restore(Post $post) {
        $post->restore();
        return redirect()
            ->route('admin.trash.index')
            ->with('success', 'Пост блога успешно восстановлен');
    }

    /**
     * Удаляет пост блога из базы данных
     */
    public function destroy(Post $post, ImageSaver $imageSaver, ImageUploader $imageUploader) {
        // удаляем основное изображение поста
        $imageSaver->remove($post);
        // удаляем изображения из контента поста
        $imageUploader->destroy($post->content);
        // удаляем сам пост блога из базы данных
        $post->forceDelete();
        return redirect()
            ->route('admin.trash.index')
            ->with('success', 'Пост блога удален навсегда');
    }
}

Шаблон resources/views/admin/trash/index.blade.php

@extends('layout.admin', ['title' => 'Все удаленные посты'])

@section('content')
    <h1>Все удаленные посты</h1>
    @if ($posts->count())
        <table class="table table-bordered">
            <tr>
                <th width="20%">Создан</th>
                <th width="20%">Удален</th>
                <th width="25%">Заголовок</th>
                <th width="25%">Автор публикации</th>
                <th><i class="fas fa-trash-restore"></i></th>
                <th><i class="fas fa-trash-alt"></i></th>
            </tr>
            @foreach ($posts as $post)
                <tr>
                    <td>{{ $post->created_at }}</td>
                    <td>{{ $post->deleted_at }}</td>
                    <td>{{ $post->name }}</td>
                    <td>{{ $post->user->name }}</td>
                    <td>
                        @perm('delete-post')
                        <a href="{{ route('admin.trash.restore', ['post' => $post->id]) }}">
                            <i class="far fa-trash-restore"></i>
                        </a>
                        @endperm
                    </td>
                    <td>
                        @perm('delete-post')
                        <form action="{{ route('admin.trash.destroy', ['post' => $post->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>
        {{ $posts->links() }}
    @endif
@endsection

Добавляем в layout-шаблон ссылку на страницу списка удаленных постов:

@perm('manage-posts')
    <li class="nav-item">
        <a class="nav-link" href="{{ route('admin.trash.index') }}">Корзина</a>
    </li>
@endperm

Работа над ошибками

Мне казалось, что теперь все готово, но при тестировании вместо восстановления поста из корзины получил 404 Not Found:

Причина в общем-то понятна — для поиска поста, который надо восстановить, Laravel использует такой SQL-запрос к БД:

SELECT * FROM `posts` WHERE `id` = 23 AND `posts`.`deleted_at` IS NULL LIMIT 1

Ну что ж, отрицательный результат — это тоже результат. Давайте исправим маршруты, чтобы передавать идентификатор поста:

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

    /* ... */

    /*
     * Восстановление постов блога
     */
    Route::get('trash/index', 'TrashController@index')
        ->name('trash.index');
    Route::get('trash/restore/{id}', 'TrashController@restore')
        ->name('trash.restore');
    Route::delete('trash/destroy/{id}', 'TrashController@destroy')
        ->name('trash.destroy');
});

Внесем изменения в шаблон списка удаленных постов resources/views/admin/trash/index.blade.php:

@perm('delete-post')
    <a href="{{ route('admin.trash.restore', ['id' => $post->id]) }}">
        <i class="far fa-trash-restore"></i>
    </a>
@endperm
@perm('delete-post')
    <form action="{{ route('admin.trash.destroy', ['id' => $post->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

А в методах контроллера будем сами искать нужный пост по идентификатору, чтобы восстановить или удалить окончательно:

namespace App\Http\Controllers\Admin;

use App\Helpers\ImageUploader;
use App\Http\Controllers\Controller;
use App\Post;
use Illuminate\Http\Request;

class TrashController extends Controller {

    public function __construct() {
        $this->middleware('perm:delete-post');
    }

    /**
     * Список всех удаленных постов блога
     */
    public function index() {
        $posts = Post::onlyTrashed()->orderBy('deleted_at', 'desc')->paginate();
        return view('admin.trash.index', compact('posts'));
    }

    /**
     * Восстанавливает удаленный пост блога
     */
    public function restore($id) {
        $id = (int)$id;
        Post::withTrashed()->findOrFail($id)->restore();
        return redirect()
            ->route('admin.trash.index')
            ->with('success', 'Пост блога успешно восстановлен');
    }

    /**
     * Удаляет пост блога из базы данных
     */
    public function destroy($id, ImageSaver $imageSaver, ImageUploader $imageUploader) {
        $id = (int)$id;
        $post = Post::withTrashed()->findOrFail($id);
        // удаляем основное изображение поста
        $imageSaver->remove($post);
        // удаляем изображения из контента поста
        $imageUploader->destroy($post->content);
        // удаляем сам пост блога из базы данных
        $post->forceDelete();
        return redirect()
            ->route('admin.trash.index')
            ->with('success', 'Пост блога удален навсегда');
    }
}

При удалении поста администратором, мы не должны удалять основное изображение и изображения, загруженные через wysiwyg-редактор. Это надо делать, когда пост блога удаляется безвозвратно из корзины. Так что изменим метод destroy() контроллера Admin\PostController — уберем код, отвечающий за удаление изображений. Сейчас это код выглядит так:

namespace App\Http\Controllers\Admin;

use App\Category;
use App\Helpers\ImageSaver;
use App\Helpers\ImageUploader;
use App\Http\Controllers\Controller;
use App\Http\Requests\PostRequest;
use App\Post;

class PostController extends Controller {

    /* ... */

    /**
     * Удаляет пост блога из базы данных
     */
    public function destroy(Post $post, ImageUploader $imageUploader) {
        // удаляем основное изображение поста
        $this->imageSaver->remove($post);
        // удаляем изображения из контента поста
        $imageUploader->destroy($post->content);
        // удаляем сам пост блога из базы данных
        $post->delete();
        // пост может быть удален в режиме пред.просмотра или из панели
        // управления, так что и редирект после удаления будет разным
        $route = 'admin.post.index';
        if (session('preview')) {
            $route = 'blog.index';
        }
        return redirect()
            ->route($route)
            ->with('success', 'Пост блога успешно удален');
    }
}

А теперь будет выглядеть так:

namespace App\Http\Controllers\Admin;

use App\Category;
use App\Helpers\ImageSaver;
use App\Helpers\ImageUploader;
use App\Http\Controllers\Controller;
use App\Http\Requests\PostRequest;
use App\Post;

class PostController extends Controller {

    /* ... */

    /**
     * Удаляет пост блога из базы данных
     */
    public function destroy(Post $post) {
        // удаляем сам пост блога из базы данных
        $post->delete();
        // пост может быть удален в режиме пред.просмотра или из панели
        // управления, так что и редирект после удаления будет разным
        $route = 'admin.post.index';
        if (session('preview')) {
            $route = 'blog.index';
        }
        return redirect()
            ->route($route)
            ->with('success', 'Пост блога успешно удален');
    }
}

Перемещение поста блога в корзину работает только для администратора блога. Обычный пользователь вообще не может удалить свой пост после того, как он был одобрен администратором. Подразумевается, что если пользователь решит удалить свой пост сразу после размещения — у него есть в запасе черновик — так что всегда можно разместить еще раз. Эта защита от ошибочных действий администратора — потому и доступ к корзине есть только у админа с правом manage-posts.

Soft Deletes для комментариев

Оказалось, что soft deletes для постов блога сломало просмотр комментариев в панели управления. После некоторых размышлений, стало понятно — почему. В шаблоне списка всех комментариев, внутри цикла по комментариям, есть обращение к объекту поста блога через отношения моделей.

@foreach ($comments as $comment)
<tr>
    <td>.....</td>
    <td>.....</td>
    <td>.....</td>
    <td>.....</td>
    <td>.....</td>
    <td>
        @php
            $params = ['comment' => $comment->id, 'page' => $comment->adminPageNumber()];
        @endphp
        <a href="{{ route('admin.comment.show', $params) }}#comment-list"
           title="Предварительный просмотр">
            <i class="far fa-eye"></i>
        </a>
    </td>
    <td>.....</td>
    <td>.....</td>
    <td>.....</td>
</tr>
@endforeach

Если пост для текущего комментария был перемещен в корзину, он не может быть получен, для Laravel он как бы не существует. Так что надо организовать soft deletes всех комментариев в момент «мягкого» удаления поста. К счастью, для этого уже есть готовые пакеты — и мы используем один из них.

> composer require dyrynda/laravel-cascade-soft-deletes
У меня слишком старая версия php, поэтому была установлена не последняя четвертая, а третья версия пакета. А в третьей версии другое пространство имен — так что на это надо обратить внимание при установке.
namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Stem\LinguaStemRu;
use Illuminate\Database\Eloquent\SoftDeletes;
use Iatstuti\Database\Support\CascadeSoftDeletes;
// use Dyrynda\Database\Support\CascadeSoftDeletes;

class Post extends Model {
    use SoftDeletes, CascadeSoftDeletes;
    protected $cascadeDeletes = ['comments'];
    /* ... */
}

Создадаем миграцию для добавления поля deleted_at:

> php artisan make:migration alter_comments_table --table=comments
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AlterCommentsTable extends Migration {
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up() {
        Schema::table('comments', function (Blueprint $table) {
            $table->softDeletes();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down() {
        Schema::table('comments', function (Blueprint $table) {
            $table->dropSoftDeletes();
        });
    }
}
> php artisan migrate:fresh --seed

Теперь добавляем в модель Comment трейт SoftDeletes:

namespace App;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;

class Comment extends Model {
    use SoftDeletes;
    protected $dates = ['deleted_at'];
    /* ... */
}

Теперь при «мягком» удалении поста — комментарии тоже будут «мягко» удалены. При восстановлении поста из корзины — комментарии тоже будут восстановлены. При окончательном удалении поста — комментарии будут удалены автоматически, потому что сработает ON DELETE CASCADE. А вот при удалении комментариев из панели управления — надо сразу их удалять по настоящему, чтобы не накапливались бесконечно.

class CommentController extends Controller {
    /* ... */
    public function destroy(Comment $comment) {
        $comment->forceDelete();
        $redirect = back();
        if (session('preview')) {
            $redirect = $redirect->withFragment('comment-list');
        }
        return $redirect->with('success', 'Комментарий успешно удален');
    }
    /* ... */
}

Slug для поста и страницы

Slug — это уникальный идентификатор для поста, категории, тега или страницы. Неудобно, что при создании нового поста (категории, тега, страницы), нужно его вводить вручную. Давайте будем создавать slug поста (категории, тега, страницы) путем транслитерации названия поста (категории, тега, страницы). Отредактируем файл resources/js/back.js и пересоберем фронтенд:

$(document).ready(function () {
    /*
     * Общие настройки ajax-запросов, отправка на сервер csrf-токена
     */
    $.ajaxSetup({
        headers: {
            'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
        }
    });
    /*
     * Автоматическое создание slug при вводе name (замена кириллицы на латиницу)
     */
    $('input[name="name"]').on('input', function() {
        var map = {
            'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', 'Ж': 'Zh',
            'З': 'Z', 'И': 'I', 'Й': 'J', 'К': 'K', 'Л': 'L', 'М': 'M', 'Н': 'N', 'О': 'O',
            'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', 'Ф': 'F', 'Х': 'H', 'Ц': 'C',
            'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sh', 'Ъ': '', 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu',
            'Я': 'Ya',
            'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh',
            'з': 'z', 'и': 'i', 'й': 'j', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o',
            'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'h', 'ц': 'c',
            'ч': 'ch', 'ш': 'sh', 'щ': 'sh', 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu',
            'я': 'ya',
        };
        var text = $(this).val();
        for (var k in map) {
            text = text.replace(RegExp(k, 'g'), map[k]);
        }
        text = text.replace(/[^- _a-zA-Z0-9]/g, '');
        text = text.replace(/\s+/g, '-');
        text = text.replace(/-+/g, '-');
        $('input[name="slug"]').val(text);
    });
    /*
     * Подключение wysiwyg-редактора + загрузка и удаление изображений
     */
    (function () {
        /* ... */
    })();
});
> npm run dev

Основное изображение поста

У нас сейчас везде вместо изображения показывается заглушка. Нам надо заменить заглушку на настоящее изображение в четырех шаблонах с помощью хелпера asset().

Шаблон поста блога в списке resources/views/blog/part/post.blade.php:

<div class="card mb-4">
    <div class="card-header">
        <h2>{{ $post->name }}</h2>
    </div>
    <div class="card-body">
        @if ($post->image)
            <img src="{{ asset('storage/post/image/'.$post->image) }}" alt="" class="img-fluid" />
        @else
            <img src="http://via.placeholder.com/1000x300" alt="" class="img-fluid">
        @endif
        <p class="mt-3 mb-0">{{ $post->excerpt }}</p>
    </div>
    <div class="card-footer">
    ..........
    </div>
</div>

Шаблон просмотра поста блога resources/views/blog/post.blade.php:

@extends('layout.site', ['title' => $post->name])

@section('content')
    <div class="card mb-4">
        <div class="card-header">
            <h1>{{ $post->name }}</h1>
        </div>
        <div class="card-body">
            @if ($post->image)
                <img src="{{ asset('storage/post/image/'.$post->image) }}" alt="" class="img-fluid" />
            @else
                <img src="http://via.placeholder.com/1000x300" alt="" class="img-fluid">
            @endif
            <div class="mt-4">{!! $post->content !!}</div>
        </div>
        <div class="card-footer">
            ..........
        </div>
        ..........
    </div>
    @include('blog.part.comments', ['comments' => $comments])
@endsection

Шаблон пред.просмотра поста автором resources/views/user/post/show.blade.php:

@extends('layout.site', ['title' => $post->name, 'user' => true])

@section('content')
    <div class="card mb-4">
        <div class="card-header">
            <h1>
                @if ( ! $post->isVisible())
                    <i class="far fa-eye-slash text-danger" title="Предварительный просмотр"></i>
                @else
                    <i class="far fa-eye text-success" title="Этот пост опубликован"></i>
                @endif
                {{ $post->name }}
            </h1>
        </div>
        <div class="card-body">
            @if ($post->image)
                <img src="{{ asset('storage/post/image/'.$post->image) }}" alt="" class="img-fluid" />
            @else
                <img src="http://via.placeholder.com/1000x300" alt="" class="img-fluid">
            @endif
            <div class="mt-4">{!! $post->content !!}</div>
        </div>
        <div class="card-footer d-flex justify-content-between">
            ..........
        </div>
        ..........
    </div>
    @include('user.post.comments', ['comments' => $comments])
@endsection

Шаблон пред.просмотра поста админом resources/views/admin/post/show.blade.php:

@extends('layout.site', ['title' => $post->name, 'admin' => true])

@section('content')
    <div class="card mb-4">
        <div class="card-header">
            <h1>
                @if ( ! $post->isVisible())
                    <i class="far fa-eye-slash text-danger" title="Предварительный просмотр"></i>
                @else
                    <i class="far fa-eye text-success" title="Этот пост опубликован"></i>
                @endif
                {{ $post->name }}
            </h1>
        </div>
        <div class="card-body">
            @if ($post->image)
                <img src="{{ asset('storage/post/image/'.$post->image) }}" alt="" class="img-fluid" />
            @else
                <img src="http://via.placeholder.com/1000x300" alt="" class="img-fluid">
            @endif
            <div class="mt-4">
                @perm('manage-posts')
                    {!! $post->content !!}
                @else
                    <p>{{ $post->excerpt }}</p>
                @endperm
            </div>
        </div>
        <div class="card-footer d-flex justify-content-between">
            ..........
        </div>
        ..........
    </div>
    @isset($comments)
        @include('admin.post.comments', ['comments' => $comments])
    @endisset
@endsection

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

Доступ к панели управления

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

namespace App\Http\Controllers\User;

use App\Http\Controllers\Controller;

class IndexController extends Controller {
    public function __invoke() {
        $perms = [
            'manage-posts', 'manage-comments', 'manage-tags',
            'manage-users', 'manage-roles', 'manage-pages'
        ];
        $admin = auth()->user()->hasAnyPerms(...$perms);
        return view('user.index', compact('admin'));
    }
}

Шаблон главной страницы личного кабинета resources/views/user/index.blade.php:

@extends('layout.user', ['title' => 'Личный кабинет'])

@section('content')
    <h1>Личный кабинет</h1>

    <p>Добрый день {{ auth()->user()->name }}!</p>

    @perm('create-post')
        <a href="{{ route('user.post.create') }}" class="btn btn-success">
            Новая публикация
        </a>
    @endperm
    <a href="{{ route('user.post.index') }}" class="btn btn-primary">
        Ваши публикации
    </a>
    <a href="{{ route('user.comment.index') }}" class="btn btn-primary">
        Ваши комментарии
    </a>
    @if ($admin)
        <a href="{{ route('admin.index') }}" class="btn btn-danger">
            Панель управления
        </a>
    @endif
@endsection

Поиск: 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.