Блог на Laravel 7, часть 13. Загрузка и ресайз изображений для категорий и постов блога
26.01.2021
Теги: Composer • Laravel • MySQL • PHP • Web-разработка • БазаДанных • Блог • Изображение • Практика • Форма • Фреймворк
Загрузка и ресайз изображений
У нас предусмотрена возможность загрузки изображения для категории и поста блога. Имя файла изображения сохраняется в базе данных — это поле image
в таблицах categories
и posts
. Давайте для начала просто загрузим изображение для категории и сохраним имя файла в БД — а потом напишем отдельный класс ImageSaver
, чтобы использовать его в контроллерах CategoryController
и PostController
.
Первым делом выполняем в консоли artisan-команду, которая создаст символическую ссылку:
> php artisan storage:link
Сохраняем изображение для категории на диск и записываем имя файла изображения в БД:
namespace App\Http\Controllers\Admin; use App\Category; use App\Http\Controllers\Controller; use App\Http\Requests\CategoryRequest; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; class CategoryController extends Controller { public function __construct() { $this->middleware('perm:manage-categories')->only('index'); $this->middleware('perm:create-category')->only(['create', 'store']); $this->middleware('perm:edit-category')->only(['edit', 'update']); $this->middleware('perm:delete-category')->only('destroy'); } /** * Показывает список всех категорий */ public function index() { $items = Category::all(); return view('admin.category.index', compact('items')); } /** * Показывает форму для создания категории */ public function create() { return view('admin.category.create'); } /** * Сохраняет новую категорию в базу данных */ public function store(CategoryRequest $request) { $image = $request->file('image'); if ($image) { // был загружен файл изображения $path = $image->store('category/source', 'public'); $base = basename($path); } $data = $request->all(); $data['image'] = $base ?? null; Category::create($data); return redirect() ->route('admin.category.index') ->with('success', 'Новая категория успешно создана'); } /** * Показывает форму для редактирования категории */ public function edit(Category $category) { return view('admin.category.edit', compact('category')); } /** * Обновляет категорию блога в базе данных */ public function update(CategoryRequest $request, Category $category) { if ($request->remove) { // если надо удалить изображение $old = $category->image; if ($old) { Storage::disk('public')->delete('category/source/' . $old); } } $file = $request->file('image'); if ($file) { // был загружен файл изображения $path = $file->store('category/source', 'public'); $base = basename($path); // удаляем старый файл изображения $old = $category->image; if ($old) { Storage::disk('public')->delete('category/source/' . $old); } } $data = $request->all(); $data['image'] = $base ?? null; $category->update($data); return redirect() ->route('admin.category.index') ->with('success', 'Категория была успешно исправлена'); } /** * Удаляет категорию блога */ public function destroy(Category $category) { if ($category->children->count()) { $errors[] = 'Нельзя удалить категорию с дочерними категориями'; } if ($category->posts->count()) { $errors[] = 'Нельзя удалить категорию, которая содержит посты'; } if (!empty($errors)) { return back()->withErrors($errors); } // удаляем файл изображения $image = $category->image; if ($image) { Storage::disk('public')->delete('category/source/' . $image); } // удаляем категорию блога $category->delete(); return redirect() ->route('admin.category.index') ->with('success', 'Категория блога успешно удалена'); } }
Для постов блога все будет аналогично, так что нет смысла повторять. Теперь создадим отдельный класс ImageSaver
в директории app/Helpers
— который будет отвечать за загрузку изображений. Но кроме того, будет еще изменять размер загружаемого изображения — чтобы его размер был 1000x300 px — у обычного пользователя может и не быть подходящего инструмента, чтобы изменить размер найденной в интернете картинки.
Устанавливаем пакет для изменения размера изображения:
> composer require intervention/image
При установке пакета получил предупреждение «You are using an outdated version of Composer. Composer 2.0 is now available and you should upgrade». Обновлять composer
до 2-ой версии пока не стал, но поискал информацию на этот счет. В принципе, обновляться достаточно безопасно:
- Composer 2.0 по-прежнему поддерживает PHP 5.3 и выше, так же как Composer 1.x
- Файлы
composer.lock
совместимы, можно обновиться до 2.0 и откатиться обратно - Большинство команд и аргументов остаются неизменными
Если запустить composer self-update
на 1.x, он предупредит, что доступна новая версия Composer, и можно использовать composer self-update --2
для перехода. Если возникнут проблемы с новой версией, можно откатиться обратно с помощью composer self-update --1
. При автоматической установке composer
из скрипта можно передать аргумент --1
, чтобы скрипт не устанавливал Composer 2.0 по умолчанию.
Открываем файл config/app.php
и добавляем следующие строки:
return [ /* ... */ 'providers' => [ /* ... */ Intervention\Image\ImageServiceProvider::class, ], 'aliases' => [ /* ... */ 'Image' => Intervention\Image\Facades\Image::class, ] /* ... */ ];
Класс ImageSaver
для загрузки и ресайза изображений:
namespace App\Helpers; use App\Category; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; use Intervention\Image\Facades\Image; class ImageSaver { /** * Сохраняет изображение при создании или редактировании категории, * или поста блога + создает уменьшеное изображение 1000x300 px. * * @param App\Item $item — модель категории блога или поста блога * @return string|null — имя файла изображения для сохранения в БД */ public function upload($item = null) { $dir = 'post'; if ($item instanceof Category) { $dir = 'category'; } $name = $item->image; if ($name && request()->remove) { // если надо удалить изображение $this->remove($item); $name = null; } $source = request()->file('image'); if ($source) { // если было загружено изображение // перед загрузкой нового изображения удаляем старое if ($item->image) { $this->remove($item); $name = null; } // сохраняем загруженное изображение на диск; $src будет // содержать путь относительно хранилища вместе с именем $src = $source->store($dir . '/source', 'public'); $name = basename($src); // имя загруженного файла // создаем уменьшенное изображение 1000x300px, качество 100% $dst = str_replace('source', 'image', $src); $this->resize($src, $dst, 1000, 300); } return $name; } /** * Создает уменьшенную копию изображения * * @param string $src — путь к исходному изображению * @param string $dst — путь к уменьшенному изображению * @param integer $width — ширина в пикселях * @param integer $height — высота в пикселях */ private function resize($src, $dst, $width, $height) { // абсолютный путь к исходному изображению $path = Storage::disk('public')->path($src); $image = Image::make($path) ->heighten($height) ->resizeCanvas($width, $height, 'center', false, 'eeeeee') ->encode(pathinfo($path, PATHINFO_EXTENSION), 100); Storage::disk('public')->put($dst, $image); $image->destroy(); } /** * Удаляет изображение при удалении категории или поста блога * * @param App\Item $item — модель категории или поста блога */ public function remove($item) { $dir = 'post'; if ($item instanceof Category) { $dir = 'category'; } $image = $item->image; if ($image) { Storage::disk('public')->delete($dir . '/source/' . $image); Storage::disk('public')->delete($dir . '/image/' . $image); } } }
Внедряем экземпляр класса ImageSaver
в контроллер CategoryController
(dependency injection):
namespace App\Http\Controllers\Admin; use App\Category; use App\Helpers\ImageSaver; use App\Http\Controllers\Controller; use App\Http\Requests\CategoryRequest; use Illuminate\Http\Request; class CategoryController extends Controller { private $imageSaver; public function __construct(ImageSaver $imageSaver) { $this->imageSaver = $imageSaver; $this->middleware('perm:manage-categories')->only('index'); $this->middleware('perm:create-category')->only(['create', 'store']); $this->middleware('perm:edit-category')->only(['edit', 'update']); $this->middleware('perm:delete-category')->only('destroy'); } /** * Показывает список всех категорий */ public function index() { $items = Category::all(); return view('admin.category.index', compact('items')); } /** * Показывает форму для создания категории */ public function create() { return view('admin.category.create'); } /** * Сохраняет новую категорию в базу данных */ public function store(CategoryRequest $request) { $category = new Category(); $category->fill($request->except('image')); $category->image = $this->imageSaver->upload($category); $category->save(); return redirect() ->route('admin.category.index') ->with('success', 'Новая категория успешно создана'); } /** * Показывает форму для редактирования категории */ public function edit(Category $category) { return view('admin.category.edit', compact('category')); } /** * Обновляет категорию блога в базе данных */ public function update(CategoryRequest $request, Category $category) { $data = $request->except('image'); $data['image'] = $this->imageSaver->upload($category); $category->update($data); return redirect() ->route('admin.category.index') ->with('success', 'Категория была успешно исправлена'); } /** * Удаляет категорию блога */ public function destroy(Category $category) { if ($category->children->count()) { $errors[] = 'Нельзя удалить категорию с дочерними категориями'; } if ($category->posts->count()) { $errors[] = 'Нельзя удалить категорию, которая содержит посты'; } if (!empty($errors)) { return back()->withErrors($errors); } // удаляем файл изображения $this->imageSaver->remove($category); // удаляем категорию блога $category->delete(); return redirect() ->route('admin.category.index') ->with('success', 'Категория блога успешно удалена'); } }
С загрузкой изображения для поста немного сложнее. Потому что пост может создать и отредактировать обычный пользователь в личном кабинете. Кроме того, пост может отредактировать администратор из панели управления. Так что мы должны внедрить экземпляр класса ImageSaver
в классы Admin\PostController
и User\PostController
.
namespace App\Http\Controllers\Admin; use App\Category; use App\Helpers\ImageSaver; use App\Http\Controllers\Controller; use App\Http\Requests\PostRequest; use App\Post; use Illuminate\Http\Request; class PostController extends Controller { private $imageSaver; public function __construct(ImageSaver $imageSaver) { $this->imageSaver = $imageSaver; /* ... */ } /* ... */ public function update(PostRequest $request, Post $post) { $data = $request->except(['image', 'tags']); $data['image'] = $this->imageSaver->upload($post); $post->update($data); $post->tags()->sync($request->tags); // кнопка редактирования может быть нажата в режиме пред.просмотра // или в панели управления блогом, так что и редирект будет разный $route = 'admin.post.index'; $param = []; if (session('preview')) { $route = 'admin.post.show'; $param = ['post' => $post->id]; } return redirect() ->route($route, $param) ->with('success', 'Пост был успешно обновлен'); } /* ... */ public function destroy(Post $post) { // удаляем изображение поста $this->imageSaver->remove($post); // удаляем пост из базы данных $post->delete(); // пост может быть удален в режиме пред.просмотра или из панели // управления, так что и редирект после удаления будет разным $route = 'admin.post.index'; if (session('preview')) { $route = 'blog.index'; } return redirect() ->route($route) ->with('success', 'Пост блога успешно удален'); } }
namespace App\Http\Controllers\User; use App\Helpers\ImageSaver; use App\Http\Controllers\Controller; use App\Http\Requests\PostRequest; use App\Post; use Illuminate\Http\Request; class PostController extends Controller { private $imageSaver; public function __construct(ImageSaver $imageSaver) { $this->imageSaver = $imageSaver; $this->middleware('perm:create-post')->only(['create', 'store']); } /* ... */ public function store(PostRequest $request) { // уникальный идентификатор автора поста $request->merge(['user_id' => auth()->user()->id]); // сохраняем новый пост в базе данных $post = new Post(); $post->fill($request->except(['image', 'tags'])); $post->image = $this->imageSaver->upload($post); $post->save(); // привязываем теги к новому посту $post->tags()->attach($request->tags); // все готово, выполняем редирект return redirect() ->route('user.post.show', ['post' => $post->id]) ->with('success', 'Новый пост успешно создан'); } /* ... */ public function update(PostRequest $request, Post $post) { // проверяем права пользователя на это действие if ( ! $this->can($post)) { abort(404); } // обновляем сам пост и привязку тегов к посту $data = $request->except(['image', 'tags']); $data['image'] = $this->imageSaver->upload($post); $post->update($data); $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 ( ! $this->can($post)) { abort(404); } // удаляем изображение поста $this->imageSaver->remove($post); // удаляем пост из базы данных $post->delete(); return redirect() ->route('user.post.index') ->with('success', 'Пост блога успешно удален'); } }
Laravel mix и сборка фронтенда
Чтобы не возникало путаницы, давайте договоримся, какие js и css файлы мы будем подключать в шаблонах layout.site
(публичная часть), layout.user
(личный кабинет) и layout.admin
(панель управления).
- файл
resources/js/app.js
будет подключаться во всех трех layout-шаблонах, при сборке будет сохраняться какpublic/js/app.js
- файл
resources/js/site.js
будет подключаться в шаблонеlayout.site
, при сборке будет сохраняться какpublic/js/site.js
- файл
resources/js/back.js
будет подключаться в шаблонахlayout.user
иlayout.admin
, при сборке — этоpublic/js/back.js
- файл
resources/sass/app.css
будет подключаться во всех трех layout-шаблонах, при сборке будет сохраняться какpublic/css/app.css
- файл
resources/css/site.css
будет подключаться в шаблонеlayout.site
, при сборке будет сохраняться какpublic/css/site.css
- файл
resources/css/back.css
будет подключаться в шаблонахlayout.user
иlayout.admin
, при сборке — этоpublic/css/back.js
В файле конфигурации mix webpack.mix.js
зададим правила сборки фронтенда:
const mix = require('laravel-mix'); mix.js('resources/js/app.js', 'public/js/app.js') .scripts('resources/js/site.js', 'public/js/site.js') .scripts('resources/js/back.js', 'public/js/back.js'); mix.sass('resources/sass/app.scss', 'public/css') .styles('resources/css/site.css', 'public/css/site.css') .styles('resources/css/back.css', 'public/css/back.css');
И подключим js и css-файлы в шаблонах layout.site
, layout.user
и layout.admin
.
Wysiwyg-редактор для постов
Будем использовать summernote — простой, легкий и есть возможность вставлять видео и изображения. Но самое главное — можно навесить свои обработчики события добавления и удаления изображений. А это означает, что можно организовать систему автозагрузки и автоудаления изображений на сервере.
Устанавливаем пакет summernote
с помощью npm
:
> npm install summernote --save-prod
Установить все пакеты из секций dependencies
и devDependencies
файла package.json
:
> npm install
Установить все пакеты из секции dependencies
файла package.json
(для production сервера)
> npm install --production
Установить пакет package-name
, но не добавлять его в файл package.json
:
> npm install package-name --no-save
Установить пакет package-name
и добавить в секцию devDependencies
файла package.json
:
> npm install package-name --save-dev > npm install -D package-name
Установить пакет package-name
и добавить в секцию dependencies
файла package.json
:
> npm install package-name --save-prod > npm install -P package-name > npm install package-name
Удалить пакет package-name
, но не удалять его из файла package.json
:
> npm uninstall package-name
Удалить пакет package-name
и удалить его из секции devDependencies
файла package.json
:
> npm uninstall package-name --save-dev > npm uninstall -D package-name
Удалить пакет package-name
и удалить его из секции dependencies
файла package.json
:
> npm uninstall package-name --save > npm uninstall -S package-name
Теперь редактируем файл resources/js/bootstrap.js
(в котором происходит подключение css-фреймворка Bootstrap и js-фреймворка jQuery), чтобы подключить редактор.
window._ = require('lodash'); try { window.Popper = require('popper.js').default; window.$ = window.jQuery = require('jquery'); require('bootstrap'); require('summernote'); require('summernote/dist/summernote-bs4.css'); require('summernote/dist/summernote-bs4.js'); require('summernote/dist/lang/summernote-ru-RU.js'); } catch (e) {} window.axios = require('axios'); window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
В шаблоне с формой редактирования поста и страницы задаем идентификатор для textarea
:
<div class="form-group"> <textarea class="form-control" name="content" id="editor" placeholder="Текст поста" rows="4">{{ old('content') ?? $post->content ?? '' }}</textarea> </div>
<div class="form-group"> <textarea class="form-control" name="content" id="editor" placeholder="Контент (html)" required rows="10">{{ old('content') ?? $page->content ?? '' }}</textarea> </div>
И добавляем код для редактора в js-файле resources/js/back.js
:
$(document).ready(function () { $('#editor').summernote({ lang: 'ru-RU', height: 300 }); });
Осталось только заново собрать фронтенд:
> npm run dev
Загрузка изображений редактора
По умолчанию редактор summernote сохраняет изображение прямо в атрибуте src
тега <img>
:
<p> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi assumenda blanditiis consequatur cum cupiditate ea facere, facilis fuga fugit ipsum itaque laboriosam, laudantium nemo, nulla odio placeat quas recusandae repellat repudiandae sint unde ut vitae voluptas voluptate voluptatem. Amet assumenda dolorum enim iusto odit quis similique. </p> <p> <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5 ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4 .......... ciI/Pv3k9zcAAACiSURBVHja7NAxAQAACAMgtX/nWcHPByLQKa5GgSxZsmTJkqVAlixZsmTJUiBLlixZsmQpkCVLlixZshTIkiVLlixZCmTJkiVLliwFsmTJ kiVLlgJZsmTJkiVLgSxZsmTJkqVAlixZsmTJUiBLlixZsmQpkCVLlixZshTIkiVLlixZCmTJkiVLliwFsmTJkiVLlgJZsmTJkiVLgSxZ31YAAQYAil4Bx7aJ z7QAAAAASUVORK5CYII=" data-filename="test.png" style="width: 100px;"> </p> <p> Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi assumenda blanditiis consequatur cum cupiditate ea facere, facilis fuga fugit ipsum itaque laboriosam, laudantium nemo, nulla odio placeat quas recusandae repellat repudiandae sint unde ut vitae voluptas voluptate voluptatem. Amet assumenda dolorum enim iusto odit quis similique. </p>
Но во-первых, в этом случае неудобно работать в редакторе, если переключиться в режим редактирования кода. А во-вторых, не хотелось бы хранить изображения в базе данных, раздувая ее без необходимости. Так что будем сохранять изображения на сервере, и заменять значение атрибута src
тега <img>
.
Редактор summernote предоставляет возможность навесить свои обработчики на события вставки и удаления изображений:
$(document).ready(function () { /* * Подключение wysiwyg-редактора + загрузка и удаление изображений */ (function () { // что сейчас редактируется — пост или страница? var entity = getEntity(); if (entity === '') return; /* * Подключение wysiwyg-редактора для формы поста или страницы */ $('#editor').summernote({ lang: 'ru-RU', height: 300, callbacks: { /* * При вставке изображения загружаем его на сервер */ onImageUpload: function(images) { for (var i = 0; i < images.length; i++) { uploadImage(images[i], this); } }, /* * При удалении изображения удаляем его на сервере */ onMediaDelete: function(target) { removeImage(target[0].src); } } }); /* * Загружает на сервер вставленное в редакторе изображение */ function uploadImage(image, textarea) { var data = new FormData(); data.append('image', image); data.append('entity', entity); $.ajax({ data: data, type: 'POST', url: '/user/upload/' + entity + '/image', cache: false, contentType: false, processData: false, success: function(url) { // $(textarea).summernote('insertImage', url); $(textarea).summernote('insertImage', url, function ($img) { $img.css('max-width', '100%'); }); } }); } /* * Удаляет на сервере удаленное в редакторе изображение */ function removeImage(src) { $.ajax({ data: {'remove': src, '_method': 'DELETE', 'entity': entity}, type: 'POST', url: '/user/remove/' + entity + '/image', cache: false, success: function(msg) { // console.log(msg); } }); } /* * Определяет, что сейчас редактируется — пост или страница */ function getEntity() { var entity = ''; if (window.location.pathname.indexOf('page') !== -1) { entity = 'page'; } if (window.location.pathname.indexOf('post') !== -1) { entity = 'post'; } return entity; } })(); });
При вставке или удалении изображения в wysiwyg-редакторе — отправляем post-запрос на /user/upload/{post|page}/image
или /user/remove/{post|page}/image
. Добавим четыре маршрута и создадим контроллеры, которые будут обрабатывать эти post-запросы.
/* * Личный кабинет пользователя */ Route::group([ 'as' => 'user.', // имя маршрута, например user.index 'prefix' => 'user', // префикс маршрута, например user/index 'namespace' => 'User', // пространство имен контроллеров 'middleware' => ['auth'] // один или несколько посредников ], function () { /* ... */ // загрузка изображения поста блога из wysiwyg-редактора Route::post('upload/post/image', 'PostImageController@upload') ->name('upload.post.image'); // удаление изображения поста блога в wysiwyg-редакторе Route::delete('remove/post/image', 'PostImageController@remove') ->name('remove.post.image'); // загрузка изображения страницы из wysiwyg-редактора Route::post('upload/page/image', 'PageImageController@upload') ->name('upload.page.image'); // удаление изображения страницы в wysiwyg-редакторе Route::delete('remove/page/image', 'PageImageController@remove') ->name('remove.page.image'); });
> php artisan make:controller User\ImageController
namespace App\Http\Controllers\User; use Illuminate\Http\Request; use App\Http\Controllers\Controller; use Illuminate\Support\Facades\Storage; class ImageController extends Controller { // TODO: нужна валидация загружаемого изображения /** * Загружает изображение, которое было добавлено в wysiwyg-редакторе и * возвращает ссылку на него, чтобы в редакторе вставить <img src="…"/> */ public function upload(Request $request) { $path = $request->file('image')->store('upload', 'public'); return Storage::disk('public')->url($path); } /** * Удаляет изображение, которое было удалено в wysiwyg-редакторе */ public function remove(Request $request) { $path = parse_url($request->remove, PHP_URL_PATH); $path = str_replace('/storage/', '', $path); Storage::disk('public')->delete($path); } }
> php artisan make:controller User\PageImageController
namespace App\Http\Controllers\User; class PageImageController extends ImageController { public function __construct() { // TODO: нужна проверка прав на редактирование страницы } }
> php artisan make:controller User\PostImageController
namespace App\Http\Controllers\User; class PostImageController extends ImageController { public function __construct() { // TODO: нужна проверка прав на редактирование поста } }
Вроде все готово, можно проверять. Но вылезла ошибка «CSRF token mismatch» — при отправке формы нужно отправлять csrf-токен.
Чтобы решить эту проблему раз и навсегда, добавим в back.js
отправку токена при всех ajax-запросах — и пересоберем фронтенд. А токен наш js-код будет брать из мета-тега, который нужно добавить в шаблоны layout.user
и layout.admin
.
<head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta name="csrf-token" content="{{ csrf_token() }}"> <title>.....</title> ..... </head>
$(document).ready(function () { /* * Общие настройки ajax-запросов, отправка на сервер csrf-токена */ $.ajaxSetup({ headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') } }); /* ... */ });
> npm run dev
Не совсем очевидно, как удалить изображение, чтобы вызвать событие удаления. Если кликнуть по изображению — появится всплывающая менюшка. И уже в этой менюшке надо кликнуть по иконке корзины.
- Мини-блог на Laravel, часть 4. Создание нового поста, загрузка и обрезка изображения
- Блог на Laravel 7, часть 9. Панель управления — создание, публикация, удаление комментариев
- Блог на Laravel 7, часть 8. Панель управления — CRUD для категорий, тегов и пользователей
- Блог на Laravel 7, часть 7. Панель управления — создание, публикация, удаление постов
- Мини-блог на Laravel, часть 8. Регистрация и аутентификация пользователей на сайте
- Мини-блог на Laravel, часть 6. Исправление ошибок, удаление поста, семь маршрутов
- Блог на Laravel 7, часть 17. Временная зона для пользователей, деплой на хостинг TimeWeb
Поиск: Composer • Laravel • MySQL • PHP • Web-разработка • База данных • Блог • Изображение • Практика • Форма • Фреймворк