Полный пример модуля
В этом разделе мы создадим полный рабочий модуль “Блог” для Flute CMS. Модуль будет демонстрировать все основные возможности системы: контроллеры, модели, представления, маршруты, локализацию, платежи, виджеты и интеграцию с профилем.
Пример носит демонстрационный характер и не покрывает все классы. Ориентируйтесь на актуальные API: bootstrapModule() для загрузки ресурсов, ActiveRecord для ORM, loadPackage() для админ-пакета.
Структура модуля
- module.json
- BlogProvider.php
- BlogController.php
- Article.php
- Category.php
- Comment.php
- Tag.php
- ArticleTag.php
- routes.php
Конфигурация модуля
module.json
{
"name": "Blog",
"version": "1.0.0",
"description": "Полнофункциональный модуль блога для Flute CMS",
"authors": ["Команда разработки"],
"url": "https://github.com/flute-cms/blog-module",
"providers": [
{
"class": "Flute\\Modules\\Blog\\Providers\\BlogProvider",
"order": 10
}
],
"dependencies": {
"php": ">=8.2",
"flute": ">=1.0.0",
"modules": {
"Payments": ">=1.0.0",
"Notifications": ">=1.0.0"
},
"extensions": ["gd", "mbstring"],
"composer": {
"intervention/image": "^2.7",
"laravel/socialite": "^5.5"
},
"theme": {
"standard": ">=1.0.0"
}
}
}В module.json указываются:
- name — уникальное имя модуля
- version — версия модуля в формате SemVer
- description — описание модуля
- providers — массив провайдеров с классом и порядком загрузки
- dependencies — зависимости модуля:
php— минимальная версия PHPflute— минимальная версия Flute CMSmodules— зависимости от других модулейextensions— требуемые PHP расширенияcomposer— Composer пакетыtheme— зависимости от тем
Конфигурация модуля
<?php
// Resources/config/blog.php
return [
// Основные настройки
'enabled' => true,
'name' => 'Блог',
'description' => 'Публикация статей и новостей',
// Настройки отображения
'articles_per_page' => 10,
'excerpt_length' => 150,
'show_author' => true,
'show_date' => true,
'show_tags' => true,
'show_categories' => true,
'show_comments' => true,
// Настройки изображений
'images' => [
'max_size' => 2048, // KB
'allowed_types' => ['jpg', 'jpeg', 'png', 'gif', 'webp'],
'quality' => 85,
'thumbnails' => [
'small' => ['width' => 300, 'height' => 200],
'medium' => ['width' => 600, 'height' => 400],
'large' => ['width' => 1200, 'height' => 800],
],
],
// Настройки комментариев
'comments' => [
'enabled' => true,
'moderation' => true,
'nested' => true,
'max_depth' => 3,
'per_page' => 20,
'require_auth' => false,
],
// Настройки SEO
'seo' => [
'title_separator' => ' | ',
'default_title' => 'Блог',
'default_description' => 'Последние статьи и новости',
'og_image' => '/images/blog-og.jpg',
'twitter_card' => 'summary_large_image',
],
// Настройки уведомлений
'notifications' => [
'email' => [
'new_comment' => true,
'new_article' => false,
'article_published' => true,
],
'database' => [
'new_comment' => true,
'new_article' => true,
],
],
// Настройки кеширования
'cache' => [
'articles' => 1800, // 30 минут
'comments' => 900, // 15 минут
'categories' => 3600, // 1 час
'tags' => 3600, // 1 час
],
// Настройки платежей (для премиум статей)
'payments' => [
'enabled' => false,
'premium_price' => 9.99,
'currency' => 'USD',
'gateway' => 'stripe',
],
// Настройки RSS
'rss' => [
'enabled' => true,
'items_count' => 20,
'cache_time' => 1800,
],
];Для реального модуля минимизируйте конфиг: храните только используемые ключи, остальное вынесите в сервисы или разумные defaults.
Переопределяйте значения через config-dev/blog.php или собственный сервис конфигурации.
Сущности базы данных
Article.php
Основная сущность для хранения статей блога:
<?php
namespace Flute\Modules\Blog\Database\Entities;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Relation\HasMany;
use Cycle\Annotated\Annotation\Relation\BelongsTo;
use Cycle\Annotated\Annotation\Table\Index;
use Cycle\Annotated\Annotation\Table;
#[Entity(table: 'blog_articles')]
#[Table(indexes: [
new Index(columns: ['slug'], unique: true),
new Index(columns: ['published', 'created_at']),
new Index(columns: ['user_id']),
new Index(columns: ['category_id']),
])]
class Article
{
#[Column(type: 'primary')]
public int $id;
#[Column(type: 'string', nullable: false)]
public string $title;
#[Column(type: 'text', nullable: false)]
public string $content;
#[Column(type: 'text', nullable: true)]
public ?string $excerpt;
#[Column(type: 'string', nullable: false, unique: true)]
public string $slug;
#[Column(type: 'boolean', default: false)]
public bool $published = false;
#[Column(type: 'datetime', nullable: true)]
public ?\DateTime $publish_date;
#[Column(type: 'boolean', default: false)]
public bool $premium = false;
#[Column(type: 'decimal', precision: 10, scale: 2, nullable: true)]
public ?float $price;
#[Column(type: 'string', nullable: true)]
public ?string $image;
#[Column(type: 'json', nullable: true)]
public ?array $meta = [];
#[Column(type: 'integer', default: 0)]
public int $views = 0;
#[Column(type: 'integer', default: 0)]
public int $likes = 0;
#[Column(type: 'datetime', default: 'now')]
public \DateTime $created_at;
#[Column(type: 'datetime', nullable: true)]
public ?\DateTime $updated_at;
// Связи
#[BelongsTo(target: \Flute\Modules\User\Database\Entities\User::class, nullable: false)]
public \Flute\Modules\User\Database\Entities\User $author;
#[BelongsTo(target: Category::class, nullable: true)]
public ?Category $category;
#[HasMany(target: Comment::class, nullable: false)]
public array $comments = [];
#[HasMany(target: Tag::class, through: ArticleTag::class, nullable: false)]
public array $tags = [];
// Геттеры и сеттеры
public function getId(): int
{
return $this->id;
}
public function getTitle(): string
{
return $this->title;
}
public function setTitle(string $title): void
{
$this->title = $title;
$this->updated_at = new \DateTime();
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): void
{
$this->content = $content;
$this->updated_at = new \DateTime();
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): void
{
$this->slug = $slug;
}
public function isPublished(): bool
{
return $this->published;
}
public function publish(): void
{
$this->published = true;
$this->publish_date = new \DateTime();
}
public function unpublish(): void
{
$this->published = false;
$this->publish_date = null;
}
public function isPremium(): bool
{
return $this->premium;
}
public function setPremium(bool $premium): void
{
$this->premium = $premium;
}
public function getPrice(): ?float
{
return $this->price;
}
public function setPrice(?float $price): void
{
$this->price = $price;
}
public function getImage(): ?string
{
return $this->image;
}
public function setImage(?string $image): void
{
$this->image = $image;
}
public function incrementViews(): void
{
$this->views++;
}
public function getViews(): int
{
return $this->views;
}
public function getLikes(): int
{
return $this->likes;
}
public function incrementLikes(): void
{
$this->likes++;
}
public function getUrl(): string
{
return route('blog.articles.show', ['slug' => $this->slug]);
}
public function getExcerpt(int $length = null): string
{
$length = $length ?? config('blog.excerpt_length', 150);
if ($this->excerpt) {
return Str::limit(strip_tags($this->excerpt), $length);
}
return Str::limit(strip_tags($this->content), $length);
}
public function getReadingTime(): int
{
$words = str_word_count(strip_tags($this->content));
$wordsPerMinute = 200; // Средняя скорость чтения
return ceil($words / $wordsPerMinute);
}
public function getMeta(string $key = null)
{
if ($key === null) {
return $this->meta ?? [];
}
return $this->meta[$key] ?? null;
}
public function setMeta(string $key, $value): void
{
if (!is_array($this->meta)) {
$this->meta = [];
}
$this->meta[$key] = $value;
}
public function hasTag(string $tagName): bool
{
foreach ($this->tags as $tag) {
if ($tag->name === $tagName) {
return true;
}
}
return false;
}
public function getTagNames(): array
{
return array_map(function($tag) {
return $tag->name;
}, $this->tags);
}
public function canEdit(\Flute\Modules\User\Database\Entities\User $user = null): bool
{
$user = $user ?? user();
return $user->isLoggedIn() && ($user->id === $this->author->id || $user->can('manage_blog'));
}
public function canDelete(\Flute\Modules\User\Database\Entities\User $user = null): bool
{
$user = $user ?? user();
return $user->isLoggedIn() && ($user->id === $this->author->id || $user->can('manage_blog'));
}
}Обратите внимание на ключевые особенности сущности:
- Индексы — создаются через атрибут
#[Table(indexes: [...])]для оптимизации запросов - Связи —
BelongsToдля автора и категории,HasManyдля комментариев и тегов - Методы доступа — геттеры и сеттеры для инкапсуляции логики
- Бизнес-логика — методы
publish(),unpublish(),canEdit(),canDelete()
Category.php
Сущность для категорий статей с поддержкой вложенности:
<?php
namespace Flute\Modules\Blog\Database\Entities;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Relation\HasMany;
use Cycle\Annotated\Annotation\Relation\BelongsTo;
use Cycle\Annotated\Annotation\Table\Index;
#[Entity(table: 'blog_categories')]
#[Table(indexes: [
new Index(columns: ['slug'], unique: true),
new Index(columns: ['parent_id']),
])]
class Category
{
#[Column(type: 'primary')]
public int $id;
#[Column(type: 'string', nullable: false)]
public string $name;
#[Column(type: 'string', nullable: false, unique: true)]
public string $slug;
#[Column(type: 'text', nullable: true)]
public ?string $description;
#[Column(type: 'string', nullable: true)]
public ?string $image;
#[Column(type: 'integer', nullable: true)]
public ?int $parent_id;
#[Column(type: 'integer', default: 0)]
public int $sort_order = 0;
#[Column(type: 'boolean', default: true)]
public bool $active = true;
#[Column(type: 'datetime', default: 'now')]
public \DateTime $created_at;
// Связи
#[BelongsTo(target: Category::class, nullable: true)]
public ?Category $parent;
#[HasMany(target: Category::class, nullable: false)]
public array $children = [];
#[HasMany(target: Article::class, nullable: false)]
public array $articles = [];
// Геттеры и сеттеры
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): void
{
$this->slug = $slug;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): void
{
$this->description = $description;
}
public function getUrl(): string
{
return route('blog.categories.show', ['slug' => $this->slug]);
}
public function getArticleCount(): int
{
return count($this->articles);
}
public function isActive(): bool
{
return $this->active;
}
public function activate(): void
{
$this->active = true;
}
public function deactivate(): void
{
$this->active = false;
}
public function hasChildren(): bool
{
return count($this->children) > 0;
}
public function isChild(): bool
{
return $this->parent_id !== null;
}
public function getLevel(): int
{
$level = 0;
$parent = $this->parent;
while ($parent) {
$level++;
$parent = $parent->parent;
}
return $level;
}
public function getPath(): array
{
$path = [];
$current = $this;
while ($current) {
array_unshift($path, $current);
$current = $current->parent;
}
return $path;
}
}Comment.php
Сущность для комментариев с поддержкой вложенных ответов:
<?php
namespace Flute\Modules\Blog\Database\Entities;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Relation\HasMany;
use Cycle\Annotated\Annotation\Relation\BelongsTo;
use Cycle\Annotated\Annotation\Table\Index;
#[Entity(table: 'blog_comments')]
#[Table(indexes: [
new Index(columns: ['article_id', 'created_at']),
new Index(columns: ['user_id']),
new Index(columns: ['approved', 'created_at']),
])]
class Comment
{
#[Column(type: 'primary')]
public int $id;
#[Column(type: 'text', nullable: false)]
public string $content;
#[Column(type: 'string', nullable: true)]
public ?string $author_name;
#[Column(type: 'string', nullable: true)]
public ?string $author_email;
#[Column(type: 'string', nullable: true)]
public ?string $author_ip;
#[Column(type: 'string', nullable: true)]
public ?string $user_agent;
#[Column(type: 'boolean', default: false)]
public bool $approved = false;
#[Column(type: 'integer', nullable: true)]
public ?int $parent_id;
#[Column(type: 'datetime', default: 'now')]
public \DateTime $created_at;
#[Column(type: 'datetime', nullable: true)]
public ?\DateTime $updated_at;
// Связи
#[BelongsTo(target: Article::class, nullable: false)]
public Article $article;
#[BelongsTo(target: \Flute\Modules\User\Database\Entities\User::class, nullable: true)]
public ?\Flute\Modules\User\Database\Entities\User $user;
#[BelongsTo(target: Comment::class, nullable: true)]
public ?Comment $parent;
#[HasMany(target: Comment::class, nullable: false)]
public array $replies = [];
// Геттеры и сеттеры
public function getId(): int
{
return $this->id;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): void
{
$this->content = $content;
$this->updated_at = new \DateTime();
}
public function isApproved(): bool
{
return $this->approved;
}
public function approve(): void
{
$this->approved = true;
}
public function disapprove(): void
{
$this->approved = false;
}
public function getAuthorName(): string
{
return $this->user ? $this->user->name : ($this->author_name ?? 'Гость');
}
public function getAuthorEmail(): ?string
{
return $this->user ? $this->user->email : $this->author_email;
}
public function getAuthorAvatar(): ?string
{
return $this->user ? $this->user->avatar : null;
}
public function isReply(): bool
{
return $this->parent_id !== null;
}
public function hasReplies(): bool
{
return count($this->replies) > 0;
}
public function getDepth(): int
{
$depth = 0;
$parent = $this->parent;
while ($parent) {
$depth++;
$parent = $parent->parent;
}
return $depth;
}
public function canEdit(\Flute\Modules\User\Database\Entities\User $user = null): bool
{
$user = $user ?? user();
if (!$user->isLoggedIn()) {
return false;
}
return $user->id === $this->user->id || $user->can('manage_blog_comments');
}
public function canDelete(\Flute\Modules\User\Database\Entities\User $user = null): bool
{
$user = $user ?? user();
if (!$user->isLoggedIn()) {
return false;
}
return $user->id === $this->user->id || $user->can('manage_blog_comments');
}
public function getTimeAgo(): string
{
// Простое форматирование даты
return $this->created_at->format('d.m.Y H:i');
}
}Tag.php
Сущность для тегов статей:
<?php
namespace Flute\Modules\Blog\Database\Entities;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Relation\HasMany;
use Cycle\Annotated\Annotation\Table\Index;
#[Entity(table: 'blog_tags')]
#[Table(indexes: [
new Index(columns: ['slug'], unique: true),
])]
class Tag
{
#[Column(type: 'primary')]
public int $id;
#[Column(type: 'string', nullable: false)]
public string $name;
#[Column(type: 'string', nullable: false, unique: true)]
public string $slug;
#[Column(type: 'text', nullable: true)]
public ?string $description;
#[Column(type: 'string', nullable: true)]
public ?string $color;
#[Column(type: 'integer', default: 0)]
public int $usage_count = 0;
#[Column(type: 'datetime', default: 'now')]
public \DateTime $created_at;
// Связи
#[HasMany(target: Article::class, through: ArticleTag::class, nullable: false)]
public array $articles = [];
// Геттеры и сеттеры
public function getId(): int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
public function getSlug(): string
{
return $this->slug;
}
public function setSlug(string $slug): void
{
$this->slug = $slug;
}
public function getUrl(): string
{
return route('blog.tags.show', ['slug' => $this->slug]);
}
public function getArticleCount(): int
{
return count($this->articles);
}
public function incrementUsage(): void
{
$this->usage_count++;
}
public function decrementUsage(): void
{
if ($this->usage_count > 0) {
$this->usage_count--;
}
}
}ArticleTag.php
Связующая таблица для связи многие-ко-многим между статьями и тегами:
<?php
namespace Flute\Modules\Blog\Database\Entities;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;
use Cycle\Annotated\Annotation\Relation\BelongsTo;
#[Entity(table: 'blog_article_tags')]
class ArticleTag
{
#[Column(type: 'primary')]
public int $id;
#[BelongsTo(target: Article::class, nullable: false)]
public Article $article;
#[BelongsTo(target: Tag::class, nullable: false)]
public Tag $tag;
}Провайдер модуля
Провайдер — это точка входа модуля, где регистрируются все сервисы, события, виджеты и выполняется настройка модуля:
<?php
namespace Flute\Modules\Blog\Providers;
use Flute\Core\Support\ModuleServiceProvider;
use Flute\Modules\Blog\Services\ArticleService;
use Flute\Modules\Blog\Services\CommentService;
use Flute\Modules\Blog\Widgets\RecentArticlesWidget;
use Flute\Modules\Blog\Widgets\CategoriesWidget;
use Flute\Modules\Blog\Events\ArticlePublished;
use Flute\Modules\Blog\Listeners\SendNotificationListener;
class BlogProvider extends ModuleServiceProvider
{
protected ?string $moduleName = 'Blog';
/**
* Регистрация сервисов в DI контейнере
*/
public function register(\DI\Container $container): void
{
// Регистрация сервисов как синглтонов
$container->set(ArticleService::class, \DI\autowire());
$container->set(CommentService::class, \DI\autowire());
}
/**
* Загрузка модуля после регистрации всех сервисов
*/
public function boot(\DI\Container $container): void
{
// Автоматическая загрузка ресурсов через bootstrapModule()
// Это загрузит: сущности, конфиги, переводы, маршруты, компоненты, виджеты, SCSS
$this->bootstrapModule();
// Дополнительная загрузка views с namespace
$this->loadViews('Resources/views', 'blog');
// Загрузка SCSS стилей модуля
$this->loadScss('Resources/assets/scss/blog.scss');
// Регистрация платежного шлюза (если платежи включены)
$this->registerPaymentGateway();
// Регистрация виджетов
$this->registerWidgets();
// Регистрация событий и слушателей
$this->registerEvents();
// Регистрация консольных команд
$this->registerCommands();
// Интеграция с профилем пользователя
$this->registerProfileIntegration();
// Настройка RSS ленты
$this->setupRssFeed();
}
/**
* Регистрация платежного шлюза для премиум статей
*/
protected function registerPaymentGateway(): void
{
// Проверяем, включены ли платежи в конфигурации
if (!config('blog.payments.enabled', false)) {
return;
}
// Используем отложенный слушатель, чтобы дождаться инициализации системы платежей
events()->addDeferredListener(
\Flute\Core\Modules\Payments\Events\RegisterPaymentFactoriesEvent::NAME,
function($event) {
$factory = app(\Flute\Core\Modules\Payments\Factories\PaymentDriverFactory::class);
$factory->register('blog_premium', \Flute\Modules\Blog\Gateways\PremiumGateway::class);
}
);
}
/**
* Регистрация виджетов модуля
*/
protected function registerWidgets(): void
{
$widgetManager = app(\Flute\Core\Modules\Page\Services\WidgetManager::class);
// Виджет последних статей
$widgetManager->registerWidget('blog_recent_articles', RecentArticlesWidget::class);
// Виджет категорий
$widgetManager->registerWidget('blog_categories', CategoriesWidget::class);
}
/**
* Регистрация событий и слушателей
*/
protected function registerEvents(): void
{
// Отложенный слушатель для события публикации статьи
events()->addDeferredListener(
ArticlePublished::NAME,
[SendNotificationListener::class, 'handleArticlePublished']
);
}
/**
* Регистрация консольных команд
*/
protected function registerCommands(): void
{
if (is_cli()) {
// Регистрация консольных команд
$this->registerConsoleCommands();
}
}
/**
* Интеграция с профилем пользователя
* Добавляет вкладку со статьями пользователя в его профиль
*/
protected function registerProfileIntegration(): void
{
events()->addDeferredListener(
\Flute\Core\Template\Events\TemplateInitialized::NAME,
function($event) {
$tpl = $event->getTemplate();
// Добавление вкладки в профиль
$tpl->prependTemplateToSection('profile_tabs', 'blog::partials.profile-tab', [
'user' => user()
]);
// Добавление контента вкладки
$tpl->prependTemplateToSection('profile_tab_content', 'blog::partials.profile-content', [
'user' => user()
]);
}
);
}
/**
* Настройка RSS ленты
*/
protected function setupRssFeed(): void
{
if (!config('blog.rss.enabled', true)) {
return;
}
// Регистрация маршрута для RSS
router()->get('/blog/rss', function() {
$articles = rep(\Flute\Modules\Blog\Database\Entities\Article::class)
->select()
->where('published', true)
->orderBy('created_at', 'DESC')
->limit(config('blog.rss.items_count', 20))
->fetchAll();
// Генерация RSS через шаблон
return response()
->make(view('blog::rss', compact('articles'))->render())
->withHeader('Content-Type', 'application/rss+xml');
})->name('blog.rss');
}
/**
* Регистрация консольных команд
*/
protected function registerConsoleCommands(): void
{
// Регистрация команд для очистки кеша, генерации sitemap и т.д.
$command = new \Flute\Modules\Blog\Console\Commands\GenerateSitemapCommand();
// Регистрация в консольном приложении
}
}Контроллеры
BlogController.php
Основной контроллер для публичной части блога:
<?php
namespace Flute\Modules\Blog\Controllers;
use Flute\Core\Router\Annotations\Get;
use Flute\Core\Router\Annotations\Post;
use Flute\Core\Support\BaseController;
use Flute\Modules\Blog\Services\ArticleService;
use Flute\Modules\Blog\Services\CommentService;
use Flute\Modules\Blog\Database\Entities\Article;
class BlogController extends BaseController
{
protected ArticleService $articleService;
protected CommentService $commentService;
public function __construct(ArticleService $articleService, CommentService $commentService)
{
$this->articleService = $articleService;
$this->commentService = $commentService;
}
/**
* Главная страница блога со списком статей
*/
#[Get('/blog', name: 'blog.index')]
public function index()
{
$page = (int) request()->get('page', 1);
$perPage = config('blog.articles_per_page', 10);
$category = request()->get('category');
$tag = request()->get('tag');
$search = request()->get('search');
// Получаем статьи с фильтрацией
$articles = $this->articleService->getArticlesPaginated([
'page' => $page,
'per_page' => $perPage,
'category' => $category,
'tag' => $tag,
'search' => $search,
]);
// Получаем категории и теги для сайдбара
$categories = $this->articleService->getCategories();
$tags = $this->articleService->getPopularTags();
return response()->view('blog::pages.index', compact(
'articles',
'categories',
'tags',
'category',
'tag',
'search'
));
}
/**
* Просмотр отдельной статьи
*/
#[Get('/blog/{slug}', name: 'blog.articles.show')]
public function show($slug)
{
$article = $this->articleService->getArticleBySlug($slug);
if (!$article) {
return $this->error(__('blog.article_not_found'), 404);
}
// Проверка публикации (неопубликованные могут видеть только авторы)
if (!$article->published && !$article->canEdit()) {
return $this->error(__('blog.access_denied'), 403);
}
// Проверка премиум доступа
if ($article->premium && !$this->checkPremiumAccess($article)) {
return $this->showPremiumGate($article);
}
// Увеличение счетчика просмотров
$this->articleService->incrementViews($article);
// Получаем комментарии и похожие статьи
$comments = $this->commentService->getArticleComments($article->id);
$relatedArticles = $this->articleService->getRelatedArticles($article);
return response()->view('blog::pages.show', compact(
'article',
'comments',
'relatedArticles'
));
}
/**
* Добавление комментария к статье
*/
#[Post('/blog/{slug}/comments', name: 'blog.articles.comments.store')]
public function storeComment($slug)
{
$article = $this->articleService->getArticleBySlug($slug);
if (!$article) {
return $this->error(__('blog.article_not_found'), 404);
}
// Проверяем, включены ли комментарии
if (!config('blog.comments.enabled', true)) {
return $this->error(__('blog.comments_disabled'), 403);
}
// Валидация данных
$validated = $this->validate(request()->all(), [
'content' => 'required|string|min-str-len:10|max-str-len:1000',
'parent_id' => 'nullable|integer',
]);
if ($validated !== true) {
return $this->json($this->errors()->getErrors(), 422);
}
try {
$comment = $this->commentService->createComment([
'article_id' => $article->id,
'content' => request()->get('content'),
'parent_id' => request()->get('parent_id'),
]);
// JSON ответ для AJAX запросов
if (request()->expectsJson()) {
return $this->json([
'success' => true,
'comment' => $comment,
'message' => __('blog.comment_added')
]);
}
$this->flash(__('blog.comment_added'), 'success');
return redirect()->back();
} catch (\Exception $e) {
logs('blog')->error('Failed to create comment', [
'article_id' => $article->id,
'error' => $e->getMessage()
]);
if (request()->expectsJson()) {
return $this->json(['error' => __('blog.comment_error')], 500);
}
$this->flash(__('blog.comment_error'), 'error');
return redirect()->back();
}
}
/**
* Лайк статьи
*/
#[Post('/blog/{slug}/like', name: 'blog.articles.like')]
public function likeArticle($slug)
{
$article = $this->articleService->getArticleBySlug($slug);
if (!$article) {
return $this->error(__('blog.article_not_found'), 404);
}
if (!$article->published) {
return $this->error(__('blog.access_denied'), 403);
}
try {
$this->articleService->likeArticle($article);
return $this->json([
'success' => true,
'likes' => $article->likes,
'message' => __('blog.article_liked')
]);
} catch (\Exception $e) {
return $this->json(['error' => __('blog.like_error')], 500);
}
}
/**
* Проверка премиум доступа к статье
*/
protected function checkPremiumAccess(Article $article): bool
{
if (!user()->isLoggedIn()) {
return false;
}
// Проверка покупки премиум доступа
$purchase = rep(\Flute\Modules\Payments\Database\Entities\Purchase::class)
->select()
->where('user_id', user()->id)
->where('item_type', 'blog_article')
->where('item_id', $article->id)
->fetchOne();
return $purchase !== null;
}
/**
* Показать страницу покупки премиум статьи
*/
protected function showPremiumGate(Article $article)
{
if (!user()->isLoggedIn()) {
return redirect(route('auth.login'));
}
return response()->view('blog::pages.premium', compact('article'));
}
}Сервисы
ArticleService.php
Сервис инкапсулирует бизнес-логику работы со статьями:
<?php
namespace Flute\Modules\Blog\Services;
use Flute\Modules\Blog\Database\Entities\Article;
use Flute\Modules\Blog\Database\Entities\Category;
use Flute\Modules\Blog\Database\Entities\Tag;
use Flute\Modules\Blog\Events\ArticlePublished;
class ArticleService
{
/**
* Получение статей с пагинацией и фильтрацией
*/
public function getArticlesPaginated(array $params = [])
{
$query = rep(Article::class)->select()
->where('published', true)
->load('author')
->load('category')
->load('tags')
->orderBy('created_at', 'DESC');
// Фильтр по категории
if (isset($params['category'])) {
$query->where('category.slug', $params['category']);
}
// Фильтр по тегу
if (isset($params['tag'])) {
$query->where('tags.slug', $params['tag']);
}
// Поиск по заголовку и содержимому
if (isset($params['search'])) {
$search = $params['search'];
$query->where(function($q) use ($search) {
$q->where('title', 'LIKE', "%{$search}%")
->orWhere('content', 'LIKE', "%{$search}%");
});
}
// Фильтр по автору
if (isset($params['author_id'])) {
$query->where('user_id', $params['author_id']);
}
$perPage = $params['per_page'] ?? config('blog.articles_per_page', 10);
$page = $params['page'] ?? 1;
return $query->paginate($perPage, $page);
}
/**
* Получение статьи по slug
*/
public function getArticleBySlug($slug)
{
return rep(Article::class)->select()
->where('slug', $slug)
->load('author')
->load('category')
->load('tags')
->fetchOne();
}
/**
* Получение статьи по ID
*/
public function getArticleById($id)
{
return rep(Article::class)->select()
->where('id', $id)
->load('author')
->load('category')
->load('tags')
->fetchOne();
}
/**
* Создание новой статьи
*/
public function createArticle(array $data)
{
$article = new Article();
$article->title = $data['title'];
$article->content = $data['content'];
$article->excerpt = $data['excerpt'] ?? null;
$article->slug = $this->generateUniqueSlug($data['title']);
$article->user_id = user()->id;
$article->created_at = new \DateTime();
// Назначение категории
if (isset($data['category_id'])) {
$category = rep(Category::class)->findByPK($data['category_id']);
if ($category) {
$article->category = $category;
}
}
// Настройки премиум статьи
if (isset($data['premium'])) {
$article->premium = $data['premium'];
$article->price = $data['price'] ?? null;
}
// Сохранение статьи
transaction($article)->run();
// Синхронизация тегов
if (isset($data['tags'])) {
$this->syncTags($article, $data['tags']);
}
// Обработка и сохранение изображения
if (isset($data['image'])) {
$article->image = $this->processImage($data['image']);
transaction($article)->run();
}
return $article;
}
/**
* Обновление статьи
*/
public function updateArticle($id, array $data)
{
$article = $this->getArticleById($id);
if (!$article) {
throw new \Exception('Article not found');
}
if (!$article->canEdit()) {
throw new \Exception('Access denied');
}
$oldPublished = $article->published;
// Обновление полей
if (isset($data['title'])) {
$article->title = $data['title'];
// Автоматическая генерация slug если не указан явно
if (!isset($data['slug']) || empty($data['slug'])) {
$article->slug = $this->generateUniqueSlug($data['title'], $article->id);
}
}
if (isset($data['content'])) {
$article->content = $data['content'];
}
if (isset($data['excerpt'])) {
$article->excerpt = $data['excerpt'];
}
if (isset($data['published'])) {
$article->published = $data['published'];
}
if (isset($data['premium'])) {
$article->premium = $data['premium'];
$article->price = $data['price'] ?? null;
}
$article->updated_at = new \DateTime();
transaction($article)->run();
// Обновление категории
if (isset($data['category_id'])) {
if ($data['category_id']) {
$category = rep(Category::class)->findByPK($data['category_id']);
$article->category = $category;
} else {
$article->category = null;
}
transaction($article)->run();
}
// Синхронизация тегов
if (isset($data['tags'])) {
$this->syncTags($article, $data['tags']);
}
// Событие публикации (если статья только что опубликована)
if (!$oldPublished && $article->published) {
events()->dispatch(new ArticlePublished($article));
}
return $article;
}
/**
* Удаление статьи
*/
public function deleteArticle($id)
{
$article = $this->getArticleById($id);
if (!$article) {
throw new \Exception('Article not found');
}
if (!$article->canDelete()) {
throw new \Exception('Access denied');
}
// Удаление связанных данных
$this->deleteArticleTags($article);
$this->deleteArticleComments($article);
// Удаление изображения
if ($article->image) {
$this->deleteImage($article->image);
}
transaction($article, 'delete')->run();
return true;
}
/**
* Увеличение счетчика просмотров
*/
public function incrementViews(Article $article)
{
$article->incrementViews();
transaction($article)->run();
}
/**
* Добавление лайка
*/
public function likeArticle(Article $article)
{
$article->incrementLikes();
transaction($article)->run();
}
/**
* Получение всех активных категорий
*/
public function getCategories()
{
return rep(Category::class)->select()
->where('active', true)
->orderBy('sort_order')
->fetchAll();
}
/**
* Получение популярных тегов
*/
public function getPopularTags($limit = 20)
{
return rep(Tag::class)->select()
->orderBy('usage_count', 'DESC')
->limit($limit)
->fetchAll();
}
/**
* Получение похожих статей
*/
public function getRelatedArticles(Article $article, $limit = 5)
{
// Сначала ищем статьи из той же категории
if ($article->category) {
return rep(Article::class)->select()
->where('category_id', $article->category->id)
->where('id', '!=', $article->id)
->where('published', true)
->orderBy('created_at', 'DESC')
->limit($limit)
->fetchAll();
}
// Если нет категории, ищем по тегам
$tagIds = array_map(function($tag) {
return $tag->id;
}, $article->tags);
if (!empty($tagIds)) {
return rep(Article::class)->select()
->where('tags.id', 'IN', $tagIds)
->where('id', '!=', $article->id)
->where('published', true)
->orderBy('created_at', 'DESC')
->limit($limit)
->fetchAll();
}
// Если ничего не нашли — возвращаем популярные статьи
return rep(Article::class)->select()
->where('published', true)
->orderBy('views', 'DESC')
->limit($limit)
->fetchAll();
}
/**
* Генерация уникального slug
*/
protected function generateUniqueSlug($title, $excludeId = null)
{
$slug = str()->slug($title);
$originalSlug = $slug;
$counter = 1;
while (true) {
$query = rep(Article::class)->select()->where('slug', $slug);
if ($excludeId) {
$query->where('id', '!=', $excludeId);
}
if (!$query->fetchOne()) {
break;
}
$slug = $originalSlug . '-' . $counter;
$counter++;
}
return $slug;
}
/**
* Синхронизация тегов статьи
*/
protected function syncTags(Article $article, array $tagNames)
{
$existingTags = [];
$newTagNames = [];
foreach ($tagNames as $tagName) {
$tag = rep(Tag::class)->select()
->where('name', $tagName)
->fetchOne();
if ($tag) {
$existingTags[] = $tag;
} else {
$newTagNames[] = $tagName;
}
}
// Создание новых тегов
foreach ($newTagNames as $tagName) {
$tag = new Tag();
$tag->name = $tagName;
$tag->slug = str()->slug($tagName);
transaction($tag)->run();
$existingTags[] = $tag;
}
// Назначение тегов статье
$article->tags = $existingTags;
transaction($article)->run();
}
/**
* Удаление тегов статьи
*/
protected function deleteArticleTags(Article $article)
{
foreach ($article->tags as $tag) {
// Уменьшение счетчика использования
$tag->decrementUsage();
transaction($tag)->run();
}
}
/**
* Удаление комментариев статьи
*/
protected function deleteArticleComments(Article $article)
{
foreach ($article->comments as $comment) {
transaction($comment, 'delete')->run();
}
}
/**
* Обработка и сохранение изображения
*/
protected function processImage($file)
{
$imageConfig = config('blog.images', []);
// Валидация размера
if ($file->getSize() > ($imageConfig['max_size'] ?? 2048) * 1024) {
throw new \Exception('Image too large');
}
// Валидация типа
$extension = strtolower($file->getClientOriginalExtension());
if (!in_array($extension, $imageConfig['allowed_types'] ?? ['jpg', 'png'])) {
throw new \Exception('Invalid image type');
}
// Сохранение оригинала
$filename = time() . '_' . uniqid() . '.' . $extension;
$file->move(public_path('uploads/blog'), $filename);
// Создание миниатюр
$this->createThumbnails($filename);
return $filename;
}
/**
* Создание миниатюр изображения
*/
protected function createThumbnails($filename)
{
$thumbnails = config('blog.images.thumbnails', []);
foreach ($thumbnails as $name => $size) {
// Создание миниатюры с помощью Intervention Image
$image = \Intervention\Image\ImageManagerStatic::make(public_path('uploads/blog/' . $filename));
$image->fit($size['width'], $size['height']);
$image->save(public_path('uploads/blog/thumbnails/' . $name . '_' . $filename));
}
}
/**
* Удаление изображения и всех миниатюр
*/
protected function deleteImage($filename)
{
$path = public_path('uploads/blog/' . $filename);
if (file_exists($path)) {
unlink($path);
}
// Удаление миниатюр
$thumbnails = config('blog.images.thumbnails', []);
foreach (array_keys($thumbnails) as $name) {
$thumbnailPath = public_path('uploads/blog/thumbnails/' . $name . '_' . $filename);
if (file_exists($thumbnailPath)) {
unlink($thumbnailPath);
}
}
}
}Представления
layouts/app.blade.php
Базовый макет для страниц блога:
{{-- Resources/views/layouts/app.blade.php --}}
@extends('flute::layouts.app')
@section('styles')
@parent
<link rel="stylesheet" href="{{ asset('css/blog.css') }}">
@endsection
@push('content')
<div class="blog-container">
<header class="blog-header">
<div class="container">
<div class="d-flex justify-content-between align-items-center">
<div class="blog-brand">
<h1><a href="{{ route('blog.index') }}">{{ __('blog.title') }}</a></h1>
<p class="blog-description">{{ __('blog.description') }}</p>
</div>
@if(config('blog.show_search', true))
<div class="blog-search">
<form action="{{ route('blog.index') }}" method="GET" class="d-flex">
<x-forms.field class="mb-0 flex-grow-1">
<x-input name="search" :value="request()->get('search')" :placeholder="__('blog.search_placeholder')" />
</x-forms.field>
<button type="submit" class="btn btn-primary ms-2">
<x-icon path="search" />
</button>
</form>
</div>
@endif
</div>
</div>
</header>
<nav class="blog-navigation">
<div class="container">
<ul class="nav nav-pills">
<li class="nav-item">
<a class="nav-link {{ is_active('blog.index') ? 'active' : '' }}"
href="{{ route('blog.index') }}">
{{ __('blog.all_articles') }}
</a>
</li>
@php
$categories = app(\Flute\Modules\Blog\Services\ArticleService::class)->getCategories();
@endphp
@foreach($categories as $category)
<li class="nav-item">
<a class="nav-link {{ request()->get('category') === $category->slug ? 'active' : '' }}"
href="{{ route('blog.index', ['category' => $category->slug]) }}">
{{ $category->name }}
</a>
</li>
@endforeach
</ul>
</div>
</nav>
<main class="blog-content">
<div class="container">
@yield('blog-content')
</div>
</main>
@include('blog::partials.footer')
</div>
@endpush
@push('scripts')
<script src="{{ asset('js/blog.js') }}"></script>
@endpushpages/index.blade.php
Страница со списком статей:
{{-- Resources/views/pages/index.blade.php --}}
@extends('blog::layouts.app')
@section('title', __('blog.articles') . ' - ' . config('blog.name'))
@section('blog-content')
<div class="row">
<div class="col-lg-8">
@if($articles->count() > 0)
<div class="articles-list">
@foreach($articles as $article)
<x-blog::article-card :article="$article" class="mb-4" />
@endforeach
</div>
{{-- Пагинация --}}
<div class="d-flex justify-content-center mt-4">
{{ $articles->links('blog::partials.pagination') }}
</div>
@else
<div class="text-center py-5">
<div class="mb-4">
<x-icon path="newspaper" class="fa-3x text-muted" />
</div>
<h3>{{ __('blog.no_articles') }}</h3>
<p class="text-muted">{{ __('blog.no_articles_description') }}</p>
@can('create_articles')
<a href="{{ route('blog.articles.create') }}" class="btn btn-primary">
{{ __('blog.create_first_article') }}
</a>
@endcan
</div>
@endif
</div>
<div class="col-lg-4">
@include('blog::partials.sidebar')
</div>
</div>
@endsectioncomponents/article-card.blade.php
Компонент карточки статьи:
{{-- Resources/views/components/article-card.blade.php --}}
@props(['article'])
<article class="card article-card h-100">
@if($article->image)
<div class="article-image">
<img src="{{ asset('uploads/blog/' . $article->image) }}"
alt="{{ $article->title }}"
class="card-img-top"
loading="lazy">
@if($article->premium)
<div class="premium-badge">
<x-icon path="crown" />
{{ __('blog.premium') }}
</div>
@endif
</div>
@endif
<div class="card-body">
<div class="article-meta mb-2">
@if(config('blog.show_category', true) && $article->category)
<span class="badge bg-primary">
<a href="{{ route('blog.index', ['category' => $article->category->slug]) }}"
class="text-decoration-none text-white">
{{ $article->category->name }}
</a>
</span>
@endif
@if(config('blog.show_date', true))
<small class="text-muted ms-2">
<x-icon path="calendar" />
{{ $article->created_at->format('d.m.Y') }}
</small>
@endif
</div>
<h5 class="card-title">
<a href="{{ $article->getUrl() }}" class="text-decoration-none">
{{ $article->title }}
</a>
</h5>
@if($article->excerpt)
<p class="card-text">{{ $article->excerpt }}</p>
@endif
<div class="article-footer">
@if(config('blog.show_author', true))
<div class="article-author">
<x-icon path="user" />
<a href="{{ route('profile.show', ['id' => $article->author->id]) }}">
{{ $article->author->name }}
</a>
</div>
@endif
<div class="article-stats">
@if(config('blog.show_views', true))
<small class="text-muted me-3">
<x-icon path="eye" />
{{ $article->views }}
</small>
@endif
@if(config('blog.show_comments', true))
<small class="text-muted">
<x-icon path="comments" />
{{ $article->comments->count() }}
</small>
@endif
</div>
</div>
@if($article->tags->count() > 0)
<div class="article-tags mt-2">
@foreach($article->tags as $tag)
<span class="badge bg-secondary me-1">
<a href="{{ route('blog.index', ['tag' => $tag->slug]) }}"
class="text-decoration-none text-white">
{{ $tag->name }}
</a>
</span>
@endforeach
</div>
@endif
</div>
<div class="card-footer bg-transparent">
<div class="d-flex justify-content-between align-items-center">
<a href="{{ $article->getUrl() }}" class="btn btn-primary btn-sm">
{{ __('blog.read_more') }}
</a>
@if(user()->isLoggedIn())
<form action="{{ route('blog.articles.like', ['slug' => $article->slug]) }}"
method="POST" class="d-inline like-form">
@csrf
<button type="submit" class="btn btn-outline-danger btn-sm">
<x-icon path="heart" />
<span class="likes-count">{{ $article->likes }}</span>
</button>
</form>
@else
<span class="text-muted">
<x-icon path="heart" />
{{ $article->likes }}
</span>
@endif
</div>
</div>
</article>Виджеты
RecentArticlesWidget.php
Виджет для отображения последних статей:
<?php
namespace Flute\Modules\Blog\Widgets;
use Flute\Core\Modules\Page\Widgets\Contracts\WidgetInterface;
use Flute\Modules\Blog\Database\Entities\Article;
class RecentArticlesWidget implements WidgetInterface
{
/**
* Название виджета
*/
public function getName(): string
{
return __('blog.widget.recent_articles');
}
/**
* Иконка виджета
*/
public function getIcon(): string
{
return '<x-icon path="clock" />';
}
/**
* Настройки виджета
*/
public function getSettings(): array
{
return [
'limit' => [
'type' => 'number',
'label' => __('blog.widget.limit'),
'default' => 5,
'min' => 1,
'max' => 20,
],
'show_excerpt' => [
'type' => 'boolean',
'label' => __('blog.widget.show_excerpt'),
'default' => false,
],
'show_date' => [
'type' => 'boolean',
'label' => __('blog.widget.show_date'),
'default' => true,
],
'show_author' => [
'type' => 'boolean',
'label' => __('blog.widget.show_author'),
'default' => false,
],
];
}
/**
* Отрисовка виджета
*/
public function render(array $settings): string|null
{
$limit = $settings['limit'] ?? 5;
$articles = rep(Article::class)->select()
->where('published', true)
->load('author')
->orderBy('created_at', 'DESC')
->limit($limit)
->fetchAll();
if ($articles->count() === 0) {
return null;
}
return view('blog::widgets.recent-articles', [
'articles' => $articles,
'settings' => $settings,
])->render();
}
/**
* Форма настроек виджета
*/
public function renderSettingsForm(array $settings): string|bool
{
return view('blog::widgets.recent-articles-settings', [
'settings' => $settings,
])->render();
}
/**
* Валидация настроек
*/
public function validateSettings(array $input): true|array
{
$validator = validator();
$validated = $validator->validate($input, [
'limit' => 'required|integer|min:1|max:20',
'show_excerpt' => 'boolean',
'show_date' => 'boolean',
'show_author' => 'boolean',
]);
if (!$validated) {
return $validator->getErrors()->toArray();
}
return true;
}
/**
* Сохранение настроек
*/
public function saveSettings(array $input): array
{
return $input;
}
/**
* Ширина виджета по умолчанию
*/
public function getDefaultWidth(): int
{
return 12;
}
/**
* Минимальная ширина
*/
public function getMinWidth(): int
{
return 6;
}
/**
* Наличие настроек
*/
public function hasSettings(): bool
{
return true;
}
/**
* Кнопки действий
*/
public function getButtons(): array
{
return [];
}
/**
* Обработка действий
*/
public function handleAction(string $action, ?string $widgetId = null): array
{
return ['success' => false, 'message' => 'Action not supported'];
}
/**
* Категория виджета
*/
public function getCategory(): string
{
return 'blog';
}
}Переводы
messages.php (en)
<?php
// Resources/lang/en/messages.php
return [
'title' => 'Blog',
'description' => 'Latest articles and news',
'articles' => 'Articles',
'article' => 'Article',
'all_articles' => 'All Articles',
'create_article' => 'Create Article',
'edit_article' => 'Edit Article',
'delete_article' => 'Delete Article',
'save_article' => 'Save Article',
'publish_article' => 'Publish Article',
'article_created' => 'Article created successfully',
'article_updated' => 'Article updated successfully',
'article_deleted' => 'Article deleted successfully',
'article_published' => 'Article published successfully',
'article_not_found' => 'Article not found',
'no_articles' => 'No articles found',
'no_articles_description' => 'There are no articles in this category yet',
'create_first_article' => 'Create First Article',
'read_more' => 'Read More',
'by' => 'by',
'on' => 'on',
'premium' => 'Premium',
'comments' => 'Comments',
'comment' => 'Comment',
'add_comment' => 'Add Comment',
'comment_added' => 'Comment added successfully',
'comments_disabled' => 'Comments are disabled',
'comment_error' => 'Failed to add comment',
'reply' => 'Reply',
'replies' => 'replies',
'tags' => 'Tags',
'categories' => 'Categories',
'category' => 'Category',
'select_category' => 'Select category',
'search' => 'Search',
'search_placeholder' => 'Search articles...',
'views' => 'Views',
'likes' => 'Likes',
'like' => 'Like',
'article_liked' => 'Article liked',
'like_error' => 'Failed to like article',
'related_articles' => 'Related Articles',
'popular_articles' => 'Popular Articles',
'latest_articles' => 'Latest Articles',
'access_denied' => 'Access denied',
'confirm_delete' => 'Are you sure you want to delete this article?',
'loading' => 'Loading...',
'error_occurred' => 'An error occurred',
'success' => 'Success',
'cancel' => 'Cancel',
'save' => 'Save',
'edit' => 'Edit',
'delete' => 'Delete',
'view' => 'View',
'back' => 'Back',
'next' => 'Next',
'previous' => 'Previous',
'page' => 'Page',
'of' => 'of',
'showing' => 'Showing',
'to' => 'to',
'results' => 'results',
'no_results' => 'No results found',
'filter' => 'Filter',
'sort_by' => 'Sort by',
'date' => 'Date',
'author' => 'Author',
'popularity' => 'Popularity',
'newest' => 'Newest',
'oldest' => 'Oldest',
'most_viewed' => 'Most Viewed',
'most_liked' => 'Most Liked',
// Виджеты
'widget' => [
'recent_articles' => 'Recent Articles',
'limit' => 'Number of articles',
'show_excerpt' => 'Show excerpt',
'show_date' => 'Show date',
'show_author' => 'Show author',
],
];messages.php (ru)
<?php
// Resources/lang/ru/messages.php
return [
'title' => 'Блог',
'description' => 'Последние статьи и новости',
'articles' => 'Статьи',
'article' => 'Статья',
'all_articles' => 'Все статьи',
'create_article' => 'Создать статью',
'edit_article' => 'Редактировать статью',
'delete_article' => 'Удалить статью',
'save_article' => 'Сохранить статью',
'publish_article' => 'Опубликовать статью',
'article_created' => 'Статья успешно создана',
'article_updated' => 'Статья успешно обновлена',
'article_deleted' => 'Статья успешно удалена',
'article_published' => 'Статья успешно опубликована',
'article_not_found' => 'Статья не найдена',
'no_articles' => 'Статьи не найдены',
'no_articles_description' => 'В этой категории пока нет статей',
'create_first_article' => 'Создать первую статью',
'read_more' => 'Читать далее',
'by' => 'автор',
'on' => 'от',
'premium' => 'Премиум',
'comments' => 'Комментарии',
'comment' => 'Комментарий',
'add_comment' => 'Добавить комментарий',
'comment_added' => 'Комментарий успешно добавлен',
'comments_disabled' => 'Комментарии отключены',
'comment_error' => 'Не удалось добавить комментарий',
'reply' => 'Ответить',
'replies' => 'ответов',
'tags' => 'Теги',
'categories' => 'Категории',
'category' => 'Категория',
'select_category' => 'Выберите категорию',
'search' => 'Поиск',
'search_placeholder' => 'Поиск статей...',
'views' => 'Просмотры',
'likes' => 'Лайки',
'like' => 'Нравится',
'article_liked' => 'Статья отмечена как понравившаяся',
'like_error' => 'Не удалось отметить статью',
'related_articles' => 'Похожие статьи',
'popular_articles' => 'Популярные статьи',
'latest_articles' => 'Последние статьи',
'access_denied' => 'Доступ запрещен',
'confirm_delete' => 'Вы уверены, что хотите удалить эту статью?',
'loading' => 'Загрузка...',
'error_occurred' => 'Произошла ошибка',
'success' => 'Успешно',
'cancel' => 'Отмена',
'save' => 'Сохранить',
'edit' => 'Редактировать',
'delete' => 'Удалить',
'view' => 'Просмотр',
'back' => 'Назад',
'next' => 'Далее',
'previous' => 'Назад',
'page' => 'Страница',
'of' => 'из',
'showing' => 'Показаны',
'to' => 'по',
'results' => 'результатов',
'no_results' => 'Результаты не найдены',
'filter' => 'Фильтр',
'sort_by' => 'Сортировать по',
'date' => 'Дате',
'author' => 'Автору',
'popularity' => 'Популярности',
'newest' => 'Сначала новые',
'oldest' => 'Сначала старые',
'most_viewed' => 'По просмотрам',
'most_liked' => 'По лайкам',
// Виджеты
'widget' => [
'recent_articles' => 'Последние статьи',
'limit' => 'Количество статей',
'show_excerpt' => 'Показывать отрывок',
'show_date' => 'Показывать дату',
'show_author' => 'Показывать автора',
],
];Итоги
Этот полный пример модуля демонстрирует все основные возможности Flute CMS:
- Структура модуля — правильная организация файлов и директорий
- Конфигурация —
module.jsonи файлы конфигурации - Сущности ORM — работа с Cycle ORM и ActiveRecord
- Провайдер модуля — регистрация сервисов и настройка модуля
- Контроллеры — обработка HTTP запросов с атрибутами маршрутов
- Сервисы — инкапсуляция бизнес-логики
- Представления — Blade шаблоны с компонентами
- Виджеты — создание настраиваемых виджетов
- Локализация — многоязычная поддержка
Модуль готов к использованию и может быть легко расширен дополнительными функциями, такими как административный интерфейс, API endpoints, события и слушатели.