This commit is contained in:
F4ilji
2026-04-09 09:09:17 +05:00
parent 169a2f5654
commit e986940680
14 changed files with 312 additions and 112 deletions
@@ -8,6 +8,7 @@ use Filament\Support\Contracts\HasColor;
enum PostStatus: string implements HasLabel, HasColor enum PostStatus: string implements HasLabel, HasColor
{ {
case DRAFT = 'draft';
case VERIFICATION = 'verification'; case VERIFICATION = 'verification';
case PUBLISHED = 'published'; case PUBLISHED = 'published';
case REJECTED = 'rejected'; case REJECTED = 'rejected';
@@ -15,6 +16,7 @@ enum PostStatus: string implements HasLabel, HasColor
public function getLabel(): ?string public function getLabel(): ?string
{ {
return match ($this) { return match ($this) {
self::DRAFT => 'Черновик',
self::VERIFICATION => 'На рассмотрении', self::VERIFICATION => 'На рассмотрении',
self::PUBLISHED => 'Опубликовано', self::PUBLISHED => 'Опубликовано',
self::REJECTED => 'Отклонено', self::REJECTED => 'Отклонено',
@@ -24,6 +26,7 @@ enum PostStatus: string implements HasLabel, HasColor
public function getColor(): string|array|null public function getColor(): string|array|null
{ {
return match ($this) { return match ($this) {
self::DRAFT => 'gray',
self::VERIFICATION => 'warning', self::VERIFICATION => 'warning',
self::PUBLISHED => 'success', self::PUBLISHED => 'success',
self::REJECTED => 'gray', self::REJECTED => 'gray',
@@ -5,7 +5,6 @@ namespace App\Containers\Dashboard\Actions\Posts;
use App\Containers\Article\Models\Post; use App\Containers\Article\Models\Post;
use App\Containers\Dashboard\Tasks\Posts\CreatePostTask; use App\Containers\Dashboard\Tasks\Posts\CreatePostTask;
use App\Containers\Dashboard\Tasks\Posts\HandlePostSliderTask; use App\Containers\Dashboard\Tasks\Posts\HandlePostSliderTask;
use App\Containers\Dashboard\Tasks\Posts\PublishPostToVkTask;
use App\Containers\Dashboard\Tasks\Posts\SendPostNotificationTask; use App\Containers\Dashboard\Tasks\Posts\SendPostNotificationTask;
class CreatePostAction class CreatePostAction
@@ -14,7 +13,6 @@ class CreatePostAction
private readonly CreatePostTask $createPostTask, private readonly CreatePostTask $createPostTask,
private readonly HandlePostSliderTask $handleSliderTask, private readonly HandlePostSliderTask $handleSliderTask,
private readonly SendPostNotificationTask $sendNotificationTask, private readonly SendPostNotificationTask $sendNotificationTask,
private readonly PublishPostToVkTask $publishToVkTask,
) {} ) {}
/** /**
@@ -27,7 +25,6 @@ class CreatePostAction
{ {
// Извлекаем данные слайдера и публикации // Извлекаем данные слайдера и публикации
$slideData = $data['slide'] ?? []; $slideData = $data['slide'] ?? [];
$shouldPublishToVk = $data['publication']['vk'] ?? false;
// Удаляем служебные данные перед созданием // Удаляем служебные данные перед созданием
unset($data['slide'], $data['publication']); unset($data['slide'], $data['publication']);
@@ -41,9 +38,6 @@ class CreatePostAction
// Отправляем уведомления (Task) // Отправляем уведомления (Task)
$this->sendNotificationTask->run($post, null, true); $this->sendNotificationTask->run($post, null, true);
// Публикуем в VK (Task)
$this->publishToVkTask->run($post, $shouldPublishToVk, false);
return $post; return $post;
} }
} }
@@ -4,7 +4,6 @@ namespace App\Containers\Dashboard\Actions\Posts;
use App\Containers\Article\Models\Post; use App\Containers\Article\Models\Post;
use App\Containers\Dashboard\Tasks\Posts\HandlePostSliderTask; use App\Containers\Dashboard\Tasks\Posts\HandlePostSliderTask;
use App\Containers\Dashboard\Tasks\Posts\PublishPostToVkTask;
use App\Containers\Dashboard\Tasks\Posts\SendPostNotificationTask; use App\Containers\Dashboard\Tasks\Posts\SendPostNotificationTask;
use App\Containers\Dashboard\Tasks\Posts\UpdatePostTask; use App\Containers\Dashboard\Tasks\Posts\UpdatePostTask;
@@ -14,7 +13,6 @@ class UpdatePostAction
private readonly UpdatePostTask $updatePostTask, private readonly UpdatePostTask $updatePostTask,
private readonly HandlePostSliderTask $handleSliderTask, private readonly HandlePostSliderTask $handleSliderTask,
private readonly SendPostNotificationTask $sendNotificationTask, private readonly SendPostNotificationTask $sendNotificationTask,
private readonly PublishPostToVkTask $publishToVkTask,
) {} ) {}
/** /**
@@ -28,8 +26,6 @@ class UpdatePostAction
{ {
// Извлекаем данные слайдера и публикации // Извлекаем данные слайдера и публикации
$slideData = $data['slide'] ?? []; $slideData = $data['slide'] ?? [];
$shouldPublishToVk = $data['publication']['vk'] ?? false;
$newStatus = $data['status'] ?? null;
// Удаляем служебные данные перед обновлением // Удаляем служебные данные перед обновлением
unset($data['slide'], $data['publication']); unset($data['slide'], $data['publication']);
@@ -41,10 +37,7 @@ class UpdatePostAction
$this->handleSliderTask->run($post, $slideData, false); $this->handleSliderTask->run($post, $slideData, false);
// Отправляем уведомления (Task) // Отправляем уведомления (Task)
$this->sendNotificationTask->run($post, $newStatus, false); $this->sendNotificationTask->run($post, $data['status'] ?? null, false);
// Публикуем в VK (Task)
$this->publishToVkTask->run($post, $shouldPublishToVk, true);
return $post; return $post;
} }
@@ -0,0 +1,41 @@
<?php
namespace App\Containers\Dashboard\Actions\Posts;
use App\Containers\Dashboard\Tasks\Files\UploadFileTask;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
class UploadPostImagesAction
{
public function __construct(
private readonly UploadFileTask $uploadFileTask,
) {}
/**
* Загружает массив изображений и возвращает пути
*
* @param UploadedFile[] $files Массив загруженных файлов
* @return string[] Массив путей к файлам
*/
public function run(array $files): array
{
$paths = [];
foreach ($files as $file) {
if (!$file instanceof UploadedFile) {
continue;
}
// Проверяем что это изображение
if (!str_starts_with($file->getMimeType(), 'image/')) {
continue;
}
$result = $this->uploadFileTask->run($file);
$paths[] = $result['path'];
}
return $paths;
}
}
@@ -4,8 +4,6 @@ namespace App\Containers\Dashboard\Tasks\Posts;
use App\Containers\Article\Enums\PostStatus; use App\Containers\Article\Enums\PostStatus;
use App\Containers\Article\Models\Category; use App\Containers\Article\Models\Category;
use App\Containers\Widget\Models\Slider;
use Illuminate\Support\Collection;
class GetPostFormDataTask class GetPostFormDataTask
{ {
@@ -13,7 +11,6 @@ class GetPostFormDataTask
{ {
return [ return [
'categories' => Category::all(['id', 'title']), 'categories' => Category::all(['id', 'title']),
'sliders' => Slider::where('is_active', true)->get(['id', 'title']),
'statuses' => PostStatus::cases(), 'statuses' => PostStatus::cases(),
]; ];
} }
@@ -13,6 +13,7 @@ use App\Containers\Dashboard\Actions\Posts\GetPostFormDataAction;
use App\Containers\Dashboard\Actions\Posts\ListAiPreparedPostsAction; use App\Containers\Dashboard\Actions\Posts\ListAiPreparedPostsAction;
use App\Containers\Dashboard\Actions\Posts\ListPostsAction; use App\Containers\Dashboard\Actions\Posts\ListPostsAction;
use App\Containers\Dashboard\Actions\Posts\UpdatePostAction; use App\Containers\Dashboard\Actions\Posts\UpdatePostAction;
use App\Containers\Dashboard\Actions\Posts\UploadPostImagesAction;
use App\Containers\Dashboard\UI\WEB\Requests\StorePostRequest; use App\Containers\Dashboard\UI\WEB\Requests\StorePostRequest;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
@@ -31,6 +32,7 @@ class PostController extends Controller
private readonly BulkDeletePostsAction $bulkDeletePostsAction, private readonly BulkDeletePostsAction $bulkDeletePostsAction,
private readonly BulkPublishPostsAction $bulkPublishPostsAction, private readonly BulkPublishPostsAction $bulkPublishPostsAction,
private readonly BulkVerificationPostsAction $bulkVerificationPostsAction, private readonly BulkVerificationPostsAction $bulkVerificationPostsAction,
private readonly UploadPostImagesAction $uploadPostImagesAction,
) {} ) {}
/** /**
@@ -49,7 +51,7 @@ class PostController extends Controller
public function store(StorePostRequest $request): RedirectResponse public function store(StorePostRequest $request): RedirectResponse
{ {
try { try {
$this->createPostAction->run($request->validated()); $post = $this->createPostAction->run($request->validated());
return redirect()->route('dashboard.posts.index') return redirect()->route('dashboard.posts.index')
->with('success', 'Новость успешно создана!'); ->with('success', 'Новость успешно создана!');
@@ -71,6 +73,7 @@ class PostController extends Controller
return Inertia::render('Dashboard/Posts/Edit', [ return Inertia::render('Dashboard/Posts/Edit', [
'post' => [ 'post' => [
...$post->toArray(), ...$post->toArray(),
'status' => $post->status?->value ?? $post->status,
'publish_setting' => [ 'publish_setting' => [
'publish_after' => $post->publish_at !== null, 'publish_after' => $post->publish_at !== null,
'publish_at' => $post->publish_at, 'publish_at' => $post->publish_at,
@@ -215,4 +218,29 @@ class PostController extends Controller
->with('error', 'Ошибка: ' . $e->getMessage()); ->with('error', 'Ошибка: ' . $e->getMessage());
} }
} }
/**
* Загружает изображения для поста и возвращает пути
*/
public function uploadImages(Request $request): \Illuminate\Http\JsonResponse
{
$request->validate([
'images' => 'required|array',
'images.*' => 'required|image|mimes:jpeg,png,jpg,webp|max:20480',
]);
try {
$paths = $this->uploadPostImagesAction->run($request->file('images'));
return response()->json([
'success' => true,
'paths' => $paths,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'error' => 'Ошибка при загрузке изображений: ' . $e->getMessage(),
], 500);
}
}
} }
@@ -12,12 +12,67 @@ class StorePostRequest extends FormRequest
return true; return true;
} }
/**
* Prepare the data for validation.
*
* Decodes JSON-encoded fields from FormData back to arrays.
*/
protected function prepareForValidation(): void
{
$fieldsToDecode = [
'content',
'tags',
'authors',
'images',
'publish_setting',
'publication',
'slide',
];
foreach ($fieldsToDecode as $field) {
$value = $this->input($field);
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
$this->merge([$field => $decoded]);
}
}
}
// Handle nested slide properties
if (is_array($this->input('slide'))) {
$slideData = $this->input('slide');
if (isset($slideData['image']) && is_string($slideData['image'])) {
$decoded = json_decode($slideData['image'], true);
if (json_last_error() === JSON_ERROR_NONE) {
$slideData['image'] = $decoded;
}
}
if (isset($slideData['settings']) && is_string($slideData['settings'])) {
$decoded = json_decode($slideData['settings'], true);
if (json_last_error() === JSON_ERROR_NONE) {
$slideData['settings'] = $decoded;
}
}
$this->merge(['slide' => $slideData]);
}
// Удаляем preview если это пустой объект
$preview = $this->input('preview');
if ($preview === '{}' || $preview === 'null' || $preview === null || $preview === '') {
$this->merge(['preview' => null]);
}
}
public function rules(): array public function rules(): array
{ {
return [ return [
'title' => ['required', 'string', 'max:255'], 'title' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', Rule::unique('posts', 'slug')->ignore($this->route('post'))], 'slug' => ['required', 'string', 'max:255', Rule::unique('posts', 'slug')->ignore($this->route('post'))],
'status' => ['required', 'integer', Rule::in([0, 1, 2, 3])], // PostStatus enum values 'status' => ['required', 'string', Rule::in(['draft', 'published', 'verification', 'rejected'])],
'category_id' => ['nullable', 'integer', 'exists:categories,id'], 'category_id' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array'], 'tags' => ['nullable', 'array'],
'authors' => ['nullable', 'array'], 'authors' => ['nullable', 'array'],
@@ -26,7 +81,7 @@ class StorePostRequest extends FormRequest
'images' => ['nullable', 'array'], 'images' => ['nullable', 'array'],
'publish_setting' => ['nullable', 'array'], 'publish_setting' => ['nullable', 'array'],
'publish_setting.publish_after' => ['nullable', 'boolean'], 'publish_setting.publish_after' => ['nullable', 'boolean'],
'publish_setting.publish_at' => ['nullable', 'date', 'after:now'], 'publish_setting.publish_at' => ['nullable', 'date'],
'publication' => ['nullable', 'array'], 'publication' => ['nullable', 'array'],
'publication.vk' => ['nullable', 'boolean'], 'publication.vk' => ['nullable', 'boolean'],
'publication.telegram' => ['nullable', 'boolean'], 'publication.telegram' => ['nullable', 'boolean'],
@@ -54,7 +109,7 @@ class StorePostRequest extends FormRequest
'slug.required' => 'URL-адрес обязателен', 'slug.required' => 'URL-адрес обязателен',
'slug.unique' => 'Такой URL-адрес уже используется', 'slug.unique' => 'Такой URL-адрес уже используется',
'status.required' => 'Статус публикации обязателен', 'status.required' => 'Статус публикации обязателен',
'status.in' => 'Некорректный статус публикации', 'status.in' => 'Некорректный статус публикации. Допустимые значения: draft, published, verification, rejected',
'category_id.exists' => 'Выбранная категория не существует', 'category_id.exists' => 'Выбранная категория не существует',
'content.required' => 'Содержание новости обязательно', 'content.required' => 'Содержание новости обязательно',
'publish_setting.publish_at.after' => 'Дата публикации должна быть в будущем', 'publish_setting.publish_at.after' => 'Дата публикации должна быть в будущем',
@@ -68,6 +68,7 @@ Route::middleware(['access-check', 'dashboard.auth'])->group(function () {
Route::post('/ai-prepared/parse-email', [ParseEmailNewsController::class, '__invoke'])->name('ai-prepared.parse-email'); Route::post('/ai-prepared/parse-email', [ParseEmailNewsController::class, '__invoke'])->name('ai-prepared.parse-email');
Route::get('/create', [PostController::class, 'create'])->name('create'); Route::get('/create', [PostController::class, 'create'])->name('create');
Route::post('/', [PostController::class, 'store'])->name('store'); Route::post('/', [PostController::class, 'store'])->name('store');
Route::post('/upload-images', [PostController::class, 'uploadImages'])->name('upload-images');
Route::delete('/bulk-destroy', [PostController::class, 'bulkDestroy'])->name('bulk-destroy'); Route::delete('/bulk-destroy', [PostController::class, 'bulkDestroy'])->name('bulk-destroy');
Route::post('/bulk-publish', [PostController::class, 'bulkPublish'])->name('bulk-publish'); Route::post('/bulk-publish', [PostController::class, 'bulkPublish'])->name('bulk-publish');
Route::post('/bulk-verification', [PostController::class, 'bulkVerification'])->name('bulk-verification'); Route::post('/bulk-verification', [PostController::class, 'bulkVerification'])->name('bulk-verification');
@@ -16,28 +16,33 @@ class PostDataProcessor
*/ */
public function processCreate(array $data): array public function processCreate(array $data): array
{ {
// Конвертируем статус в enum, если необходимо
if (isset($data['status']) && !$data['status'] instanceof PostStatus) {
$data['status'] = PostStatus::tryFrom($data['status']) ?? PostStatus::DRAFT;
}
// Удаляем ненужные данные // Удаляем ненужные данные
unset($data['publication']); unset($data['publication']);
// Устанавливаем текст для предпросмотра // Устанавливаем текст для предпросмотра
$data['preview_text'] = $this->setPreviewText($data); $data['preview_text'] = $this->setPreviewText($data);
if (($data['status']->value === PostStatus::PUBLISHED->value)) { if ($data['status'] === PostStatus::PUBLISHED) {
$data['publish_at'] = $this->setPublishDateTime(); $data['publish_at'] = $this->setPublishDateTime();
} }
// Устанавливаем время публикации // Устанавливаем время публикации
if ($data['publish_setting']['publish_after'] === true) { if (isset($data['publish_setting']['publish_after']) && $data['publish_setting']['publish_after'] === true) {
$data['publish_at'] = $this->setPublishDateTimeInFuture($data['publish_setting']); $data['publish_at'] = $this->setPublishDateTimeInFuture($data['publish_setting']);
} }
unset($data['publish_setting']); unset($data['publish_setting']);
// Генерируем данные для поиска // Генерируем данные для поиска только если content - массив
$data['search_data'] = $this->generateSearchData($data['content']); if (isset($data['content']) && is_array($data['content'])) {
$data['search_data'] = $this->generateSearchData($data['content']);
// Рассчитываем время чтения $data['reading_time'] = $this->calculateReadingTime($data['search_data']);
$data['reading_time'] = $this->calculateReadingTime($data['search_data']); }
// Устанавливаем ID текущего пользователя // Устанавливаем ID текущего пользователя
$data['user_id'] = auth()->id(); $data['user_id'] = auth()->id();
@@ -48,32 +53,36 @@ class PostDataProcessor
public function processUpdate(array $data): array public function processUpdate(array $data): array
{ {
// Конвертируем статус в enum, если необходимо
if (isset($data['status']) && !$data['status'] instanceof PostStatus) { if (isset($data['status']) && !$data['status'] instanceof PostStatus) {
$data['status'] = PostStatus::tryFrom($data['status']); $data['status'] = PostStatus::tryFrom($data['status']) ?? PostStatus::DRAFT;
} }
// Если статус не установлен, используем черновик
if (!isset($data['status'])) {
$data['status'] = PostStatus::DRAFT;
}
unset($data['publication']); unset($data['publication']);
// Устанавливаем текст для предпросмотра // Устанавливаем текст для предпросмотра
$data['preview_text'] = $this->setPreviewText($data); $data['preview_text'] = $this->setPreviewText($data);
// Обновляем publish_at из publish_setting если дата передана
if (!$data['publish_at'] && ($data['status']->value === PostStatus::PUBLISHED->value)) { if (isset($data['publish_setting']['publish_at']) && $data['publish_setting']['publish_at']) {
$data['publish_at'] = Carbon::parse($data['publish_setting']['publish_at']);
} elseif (!isset($data['publish_at']) && $data['status'] === PostStatus::PUBLISHED) {
// Если дата не передана и пост публикуется — ставим текущее время
$data['publish_at'] = $this->setPublishDateTime(); $data['publish_at'] = $this->setPublishDateTime();
} }
// Устанавливаем время публикации
if ((isset($data['publish_setting']['publish_after']) && $data['publish_setting']['publish_after']) ||
(isset($data['publish_setting']['publish_at']) && $data['publish_setting']['publish_at'])) {
$data['publish_at'] = $this->setPublishDateTimeInFuture($data['publish_setting']);
}
unset($data['publish_setting']); unset($data['publish_setting']);
// Генерируем данные для поиска // Генерируем данные для поиска только если content - массив
$data['search_data'] = $this->generateSearchData($data['content']); if (isset($data['content']) && is_array($data['content'])) {
$data['search_data'] = $this->generateSearchData($data['content']);
// Рассчитываем время чтения $data['reading_time'] = $this->calculateReadingTime($data['search_data']);
$data['reading_time'] = $this->calculateReadingTime($data['search_data']); }
return $data; return $data;
} }
@@ -86,6 +95,11 @@ class PostDataProcessor
*/ */
private function setPreviewText(array $data): string private function setPreviewText(array $data): string
{ {
// Если content не массив или пустой, возвращаем пустую строку
if (!isset($data['content']) || !is_array($data['content']) || empty($data['content'])) {
return '';
}
$rowData = $this->getBlockBySeoActiveState('paragraph', $data['content']); $rowData = $this->getBlockBySeoActiveState('paragraph', $data['content']);
if ($rowData === null) { if ($rowData === null) {
$rowData = $this->getFirstBlockByName('paragraph', $data['content']); $rowData = $this->getFirstBlockByName('paragraph', $data['content']);
@@ -29,7 +29,7 @@ class VkPostPublisher
$imagesFromPost = $post->images ?? []; $imagesFromPost = $post->images ?? [];
$imagesFromContent = $this->extractImagesFromContent($post->content); $imagesFromContent = $this->extractImagesFromContent($post->content);
$allImages = array_merge($imagesFromPost, $imagesFromContent); $allImages = array_merge($imagesFromPost, $imagesFromContent);
$imageLinks = $this->generateImageLinksForVk($allImages); $imageLinks = array_filter($this->generateImageLinksForVk($allImages)); // Удаляем null
$videos = $this->extractVideosFromContent($post->content); $videos = $this->extractVideosFromContent($post->content);
$publishDate = $post->publish_at > now() ? $post->publish_at->timestamp : null; $publishDate = $post->publish_at > now() ? $post->publish_at->timestamp : null;
@@ -54,7 +54,7 @@ class VkPostPublisher
$imagesFromPost = $post->images ?? []; $imagesFromPost = $post->images ?? [];
$imagesFromContent = $this->extractImagesFromContent($post->content); $imagesFromContent = $this->extractImagesFromContent($post->content);
$allImages = array_merge($imagesFromPost, $imagesFromContent); $allImages = array_merge($imagesFromPost, $imagesFromContent);
$imageLinks = $this->generateImageLinksForVk($allImages); $imageLinks = array_filter($this->generateImageLinksForVk($allImages)); // Удаляем null
$videos = $this->extractVideosFromContent($post->content); $videos = $this->extractVideosFromContent($post->content);
$publishDate = $post->publish_at > now() ? $post->publish_at->timestamp : null; $publishDate = $post->publish_at > now() ? $post->publish_at->timestamp : null;
@@ -183,7 +183,11 @@ class VkPostPublisher
private function generateImageLinksForVk(array $images): array private function generateImageLinksForVk(array $images): array
{ {
return array_map(function ($file) { return array_map(function ($file) {
return Storage::url($file); // Генерируем полный URL для изображения // Пропускаем невалидные данные (пустые массивы, null и т.д.)
if (!is_string($file) || empty($file)) {
return null;
}
return Storage::url($file);
}, $images); }, $images);
} }
} }
@@ -22,7 +22,7 @@
<div <div
v-for="(block, index) in blocks" v-for="(block, index) in blocks"
:key="block._uid" :key="block._uid"
class="group relative bg-white border border-layer-line rounded-lg overflow-hidden" class="group relative bg-white border border-layer-line rounded-lg"
> >
<!-- Block Header --> <!-- Block Header -->
<div class="flex items-center gap-2 px-4 py-3 bg-muted/30 border-b border-layer-line"> <div class="flex items-center gap-2 px-4 py-3 bg-muted/30 border-b border-layer-line">
@@ -1,5 +1,5 @@
<template> <template>
<div class="space-y-3"> <div class="space-y-3" style="overflow: visible;">
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
<input <input
type="checkbox" type="checkbox"
@@ -12,7 +12,7 @@
</label> </label>
</div> </div>
<div> <div style="overflow: visible;">
<label class="block text-sm font-medium text-foreground mb-1"> <label class="block text-sm font-medium text-foreground mb-1">
Текст <span class="text-danger">*</span> Текст <span class="text-danger">*</span>
</label> </label>
@@ -90,9 +90,9 @@ export default {
tinymce.init({ tinymce.init({
selector: `#${this.editorId}`, selector: `#${this.editorId}`,
license_key: 'gpl', license_key: 'gpl',
autoresize_bottom_margin: 20, min_height: 400,
autoresize_overflow_padding: 20, autoresize_bottom_margin: 30,
max_height: 600, autoresize_overflow_padding: 30,
menubar: false, menubar: false,
statusbar: true, statusbar: true,
branding: false, branding: false,
@@ -105,14 +105,13 @@ export default {
toolbar: 'undo redo | blocks | ' + toolbar: 'undo redo | blocks | ' +
'bold italic forecolor | alignleft aligncenter ' + 'bold italic forecolor | alignleft aligncenter ' +
'alignright alignjustify | bullist numlist outdent indent | ' + 'alignright alignjustify | bullist numlist outdent indent | ' +
'removeformat | help', 'removeformat | help | fullscreen',
content_style: 'body { font-family: Inter, -apple-system, sans-serif; font-size: 14px; }', content_style: 'body { font-family: Inter, -apple-system, sans-serif; font-size: 14px; }',
setup: (editor) => { setup: (editor) => {
this.editor = editor; this.editor = editor;
editor.on('init', () => { editor.on('init', () => {
editor.setContent(this.modelValue.content || ''); editor.setContent(this.modelValue.content || '');
this.loading = false; this.loading = false;
// Emit initial content to ensure parent has correct value
this.update('content', editor.getContent()); this.update('content', editor.getContent());
}); });
editor.on('change', () => { editor.on('change', () => {
+121 -59
View File
@@ -96,7 +96,6 @@
<DashboardIcon v-if="tab.id === 'main'" name="information-circle" size="4" :class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground-2'" /> <DashboardIcon v-if="tab.id === 'main'" name="information-circle" size="4" :class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground-2'" />
<DashboardIcon v-if="tab.id === 'content'" name="document-text" size="4" :class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground-2'" /> <DashboardIcon v-if="tab.id === 'content'" name="document-text" size="4" :class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground-2'" />
<DashboardIcon v-if="tab.id === 'media'" name="photo" size="4" :class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground-2'" /> <DashboardIcon v-if="tab.id === 'media'" name="photo" size="4" :class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground-2'" />
<DashboardIcon v-if="tab.id === 'slider'" name="presentation-chart-bar" size="4" :class="activeTab === tab.id ? 'text-primary' : 'text-muted-foreground-2'" />
{{ tab.label }} {{ tab.label }}
</button> </button>
</div> </div>
@@ -305,7 +304,8 @@
</div> </div>
</div> </div>
<!-- Social Media --> <!-- Social Media ВРЕМЕННО СКРЫТО -->
<!--
<div class="border border-layer-line rounded-lg overflow-hidden"> <div class="border border-layer-line rounded-lg overflow-hidden">
<div class="px-4 py-3 bg-surface/50 border-b border-line-2"> <div class="px-4 py-3 bg-surface/50 border-b border-line-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -325,9 +325,12 @@
Опубликовать ВКонтакте Опубликовать ВКонтакте
</label> </label>
</div> </div>
<p class="mt-2 text-xs text-muted-foreground-1">Новость будет автоматически опубликована в VK</p> <p class="mt-2 text-xs text-muted-foreground-1">
{{ isEdit ? 'Новость будет обновлена в VK' : 'Новость будет автоматически опубликована в VK' }}
</p>
</div> </div>
</div> </div>
-->
</div> </div>
<!-- Tab: Content --> <!-- Tab: Content -->
@@ -473,14 +476,19 @@
<div class="space-y-3 text-center"> <div class="space-y-3 text-center">
<div class="flex justify-center"> <div class="flex justify-center">
<div :class="[isDraggingGallery ? 'bg-primary/20' : 'bg-primary/10', 'w-12 h-12 rounded-lg flex items-center justify-center transition-all']"> <div :class="[isDraggingGallery ? 'bg-primary/20' : 'bg-primary/10', 'w-12 h-12 rounded-lg flex items-center justify-center transition-all']">
<DashboardIcon name="cloud-arrow-up" size="6" class="text-primary" /> <svg v-if="uploadingImages" class="animate-spin w-6 h-6 text-primary" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<DashboardIcon v-else name="cloud-arrow-up" size="6" class="text-primary" />
</div> </div>
</div> </div>
<div> <div>
<p class="text-sm font-medium text-foreground"> <p class="text-sm font-medium text-foreground">
<span class="text-primary">Нажмите для выбора</span> или перетащите файлы <span v-if="uploadingImages">Загрузка...</span>
<span v-else><span class="text-primary">Нажмите для выбора</span> или перетащите файлы</span>
</p> </p>
<p class="text-xs text-muted-foreground-1 mt-1">JPG, PNG (до 20MB каждый, макс. 200 файлов)</p> <p class="text-xs text-muted-foreground-1 mt-1">JPG, PNG, WebP (до 20MB каждый, макс. 200 файлов)</p>
</div> </div>
</div> </div>
</div> </div>
@@ -546,14 +554,6 @@
</p> </p>
</div> </div>
</div> </div>
<!-- Tab: Slider -->
<div v-if="activeTab === 'slider'" class="space-y-6">
<div class="text-center py-12">
<DashboardIcon name="information-circle" size="16" class="text-gray-400 mx-auto mb-4" />
<p class="text-sm text-gray-600">Управление слайдами доступно в разделе "Слайдеры" Dashboard</p>
</div>
</div>
</div> </div>
</div> </div>
</form> </form>
@@ -582,10 +582,6 @@ export default {
type: Array, type: Array,
required: true required: true
}, },
sliders: {
type: Array,
required: true
},
statuses: { statuses: {
type: Array, type: Array,
required: true required: true
@@ -596,10 +592,10 @@ export default {
return { return {
activeTab: 'main', activeTab: 'main',
isEdit: false, isEdit: false,
isSliderEnabled: false,
activeButton: true, activeButton: true,
isDraggingPreview: false, isDraggingPreview: false,
isDraggingGallery: false, isDraggingGallery: false,
uploadingImages: false,
newTag: '', newTag: '',
newAuthor: '', newAuthor: '',
PostStatus, PostStatus,
@@ -642,8 +638,7 @@ export default {
tabs: [ tabs: [
{ id: 'main', label: 'Основная информация' }, { id: 'main', label: 'Основная информация' },
{ id: 'content', label: 'Содержание' }, { id: 'content', label: 'Содержание' },
{ id: 'media', label: 'Медиа' }, { id: 'media', label: 'Медиа' }
{ id: 'slider', label: 'Слайдер' }
], ],
slugGenerated: false slugGenerated: false
} }
@@ -685,25 +680,24 @@ export default {
if (typeof this.form.preview === 'string') { if (typeof this.form.preview === 'string') {
return `/storage/${this.form.preview}`; return `/storage/${this.form.preview}`;
} }
return URL.createObjectURL(this.form.preview); if (this.form.preview instanceof File || this.form.preview instanceof Blob) {
return URL.createObjectURL(this.form.preview);
}
return null;
}, },
galleryUrls() { galleryUrls() {
return this.form.images.map(img => { return this.form.images.map(img => {
if (!img) return null;
if (typeof img === 'string') { if (typeof img === 'string') {
return `/storage/${img}`; return `/storage/${img}`;
} }
return URL.createObjectURL(img); if (img instanceof File || img instanceof Blob) {
}); return URL.createObjectURL(img);
}
return null;
}).filter(url => url !== null);
}, },
slideImageUrl() {
if (!this.form.slide.image.url) return null;
if (typeof this.form.slide.image.url === 'string') {
return `/storage/${this.form.slide.image.url}`;
}
return URL.createObjectURL(this.form.slide.image.url);
}
}, },
beforeUnmount() { beforeUnmount() {
@@ -729,6 +723,7 @@ export default {
initializeForm() { initializeForm() {
const p = this.post; const p = this.post;
this.form = { this.form = {
...this.form, ...this.form,
title: p.title || '', title: p.title || '',
@@ -739,13 +734,11 @@ export default {
authors: p.authors || [], authors: p.authors || [],
content: p.content || [], content: p.content || [],
preview: p.preview || null, preview: p.preview || null,
images: p.images || [], images: Array.isArray(p.images) ? [...p.images] : [],
publish_setting: p.publish_setting || { publish_after: false, publish_at: null }, publish_setting: p.publish_setting || { publish_after: false, publish_at: null },
publication: p.publication || { vk: true, telegram: true }, publication: p.publication || { vk: true, telegram: true },
slide: p.slide || this.form.slide slide: p.slide || this.form.slide
}; };
this.isSliderEnabled = !!(p.slide && p.slide.slider_id);
}, },
inputClass(field) { inputClass(field) {
@@ -793,14 +786,44 @@ export default {
handlePreviewDrop(e) { handlePreviewDrop(e) {
const files = e.dataTransfer.files; const files = e.dataTransfer.files;
if (files.length > 0 && this.isImageFile(files[0])) { if (files.length > 0 && this.isImageFile(files[0])) {
this.form.preview = files[0]; this.uploadPreviewToServer(files[0]);
} }
}, },
handlePreviewFileSelect(e) { handlePreviewFileSelect(e) {
const file = e.target.files[0]; const file = e.target.files[0];
if (file && this.isImageFile(file)) { if (file && this.isImageFile(file)) {
this.form.preview = file; this.uploadPreviewToServer(file);
}
},
async uploadPreviewToServer(file) {
if (!file) return;
this.uploadingImages = true;
try {
const formData = new FormData();
formData.append('images[]', file);
const response = await fetch(route('dashboard.posts.upload-images'), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: formData,
});
const result = await response.json();
if (result.success && result.paths && result.paths.length > 0) {
this.form.preview = result.paths[0];
}
} catch (error) {
console.error('Preview upload error:', error);
} finally {
this.uploadingImages = false;
} }
}, },
@@ -810,16 +833,48 @@ export default {
handleGalleryDrop(e) { handleGalleryDrop(e) {
const files = Array.from(e.dataTransfer.files).filter(f => this.isImageFile(f)); const files = Array.from(e.dataTransfer.files).filter(f => this.isImageFile(f));
files.forEach(file => { this.uploadImagesToServer(files);
this.form.images.push(file);
});
}, },
handleGalleryFileSelect(e) { handleGalleryFileSelect(e) {
const files = Array.from(e.target.files).filter(f => this.isImageFile(f)); const files = Array.from(e.target.files).filter(f => this.isImageFile(f));
files.forEach(file => { this.uploadImagesToServer(files);
this.form.images.push(file); // Сбрасываем input чтобы можно было выбрать те же файлы снова
}); e.target.value = '';
},
async uploadImagesToServer(files) {
if (files.length === 0) return;
this.uploadingImages = true;
try {
const formData = new FormData();
files.forEach(file => {
formData.append('images[]', file);
});
const response = await fetch(route('dashboard.posts.upload-images'), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: formData,
});
const result = await response.json();
if (result.success && result.paths) {
result.paths.forEach(path => {
this.form.images.push(path);
});
}
} catch (error) {
console.error('Image upload error:', error);
} finally {
this.uploadingImages = false;
}
}, },
triggerGalleryInput() { triggerGalleryInput() {
@@ -884,27 +939,36 @@ export default {
}); });
}, },
handleSlideImageSelect(e) {
const file = e.target.files[0];
if (file && this.isImageFile(file)) {
this.form.slide.image.url = file;
}
},
isImageFile(file) {
return ['image/jpeg', 'image/png', 'image/webp'].includes(file.type);
},
submitForm() { submitForm() {
this.form.processing = true; this.form.processing = true;
this.form.errors = {}; this.form.errors = {};
// Фильтруем изображения - оставляем только строки (пути)
this.form.images = this.form.images.filter(img => typeof img === 'string' && img.length > 0);
const formData = new FormData(); const formData = new FormData();
Object.keys(this.form).forEach(key => { Object.keys(this.form).forEach(key => {
if (key === 'errors' || key === 'processing') return; if (key === 'errors' || key === 'processing') return;
const value = this.form[key]; let value = this.form[key];
// Пропускаем preview если null, пустая строка или пустой объект
if (key === 'preview') {
if (!value || value === '' || (typeof value === 'object' && Object.keys(value).length === 0)) {
return;
}
}
// Пропускаем images если массив пустой
if (key === 'images' && Array.isArray(value) && value.length === 0) return;
// Пропускаем category_id если null
if (key === 'category_id' && (value === null || value === '')) return;
// Пропускаем slide если slider_id null и нет данных
if (key === 'slide' && value && !value.slider_id && !value.title && !value.content) return;
if (typeof value === 'object' && value !== null) { if (typeof value === 'object' && value !== null) {
formData.append(key, JSON.stringify(value)); formData.append(key, JSON.stringify(value));
} else { } else {
@@ -916,15 +980,13 @@ export default {
? route('dashboard.posts.update', this.post.id) ? route('dashboard.posts.update', this.post.id)
: route('dashboard.posts.store'); : route('dashboard.posts.store');
const method = this.isEdit ? 'POST' : 'POST';
if (this.isEdit) { if (this.isEdit) {
formData.append('_method', 'PUT'); formData.append('_method', 'PUT');
} }
this.$inertia.post(url, formData, { this.$inertia.post(url, formData, {
preserveScroll: true, preserveScroll: false,
onSuccess: () => { onSuccess: (page) => {
this.form.processing = false; this.form.processing = false;
}, },
onError: (errors) => { onError: (errors) => {
@@ -194,6 +194,13 @@
</td> </td>
<td class="px-6 py-4 whitespace-nowrap text-right"> <td class="px-6 py-4 whitespace-nowrap text-right">
<div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity"> <div class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<Link
:href="route('dashboard.pages.edit', page.id)"
class="p-2 text-muted-foreground-1 hover:text-primary hover:bg-primary/10 rounded-lg transition-all"
title="Редактировать"
>
<DashboardIcon name="pencil-square" size="4" />
</Link>
<button <button
@click.prevent="detachPage(page)" @click.prevent="detachPage(page)"
class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all" class="p-2 text-muted-foreground-1 hover:text-rose-600 hover:bg-rose-500/10 rounded-lg transition-all"
@@ -224,6 +231,7 @@
</template> </template>
<script> <script>
import { Link } from '@inertiajs/vue3';
import DashboardIcon from '../Components/DashboardIcon.vue'; import DashboardIcon from '../Components/DashboardIcon.vue';
import FlashMessages from '../Components/shared/FlashMessages.vue'; import FlashMessages from '../Components/shared/FlashMessages.vue';
import EmptyState from '../Components/shared/EmptyState.vue'; import EmptyState from '../Components/shared/EmptyState.vue';
@@ -231,6 +239,7 @@ import EmptyState from '../Components/shared/EmptyState.vue';
export default { export default {
name: 'SubSectionEdit', name: 'SubSectionEdit',
components: { components: {
Link,
DashboardIcon, DashboardIcon,
FlashMessages, FlashMessages,
EmptyState EmptyState