changes
This commit is contained in:
@@ -20,6 +20,6 @@ class SubSection extends Model
|
|||||||
|
|
||||||
public function pages() : HasMany
|
public function pages() : HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(Page::class);
|
return $this->hasMany(Page::class)->orderBy('sort', 'asc');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ class PageNavigateResource extends JsonResource
|
|||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
'slug' => $this->slug,
|
'slug' => $this->slug,
|
||||||
'path' => $this->path,
|
'path' => $this->path,
|
||||||
'is_url' => $this->is_url,
|
'is_url' => $this->is_url
|
||||||
'icon' => $this->icon
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class PageResource extends JsonResource
|
|||||||
'path' => $this->path,
|
'path' => $this->path,
|
||||||
'is_url' => $this->is_url,
|
'is_url' => $this->is_url,
|
||||||
'settings' => $this->settings,
|
'settings' => $this->settings,
|
||||||
'icon' => $this->icon,
|
|
||||||
'section' => $this->section ? $this->section->title : null,
|
'section' => $this->section ? $this->section->title : null,
|
||||||
'created_at' => $this->created_at->diffforhumans()
|
'created_at' => $this->created_at->diffforhumans()
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class CreatePageAction
|
|||||||
*/
|
*/
|
||||||
public function run(array $data): Page
|
public function run(array $data): Page
|
||||||
{
|
{
|
||||||
|
// Ensure icon is never saved even if sent from old forms
|
||||||
|
unset($data['icon']);
|
||||||
|
|
||||||
// Генерируем search_data из контента
|
// Генерируем search_data из контента
|
||||||
if (!empty($data['content'])) {
|
if (!empty($data['content'])) {
|
||||||
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
|
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ class UpdatePageAction
|
|||||||
*/
|
*/
|
||||||
public function run(Page $page, array $data): Page
|
public function run(Page $page, array $data): Page
|
||||||
{
|
{
|
||||||
|
// Ensure icon is never saved even if sent from old forms
|
||||||
|
unset($data['icon']);
|
||||||
|
|
||||||
// Если контент изменился, перегенерируем search_data
|
// Если контент изменился, перегенерируем search_data
|
||||||
if (isset($data['content']) && json_encode($data['content']) !== json_encode($page->content)) {
|
if (isset($data['content']) && json_encode($data['content']) !== json_encode($page->content)) {
|
||||||
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
|
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ class DeploySiteAction
|
|||||||
unlink($this->logFile);
|
unlink($this->logFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Запускаем deploy.sh в фоне через sudo (полный путь + NOPASSWD для www-data)
|
// Запускаем deploy.sh в фоне (docker socket проброшен в контейнер)
|
||||||
$command = sprintf(
|
$command = sprintf(
|
||||||
'/usr/bin/sudo bash %s > %s 2>&1 &',
|
'bash %s > %s 2>&1 &',
|
||||||
escapeshellarg($this->deployScript),
|
escapeshellarg($this->deployScript),
|
||||||
escapeshellarg($this->logFile)
|
escapeshellarg($this->logFile)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class CreatePostFromAiDataTask
|
|||||||
[
|
[
|
||||||
'type' => 'paragraph',
|
'type' => 'paragraph',
|
||||||
'data' => [
|
'data' => [
|
||||||
'seo_active' => true,
|
|
||||||
'content' => $newsData['body'] ?? '',
|
'content' => $newsData['body'] ?? '',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ class VkPostGenerationTest extends TestCase
|
|||||||
[
|
[
|
||||||
'type' => 'paragraph',
|
'type' => 'paragraph',
|
||||||
'data' => [
|
'data' => [
|
||||||
'seo_active' => true,
|
|
||||||
'content' => '<p>Это основной текст новости. Он содержит важную информацию о событии.</p>',
|
'content' => '<p>Это основной текст новости. Он содержит важную информацию о событии.</p>',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -115,7 +114,6 @@ class VkPostGenerationTest extends TestCase
|
|||||||
[
|
[
|
||||||
'type' => 'paragraph',
|
'type' => 'paragraph',
|
||||||
'data' => [
|
'data' => [
|
||||||
'seo_active' => true,
|
|
||||||
'content' => '<p>Текст новости без файлов.</p>',
|
'content' => '<p>Текст новости без файлов.</p>',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Containers\AppStructure\Models\Page;
|
|||||||
use App\Containers\AppStructure\Models\SubSection;
|
use App\Containers\AppStructure\Models\SubSection;
|
||||||
use App\Containers\Dashboard\Actions\Pages\AttachPageToSubSectionAction;
|
use App\Containers\Dashboard\Actions\Pages\AttachPageToSubSectionAction;
|
||||||
use App\Containers\Dashboard\Actions\Pages\DetachPageFromSubSectionAction;
|
use App\Containers\Dashboard\Actions\Pages\DetachPageFromSubSectionAction;
|
||||||
|
use App\Containers\Dashboard\Actions\Pages\ReorderPagesAction;
|
||||||
use App\Containers\Dashboard\Actions\SubSections\AttachSubSectionToMainSectionAction;
|
use App\Containers\Dashboard\Actions\SubSections\AttachSubSectionToMainSectionAction;
|
||||||
use App\Containers\Dashboard\Actions\SubSections\CreateSubSectionAction;
|
use App\Containers\Dashboard\Actions\SubSections\CreateSubSectionAction;
|
||||||
use App\Containers\Dashboard\Actions\SubSections\DeleteSubSectionAction;
|
use App\Containers\Dashboard\Actions\SubSections\DeleteSubSectionAction;
|
||||||
@@ -31,6 +32,7 @@ class SubSectionController extends Controller
|
|||||||
private readonly DetachSubSectionFromMainSectionAction $detachSubSectionAction,
|
private readonly DetachSubSectionFromMainSectionAction $detachSubSectionAction,
|
||||||
private readonly AttachPageToSubSectionAction $attachPageAction,
|
private readonly AttachPageToSubSectionAction $attachPageAction,
|
||||||
private readonly DetachPageFromSubSectionAction $detachPageAction,
|
private readonly DetachPageFromSubSectionAction $detachPageAction,
|
||||||
|
private readonly ReorderPagesAction $reorderPagesAction,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,7 +84,9 @@ class SubSectionController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function edit(SubSection $subSection): \Inertia\Response
|
public function edit(SubSection $subSection): \Inertia\Response
|
||||||
{
|
{
|
||||||
$subSection->load(['mainSection', 'pages']);
|
$subSection->load(['mainSection', 'pages' => function ($query) {
|
||||||
|
$query->orderBy('sort', 'asc');
|
||||||
|
}]);
|
||||||
|
|
||||||
$mainSections = MainSection::pluck('title', 'id');
|
$mainSections = MainSection::pluck('title', 'id');
|
||||||
$availablePages = Page::whereNull('sub_section_id')
|
$availablePages = Page::whereNull('sub_section_id')
|
||||||
@@ -200,4 +204,23 @@ class SubSectionController extends Controller
|
|||||||
return back()->with('error', 'Ошибка при откреплении страницы: ' . $e->getMessage());
|
return back()->with('error', 'Ошибка при откреплении страницы: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Изменяет порядок страниц в подразделе
|
||||||
|
*/
|
||||||
|
public function reorderPages(Request $request, SubSection $subSection): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'page_ids' => ['required', 'array'],
|
||||||
|
'page_ids.*' => ['integer', 'exists:pages,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->reorderPagesAction->run($subSection->id, $request->page_ids);
|
||||||
|
|
||||||
|
return back()->with('success', 'Порядок страниц обновлен!');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return back()->with('error', 'Ошибка при изменении порядка страниц: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class StorePageRequest extends FormRequest
|
|||||||
'sub_section_id' => ['nullable', 'exists:sub_sections,id'],
|
'sub_section_id' => ['nullable', 'exists:sub_sections,id'],
|
||||||
'code' => ['required', Rule::in(['200', '404', '500'])],
|
'code' => ['required', Rule::in(['200', '404', '500'])],
|
||||||
'searchable' => ['boolean'],
|
'searchable' => ['boolean'],
|
||||||
'icon' => ['nullable', 'string'],
|
|
||||||
'content' => ['nullable', 'array'],
|
'content' => ['nullable', 'array'],
|
||||||
'settings' => ['nullable', 'array'],
|
'settings' => ['nullable', 'array'],
|
||||||
'settings.hide_page_sub_section_links' => ['nullable', 'boolean'],
|
'settings.hide_page_sub_section_links' => ['nullable', 'boolean'],
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ class UpdatePageRequest extends FormRequest
|
|||||||
'sub_section_id' => ['nullable', 'exists:sub_sections,id'],
|
'sub_section_id' => ['nullable', 'exists:sub_sections,id'],
|
||||||
'code' => ['required', Rule::in(['200', '404', '500'])],
|
'code' => ['required', Rule::in(['200', '404', '500'])],
|
||||||
'searchable' => ['boolean'],
|
'searchable' => ['boolean'],
|
||||||
'icon' => ['nullable', 'string'],
|
|
||||||
'content' => ['nullable', 'array'],
|
'content' => ['nullable', 'array'],
|
||||||
'settings' => ['nullable', 'array'],
|
'settings' => ['nullable', 'array'],
|
||||||
'settings.hide_page_sub_section_links' => ['nullable', 'boolean'],
|
'settings.hide_page_sub_section_links' => ['nullable', 'boolean'],
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ Route::middleware(['access-check', 'dashboard.auth'])->group(function () {
|
|||||||
// Управление страницами (Relation Manager)
|
// Управление страницами (Relation Manager)
|
||||||
Route::post('/{subSection}/pages/attach', [SubSectionController::class, 'attachPage'])->name('pages.attach');
|
Route::post('/{subSection}/pages/attach', [SubSectionController::class, 'attachPage'])->name('pages.attach');
|
||||||
Route::delete('/{subSection}/pages/{page}/detach', [SubSectionController::class, 'detachPage'])->name('pages.detach');
|
Route::delete('/{subSection}/pages/{page}/detach', [SubSectionController::class, 'detachPage'])->name('pages.detach');
|
||||||
|
Route::post('/{subSection}/pages/reorder', [SubSectionController::class, 'reorderPages'])->name('pages.reorder');
|
||||||
});
|
});
|
||||||
|
|
||||||
// CRUD страниц
|
// CRUD страниц
|
||||||
|
|||||||
@@ -3,9 +3,6 @@
|
|||||||
namespace App\Filament\Components\Forms\ItemForm\Blocks;
|
namespace App\Filament\Components\Forms\ItemForm\Blocks;
|
||||||
|
|
||||||
use App\Filament\Components\Forms\ItemForm\Blocks\BlockSchema;
|
use App\Filament\Components\Forms\ItemForm\Blocks\BlockSchema;
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Forms\Get;
|
|
||||||
use Mohamedsabil83\FilamentFormsTinyeditor\Components\TinyEditor;
|
use Mohamedsabil83\FilamentFormsTinyeditor\Components\TinyEditor;
|
||||||
|
|
||||||
class ParagraphBlock implements BlockSchema
|
class ParagraphBlock implements BlockSchema
|
||||||
@@ -14,16 +11,6 @@ class ParagraphBlock implements BlockSchema
|
|||||||
public static function schema(): array
|
public static function schema(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Toggle::make('seo_active')
|
|
||||||
->label('Использовать блок как SEO-текст')
|
|
||||||
->helperText('Этот текст будет использоваться для SEO-оптимизации')
|
|
||||||
->live(onBlur: true)
|
|
||||||
->required()
|
|
||||||
->disabled(function ($state, Get $get) {
|
|
||||||
$data = $get('../../');
|
|
||||||
return self::findSeoActive($data) && !$state;
|
|
||||||
})
|
|
||||||
->dehydrated(),
|
|
||||||
TinyEditor::make('content')
|
TinyEditor::make('content')
|
||||||
->label('Текст')
|
->label('Текст')
|
||||||
->placeholder('Начните вводить текст...')
|
->placeholder('Начните вводить текст...')
|
||||||
@@ -32,20 +19,4 @@ class ParagraphBlock implements BlockSchema
|
|||||||
->helperText('Основное текстовое содержимое блока'),
|
->helperText('Основное текстовое содержимое блока'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function findSeoActive(array $data) : bool
|
|
||||||
{
|
|
||||||
$bool = false;
|
|
||||||
|
|
||||||
foreach ($data as $item) {
|
|
||||||
if ($item['type'] !== 'paragraph') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($item['data']['seo_active'] === true) {
|
|
||||||
$bool = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $bool;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -53,16 +53,6 @@ class TabBuilderItem
|
|||||||
->label('Текст')
|
->label('Текст')
|
||||||
->icon('heroicon-o-document-text')
|
->icon('heroicon-o-document-text')
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('seo_active')
|
|
||||||
->label('Использовать блок как SEO-текст')
|
|
||||||
->helperText('Этот текст будет использоваться для SEO-оптимизации')
|
|
||||||
->live(onBlur: true)
|
|
||||||
->required()
|
|
||||||
->disabled(function ($state, Forms\Get $get) {
|
|
||||||
$data = $get('../../');
|
|
||||||
return self::findSeoActive($data) && !$state;
|
|
||||||
})
|
|
||||||
->dehydrated(),
|
|
||||||
TinyEditor::make('content')
|
TinyEditor::make('content')
|
||||||
->label('Текст')
|
->label('Текст')
|
||||||
->placeholder('Начните вводить текст...')
|
->placeholder('Начните вводить текст...')
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ use Filament\Forms\Components\TextInput;
|
|||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use TomatoPHP\FilamentIcons\Components\IconPicker;
|
|
||||||
|
|
||||||
class PageForm
|
class PageForm
|
||||||
{
|
{
|
||||||
@@ -109,11 +108,6 @@ class PageForm
|
|||||||
->default(true)
|
->default(true)
|
||||||
->inline(false)
|
->inline(false)
|
||||||
->helperText('Разрешить локальному поиску индексировать страницу'),
|
->helperText('Разрешить локальному поиску индексировать страницу'),
|
||||||
IconPicker::make('icon')
|
|
||||||
->label('Иконка страницы')
|
|
||||||
->default('heroicon-o-academic-cap')
|
|
||||||
->helperText('Выберите иконку для отображения в навигации')
|
|
||||||
->columns(6),
|
|
||||||
TextInput::make('search_data')
|
TextInput::make('search_data')
|
||||||
->hidden(),
|
->hidden(),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -100,10 +100,7 @@ class PostDataProcessor
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
$rowData = $this->getBlockBySeoActiveState('paragraph', $data['content']);
|
|
||||||
if ($rowData === null) {
|
|
||||||
$rowData = $this->getFirstBlockByName('paragraph', $data['content']);
|
$rowData = $this->getFirstBlockByName('paragraph', $data['content']);
|
||||||
}
|
|
||||||
|
|
||||||
$previewText = $rowData ? html_entity_decode(strip_tags($rowData['data']['content'])) : '';
|
$previewText = $rowData ? html_entity_decode(strip_tags($rowData['data']['content'])) : '';
|
||||||
return Str::limit($previewText, 160);
|
return Str::limit($previewText, 160);
|
||||||
@@ -221,21 +218,4 @@ class PostDataProcessor
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Находит блок по SEO-активности.
|
|
||||||
*
|
|
||||||
* @param string $name
|
|
||||||
* @param array $content
|
|
||||||
* @return array|null
|
|
||||||
*/
|
|
||||||
private function getBlockBySeoActiveState(string $name, array $content): ?array
|
|
||||||
{
|
|
||||||
foreach ($content as $block) {
|
|
||||||
if ($block['type'] === $name && ($block['data']['seo_active'] ?? false)) {
|
|
||||||
return $block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -40,10 +40,7 @@ class PostSeoGenerator
|
|||||||
*/
|
*/
|
||||||
private function extractSeoDescription(array $content): string
|
private function extractSeoDescription(array $content): string
|
||||||
{
|
{
|
||||||
$rowData = $this->getBlockBySeoActiveState('paragraph', $content);
|
|
||||||
if ($rowData === null) {
|
|
||||||
$rowData = $this->getFirstBlockByName('paragraph', $content);
|
$rowData = $this->getFirstBlockByName('paragraph', $content);
|
||||||
}
|
|
||||||
|
|
||||||
$description = $rowData ? html_entity_decode(strip_tags($rowData['data']['content'])) : '';
|
$description = $rowData ? html_entity_decode(strip_tags($rowData['data']['content'])) : '';
|
||||||
return Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160);
|
return Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160);
|
||||||
@@ -77,29 +74,9 @@ class PostSeoGenerator
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Находит блок по SEO-активности.
|
|
||||||
*
|
|
||||||
* @param string $name
|
|
||||||
* @param array $content
|
|
||||||
* @return array|null
|
|
||||||
*/
|
|
||||||
private function getBlockBySeoActiveState(string $name, array $content): ?array
|
|
||||||
{
|
|
||||||
foreach ($content as $block) {
|
|
||||||
if ($block['type'] === $name && ($block['data']['seo_active'] ?? false)) {
|
|
||||||
return $block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPreviewText(array $data): ?string
|
public function setPreviewText(array $data): ?string
|
||||||
{
|
{
|
||||||
$rowData = $this->getBlockBySeoActiveState('paragraph', $data['content']);
|
|
||||||
if ($rowData === null) {
|
|
||||||
$rowData = $this->getFirstBlockByName('paragraph', $data['content']);
|
$rowData = $this->getFirstBlockByName('paragraph', $data['content']);
|
||||||
}
|
|
||||||
|
|
||||||
if ($rowData !== null) {
|
if ($rowData !== null) {
|
||||||
$previewText = html_entity_decode(strip_tags($rowData['data']['content']));
|
$previewText = html_entity_decode(strip_tags($rowData['data']['content']));
|
||||||
|
|||||||
@@ -39,10 +39,7 @@ class SeoGeneratorService
|
|||||||
*/
|
*/
|
||||||
private function extractSeoDescription(array $content): string
|
private function extractSeoDescription(array $content): string
|
||||||
{
|
{
|
||||||
$rowData = $this->getBlockBySeoActiveState('paragraph', $content);
|
|
||||||
if ($rowData === null) {
|
|
||||||
$rowData = $this->getFirstBlockByName('paragraph', $content);
|
$rowData = $this->getFirstBlockByName('paragraph', $content);
|
||||||
}
|
|
||||||
|
|
||||||
$description = $rowData ? html_entity_decode(strip_tags($rowData['data']['content'])) : '';
|
$description = $rowData ? html_entity_decode(strip_tags($rowData['data']['content'])) : '';
|
||||||
return Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160);
|
return Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160);
|
||||||
@@ -75,21 +72,4 @@ class SeoGeneratorService
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Находит блок по SEO-активности.
|
|
||||||
*
|
|
||||||
* @param string $name
|
|
||||||
* @param array $content
|
|
||||||
* @return array|null
|
|
||||||
*/
|
|
||||||
private function getBlockBySeoActiveState(string $name, array $content): ?array
|
|
||||||
{
|
|
||||||
foreach ($content as $block) {
|
|
||||||
if ($block['type'] === $name && ($block['data']['seo_active'] ?? false)) {
|
|
||||||
return $block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -291,7 +291,7 @@ export default {
|
|||||||
|
|
||||||
const blockDefaults = {
|
const blockDefaults = {
|
||||||
heading: () => ({ id: `anchor-${Date.now()}`, content: '' }),
|
heading: () => ({ id: `anchor-${Date.now()}`, content: '' }),
|
||||||
paragraph: () => ({ seo_active: true, content: '' }),
|
paragraph: () => ({ content: '' }),
|
||||||
image: () => ({ url: '', alt: '' }),
|
image: () => ({ url: '', alt: '' }),
|
||||||
images: () => ({ url: [], alt: '' }),
|
images: () => ({ url: [], alt: '' }),
|
||||||
files: () => ({ file: [] }),
|
files: () => ({ file: [] }),
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ export default {
|
|||||||
|
|
||||||
const blockDefaults = {
|
const blockDefaults = {
|
||||||
heading: () => ({ id: `anchor-${Date.now()}`, content: '' }),
|
heading: () => ({ id: `anchor-${Date.now()}`, content: '' }),
|
||||||
paragraph: () => ({ seo_active: true, content: '' }),
|
paragraph: () => ({ content: '' }),
|
||||||
image: () => ({ url: '', alt: '' }),
|
image: () => ({ url: '', alt: '' }),
|
||||||
images: () => ({ url: [], alt: '' }),
|
images: () => ({ url: [], alt: '' }),
|
||||||
files: () => ({ file: [] }),
|
files: () => ({ file: [] }),
|
||||||
@@ -402,24 +402,26 @@ export default {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.modelValue && props.modelValue.length > 0) {
|
if (props.modelValue && props.modelValue.length > 0) {
|
||||||
blocks.value = props.modelValue.map(block => ({
|
blocks.value = props.modelValue.map(block => ({
|
||||||
_uid: generateUid(),
|
_uid: block._uid || generateUid(),
|
||||||
...block
|
...block
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch for modelValue changes (e.g., when loading existing content)
|
// Watch for modelValue changes — only for initial load or when parent reloads
|
||||||
|
// DO NOT sync on every change to avoid infinite loops with emitChange
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
|
// Only update if blocks are empty (page reload or tab switch)
|
||||||
if (newValue && newValue.length > 0 && blocks.value.length === 0) {
|
if (newValue && newValue.length > 0 && blocks.value.length === 0) {
|
||||||
// Only initialize if blocks are empty (initial load from server)
|
|
||||||
blocks.value = newValue.map(block => ({
|
blocks.value = newValue.map(block => ({
|
||||||
_uid: generateUid(),
|
_uid: block._uid || generateUid(),
|
||||||
...block
|
...block
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{ deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,17 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-3" style="overflow: visible;">
|
<div class="space-y-3" style="overflow: visible;">
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="modelValue.seo_active"
|
|
||||||
@change="update('seo_active', $event.target.checked)"
|
|
||||||
class="h-4 w-4 text-primary focus:ring-primary border-layer-line rounded"
|
|
||||||
/>
|
|
||||||
<label class="text-sm text-foreground">
|
|
||||||
Активировать SEO для этого блока
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="overflow: visible;">
|
<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>
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export default {
|
|||||||
props: {
|
props: {
|
||||||
modelValue: { type: Object, required: true }
|
modelValue: { type: Object, required: true }
|
||||||
},
|
},
|
||||||
emits: ['update:modelValue'],
|
emits: ['update:modelValue', 'update'],
|
||||||
computed: {
|
computed: {
|
||||||
tabs() {
|
tabs() {
|
||||||
const rawTabs = this.modelValue?.tab || [];
|
const rawTabs = this.modelValue?.tab || [];
|
||||||
@@ -95,6 +95,7 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
update(field, value) {
|
update(field, value) {
|
||||||
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
this.$emit('update:modelValue', { ...this.modelValue, [field]: value });
|
||||||
|
this.$emit('update');
|
||||||
},
|
},
|
||||||
updateSettings(field, value) {
|
updateSettings(field, value) {
|
||||||
this.update('settings', { ...(this.modelValue.settings || {}), [field]: value });
|
this.update('settings', { ...(this.modelValue.settings || {}), [field]: value });
|
||||||
|
|||||||
@@ -333,7 +333,6 @@ export default {
|
|||||||
sub_section_id: '',
|
sub_section_id: '',
|
||||||
code: '200',
|
code: '200',
|
||||||
searchable: true,
|
searchable: true,
|
||||||
icon: 'heroicon-o-academic-cap',
|
|
||||||
content: [],
|
content: [],
|
||||||
settings: {
|
settings: {
|
||||||
hide_page_sub_section_links: false,
|
hide_page_sub_section_links: false,
|
||||||
|
|||||||
@@ -350,7 +350,6 @@ export default {
|
|||||||
sub_section_id: this.page.sub_section_id || '',
|
sub_section_id: this.page.sub_section_id || '',
|
||||||
code: this.page.code || '200',
|
code: this.page.code || '200',
|
||||||
searchable: this.page.searchable === 1 || this.page.searchable === true || this.page.searchable === '1',
|
searchable: this.page.searchable === 1 || this.page.searchable === true || this.page.searchable === '1',
|
||||||
icon: this.page.icon || 'heroicon-o-academic-cap',
|
|
||||||
content: this.page.content || [],
|
content: this.page.content || [],
|
||||||
settings: {
|
settings: {
|
||||||
hide_page_sub_section_links: this.page.settings?.hide_page_sub_section_links || false,
|
hide_page_sub_section_links: this.page.settings?.hide_page_sub_section_links || false,
|
||||||
|
|||||||
@@ -161,10 +161,13 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto" :class="{ 'select-none': dragIndex !== null }">
|
||||||
<table class="min-w-full divide-y divide-line-2">
|
<table class="min-w-full divide-y divide-line-2">
|
||||||
<thead class="bg-surface/50">
|
<thead class="bg-surface/50">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider w-10">
|
||||||
|
<!-- Drag handle column -->
|
||||||
|
</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
<th class="px-6 py-3 text-left text-xs font-medium text-muted-foreground-1 uppercase tracking-wider">
|
||||||
Заголовок страницы
|
Заголовок страницы
|
||||||
</th>
|
</th>
|
||||||
@@ -178,10 +181,26 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-line-2">
|
<tbody class="divide-y divide-line-2">
|
||||||
<tr
|
<tr
|
||||||
v-for="page in pages"
|
v-for="(page, index) in pages"
|
||||||
:key="page.id"
|
:key="page.id"
|
||||||
class="group hover:bg-muted-hover/50 transition-all duration-200"
|
:data-index="index"
|
||||||
|
:draggable="true"
|
||||||
|
@dragstart="onDragStart($event, index)"
|
||||||
|
@dragover.prevent="onDragOver($event, index)"
|
||||||
|
@dragenter.prevent="onDragEnter($event, index)"
|
||||||
|
@drop.prevent="onDrop($event, index)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
:class="[
|
||||||
|
'group hover:bg-muted-hover/50 transition-all duration-200',
|
||||||
|
dragIndex === index ? 'opacity-40 bg-primary/10 border-y-2 border-primary/30' : '',
|
||||||
|
dropIndex === index && dragIndex !== index ? 'border-t-2 border-primary bg-primary/5' : ''
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="cursor-move text-muted-foreground-1 hover:text-foreground transition-colors">
|
||||||
|
<DashboardIcon name="bars-3" size="4" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="text-sm font-medium text-foreground">
|
<div class="text-sm font-medium text-foreground">
|
||||||
{{ page.title }}
|
{{ page.title }}
|
||||||
@@ -215,7 +234,7 @@
|
|||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<EmptyState
|
<EmptyState
|
||||||
v-if="pages.length === 0"
|
v-if="pages.length === 0"
|
||||||
:columns="3"
|
:columns="4"
|
||||||
title="Страницы не найдены"
|
title="Страницы не найдены"
|
||||||
description="Прикрепите страницы к этому подразделу"
|
description="Прикрепите страницы к этому подразделу"
|
||||||
icon-path="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
icon-path="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||||
@@ -223,6 +242,28 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Save Order Button (shown when order changed) -->
|
||||||
|
<div v-if="orderChanged" class="px-6 py-4 border-t border-line-2 bg-surface/30">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm text-muted-foreground-1">
|
||||||
|
<DashboardIcon name="information-circle" size="4" class="inline mr-1" />
|
||||||
|
Порядок страниц изменен. Сохраните изменения.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
@click="saveOrder"
|
||||||
|
:disabled="savingOrder"
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-2.5 bg-primary text-white text-sm font-medium rounded-lg hover:bg-primary-hover transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<svg v-if="savingOrder" class="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
<DashboardIcon v-else name="check" size="4" />
|
||||||
|
{{ savingOrder ? 'Сохранение...' : 'Сохранить порядок' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,13 +312,19 @@ export default {
|
|||||||
selectedPageId: '',
|
selectedPageId: '',
|
||||||
errors: {},
|
errors: {},
|
||||||
processing: false,
|
processing: false,
|
||||||
attachingPage: false
|
attachingPage: false,
|
||||||
|
// Drag and drop state
|
||||||
|
dragIndex: null,
|
||||||
|
dropIndex: null,
|
||||||
|
localPages: [],
|
||||||
|
orderChanged: false,
|
||||||
|
savingOrder: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
pages() {
|
pages() {
|
||||||
return this.subSection.pages || [];
|
return this.orderChanged ? this.localPages : (this.subSection.pages || []);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -316,9 +363,17 @@ export default {
|
|||||||
{ page_id: this.selectedPageId },
|
{ page_id: this.selectedPageId },
|
||||||
{
|
{
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onFinish: () => {
|
onSuccess: () => {
|
||||||
this.attachingPage = false;
|
this.attachingPage = false;
|
||||||
this.selectedPageId = '';
|
this.selectedPageId = '';
|
||||||
|
// Reset local changes if page is attached
|
||||||
|
if (this.orderChanged) {
|
||||||
|
this.orderChanged = false;
|
||||||
|
this.localPages = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
this.attachingPage = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -326,13 +381,94 @@ export default {
|
|||||||
|
|
||||||
detachPage(page) {
|
detachPage(page) {
|
||||||
if (confirm(`Открепить страницу "${page.title}"?`)) {
|
if (confirm(`Открепить страницу "${page.title}"?`)) {
|
||||||
|
// If we have local changes, remove from local state first
|
||||||
|
if (this.orderChanged) {
|
||||||
|
const index = this.localPages.findIndex(p => p.id === page.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.localPages.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.$inertia.delete(route('dashboard.sub-sections.pages.detach', {
|
this.$inertia.delete(route('dashboard.sub-sections.pages.detach', {
|
||||||
subSection: this.subSection.id,
|
subSection: this.subSection.id,
|
||||||
page: page.id
|
page: page.id
|
||||||
}), {
|
}), {
|
||||||
preserveScroll: true
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
// Reset local changes if page is detached
|
||||||
|
if (this.orderChanged && this.localPages.length === 0) {
|
||||||
|
this.orderChanged = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Drag and Drop methods
|
||||||
|
onDragStart(event, index) {
|
||||||
|
this.dragIndex = index;
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
event.dataTransfer.setData('text/plain', index.toString());
|
||||||
|
},
|
||||||
|
|
||||||
|
onDragOver(event, index) {
|
||||||
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
},
|
||||||
|
|
||||||
|
onDragEnter(event, index) {
|
||||||
|
this.dropIndex = index;
|
||||||
|
},
|
||||||
|
|
||||||
|
onDrop(event, targetIndex) {
|
||||||
|
const sourceIndex = this.dragIndex;
|
||||||
|
|
||||||
|
if (sourceIndex === null || sourceIndex === targetIndex) {
|
||||||
|
this.dragIndex = null;
|
||||||
|
this.dropIndex = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize localPages if not already done
|
||||||
|
if (!this.orderChanged) {
|
||||||
|
this.localPages = [...this.pages];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the item in the array
|
||||||
|
const item = this.localPages.splice(sourceIndex, 1)[0];
|
||||||
|
this.localPages.splice(targetIndex, 0, item);
|
||||||
|
|
||||||
|
this.orderChanged = true;
|
||||||
|
this.dragIndex = null;
|
||||||
|
this.dropIndex = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
onDragEnd() {
|
||||||
|
this.dragIndex = null;
|
||||||
|
this.dropIndex = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
saveOrder() {
|
||||||
|
if (!this.orderChanged) return;
|
||||||
|
|
||||||
|
this.savingOrder = true;
|
||||||
|
|
||||||
|
const pageIds = this.localPages.map(page => page.id);
|
||||||
|
|
||||||
|
this.$inertia.post(
|
||||||
|
route('dashboard.sub-sections.pages.reorder', this.subSection.id),
|
||||||
|
{ page_ids: pageIds },
|
||||||
|
{
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
this.savingOrder = false;
|
||||||
|
this.orderChanged = false;
|
||||||
|
this.localPages = [];
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
this.savingOrder = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user