Блог на Laravel 7, часть 10. Личный кабинет — CRUD-операции над постами и комментариями
12.01.2021
Теги: Laravel • MySQL • PHP • Web-разработка • БазаДанных • Блог • ПанельУправления • Пользователь • Практика • Фреймворк • ШаблонСайта
Теперь поработаем над личным кабинетом обычного пользователя. Здесь пользователь сможет увидеть все свои посты и комментарии. Еще не опубликованные посты и комментарии можно редактировать и удалять. Но после проверки администратором и публикации у пользователя больше не будет такой возможности. Чтобы личный кабинет визуально отличался — создадим еще один layout-шаблон.
namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; class IndexController extends Controller { public function __invoke() { return view('user.index'); } }
Шаблон resporces/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> @endsection
Мы здесь используем layout-шаблон resources/views/layout/user.blade.php
:
<!doctype html> <html lang="ru"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <title>{{ $title ?? 'Личный кабинет' }}</title> <link href="{{ asset('css/app.css') }}" rel="stylesheet"> <link rel="stylesheet" href="https://pro.fontawesome.com/releases/v5.10.0/css/all.css"/> <script src="{{ asset('js/app.js') }}"></script> </head> <body> <div class="container"> <nav class="navbar navbar-expand-lg navbar-dark bg-success mb-4"> <!-- Логотип и кнопка «Гамбургер» --> <a class="navbar-brand" href="{{ route('user.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"> <li class="nav-item"> <a class="nav-link" href="{{ route('blog.index') }}">Перейти к блогу</a> </li> <li class="nav-item"> <a class="nav-link" href="{{ route('user.post.index') }}">Ваши публикации</a> </li> <li class="nav-item"> <a class="nav-link" href="{{ route('user.comment.index') }}">Ваши комментарии</a> </li> </ul> <!-- Этот блок расположен справа --> <ul class="navbar-nav ml-auto"> @guest <li class="nav-item"> <a class="nav-link" href="{{ route('auth.login') }}">Войти</a> </li> <li class="nav-item"> <a class="nav-link" href="{{ route('auth.register') }}">Регистрация</a> </li> @else <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> @endif </ul> </div> </nav> <div class="row"> <div class="col"> @if ($message = session('success')) <div class="alert alert-success alert-dismissible mt-0" role="alert"> <button type="button" class="close" data-dismiss="alert" aria-label="Закрыть"> <span aria-hidden="true">×</span> </button> {{ $message }} </div> @endif @if ($errors->any()) <div class="alert alert-danger alert-dismissible mt-4" role="alert"> <button type="button" class="close" data-dismiss="alert" aria-label="Закрыть"> <span aria-hidden="true">×</span> </button> <ul class="mb-0"> @foreach ($errors->all() as $error) <li>{{ $error }}</li> @endforeach </ul> </div> @endif @yield('content') </div> </div> </div> </body> </html>
Нам потребуется еще два ресурсных контроллера PostController
и CommentController
для CRUD-операций над постами и комментариями.
CRUD-операции над постами блога
Новые маршруты:
/* * Личный кабинет пользователя */ Route::group([ 'as' => 'user.', // имя маршрута, например user.index 'prefix' => 'user', // префикс маршрута, например user/index 'namespace' => 'User', // пространство имен контроллеров 'middleware' => ['auth'] // один или несколько посредников ], function () { /* * Главная страница личного кабинета */ Route::get('index', 'IndexController') ->name('index'); /* * CRUD-операции над постами пользователя */ Route::resource('post', 'PostController'); });
Контроллер PostController
:
> php artisan make:controller User/PostController --resource --model=Post
namespace App\Http\Controllers\User; use App\Http\Controllers\Controller; use App\Http\Requests\PostRequest; use App\Post; use Illuminate\Http\Request; class PostController extends Controller { public function __construct() { $this->middleware('perm:create-post')->only(['create', 'store']); } /** * Список всех постов пользователя */ public function index() { $posts = Post::whereUserId(auth()->user()->id)->orderByDesc('created_at')->paginate(); return view('user.post.index', compact('posts')); } /** * Показывает форму создания поста */ public function create() { return view('user.post.create'); } /** * Сохраняет новый пост в базу данных */ public function store(PostRequest $request) { $request->merge(['user_id' => auth()->user()->id]); $post = Post::create($request->all()); $post->tags()->attach($request->tags); return redirect() ->route('user.post.show', ['post' => $post->id]) ->with('success', 'Новый пост успешно создан'); } /** * Страница пред.просмотра поста блога */ public function show(Post $post) { // можно просматривать только свои посты if ( ! $post->isAuthor()) { abort(404); } // сигнализирует о том, что это режим пред.просмотра session()->flash('preview', 'yes'); // все опубликованные комментарии других пользователей $others = $post->comments()->published(); // и не опубликованные комментарии этого пользователя $comments = $post->comments() ->whereUserId(auth()->user()->id) ->whereNull('published_by') ->union($others) ->orderBy('created_at') ->paginate(); return view('user.post.show', compact('post', 'comments')); } /** * Показывает форму редактирования поста */ public function edit(Post $post) { // редактировать можно только свои посты if ( ! $post->isAuthor()) { abort(404); } // редактировать можно не опубликованные if ($post->isVisible()) { abort(404); } // нужно сохранить flash-переменную, которая сигнализирует о том, // что кнопка редактирования была нажата в режиме пред.просмотра session()->keep('preview'); return view('user.post.edit', compact('post' )); } /** * Обновляет пост блога в базе данных */ public function update(PostRequest $request, Post $post) { // обновлять можно только свои посты if ( ! $post->isAuthor()) { abort(404); } // обновлять можно не опубликованные if ($post->isVisible()) { abort(404); } $post->update($request->all()); $post->tags()->sync($request->tags); // кнопка редактирования может быть нажата в режиме пред.просмотра // или в личном кабинете пользователя, поэтому редирект разный $route = 'user.post.index'; $param = []; if (session('preview')) { $route = 'user.post.show'; $param = ['post' => $post->id]; } return redirect() ->route($route, $param) ->with('success', 'Пост был успешно обновлен'); } /** * Удаляет пост блога из базы данных */ public function destroy(Post $post) { // удалять можно только свои посты if ( ! $post->isAuthor()) { abort(404); } // удалять можно не опубликованные if ($post->isVisible()) { abort(404); } $post->delete(); return redirect() ->route('user.post.index') ->with('success', 'Пост блога успешно удален'); } }
Мы здесь активно используем метод isAuthor()
модели Post
, так что сразу его добавим:
class Post extends Model { /** * Возвращает true, если пользователь является автором */ public function isAuthor() { return $this->user->id === auth()->user()->id; } }
Здесь есть небольшая проблема с классом валидации PostRequest
— мы используем имя маршрута при обновлении поста, чтобы изменить правило валидации уникальности slug
. Но теперь маршрутов будет два, потому что пост может обновить администратор и автор.
class PostRequest extends FormRequest { /* ... */ public function rules() { $unique = 'unique:posts,slug'; if (in_array($this->route()->getName(), ['admin.post.update', 'user.post.update'])) { // получаем модель Post через маршрут admin/post/{post} $model = $this->route('post'); $unique = 'unique:posts,slug,'.$model->id.',id'; } /* ... */ } /* ... */ }
Шаблон resporces/views/user/post/index.blade.php
для просмотра всех постов пользователя:
@extends('layout.user', ['title' => 'Ваши публикации']) @section('content') <h1>Ваши публикации</h1> @perm('create-post') <a href="{{ route('user.post.create') }}" class="btn btn-success mb-4"> Новая публикация </a> @endperm @if ($posts->count()) <table class="table table-bordered"> <tr> <th width="10%">Дата</th> <th width="40%">Наименование</th> <th width="20%">Автор публикации</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 ($posts as $post) <tr> <td>{{ $post->created_at }}</td> <td>{{ $post->name }}</td> <td>{{ $post->user->name }}</td> <td> @if ($post->editor) {{ $post->editor->name }} @endif </td> <td> <a href="{{ route('user.post.show', ['post' => $post->id]) }}" title="Предварительный просмотр"> <i class="far fa-eye"></i> </a> </td> <td> @if ($post->isVisible()) <i class="far fa-toggle-on text-success"></i> @else <i class="far fa-toggle-off text-black-50"></i> @endif </td> <td> @if ( ! $post->isVisible()) <a href="{{ route('user.post.edit', ['post' => $post->id]) }}"> <i class="far fa-edit"></i> </a> @else <i class="far fa-edit text-black-50"></i> @endif </td> <td> @if ( ! $post->isVisible()) <form action="{{ route('user.post.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> @else <i class="far fa-trash-alt text-black-50"></i> @endif </td> </tr> @endforeach </table> {{ $posts->links() }} @endif @endsection
Шаблон views/user/post/create.blade.php
для создания нового поста:
@extends('layout.user', ['title' => 'Создание поста']) @section('content') <h1>Создание поста</h1> <form method="post" action="{{ route('user.post.store') }}" enctype="multipart/form-data"> @include('admin.post.part.form') </form> @endsection
Шаблон views/user/post/edit.blade.php
для редактирования поста:
@extends('layout.user', ['title' => 'Редактирование поста']) @section('content') <h1>Редактирование поста</h1> <form method="post" enctype="multipart/form-data" action="{{ route('user.post.update', ['post' => $post->id]) }}"> @method('PUT') @include('admin.post.part.form') </form> @endsection
Мы здесь используем шаблон формы admin.post.part.form
для создания и редактирования поста блога. Этот шаблон мы уже создали, когда работали над панелью управления. Расположение шаблона не слишком удачное, лучше разместить его в директории, не связаной с директорией admin
. Так что переместим его в директорию resources/views/common
. И отредактируем три шаблона, которые его подключают — admin.post.edit
, user.post.create
и user.post.edit
. Не буду это подробно описывать, здесь все достаточно просто.
Шаблон resporces/views/user/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"> <img src="http://via.placeholder.com/1000x300" alt="" class="img-fluid"> <div class="mt-4">{!! $post->content !!}</div> </div> <div class="card-footer d-flex justify-content-between"> <span> Автор: <a href="{{ route('blog.author', ['user' => $post->user->id]) }}"> {{ $post->user->name }} </a> <br> Дата: {{ $post->created_at }} </span> <span> @if ( ! $post->isVisible()) <a href="{{ route('user.post.edit', ['post' => $post->id]) }}" class="btn btn-primary" title="Редактировать пост"> <i class="far fa-edit"></i> </a> <form action="{{ route('user.post.destroy', ['post' => $post->id]) }}" method="post" class="d-inline" onsubmit="return confirm('Удалить этот пост?')"> @csrf @method('DELETE') <button type="submit" class="btn btn-danger" title="Удалить пост"> <i class="far fa-trash-alt"></i> </button> </form> @endif </span> </div> @if ($post->tags->count()) <div class="card-footer"> Теги: @foreach($post->tags as $tag) @php $comma = $loop->last ? '' : ' • ' @endphp <a href="{{ route('blog.tag', ['tag' => $tag->slug]) }}"> {{ $tag->name }}</a> {{ $comma }} @endforeach </div> @endif </div> @include('user.post.comments', ['comments' => $comments]) @endsection
Об этом шаблоне надо поговорить подробнее. Во-первых, он расширяет layout-шаблон layout.site
публичной части — так что в режиме предварительного просмотра пост будет выглядеть практически так же, как в публичной части. Во-вторых, он подключает шаблон user.post.comments
для показа комментариев к посту. Этого делать не обязательно, но мы используем этот шаблон и для предварительного просмотра комментариев чуть ниже. В-третьих, мы передаем в layout-шаблон переменную user
— чтобы все-таки было понятно, что это режим пред.просмотра и у него есть некоторые ограничения (а то сам запутался).
Раз мы передаем переменную user
в layout-шаблон layout.site
— нужно ее использовать. Кроме того, будем передавать аналогичную переменную admin
из шаблона admin.post.show
— чтобы режим пред.просмотра пользователя и администратора визуально отличались.
<div class="container"> <nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4"> <!-- Логотип и кнопка «Гамбургер» --> @isset($admin) <i class="far fa-user text-danger mr-2"></i> @endisset @isset($user) <i class="far fa-user text-success mr-2"></i> @endisset <a class="navbar-brand" href="/">{{ env('APP_NAME') }}</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> <!-- ..... --> </nav> <!-- ..... --> </div>
Обратите внимание, что иконка в левом верхнем углу для пользователя зеленая, а для администратора красная.
Шаблон resporces/views/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> @if ( ! $comment->isVisible()) <a href="{{ route('user.comment.edit', ['comment' => $comment->id]) }}" class="btn btn-outline-primary btn-sm" title="Редактировать комментарий"> <i class="far fa-edit"></i> </a> <form action="{{ route('user.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> @endif </span> </div> </div> @endforeach {{ $comments->fragment('comment-list')->links() }} @else <p>К этому посту еще нет комментариев</p> @endif
CRUD-операции над комментариями
Новые маршруты:
/* * Личный кабинет пользователя */ Route::group([ 'as' => 'user.', // имя маршрута, например user.index 'prefix' => 'user', // префикс маршрута, например user/index 'namespace' => 'User', // пространство имен контроллеров 'middleware' => ['auth'] // один или несколько посредников ], function () { /* * Главная страница личного кабинета */ Route::get('index', 'IndexController') ->name('index'); /* * CRUD-операции над постами пользователя */ Route::resource('post', 'PostController'); /* * CRUD-операции над комментариями пользователя */ Route::resource('comment', 'CommentController', ['except' => [ 'create', 'store' ]]); });
Контроллер CommentController
:
> php artisan make:controller User/CommentController --resource --model=Comment
namespace App\Http\Controllers\User; use App\Comment; use App\Http\Controllers\Controller; use App\Http\Requests\CommentRequest; use Illuminate\Http\Request; class CommentController extends Controller { /** * Список всех комментариев пользователя */ public function index() { $comments = Comment::whereUserId(auth()->user()->id) ->orderByDesc('created_at') ->paginate(); return view('user.comment.index', compact('comments')); } /** * Просмотр комментария к посту блога */ public function show(Comment $comment) { // можно просматривать только свои комментарии if ( ! $comment->isAuthor()) { abort(404); } // сигнализирует о том, что это режим пред.просмотра session()->flash('preview', 'yes'); // это тот пост блога, к которому оставлен комментарий $post = $comment->post; // все опубликованные комментарии других пользователей $others = $post->comments()->published(); // и не опубликованные комментарии этого пользователя $comments = $post->comments() ->whereUserId(auth()->user()->id) ->whereNull('published_by') ->union($others) ->orderBy('created_at') ->paginate(); // используем шаблон предварительного просмотра поста return view('user.post.show', compact('post', 'comments')); } /** * Показывает форму редактирования комментария */ public function edit(Comment $comment) { // проверяем права пользователя на это действие if ( ! $this->can($comment)) { abort(404); } // нужно сохранить flash-переменную, которая сигнализирует о том, // что кнопка редактирования была нажата в режиме пред.просмотра session()->keep('preview'); return view('user.comment.edit', compact('comment')); } /** * Обновляет комментарий в базе данных */ public function update(CommentRequest $request, Comment $comment) { // проверяем права пользователя на это действие if ( ! $this->can($comment)) { abort(404); } $comment->update($request->all()); return $this->redirectAfterUpdate($comment); } /** * Удаляет комментарий из базы данных */ public function destroy(Comment $comment) { // проверяем права пользователя на это действие if ( ! $this->can($comment)) { abort(404); } $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( 'user.comment.show', ['comment' => $comment->id, 'page' => $comment->userPageNumber()] )->withFragment('comment-list'); } else { $redirect = $redirect->route('user.comment.index'); } return $redirect->with('success', 'Комментарий был успешно исправлен'); } /** * Проверяет, что пользователь может редактировать * или удалять пост блога */ private function can(Comment $comment) { return $comment->isAuthor() && !$comment->isVisible(); } }
Мы здесь активно используем метод isAuthor()
модели Comment
, так что сразу его добавим:
class Comment extends Model { /** * Возвращает true, если пользователь является автором */ public function isAuthor() { return $this->user->id === auth()->user()->id; } }
Шаблон resporces/views/user/comment/index
для просмотра всех комментариев пользователя:
@extends('layout.user', ['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->userPageNumber()] @endphp <a href="{{ route('user.comment.show', $params) }}" title="Предварительный просмотр"> <i class="far fa-eye"></i> </a> </td> <td> @if ($comment->isVisible()) <i class="far fa-toggle-on text-success"></i> @else <i class="far fa-toggle-off text-black-50"></i> @endif </td> <td> @if ( ! $comment->isVisible()) <a href="{{ route('user.comment.edit', ['comment' => $comment->id]) }}"> <i class="far fa-edit"></i> </a> @else <i class="far fa-edit text-black-50"></i> @endif </td> <td> @if ( ! $comment->isVisible()) <form action="{{ route('user.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> @else <i class="far fa-trash-alt text-black-50"></i> @endif </td> </tr> @endforeach </table> {{ $comments->links() }} @endsection
Для предварительного просмотра комментария используем шаблон resporces/views/user/post/show.blade.php
— этот же шаблон используется для предварительного просмотра поста блога. Но если в режиме предварительного просмотра поста мы отбираем только опубликованные комментарии, то здесь отбираем опубликованные + не опубликованные этого пользователя. Комментариев к посту может быть много, поэтому используется постраничная навигация — а нам нужно попасть на ту страницу, где расположен комментарий. Для этого используем метод userPageNumber()
модели Comment
.
class Comment extends Model { /** * Номер страницы пагинации, на которой расположен комментарий; * все опубликованные + не опубликованные этого пользователя */ public function userPageNumber() { // все опубликованные комментарии других пользователей $others = $this->post->comments()->published(); // и не опубликованные комментарии этого пользователя $comments = $this->post->comments() ->whereUserId(auth()->user()->id) ->whereNull('published_by') ->union($others) ->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()); } }
Мы уже использовали похожий метод adminPageNumber()
— отличие между ними в том, сколько всего комментариев к посту будет показано для администратора и пользователя. Обычный пользователь видит все опубликованные + свои не опубликованные, а администратор видит комментарии всех пользователей — в том числе еще не опубликованные. Один и тот же комментарий для администратора и пользователя может быть расположен на разных страницах — поэтому два метода, а не один.
Шаблон resporces/views/user/comment/edit.blade.php
для редактирования комментария к посту:
- Блог на Laravel 7, часть 12. Доп.страницы сайта в панели управления и в публичной части
- Блог на Laravel 7, часть 11. Панель управления — назначение ролей и прав для пользователей
- Блог на Laravel 7, часть 9. Панель управления — создание, публикация, удаление комментариев
- Блог на Laravel 7, часть 8. Панель управления — CRUD для категорий, тегов и пользователей
- Блог на Laravel 7, часть 7. Панель управления — создание, публикация, удаление постов
- Блог на Laravel 7, часть 3. Checkbox «Запомнить меня» и подтверждение адреса почты
- Блог на Laravel 7, часть 17. Временная зона для пользователей, деплой на хостинг TimeWeb
Поиск: Laravel • MySQL • PHP • Web-разработка • База данных • Блог • Панель управления • Пользователь • Практика • Фреймворк • Шаблон сайта