From 3385dc58073c35f04d87de84dc8fd5aec4c29300 Mon Sep 17 00:00:00 2001 From: f4ilji Date: Thu, 30 Jan 2025 00:31:40 +0500 Subject: [PATCH] Changes --- app/Dto/MainSliderDTO.php | 58 ++++ app/Filament/Components/Forms/PostForm.php | 48 ++- .../Resources/AcceptedInvitationResource.php | 6 +- .../Resources/ContactWidgetResource.php | 12 + .../Resources/EducationalGroupResource.php | 5 +- app/Filament/Resources/MainSliderResource.php | 91 +++-- app/Filament/Resources/PageResource.php | 2 - app/Filament/Resources/PostResource.php | 6 +- .../PostResource/Pages/CreatePost.php | 327 +++--------------- .../Resources/PostResource/Pages/EditPost.php | 246 +++---------- app/Filament/Resources/UserResource.php | 1 - .../ClientAcademicJournalController.php | 5 +- .../Controllers/ClientScheduleController.php | 76 +++- app/Http/Controllers/MainController.php | 36 +- app/Http/Kernel.php | 2 + app/Http/Middleware/Authenticate.php | 3 +- app/Http/Middleware/LimitPost.php | 28 ++ app/Models/MainSlider.php | 6 + app/Models/Post.php | 5 + app/Models/User.php | 1 + app/Observers/MainSliderObserver.php | 75 ++++ app/Observers/PostObserver.php | 2 +- app/Policies/ContactWidgetPolicy.php | 108 ++++++ app/Providers/AppServiceProvider.php | 30 +- app/Providers/Filament/AdminPanelProvider.php | 2 +- .../Filament/DashboardPanelProvider.php | 2 + .../App/Cache/MainSliderCacheService.php | 48 +++ app/Services/App/Cache/PostCacheService.php | 8 +- .../Domain/Posts/PostDataProcessor.php | 187 ++++++++++ .../Domain/Posts/PostNotificationService.php | 80 +++++ .../Domain/Posts/PostSeoGenerator.php | 111 ++++++ .../Domain/Posts/PostSliderService.php | 59 ++++ .../Filament/Domain/Posts/VkPostPublisher.php | 66 ++++ composer.json | 3 +- composer.lock | 71 +++- config/filament-forms-tinyeditor.php | 6 +- ...on_form_id_to_educational_groups_table.php | 30 ++ ...1_remove_is_zaoch_from_schedules_table.php | 30 ++ ..._start_end_times_to_main_sliders_table.php | 31 ++ ...nge_columns_to_null_main_sliders_table.php | 38 ++ package-lock.json | 9 + package.json | 1 + .../BuilderUi/Pages/PageNavigateLinks.vue | 2 +- .../Programs/Filters/DirectionFilter.vue | 25 +- .../Schedules/ClientScheduleFilter.vue | 82 +++++ resources/js/Components/ClientMainSlider.vue | 28 +- .../js/Components/ClientProgramFilter.vue | 2 - resources/js/Components/other/icons.js | 17 +- resources/js/Navbars/DesktopNavBar.vue | 9 +- resources/js/Navbars/MainNavbar.vue | 70 +--- resources/js/Navbars/MainPageNavbar.vue | 43 ++- resources/js/Pages/Client/Programs/Show.vue | 13 +- resources/js/Pages/Client/Schedules/Index.vue | 305 ++++++++-------- resources/js/Pages/Main.vue | 15 +- resources/js/Pages/Page.vue | 2 +- resources/js/app.js | 2 + resources/js/mixins/cookieMixin.js | 43 +++ resources/views/app.blade.php | 9 + routes/api.php | 6 + routes/web.php | 2 - 60 files changed, 1811 insertions(+), 825 deletions(-) create mode 100644 app/Dto/MainSliderDTO.php create mode 100644 app/Http/Middleware/LimitPost.php create mode 100644 app/Observers/MainSliderObserver.php create mode 100644 app/Policies/ContactWidgetPolicy.php create mode 100644 app/Services/App/Cache/MainSliderCacheService.php create mode 100644 app/Services/Filament/Domain/Posts/PostDataProcessor.php create mode 100644 app/Services/Filament/Domain/Posts/PostNotificationService.php create mode 100644 app/Services/Filament/Domain/Posts/PostSeoGenerator.php create mode 100644 app/Services/Filament/Domain/Posts/PostSliderService.php create mode 100644 app/Services/Filament/Domain/Posts/VkPostPublisher.php create mode 100644 database/migrations/2025_01_27_154953_add_education_form_id_to_educational_groups_table.php create mode 100644 database/migrations/2025_01_27_155121_remove_is_zaoch_from_schedules_table.php create mode 100644 database/migrations/2025_01_27_162420_add_start_end_times_to_main_sliders_table.php create mode 100644 database/migrations/2025_01_29_162363_change_columns_to_null_main_sliders_table.php create mode 100644 resources/js/Components/BuilderUi/Schedules/ClientScheduleFilter.vue create mode 100644 resources/js/mixins/cookieMixin.js diff --git a/app/Dto/MainSliderDTO.php b/app/Dto/MainSliderDTO.php new file mode 100644 index 0000000..dd585d2 --- /dev/null +++ b/app/Dto/MainSliderDTO.php @@ -0,0 +1,58 @@ + $this->id, + 'title' => $this->title, + 'content' => $this->content, + 'image' => $this->image, + 'link' => $this->link, + 'link_text' => $this->link_text, + 'color_theme' => $this->color_theme, + 'is_active' => $this->is_active, + 'start_time' => $this->start_time?->toDateTimeString(), + 'end_time' => $this->end_time?->toDateTimeString(), + 'sort' => $this->sort, + 'created_at' => $this->created_at?->toDateTimeString(), + 'updated_at' => $this->updated_at?->toDateTimeString(), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Components/Forms/PostForm.php b/app/Filament/Components/Forms/PostForm.php index 1748395..962c4aa 100644 --- a/app/Filament/Components/Forms/PostForm.php +++ b/app/Filament/Components/Forms/PostForm.php @@ -13,6 +13,7 @@ use App\Models\PageReferenceList; use App\Models\Post; use Filament\Forms; use Filament\Forms\Components\Builder; +use Filament\Forms\Components\ColorPicker; use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Grid; @@ -97,7 +98,7 @@ class PostForm ]), Tabs\Tab::make('Содержание новости') ->schema([ - ContentBuilderItem::getItem('content') + ContentBuilderItem::getItem('content')->required(), ]), Tabs\Tab::make('Изображения') ->schema([ @@ -118,6 +119,51 @@ class PostForm ->multiple() ->directory('images'), ]), + Tabs\Tab::make('Добавление новости в слайдер') + ->schema([ + Toggle::make('is_slider_enabled') + ->label('Добавить новый слайд') + ->live() + ->dehydrated(false) + ->default(false), + Section::make() + ->schema([ + Forms\Components\TextInput::make('slide.title') + ->label('Заголовок слайда'), + Forms\Components\Textarea::make('slide.content') + ->label('Текст слайда'), + FileUpload::make('slide.image') + ->label('Изображение') + ->image() + ->optimize('webp') + ->resize(50) + ->disk('public') + ->directory('images') + ->imageEditor() + ->required(), + Grid::make(2)->schema([ + Toggle::make('disable_link_text') + ->label('Отключить текст кнопки (ссылка будет открываться при нажатии на слайд)') + ->live() + ->inline(false) + ->dehydrated(false) + ->default(false), + Forms\Components\TextInput::make('slide.link_text') + ->default('Читать') + ->label('Текст кнопки') + ->disabled(fn (Forms\Get $get) => $get('disable_link_text')), + ]), + + DateTimePicker::make('slide.end_time') + ->label('Слайд действует до') + ->native() + ->displayFormat('d/m/Y') + ->minDate(Carbon::now()) + ->maxDate(Carbon::now()->addWeek()), + ]) + ->disabled(fn (Forms\Get $get) => !$get('is_slider_enabled')) // Отключаем секцию, если Toggle выключен + ->hidden(fn (Forms\Get $get) => !$get('is_slider_enabled')), // Скрываем секцию, если Toggle выключен + ])->hidden(fn (string $context): bool => $context === 'edit'), ]), ]) ]); diff --git a/app/Filament/Resources/AcceptedInvitationResource.php b/app/Filament/Resources/AcceptedInvitationResource.php index 76ebdc1..12471ed 100644 --- a/app/Filament/Resources/AcceptedInvitationResource.php +++ b/app/Filament/Resources/AcceptedInvitationResource.php @@ -10,6 +10,7 @@ use Filament\Forms; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Columns\TextInputColumn; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\SoftDeletingScope; @@ -29,7 +30,7 @@ class AcceptedInvitationResource extends Resource implements HasShieldPermission { return $form ->schema([ - // + ]); } @@ -37,7 +38,8 @@ class AcceptedInvitationResource extends Resource implements HasShieldPermission { return $table ->columns([ - // + Tables\Columns\TextColumn::make('receiver.name')->label('Приглашенный пользователь'), + TextInputColumn::make('post_limit')->label('Лимит постов')->default(0)->rules(['required', 'max:10', 'integer']) ]) ->filters([ // diff --git a/app/Filament/Resources/ContactWidgetResource.php b/app/Filament/Resources/ContactWidgetResource.php index 607c2ff..4b06a7c 100644 --- a/app/Filament/Resources/ContactWidgetResource.php +++ b/app/Filament/Resources/ContactWidgetResource.php @@ -104,4 +104,16 @@ class ContactWidgetResource extends Resource 'edit' => Pages\EditContactWidget::route('/{record}/edit'), ]; } + + public static function getPermissionPrefixes(): array + { + return [ + 'view', + 'view_any', + 'create', + 'update', + 'delete', + 'delete_any', + ]; + } } diff --git a/app/Filament/Resources/EducationalGroupResource.php b/app/Filament/Resources/EducationalGroupResource.php index 718b83b..069f70c 100644 --- a/app/Filament/Resources/EducationalGroupResource.php +++ b/app/Filament/Resources/EducationalGroupResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Enums\FormEducation; use App\Filament\Resources\EducationalGroupResource\Pages; use App\Filament\Resources\EducationalGroupResource\RelationManagers; use App\Models\EducationalGroup; @@ -33,7 +34,9 @@ class EducationalGroupResource extends Resource Forms\Components\Grid::make(2)->schema([ Forms\Components\TextInput::make('title')->label('Название группы')->required(), Forms\Components\Select::make('faculty_id')->label('Факультет')->required() - ->options(Faculty::all()->pluck('title', 'id')) + ->options(Faculty::all()->pluck('title', 'id')), + Forms\Components\Select::make('education_form_id')->label('Форма обучения') + ->options(FormEducation::class) ]), ]), ]); diff --git a/app/Filament/Resources/MainSliderResource.php b/app/Filament/Resources/MainSliderResource.php index cd97794..05cc612 100644 --- a/app/Filament/Resources/MainSliderResource.php +++ b/app/Filament/Resources/MainSliderResource.php @@ -10,15 +10,19 @@ use App\Models\Page; use App\Models\Post; use Filament\Forms; use Filament\Forms\Components\ColorPicker; +use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\Toggle; +use Filament\Forms\Components\ToggleButtons; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\SoftDeletingScope; +use Illuminate\Support\Carbon; use Illuminate\Support\Str; +use Yepsua\Filament\Forms\Components\RangeSlider; class MainSliderResource extends Resource { @@ -96,32 +100,75 @@ class MainSliderResource extends Resource ]), ]), Forms\Components\Section::make('Слайдер')->schema([ - Forms\Components\TextInput::make('title') - ->label('Заголовок слайда') - ->required(), - Forms\Components\Textarea::make('content') - ->label('Текст слайда'), - FileUpload::make('image') - ->label('Изображение') - ->image() - ->optimize('webp') - ->resize(50) - ->disk('public') - ->directory('images') - ->imageEditor(), - Forms\Components\Grid::make(2)->schema([ + + Forms\Components\Section::make('Информация слайда')->schema([ + Forms\Components\TextInput::make('title') + ->label('Заголовок слайда'), + Forms\Components\Textarea::make('content') + ->label('Текст слайда'), + Forms\Components\Grid::make()->schema([ + ColorPicker::make('color_theme') + ->label('Цвет текста') + ->default('#ffffff') + ->required(), + Forms\Components\ToggleButtons::make('settings.text_position') + ->options([ + 'left' => 'Текст слева', + 'center' => 'Текст по середине', + 'right' => 'Текст справа' + ]) + ->inline()->default('left')->grouped() + ->label('Позиция текста на слайде'), + ]), + Forms\Components\Grid::make()->schema([ + Toggle::make('active_button') + ->label('Использовать кнопку для ссылки (Ссылка будет открываться при нажатии на слайд)') + ->inline(false) + ->default(true) + ->live() + ->dehydrated(false), + Forms\Components\TextInput::make('settings.link_text') + ->default('Читать') + ->label('Текст кнопки') + ->disabled(fn (Forms\Get $get) => !$get('active_button')) + ]), + ]), + Forms\Components\Section::make('Изображение')->schema([ + FileUpload::make('image.url') + ->label('Изображение') + ->image() + ->optimize('webp') + ->resize(50) + ->disk('public') + ->directory('images') + ->imageEditor() + ->required(), + ToggleButtons::make('image.shading')->inline()->grouped()->label('Уровень затемнения изображения')->options([ + '1' => 'Без затемнения', + '0.7' => 'Слабое затемнение', + '0.5' => 'Среднее затемнение', + '0.3' => 'Сильное затемнение', + ]), + ]), + Forms\Components\Section::make('Общая часть')->schema([ + Forms\Components\Grid::make()->schema([ + DateTimePicker::make('start_time') + ->label('Слайд начинается с') + ->native() + ->displayFormat('d/m/Y') + ->minDate(Carbon::now()->subDay()) + ->maxDate(Carbon::now()->addWeek()), + DateTimePicker::make('end_time') + ->label('Слайд действует до') + ->native() + ->displayFormat('d/m/Y') + ->minDate(Carbon::now()) + ->maxDate(Carbon::now()->addMonth()), + ]), Forms\Components\TextInput::make('link') ->label('Ссылка кнопки') ->required(), - Forms\Components\TextInput::make('link_text') - ->default('Читать') - ->label('Текст кнопки') - ->required(), ]), - ColorPicker::make('color_theme') - ->label('Цвет текста') - ->default('#ffffff') - ->required(), Toggle::make('is_active')->default(true)->label('Активный слайдер')->inline(false), ]), diff --git a/app/Filament/Resources/PageResource.php b/app/Filament/Resources/PageResource.php index a781110..46fbff1 100644 --- a/app/Filament/Resources/PageResource.php +++ b/app/Filament/Resources/PageResource.php @@ -44,8 +44,6 @@ class PageResource extends Resource protected static ?string $pluralLabel = 'Страницы'; - public static ?string $label = 'Страница'; - protected static ?string $navigationGroup = 'Структура приложения'; diff --git a/app/Filament/Resources/PostResource.php b/app/Filament/Resources/PostResource.php index 8181a30..8935126 100644 --- a/app/Filament/Resources/PostResource.php +++ b/app/Filament/Resources/PostResource.php @@ -59,10 +59,12 @@ class PostResource extends Resource implements HasShieldPermissions { return $table ->columns([ - Tables\Columns\TextColumn::make('id')->sortable(), +// Tables\Columns\TextColumn::make('id')->sortable(), + Tables\Columns\TextColumn::make('created_at')->label('Дата создания')->sortable(), Tables\Columns\TextColumn::make('title')->label('Заголовок')->sortable()->searchable(), Tables\Columns\TextColumn::make('status')->label('Статус')->sortable()->badge(), Tables\Columns\TextColumn::make('publish_at')->label('Дата публикации')->sortable(), + Tables\Columns\TextColumn::make('author.name')->label('Автор')->sortable()->searchable(), ])->defaultSort('publish_at', 'desc') ->filters([ @@ -92,6 +94,8 @@ class PostResource extends Resource implements HasShieldPermissions 'index' => Pages\ListPosts::route('/'), 'create' => Pages\CreatePost::route('/create'), 'edit' => Pages\EditPost::route('/{record}/edit'), + 'view' => Pages\ViewPost::route('/{record}'), + ]; } diff --git a/app/Filament/Resources/PostResource/Pages/CreatePost.php b/app/Filament/Resources/PostResource/Pages/CreatePost.php index b91a618..5de74b6 100644 --- a/app/Filament/Resources/PostResource/Pages/CreatePost.php +++ b/app/Filament/Resources/PostResource/Pages/CreatePost.php @@ -2,29 +2,15 @@ namespace App\Filament\Resources\PostResource\Pages; +use App\Dto\MainSliderDTO; use App\Enums\PostStatus; use App\Filament\Resources\PostResource; -use App\Jobs\CreateVkPost; -use App\Models\Post; -use App\Models\User; -use App\Services\VK\VkService; -use Carbon\Carbon; -use Closure; -use Filament\Actions; -use Filament\Notifications\Actions\Action; -use Filament\Notifications\Notification; +use App\Services\Filament\Domain\Posts\PostDataProcessor; +use App\Services\Filament\Domain\Posts\PostNotificationService; +use App\Services\Filament\Domain\Posts\PostSeoGenerator; +use App\Services\Filament\Domain\Posts\PostSliderService; +use App\Services\Filament\Domain\Posts\VkPostPublisher; use Filament\Resources\Pages\CreateRecord; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Notifications\Messages\BroadcastMessage; -use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Str; -use PhpParser\Node\Expr\AssignOp\Mod; -use VK\Client\VKApiClient; -use VK\OAuth\Scopes\VKOAuthGroupScope; -use VK\OAuth\Scopes\VKOAuthUserScope; -use VK\OAuth\VKOAuth; -use VK\OAuth\VKOAuthDisplay; -use VK\OAuth\VKOAuthResponseType; class CreatePost extends CreateRecord { @@ -33,277 +19,68 @@ class CreatePost extends CreateRecord protected array $seoData; protected array $publicationAgreements; + protected array $slideData; + + protected static array|string $routeMiddleware = ['limit.post']; + protected function mutateFormDataBeforeCreate(array $data): array { - $this->publicationAgreements = $data['publication']; - unset($data['publication']); - $this->seoData = $this->generateSeo($data); - $data['preview_text'] = $this->setPreviewText($data); - $data['publish_at'] = $this->setPublishDateTime($data['publish_setting']); - unset($data['publish_setting']); - $data['search_data'] = $this->generateSearchData($data['content']); - $data['reading_time'] = $this->calculateReadingTime($data['search_data']); - return $data; + $this->extractAdditionalData($data); + return $this->processPostData($data); + } + + protected function extractAdditionalData(array &$data): void + { + $this->publicationAgreements = $data['publication'] ?? []; + $this->slideData = $data['slide'] ?? []; + unset($data['slide'], $data['publication']); + } + + protected function processPostData(array $data): array + { + return (new PostDataProcessor())->process($data); } protected function afterCreate(): void { - $this->record->seo()->create($this->seoData); - $this->sendNotify($this->record, auth()->user()); - $publish_date = ($this->record->publish_at > now()) ? Carbon::parse($this->record->publish_at)->timestamp : null; - $this->postToSocialMedia($this->publicationAgreements, $this->record->content, $this->record->title, $publish_date); + $this->handleSlides(); + $this->generateSeo(); + $this->sendNotifications(); + $this->publishToVk(); } - private function generateSeo(array $data) : array + protected function handleSlides(): void { - $title = $data['title']; - $rowData = $this->getBlockBySeoActiveState('paragraph', $data['content']); - if ($rowData === null) { - $rowData = $this->getFirstBlockByName('paragraph', $data['content']); - } - if ($rowData !== null) { - $description = html_entity_decode(strip_tags($rowData['data']['content'])); - } else { - $description = null; - } $image = ($data['preview'] !== null) ? $data['preview'] : null; - - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - 'image' => $image, - ]; - } - - private function setPreviewText(array $data) : string - { - $rowData = $this->getBlockBySeoActiveState('paragraph', $data['content']); - if ($rowData === null) { - $rowData = $this->getFirstBlockByName('paragraph', $data['content']); - } - $preview_text = html_entity_decode(strip_tags($rowData['data']['content'])); - return Str::limit($preview_text, 160); - } - - private function generateSearchData(array $data) : string - { - $result = ""; - foreach ($data as $block) { - $result .= $this->getDataFromBlocks($block); - } - // Удаляем лишние пробелы и переносы строк - $result = preg_replace('/\s+/', ' ', $result); - $result = trim($result); - - return strtolower($result); - } - private function getFirstBlockByName(string $name, array $content) : array|null - { - $data = null; - foreach ($content as $block) { - $data = ($block['type'] === $name) ? $block : null; - break; - } - return $data; - } - - private function getBlockBySeoActiveState(string $name, array $content) : array|null - { - $data = []; - foreach ($content as $block) { - if ($block['type'] === $name) { - $data[] = $block; - } - } - $block = null; - foreach ($data as $item) { - if ($item['data']['seo_active'] === true) { - $block = $item; - } - } - return $block; - } - - private function sendNotify($post, $recipient) : void - { - Notification::make() - ->title('Новость на проверку') - ->body('Новая запись была создана!') - ->actions([ - Action::make('view') - ->label('Проверить') - ->button() - ->markAsRead() - ->url(PostResource::getUrl('edit', ['record' => $post])), - - ])->sendToDatabase($recipient); - } - private function setPublishDateTime(array $data) : Carbon|null - { - if ($data['publish_after'] === true) { - return Carbon::parse($data['publish_at']); - } - return Carbon::now(); - } - private function calculateReadingTime(string $text): int - { - - // Calculate the number of words in the text - $wordCount = str_word_count($text,0,"АаБбВвГгДдЕеЁёЖжЗзИиЙйКкЛлМмНнОоПпРрСсТтУуФфХхЦцЧчШшЩщЪъЫыЬьЭэЮюЯя"); - - - - - // Calculate the average reading speed in words per minute - $wordsPerMinute = 120; // You can adjust this value based on your desired reading speed - - // Calculate the reading time in minutes - $readingTime = $wordCount / $wordsPerMinute; - - // Round the reading time to the nearest integer - $readingTime = round($readingTime); - - - return $readingTime; - } - protected function convertDataToHtml($blocks) { - $convertedHtml = ""; - foreach ($blocks as $block) { - switch ($block['type']) { - case "header": - $convertedHtml .= "" . $block['data']['text'] . ""; - break; - case "embded": - $convertedHtml .= "
"; - break; - case "paragraph": - $convertedHtml .= "

" . $block['data']['text'] . "

"; - break; - case "delimiter": - $convertedHtml .= "
"; - break; - case "image": - $convertedHtml .= "
" . $block['data']['caption'] . ""; - break; - case "list": - $convertedHtml .= ""; - break; - case "table": - $convertedHtml .= ""; - if ($block['data']['withHeadings']) { - $convertedHtml .= ""; - foreach ($block['data']['content'][0] as $th) { - $convertedHtml .= ""; - } - $convertedHtml .= ""; - } - $convertedHtml .= ""; - foreach ($block['data']['content'] as $row) { - $convertedHtml .= ""; - foreach ($row as $td) { - $convertedHtml .= ""; - } - $convertedHtml .= ""; - } - $convertedHtml .= "
" . $th . "
" . $td . "
"; - break; - default: - echo "Unknown block type " . $block['type']; - break; - } - } - return $convertedHtml; - } - private function getDataFromBlocks($block) : string - { - $data = ""; - switch ($block['type']) { - case 'paragraph': - $data .= strip_tags($block['data']['content']) . " "; - break; - case 'heading': - $data .= strip_tags($block['data']['content']) . " "; - break; - case 'files': - foreach ($block['data']['file'] as $file) { - $data .= $file['title'] . " "; - } - break; - case 'person': - $data .= $block['data']['name'] . " "; - break; - case 'stepper': - $data .= $block['data']['step_name'] . " "; - foreach ($block['data']['steps'] as $step) { - $data .= $step['title'] . " "; - $data .= strip_tags($step['content']) . " "; - } - break; - case 'tabs': - foreach ($block['data']['tab'] as $item) { - foreach ($item['content'] as $block) { - $data .= $this->getDataFromBlocks($block); - }; - }; - break; - - } - return $data; - } - - private function postToSocialMedia($settings, $content, $title, $publish_date) : void - { - if ($this->record->status === PostStatus::PUBLISHED) { - if ($settings['vk']) { - $text = ""; - foreach ($content as $block) { - $text .= $this->generateContentToVK($block); - } - - $images = $this->generateImageLinksToVK($this->record->images); - - $post_id = $this->record->id; - - - - dispatch(new CreateVkPost($title, $text, $images, $post_id, $publish_date)); - } - } - } - - - private function generateContentToVK($block) : string - { - $data = ""; - - switch ($block['type']) { - case 'paragraph': - // Удаляем все HTML-теги и заменяем закрывающие теги p и h2 на двойной отступ - $content = preg_replace('/<\/(p|h2)>/', "\n\n", $block['data']['content']); - $data .= html_entity_decode(strip_tags($content)); - break; - - case 'heading': - // Удаляем теги заголовка и добавляем двойной отступ - $data .= $block['data']['content'] . "\n\n"; - break; + if (empty($this->slideData)) { + return; } - return $data; + $this->slideData['is_active'] = $this->record->status === PostStatus::PUBLISHED; + $this->slideData['start_time'] = $this->record->publish_at; + + $sliderDTO = MainSliderDTO::fromArray($this->slideData); + (new PostSliderService($sliderDTO, $this->record->slug))->create(); } - private function generateImageLinksToVK($images) + + protected function generateSeo(): void { + $seoData = (new PostSeoGenerator())->generate([ + 'title' => $this->record->title, + 'content' => $this->record->content, + 'preview' => $this->record->preview, + ]); + $this->record->seo()->create($seoData); + } - $imageUrls = array_map(function ($file) { - return url(Storage::url($file)); // Добавляем домен - }, $images); + protected function sendNotifications(): void + { + (new PostNotificationService())->send($this->record); + } - - return $imageUrls; // Возвращаем массив с полными URL изображений + protected function publishToVk(): void + { + (new VkPostPublisher())->publish($this->publicationAgreements, $this->record); } diff --git a/app/Filament/Resources/PostResource/Pages/EditPost.php b/app/Filament/Resources/PostResource/Pages/EditPost.php index 930e4c8..e316761 100644 --- a/app/Filament/Resources/PostResource/Pages/EditPost.php +++ b/app/Filament/Resources/PostResource/Pages/EditPost.php @@ -2,14 +2,17 @@ namespace App\Filament\Resources\PostResource\Pages; +use App\Dto\MainSliderDTO; use App\Enums\PostStatus; use App\Filament\Resources\PostResource; -use App\Jobs\UpdateVkPost; +use App\Services\Filament\Domain\Posts\PostDataProcessor; +use App\Services\Filament\Domain\Posts\PostNotificationService; +use App\Services\Filament\Domain\Posts\PostSeoGenerator; +use App\Services\Filament\Domain\Posts\PostSliderService; +use App\Services\Filament\Domain\Posts\VkPostPublisher; use Carbon\Carbon; use Filament\Actions; use Filament\Resources\Pages\EditRecord; -use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Str; class EditPost extends EditRecord { @@ -18,224 +21,75 @@ class EditPost extends EditRecord protected array $seoData; protected array $publicationAgreements; + protected array $slideData; + protected function mutateFormDataBeforeSave(array $data): array { - $this->seoData = $this->generateSeo($data); - $this->publicationAgreements = $data['publication']; - unset($data['publication']); - unset($data['publish_setting']); - $data['preview_text'] = $this->setPreviewText($data); - $data['publish_at'] = $this->setPublishDateTime($data['status'], $this->record->publish_at); - $data['search_data'] = $this->generateSearchData($data['content']); - $data['reading_time'] = $this->calculateReadingTime($data['search_data']); + $this->extractAdditionalData($data); + return $this->processPostData($data); + } - return $data; + protected function extractAdditionalData(array &$data): void + { + $this->publicationAgreements = $data['publication'] ?? []; + $this->slideData = $data['slide'] ?? []; + unset($data['slide'], $data['publication']); + } + + protected function processPostData(array $data): array + { + return (new PostDataProcessor())->process($data); } protected function afterSave(): void { - $this->record->seo()->update($this->seoData); - $publish_date = ($this->record->publish_at > now()) ? Carbon::parse($this->record->publish_at)->timestamp : null; - $this->postToSocialMedia($this->publicationAgreements, $this->record->content, $this->record->title, $publish_date); + $this->handleSlides(); + $this->generateSeo(); + $this->sendNotifications(); + $this->publishToVk(); } - private function setPreviewText(array $data) : string|null + + protected function handleSlides(): void { - $rowData = $this->getBlockBySeoActiveState('paragraph', $data['content']); - if ($rowData === null) { - $rowData = $this->getFirstBlockByName('paragraph', $data['content']); - } - if ($rowData !== null) { - $preview_text = html_entity_decode(strip_tags($rowData['data']['content'])); - return Str::limit($preview_text, 160); - } else { - $preview_text = null; - return $preview_text; + if (empty($this->slideData)) { + return; } + + $this->slideData['is_active'] = $this->record->status === PostStatus::PUBLISHED; + $this->slideData['start_time'] = $this->record->publish_at; + + $sliderDTO = MainSliderDTO::fromArray($this->slideData); + (new PostSliderService($sliderDTO, $this->record->slug))->update(); } - private function getBlockBySeoActiveState(string $name, array $content) : array|null + protected function generateSeo(): void { - $data = []; - foreach ($content as $block) { - if ($block['type'] === $name) { - $data[] = $block; - } - } - $block = null; - foreach ($data as $item) { - if ($item['data']['seo_active'] === true) { - $block = $item; - } - } - return $block; + $seoData = (new PostSeoGenerator())->generate([ + 'title' => $this->record->title, + 'content' => $this->record->content, + 'preview' => $this->record->preview, + ]); + $this->record->seo()->update($seoData); } - private function generateSeo(array $data) : array - { - $title = $data['title']; - $rowData = $this->getBlockBySeoActiveState('paragraph', $data['content']); - if ($rowData === null) { - $rowData = $this->getFirstBlockByName('paragraph', $data['content']); - } - if ($rowData !== null) { - $description = html_entity_decode(strip_tags($rowData['data']['content'])); - } else { - $description = null; - } - $image = ($this->record->preview !== null) ? $this->record->preview : null; - - return [ - 'title' => $title, - 'description' => Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160), - 'image' => $image, - ]; - } - - private function setPublishDateTime($status, $publish_at) - { - if ($publish_at !== null) { - return $publish_at; - } - return PostStatus::tryFrom($status) === PostStatus::PUBLISHED ? Carbon::now() : null; - } - private function getDataFromBlocks($block) : string - { - $data = ""; - switch ($block['type']) { - case 'paragraph': - $data .= strip_tags($block['data']['content']) . " "; - break; - case 'heading': - $data .= strip_tags($block['data']['content']) . " "; - break; - case 'files': - foreach ($block['data']['file'] as $file) { - $data .= $file['title'] . " "; - } - break; - case 'person': - $data .= $block['data']['name'] . " "; - break; - case 'stepper': - $data .= $block['data']['step_name'] . " "; - foreach ($block['data']['steps'] as $step) { - $data .= $step['title'] . " "; - $data .= strip_tags($step['content']) . " "; - } - break; - case 'tabs': - foreach ($block['data']['tab'] as $item) { - foreach ($item['content'] as $block) { - $data .= $this->getDataFromBlocks($block); - }; - }; - break; - - } - return $data; - } - - private function calculateReadingTime(string $text): int - { - - // Calculate the number of words in the text - $wordCount = str_word_count($text,0,"АаБбВвГгДдЕеЁёЖжЗзИиЙйКкЛлМмНнОоПпРрСсТтУуФфХхЦцЧчШшЩщЪъЫыЬьЭэЮюЯя"); - - - - - // Calculate the average reading speed in words per minute - $wordsPerMinute = 120; // You can adjust this value based on your desired reading speed - - // Calculate the reading time in minutes - $readingTime = $wordCount / $wordsPerMinute; - - // Round the reading time to the nearest integer - $readingTime = round($readingTime); - - - return $readingTime; - } - - private function generateSearchData(array $data) : string - { - $result = ""; - foreach ($data as $block) { - $result .= $this->getDataFromBlocks($block); - } - // Удаляем лишние пробелы и переносы строк - $result = preg_replace('/\s+/', ' ', $result); - $result = htmlspecialchars(trim($result)); - - return strtolower($result); - } - private function getFirstBlockByName(string $name, array $content) : array|null - { - $data = null; - foreach ($content as $block) { - $data = ($block['type'] === $name) ? $block : null; - break; - } - return $data; - } - - private function generateContentToVK($block) : string - { - $data = ""; - - switch ($block['type']) { - case 'paragraph': - // Удаляем все HTML-теги и заменяем закрывающие теги p и h2 на двойной отступ - $content = preg_replace('/<\/(p|h2)>/', "\n\n", $block['data']['content']); - $data .= html_entity_decode(strip_tags($content)); - break; - - case 'heading': - // Удаляем теги заголовка и добавляем двойной отступ - $data .= $block['data']['content'] . "\n\n"; - break; - } - - return $data; - } - - private function postToSocialMedia($settings, $content, $title, $publish_date) : void + protected function sendNotifications(): void { + // Отправляем уведомления + $notificationService = new PostNotificationService(); if ($this->record->status === PostStatus::PUBLISHED) { - if ($settings['vk']) { - $text = ""; - foreach ($content as $block) { - $text .= $this->generateContentToVK($block); - } - - $images = $this->generateImageLinksToVK($this->record->images); - - $post_id = $this->record->id; - - - dispatch(new UpdateVkPost($title, $text, $images, $post_id, $publish_date)); - } + $notificationService->sendSuccessNotification($this->record); + } elseif ($this->record->status === PostStatus::REJECTED) { + $notificationService->sendDeniedNotification($this->record); } } - private function generateImageLinksToVK($images) + protected function publishToVk(): void { - - $imageUrls = array_map(function ($file) { - return url(Storage::url($file)); // Добавляем домен - }, $images); - - - return $imageUrls; // Возвращаем массив с полными URL изображений + (new VkPostPublisher())->publish($this->publicationAgreements, $this->record); } - - - - - protected function getHeaderActions(): array { return [ diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index 3400404..3a169e0 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -50,7 +50,6 @@ class UserResource extends Resource implements HasShieldPermissions Forms\Components\TextInput::make('password') ->label('Пароль') ->password() - ->default(Str::password(15)) ->required(fn (string $context): bool => $context === 'create') ->dehydrated(fn ($state) => filled($state)) ->maxLength(255), diff --git a/app/Http/Controllers/ClientAcademicJournalController.php b/app/Http/Controllers/ClientAcademicJournalController.php index 396cefb..12658eb 100644 --- a/app/Http/Controllers/ClientAcademicJournalController.php +++ b/app/Http/Controllers/ClientAcademicJournalController.php @@ -22,11 +22,10 @@ class ClientAcademicJournalController extends Controller public function show(string $slug) { $journal = new ClientAcademicJournalListResource(AcademicJournal::query()->where('slug', '=', $slug)->firstOrFail()); - $journalIssues = JournalIssue::all() - ->groupBy('year_publication'); + $journalIssues = JournalIssue::where('academic_journal_id', $journal->id) + ->groupBy('year_publication')->get(); $journals = []; - $years = JournalIssue::select('year_publication')->distinct()->get(); foreach ($journalIssues as $year => $journalGroup) { $journals[] = [ diff --git a/app/Http/Controllers/ClientScheduleController.php b/app/Http/Controllers/ClientScheduleController.php index 8e262dc..52ce791 100644 --- a/app/Http/Controllers/ClientScheduleController.php +++ b/app/Http/Controllers/ClientScheduleController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Enums\FormEducation; use App\Http\Resources\ClientEducationalGroupResource; use App\Http\Resources\ScheduleResource; use App\Models\EducationalGroup; @@ -14,22 +15,71 @@ class ClientScheduleController extends Controller { public function index(Request $request) { - $educationalGroups = collect(); + $educationalGroups = ClientEducationalGroupResource::collection(EducationalGroup::query() + ->has('schedules') + ->when(request()->input('search'), function ($query, $search) { + $query->whereRaw('LOWER(title) like ?', ["%".strtolower($search)."%"]); + }) + ->when(request()->input('favorite'), function ($query, $favorite) { + $query->whereHas('schedules', function ($query) use ($favorite) { + $query->whereIn('id', $favorite); + }); + }) + ->when(request()->input('form'), function ($query, $form) { + $query->where('education_form_id', FormEducation::fromName($form)->value); - if (request()->filled('search')) { - $educationalGroups = ClientEducationalGroupResource::collection(EducationalGroup::query() - ->has('schedules') - ->when(request()->input('search'), function ($query, $search) { - $query->whereRaw('LOWER(title) like ?', ["%".strtolower($search)."%"]); - }) - ->with('schedules') - ->with('faculty') - ->orderBy('title') - ->get()); + }) + + ->with('schedules') + ->with('faculty') + ->orderBy('title') + ->get()); + + $schedulesByFaculty = $educationalGroups->groupBy(function ($group) { + return $group->faculty->title; // Предполагаем, что у факультета есть поле 'name' + }); + + $schedulesByFaculty = $schedulesByFaculty->toArray(); + + if ($request->has('favorite') && empty($request->input('favorite'))) { + $schedulesByFaculty = []; } - $searchRequest = request()->input('search'); - return Inertia::render('Client/Schedules/Index', compact('educationalGroups', 'searchRequest')); + + + + + + $forms_education = []; + foreach (FormEducation::cases() as $case) { + $forms_education[$case->name] = $case->getLabel(); + } + + $filters = [ + 'direction_filter' => [ + 'type' => 'direction', + 'value' => request()->input('direction'), + 'param' => 'direction' + ], + 'form_education_filter' => [ + 'type' => 'form', + 'value' => request()->input('form'), + 'param' => 'form' + ], + 'search_filter' => [ + 'type' => 'search', + 'value' => $request->input('search'), + 'param' => 'search' + ], + 'favorite_filter' => [ + 'type' => 'favorite', + 'value' => $request->input('favorite'), + 'param' => 'favorite' + ] + ]; + + // Возвращаем данные в представление + return Inertia::render('Client/Schedules/Index', compact('educationalGroups', 'filters', 'forms_education', 'schedulesByFaculty')); } public function show($id) diff --git a/app/Http/Controllers/MainController.php b/app/Http/Controllers/MainController.php index fbdb383..d4df601 100644 --- a/app/Http/Controllers/MainController.php +++ b/app/Http/Controllers/MainController.php @@ -27,6 +27,7 @@ use App\Services\Vicon\EducationalProgram\EducationalProgramService; use Carbon\Carbon; use DateTime; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Http; @@ -37,14 +38,32 @@ class MainController extends Controller { public function index() { - $admissionCampaign = $this->getAdmissionCampaign(); - $educations = $this->getEducationsData(); - $sliders = $this->getActiveSliders(); - $posts = $this->getRecentPosts(); - $events = $this->getUpcomingEvents(); + // Кешируем данные на 60 минут (можно изменить время по необходимости) +// $admissionCampaign = Cache::remember('admission_campaign', now()->addHour(), function () { +// return $this->getAdmissionCampaign(); +// }); + + $educations = Cache::remember('educations_data', now()->addHour(), function () { + return $this->getEducationsData(); + }); + + $sliders = Cache::remember('active_sliders', now()->addHour(), function () { + return $this->getActiveSliders(); + }); + + $posts = Cache::remember('recent_posts', now()->addHour(), function () { + return $this->getRecentPosts(); + }); + + $events = Cache::remember('upcoming_events', now()->addHour(), function () { + return $this->getUpcomingEvents(); + }); $path = route('index', null, false); - $page = Page::where('path', $path)->first(); + $page = Cache::remember('page_' . $path, now()->addHour(), function () use ($path) { + return Page::where('path', $path)->first(); + }); + $seo = $page->seo ?? null; return Inertia::render('Main', compact('posts', 'events', 'sliders', 'educations', 'seo')); @@ -82,11 +101,10 @@ class MainController extends Controller private function getActiveSliders() { - return ClientMainSliderResource::collection( + return (ClientMainSliderResource::collection( MainSlider::where('is_active', true) ->orderBy('sort', 'asc') - ->get() - ); + ->get())); } private function getRecentPosts() diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index d2c0eb7..30caad4 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -4,6 +4,7 @@ namespace App\Http; use App\Http\Middleware\AccessCheck; use App\Http\Middleware\InternalRequestOnly; +use App\Http\Middleware\LimitPost; use App\Http\Middleware\RateLimitCheckMiddleware; use App\Http\Middleware\RateLimitCounterMiddleware; use Illuminate\Foundation\Http\Kernel as HttpKernel; @@ -84,6 +85,7 @@ class Kernel extends HttpKernel 'rate.limited.check' => RateLimitCheckMiddleware::class, 'ensure.browser' => InternalRequestOnly::class, 'superadmin' => \App\Http\Middleware\EnsureUserIsSuperadmin::class, + 'limit.post' => LimitPost::class, ); } diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php index d4ef644..8d003ca 100644 --- a/app/Http/Middleware/Authenticate.php +++ b/app/Http/Middleware/Authenticate.php @@ -2,6 +2,7 @@ namespace App\Http\Middleware; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Middleware\Authenticate as Middleware; use Illuminate\Http\Request; @@ -12,6 +13,6 @@ class Authenticate extends Middleware */ protected function redirectTo(Request $request): ?string { - return $request->expectsJson() ? null : route('login'); + return $request->expectsJson() ? null : throw new AuthorizationException('Forbidden', 403); } } diff --git a/app/Http/Middleware/LimitPost.php b/app/Http/Middleware/LimitPost.php new file mode 100644 index 0000000..d4030a6 --- /dev/null +++ b/app/Http/Middleware/LimitPost.php @@ -0,0 +1,28 @@ +user()->receivedInvitation === null) { + return $next($request); + } + if (auth()->user()->receivedInvitation->post_limit > 0) { + return $next($request); + } else { + throw new HttpException(403, 'Лимит постов исчерпан'); + } + } +} diff --git a/app/Models/MainSlider.php b/app/Models/MainSlider.php index 76fef6c..832fa6e 100644 --- a/app/Models/MainSlider.php +++ b/app/Models/MainSlider.php @@ -10,4 +10,10 @@ class MainSlider extends Model use HasFactory; protected $guarded = false; + + + protected $casts = [ + 'settings' => 'array', + 'image' => 'array', + ]; } diff --git a/app/Models/Post.php b/app/Models/Post.php index 1a8eba8..8a88f8b 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -34,6 +34,11 @@ class Post extends Model return $this->belongsTo(Category::class); } + public function author() : BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + public function seo() { diff --git a/app/Models/User.php b/app/Models/User.php index f7a35d8..615098f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -94,6 +94,7 @@ class User extends Authenticatable implements FilamentUser { if (config('filament-shield.dashboard_user.enabled', false)) { FilamentShield::createRole(name: config('filament-shield.dashboard_user.name', 'dashboard_user')); + FilamentShield::createRole(name: config('', 'editor')); static::created(function (User $user) { $user->assignRole(config('filament-shield.dashboard_user.name', 'dashboard_user')); }); diff --git a/app/Observers/MainSliderObserver.php b/app/Observers/MainSliderObserver.php new file mode 100644 index 0000000..02dd4e7 --- /dev/null +++ b/app/Observers/MainSliderObserver.php @@ -0,0 +1,75 @@ +cacheService = new MainSliderCacheService(); + } + /** + * Handle the MainSlider "created" event. + */ + public function created(MainSlider $mainSlider): void + { + // Устанавливаем сортировку для новой записи + $mainSlider->sort = 1; + $mainSlider->save(); + + // Обновляем сортировку для всех остальных записей + $this->updateSortOrder(); + + $this->cacheService->clearAllCacheByModel(); + } + + /** + * Handle the MainSlider "updated" event. + */ + public function updated(MainSlider $mainSlider): void + { + $this->cacheService->clearAllCacheByModel(); + } + + /** + * Handle the MainSlider "deleted" event. + */ + public function deleted(MainSlider $mainSlider): void + { + $this->cacheService->clearAllCacheByModel(); + } + + /** + * Handle the MainSlider "restored" event. + */ + public function restored(MainSlider $mainSlider): void + { + // + } + + /** + * Handle the MainSlider "force deleted" event. + */ + public function forceDeleted(MainSlider $mainSlider): void + { + // + } + + protected function updateSortOrder(): void + { + // Получаем все записи, отсортированные по текущему значению sort + $slides = MainSlider::orderBy('sort', 'asc')->get(); + + // Обновляем сортировку для каждой записи + foreach ($slides as $index => $slide) { + $slide->sort = $index + 1; // Начинаем с 1 + $slide->save(); + } + } +} diff --git a/app/Observers/PostObserver.php b/app/Observers/PostObserver.php index 1d6cfcc..2c9eda3 100644 --- a/app/Observers/PostObserver.php +++ b/app/Observers/PostObserver.php @@ -30,7 +30,7 @@ class PostObserver */ public function updated(Post $post) { - $this->postCacheService->clearCache($post); + $this->postCacheService->clearAllCacheByModel(); } /** diff --git a/app/Policies/ContactWidgetPolicy.php b/app/Policies/ContactWidgetPolicy.php new file mode 100644 index 0000000..01afafd --- /dev/null +++ b/app/Policies/ContactWidgetPolicy.php @@ -0,0 +1,108 @@ +can('view_any_contact::widget'); + } + + /** + * Determine whether the user can view the model. + */ + public function view(User $user, ContactWidget $contactWidget): bool + { + return $user->can('view_contact::widget'); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->can('create_contact::widget'); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, ContactWidget $contactWidget): bool + { + return $user->can('update_contact::widget'); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, ContactWidget $contactWidget): bool + { + return $user->can('delete_contact::widget'); + } + + /** + * Determine whether the user can bulk delete. + */ + public function deleteAny(User $user): bool + { + return $user->can('delete_any_contact::widget'); + } + + /** + * Determine whether the user can permanently delete. + */ + public function forceDelete(User $user, ContactWidget $contactWidget): bool + { + return $user->can('force_delete_contact::widget'); + } + + /** + * Determine whether the user can permanently bulk delete. + */ + public function forceDeleteAny(User $user): bool + { + return $user->can('force_delete_any_contact::widget'); + } + + /** + * Determine whether the user can restore. + */ + public function restore(User $user, ContactWidget $contactWidget): bool + { + return $user->can('restore_contact::widget'); + } + + /** + * Determine whether the user can bulk restore. + */ + public function restoreAny(User $user): bool + { + return $user->can('restore_any_contact::widget'); + } + + /** + * Determine whether the user can replicate. + */ + public function replicate(User $user, ContactWidget $contactWidget): bool + { + return $user->can('replicate_contact::widget'); + } + + /** + * Determine whether the user can reorder. + */ + public function reorder(User $user): bool + { + return $user->can('reorder_contact::widget'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 60b0a8d..986a946 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,15 +3,20 @@ namespace App\Providers; use App\Models\MainSection; +use App\Models\MainSlider; use App\Models\Page; use App\Models\Post; use App\Models\SubSection; use App\Observers\MainSectionObserver; +use App\Observers\MainSliderObserver; use App\Observers\PageObserver; use App\Observers\PostObserver; use App\Observers\SubSectionObserver; +use App\Services\App\Cache\MainSliderCacheService; use Carbon\Carbon; use Filament\Facades\Filament; +use Filament\Support\Facades\FilamentView; +use Filament\Tables\View\TablesRenderHook; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\Vite; @@ -34,16 +39,29 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - setlocale(LC_TIME, 'ru_RU.UTF-8'); - Page::observe(PageObserver::class); - Post::observe(PostObserver::class); - MainSection::observe(MainSectionObserver::class); - SubSection::observe(SubSectionObserver::class); - Carbon::setLocale(config('app.locale')); + self::setObserversByModel(); + self::setLocaleTime(); Model::preventLazyLoading(!app()->isProduction()); // URL::forceScheme('https'); self::registerFilamentNavigationGroups(); $this->loadViewsFrom(__DIR__.'/path/to/views', 'checkpoint'); + + FilamentView::registerRenderHook(TablesRenderHook::TOOLBAR_REORDER_TRIGGER_AFTER, function () { + (new MainSliderCacheService())->clearAllCacheByModel(); + }); + } + + private static function setObserversByModel() : void { + Page::observe(PageObserver::class); + Post::observe(PostObserver::class); + MainSection::observe(MainSectionObserver::class); + SubSection::observe(SubSectionObserver::class); + MainSlider::observe(MainSliderObserver::class); + } + + private static function setLocaleTime() : void { + setlocale(LC_TIME, 'ru_RU.UTF-8'); + Carbon::setLocale(config('app.locale')); } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index d601848..84edc65 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -31,7 +31,7 @@ class AdminPanelProvider extends PanelProvider ->default() ->id('admin') ->path('admin') - ->registration() +// ->registration() ->login() ->databaseNotifications() ->databaseNotificationsPolling('5s') diff --git a/app/Providers/Filament/DashboardPanelProvider.php b/app/Providers/Filament/DashboardPanelProvider.php index 3d48b55..17a409a 100644 --- a/app/Providers/Filament/DashboardPanelProvider.php +++ b/app/Providers/Filament/DashboardPanelProvider.php @@ -29,6 +29,8 @@ class DashboardPanelProvider extends PanelProvider 'primary' => Color::Amber, ]) ->login() + ->databaseNotifications() + ->databaseNotificationsPolling('5s') ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') ->discoverPages(in: app_path('Filament/Dashboard/Pages'), for: 'App\\Filament\\Dashboard\\Pages') ->pages([ diff --git a/app/Services/App/Cache/MainSliderCacheService.php b/app/Services/App/Cache/MainSliderCacheService.php new file mode 100644 index 0000000..8e9087c --- /dev/null +++ b/app/Services/App/Cache/MainSliderCacheService.php @@ -0,0 +1,48 @@ +clearCacheByPrefix('active_sliders*'); + } + + + /** + * Получает кешированные данные по ключу. + * + * @param string $key Ключ кеша + * @return mixed + */ + public function getCachedData(string $key) + { + return Cache::get($key); + } + + /** + * Кеширует данные по ключу. + * + * @param string $key Ключ кеша + * @param mixed $data Данные для кеширования + * @param int $ttl Время жизни кеша в секундах + * @return void + */ + public function cacheData(string $key, $data, int $ttl = 3600): void + { + Cache::put($key, $data, $ttl); + } +} \ No newline at end of file diff --git a/app/Services/App/Cache/PostCacheService.php b/app/Services/App/Cache/PostCacheService.php index c345950..abb2dbb 100644 --- a/app/Services/App/Cache/PostCacheService.php +++ b/app/Services/App/Cache/PostCacheService.php @@ -18,14 +18,18 @@ class PostCacheService extends AbstractCacheService implements CacheInterface $cacheKeyBySlug = md5($entity->slug); $cacheKeyById = md5($entity->id); - Cache::forget($cacheKeyBySlug); - Cache::forget($cacheKeyById); + Cache::forget('post_' .$cacheKeyBySlug); + Cache::forget('post_' .$cacheKeyById); + + $this->clearCacheByPrefix('recent_posts*'); + } public function clearAllCacheByModel(): void { $this->clearCacheByPrefix('post_*'); $this->clearCacheByPrefix('posts_*'); + $this->clearCacheByPrefix('recent_posts*'); } /** diff --git a/app/Services/Filament/Domain/Posts/PostDataProcessor.php b/app/Services/Filament/Domain/Posts/PostDataProcessor.php new file mode 100644 index 0000000..e6e35aa --- /dev/null +++ b/app/Services/Filament/Domain/Posts/PostDataProcessor.php @@ -0,0 +1,187 @@ +setPreviewText($data); + + // Устанавливаем время публикации + $data['publish_at'] = $this->setPublishDateTime($data['publish_setting'], $data['status']); + unset($data['publish_setting']); + + // Генерируем данные для поиска + $data['search_data'] = $this->generateSearchData($data['content']); + + // Рассчитываем время чтения + $data['reading_time'] = $this->calculateReadingTime($data['search_data']); + + // Устанавливаем ID текущего пользователя + $data['user_id'] = auth()->id(); + + return $data; + } + + /** + * Устанавливает текст для предпросмотра. + * + * @param array $data + * @return string + */ + private function setPreviewText(array $data): string + { + $rowData = $this->getBlockBySeoActiveState('paragraph', $data['content']); + if ($rowData === null) { + $rowData = $this->getFirstBlockByName('paragraph', $data['content']); + } + + $previewText = $rowData ? html_entity_decode(strip_tags($rowData['data']['content'])) : ''; + return Str::limit($previewText, 160); + } + + /** + * Устанавливает время публикации. + * + * @param array $publishSetting + * @param string $status + * @return Carbon|null + */ + private function setPublishDateTime(array $publishSetting, string|PostStatus $status): ?Carbon + { + if ($publishSetting['publish_after'] === true) { + return Carbon::parse($publishSetting['publish_at']); + } + + if ($status === PostStatus::PUBLISHED) { + return Carbon::now(); + } + + + return null; + } + + /** + * Генерирует данные для поиска. + * + * @param array $content + * @return string + */ + private function generateSearchData(array $content): string + { + $result = ""; + foreach ($content as $block) { + $result .= $this->getDataFromBlocks($block); + } + + // Удаляем лишние пробелы и переносы строк + $result = preg_replace('/\s+/', ' ', $result); + $result = trim($result); + + return strtolower($result); + } + + /** + * Извлекает данные из блоков контента. + * + * @param array $block + * @return string + */ + private function getDataFromBlocks(array $block): string + { + $data = ""; + switch ($block['type']) { + case 'paragraph': + $data .= strip_tags($block['data']['content']) . " "; + break; + case 'heading': + $data .= strip_tags($block['data']['content']) . " "; + break; + case 'files': + foreach ($block['data']['file'] as $file) { + $data .= $file['title'] . " "; + } + break; + case 'person': + $data .= $block['data']['name'] . " "; + break; + case 'stepper': + $data .= $block['data']['step_name'] . " "; + foreach ($block['data']['steps'] as $step) { + $data .= $step['title'] . " "; + $data .= strip_tags($step['content']) . " "; + } + break; + case 'tabs': + foreach ($block['data']['tab'] as $item) { + foreach ($item['content'] as $block) { + $data .= $this->getDataFromBlocks($block); + } + } + break; + } + return $data; + } + + /** + * Рассчитывает время чтения. + * + * @param string $text + * @return int + */ + private function calculateReadingTime(string $text): int + { + $wordCount = str_word_count($text, 0, "АаБбВвГгДдЕеЁёЖжЗзИиЙйКкЛлМмНнОоПпРрСсТтУуФфХхЦцЧчШшЩщЪъЫыЬьЭэЮюЯя"); + $wordsPerMinute = 120; // Средняя скорость чтения + return max(1, round($wordCount / $wordsPerMinute)); + } + + /** + * Находит первый блок по имени. + * + * @param string $name + * @param array $content + * @return array|null + */ + private function getFirstBlockByName(string $name, array $content): ?array + { + foreach ($content as $block) { + if ($block['type'] === $name) { + return $block; + } + } + 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; + } +} \ No newline at end of file diff --git a/app/Services/Filament/Domain/Posts/PostNotificationService.php b/app/Services/Filament/Domain/Posts/PostNotificationService.php new file mode 100644 index 0000000..4b3dfe8 --- /dev/null +++ b/app/Services/Filament/Domain/Posts/PostNotificationService.php @@ -0,0 +1,80 @@ +users; + + // Отправляем уведомление каждому редактору + foreach ($editors as $editor) { + Notification::make() + ->title('Новость на проверку') + ->body('Новость "' . $post->title . '" нуждается в проверке') + ->actions([ + Action::make('view') + ->label('Проверить') + ->button() + ->markAsRead() + ->url(PostResource::getUrl('edit', ['record' => $post])), + ]) + ->sendToDatabase($editor); // Отправляем уведомление конкретному редактору + } + } + + public function sendSuccessNotification(Post $post): void + { + $user = User::find($post->user_id); + + Notification::make() + ->title('Ваша новость опубликована') + ->body('Новость "' . $post->title . '" опубликована') + ->actions([ + Action::make('view') + ->label('Смотреть') + ->button() + ->markAsRead() + ->url(route('client.post.show', $post->slug)), + ]) + ->sendToDatabase($user); + } + + /** + * Отправляет уведомление об отклонении. + * + * @param Post $post + * @return void + */ + public function sendDeniedNotification(Post $post): void + { + $user = User::find($post->user_id); + + Notification::make() + ->title('Ваша новость отклонена :(') + ->body('Новость "' . $post->title . '" была отклонена') + ->actions([ + Action::make('view') + ->label('Смотреть') + ->button() + ->markAsRead() + ->url(PostResource::getUrl('edit', ['record' => $post])), + ]) + ->sendToDatabase($user); + } +} \ No newline at end of file diff --git a/app/Services/Filament/Domain/Posts/PostSeoGenerator.php b/app/Services/Filament/Domain/Posts/PostSeoGenerator.php new file mode 100644 index 0000000..b1584f0 --- /dev/null +++ b/app/Services/Filament/Domain/Posts/PostSeoGenerator.php @@ -0,0 +1,111 @@ + $this->extractSeoTitle($data), + 'description' => $this->extractSeoDescription($data['content']), + 'image' => $this->extractSeoImage($data), + ]; + } + + /** + * Извлекает SEO-заголовок. + * + * @param array $data + * @return string + */ + private function extractSeoTitle(array $data): string + { + return $data['title']; + } + + /** + * Извлекает SEO-описание. + * + * @param array $content + * @return string + */ + private function extractSeoDescription(array $content): string + { + $rowData = $this->getBlockBySeoActiveState('paragraph', $content); + if ($rowData === null) { + $rowData = $this->getFirstBlockByName('paragraph', $content); + } + + $description = $rowData ? html_entity_decode(strip_tags($rowData['data']['content'])) : ''; + return Str::limit(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), 160); + } + + /** + * Извлекает SEO-изображение. + * + * @param array $data + * @return string|null + */ + private function extractSeoImage(array $data): ?string + { + return $data['preview'] ?? null; + } + + /** + * Находит первый блок по имени. + * + * @param string $name + * @param array $content + * @return array|null + */ + private function getFirstBlockByName(string $name, array $content): ?array + { + foreach ($content as $block) { + if ($block['type'] === $name) { + return $block; + } + } + 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 + { + $rowData = $this->getBlockBySeoActiveState('paragraph', $data['content']); + if ($rowData === null) { + $rowData = $this->getFirstBlockByName('paragraph', $data['content']); + } + + if ($rowData !== null) { + $previewText = html_entity_decode(strip_tags($rowData['data']['content'])); + return Str::limit($previewText, 160); + } + + return null; + } +} \ No newline at end of file diff --git a/app/Services/Filament/Domain/Posts/PostSliderService.php b/app/Services/Filament/Domain/Posts/PostSliderService.php new file mode 100644 index 0000000..0d8244b --- /dev/null +++ b/app/Services/Filament/Domain/Posts/PostSliderService.php @@ -0,0 +1,59 @@ + $this->dto->title, + 'content' => $this->dto->content, + 'image' => $this->dto->image, + 'link' => parse_url(route('client.post.show', $this->postSlug), PHP_URL_PATH), + 'link_text' => $this->dto->link_text, + 'is_active' => $this->dto->is_active, + 'color_theme' => $this->dto->color_theme, + 'start_time' => $this->dto->start_time, + 'end_time' => $this->dto->end_time, + ]); + + Log::info('MainSlider created successfully', ['postSlug' => $this->postSlug]); + } catch (\Exception $e) { + Log::error('Failed to create MainSlider', ['error' => $e->getMessage()]); + throw $e; + } + } + + public function update(): void + { + try { + MainSlider::update([ + 'title' => $this->dto->title, + 'content' => $this->dto->content, + 'image' => $this->dto->image, + 'link' => parse_url(route('client.post.show', $this->postSlug), PHP_URL_PATH), + 'link_text' => $this->dto->link_text, + 'is_active' => $this->dto->is_active, + 'color_theme' => $this->dto->color_theme, + 'start_time' => $this->dto->start_time, + 'end_time' => $this->dto->end_time, + ]); + + Log::info('MainSlider updated successfully', ['postSlug' => $this->postSlug]); + } catch (\Exception $e) { + Log::error('Failed to update MainSlider', ['error' => $e->getMessage()]); + throw $e; + } + } +} \ No newline at end of file diff --git a/app/Services/Filament/Domain/Posts/VkPostPublisher.php b/app/Services/Filament/Domain/Posts/VkPostPublisher.php new file mode 100644 index 0000000..88958e6 --- /dev/null +++ b/app/Services/Filament/Domain/Posts/VkPostPublisher.php @@ -0,0 +1,66 @@ +status === PostStatus::PUBLISHED) { + if ($settings['vk']) { + $text = $this->generateContentForVk($post->content); + $images = $this->generateImageLinksForVk($post->images); + $publishDate = $post->publish_at > now() ? $post->publish_at->timestamp : null; + + dispatch(new CreateVkPost($post->title, $text, $images, $post->id, $publishDate)); + } + } + } + + /** + * Генерирует текстовый контент для ВКонтакте. + * + * @param array $content + * @return string + */ + private function generateContentForVk(array $content): string + { + $text = ''; + foreach ($content as $block) { + switch ($block['type']) { + case 'paragraph': + $text .= strip_tags($block['data']['content']) . "\n\n"; + break; + case 'heading': + $text .= strip_tags($block['data']['content']) . "\n\n"; + break; + } + } + return trim($text); + } + + /** + * Генерирует ссылки на изображения для ВКонтакте. + * + * @param array $images + * @return array + */ + private function generateImageLinksForVk(array $images): array + { + return array_map(function ($file) { + return Storage::url($file); // Генерируем полный URL для изображения + }, $images); + } +} diff --git a/composer.json b/composer.json index b1b593b..6f1a7f0 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,8 @@ "symfony/filesystem": "^6.3", "tightenco/ziggy": "^1.0", "vkcom/vk-php-sdk": "^5.131", - "xvladqt/faker-lorem-flickr": "^1.0" + "xvladqt/faker-lorem-flickr": "^1.0", + "yepsua/filament-range-field": "^0.3.4" }, "require-dev": { "fakerphp/faker": "^1.9.1", diff --git a/composer.lock b/composer.lock index 915a9d7..3a43256 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "25ebd7fca281d22d4022ea15464d54f2", + "content-hash": "0e0616783641aeb05f872887cfa59e66", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -10915,6 +10915,75 @@ "source": "https://github.com/xvladxtremal/Faker-LoremFlickr/tree/v1.0.0" }, "time": "2021-01-18T02:03:35+00:00" + }, + { + "name": "yepsua/filament-range-field", + "version": "v0.3.4", + "source": { + "type": "git", + "url": "https://github.com/yepsua/filament-range-field.git", + "reference": "fc29d84819960b3ad12354c5473649d7989f1883" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/yepsua/filament-range-field/zipball/fc29d84819960b3ad12354c5473649d7989f1883", + "reference": "fc29d84819960b3ad12354c5473649d7989f1883", + "shasum": "" + }, + "require": { + "filament/filament": "^3.0", + "illuminate/contracts": "^8.0|^9.0|^10.0", + "php": "^8.0", + "spatie/laravel-package-tools": "^1.9.2" + }, + "require-dev": { + "nunomaduro/collision": "^6.0", + "nunomaduro/larastan": "^2.0.1", + "orchestra/testbench": "^7.0", + "pestphp/pest": "^1.21", + "pestphp/pest-plugin-laravel": "^1.1", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.5", + "spatie/laravel-ray": "^1.26" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Yepsua\\Filament\\FilamentRangeFieldServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Yepsua\\Filament\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Omar Yepez", + "email": "oyepez003@gmail.com", + "role": "Developer" + } + ], + "description": "The missing range/slider field for the Filament forms.", + "homepage": "https://github.com/yepsua/filament-range-field", + "keywords": [ + "filament-range-field", + "laravel", + "yepsua" + ], + "support": { + "issues": "https://github.com/yepsua/filament-range-field/issues", + "source": "https://github.com/yepsua/filament-range-field/tree/v0.3.4" + }, + "time": "2023-12-29T06:06:23+00:00" } ], "packages-dev": [ diff --git a/config/filament-forms-tinyeditor.php b/config/filament-forms-tinyeditor.php index 474028b..2fa9d4e 100644 --- a/config/filament-forms-tinyeditor.php +++ b/config/filament-forms-tinyeditor.php @@ -28,13 +28,17 @@ return [ 'plugins' => 'advlist paste autoresize codesample directionality emoticons fullscreen hr image imagetools link lists media table toc wordcount', 'toolbar' => ' bold italic | numlist bullist | blockquote table hr | link | fullscreen', 'upload_directory' => null, + 'browser_spellcheck' => true, 'custom_configs' => [ 'table_advtab' => false, 'table_row_advtab' => false, 'table_cell_advtab' => false, 'contextmenu' => '', 'table_resize_bars' => false, - 'paste_as_text' => true + 'paste_as_text' => true, + 'browser_spellcheck' => true, // Включение проверки орфографии + + ], ] diff --git a/database/migrations/2025_01_27_154953_add_education_form_id_to_educational_groups_table.php b/database/migrations/2025_01_27_154953_add_education_form_id_to_educational_groups_table.php new file mode 100644 index 0000000..7f3472b --- /dev/null +++ b/database/migrations/2025_01_27_154953_add_education_form_id_to_educational_groups_table.php @@ -0,0 +1,30 @@ +unsignedBigInteger('education_form_id')->nullable()->after('faculty_id'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() : void + { + Schema::table('educational_groups', function (Blueprint $table) { + $table->dropColumn('education_form_id'); + }); + } +}; diff --git a/database/migrations/2025_01_27_155121_remove_is_zaoch_from_schedules_table.php b/database/migrations/2025_01_27_155121_remove_is_zaoch_from_schedules_table.php new file mode 100644 index 0000000..9083022 --- /dev/null +++ b/database/migrations/2025_01_27_155121_remove_is_zaoch_from_schedules_table.php @@ -0,0 +1,30 @@ +dropColumn('is_zaoch'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() : void + { + Schema::table('schedules', function (Blueprint $table) { + $table->boolean('is_zaoch')->default(false); + }); + } +}; diff --git a/database/migrations/2025_01_27_162420_add_start_end_times_to_main_sliders_table.php b/database/migrations/2025_01_27_162420_add_start_end_times_to_main_sliders_table.php new file mode 100644 index 0000000..a4f538d --- /dev/null +++ b/database/migrations/2025_01_27_162420_add_start_end_times_to_main_sliders_table.php @@ -0,0 +1,31 @@ +timestamp('start_time')->nullable()->after('is_active'); // Дата и время начала действия слайда + $table->timestamp('end_time')->nullable()->after('start_time'); // Дата и время окончания действия слайда + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() : void + { + Schema::table('main_sliders', function (Blueprint $table) { + $table->dropColumn(['start_time', 'end_time']); // Удаление колонок при откате миграции + }); + } +}; diff --git a/database/migrations/2025_01_29_162363_change_columns_to_null_main_sliders_table.php b/database/migrations/2025_01_29_162363_change_columns_to_null_main_sliders_table.php new file mode 100644 index 0000000..dcdf369 --- /dev/null +++ b/database/migrations/2025_01_29_162363_change_columns_to_null_main_sliders_table.php @@ -0,0 +1,38 @@ +string('title')->nullable()->change(); + $table->text('content')->nullable()->change(); + $table->dropColumn('link_text'); + $table->text('settings')->nullable()->after('link'); + $table->text('image')->nullable()->change(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() : void + { + Schema::table('main_sliders', function (Blueprint $table) { + $table->string('title')->nullable(false)->change(); + $table->text('content')->nullable(false)->change(); + $table->string('link_text')->nullable(false); + $table->dropColumn('settings'); + $table->string('image')->nullable()->change(); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 8b23941..1c1c381 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "flowbite": "^2.5.2", "fslightbox": "^3.4.1", "fslightbox-vue": "^2.1.3", + "js-cookie": "^3.0.5", "preline": "^1.9.0", "slugify": "^1.6.6", "vue3-yandex-smartcaptcha": "^1.0.0" @@ -2022,6 +2023,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, "node_modules/laravel-vite-plugin": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-0.8.1.tgz", diff --git a/package.json b/package.json index 2453661..b260e5c 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "flowbite": "^2.5.2", "fslightbox": "^3.4.1", "fslightbox-vue": "^2.1.3", + "js-cookie": "^3.0.5", "preline": "^1.9.0", "slugify": "^1.6.6", "vue3-yandex-smartcaptcha": "^1.0.0" diff --git a/resources/js/Components/BuilderUi/Pages/PageNavigateLinks.vue b/resources/js/Components/BuilderUi/Pages/PageNavigateLinks.vue index 944e9db..05fde77 100644 --- a/resources/js/Components/BuilderUi/Pages/PageNavigateLinks.vue +++ b/resources/js/Components/BuilderUi/Pages/PageNavigateLinks.vue @@ -1,5 +1,5 @@ - - - + @@ -127,6 +63,7 @@ import BaseIcon from "@/Components/BaseComponents/BaseIcon.vue"; import MobileNavbar from "@/Navbars/MobileNavbar.vue"; import SearchModal from "@/Components/Modals/SearchModal.vue"; import {defineAsyncComponent} from "vue"; +import DesktopNavBar from "@/Navbars/DesktopNavBar.vue"; export default { @@ -141,6 +78,7 @@ export default { return {} }, components: { + DesktopNavBar, SearchModal, MobileNavbar, BaseIcon, diff --git a/resources/js/Navbars/MainPageNavbar.vue b/resources/js/Navbars/MainPageNavbar.vue index 55b915f..82d9dab 100644 --- a/resources/js/Navbars/MainPageNavbar.vue +++ b/resources/js/Navbars/MainPageNavbar.vue @@ -44,6 +44,7 @@ + @@ -63,6 +64,7 @@ import SearchModal from "@/Components/Modals/SearchModal.vue"; import DesktopNavBar from "@/Navbars/DesktopNavBar.vue"; + export default { name: 'MainPageNavBar', components: { @@ -87,6 +89,7 @@ export default { headerFilter: false, underSliderHeader: this.sliderRef, bvi: null, + isActiveBvi: null, logos: { default: '/logos/white_ntspi_logo.svg', alternate: '/logos/ntspi-logo.svg', @@ -123,6 +126,19 @@ export default { }, + iniBvi() { + if (this.getCookie('bvi_panelActive') === null) { + this.bvi = new isvek.Bvi({ + target: '.open-bvi', + fontSize: 24, + theme: 'black', + speech: false, + reload: true, + panelHide: true + }); + } + }, + handleScroll() { if (typeof this.sliderRef === 'object') { const mainSlider = this.sliderRef; @@ -133,30 +149,19 @@ export default { this.headerFilter = true } }, - getCookie(name) { - let cookies = document.cookie.split(';'); - for (let i = 0; i < cookies.length; i++) { - let cookie = cookies[i].trim(); - if (cookie.startsWith(name + '=')) { - return cookie.substring(name.length + 1); - } - } - return null; // Если cookie не найден - } + }, mounted() { window.addEventListener('scroll', this.handleScroll) - // if (this.getCookie('bvi_panelActive') === null) { - // this.bvi = new isvek.Bvi({ - // target: '.className', - // fontSize: 24, - // theme: 'black', - // speech: false, - // reload: true, - // }); - // } + + + if (this.getCookie('bvi_panelActive') === null) { + this.iniBvi() + } + + }, beforeDestroy() { window.removeEventListener('scroll', this.handleScroll) diff --git a/resources/js/Pages/Client/Programs/Show.vue b/resources/js/Pages/Client/Programs/Show.vue index 516604a..bfb95bd 100644 --- a/resources/js/Pages/Client/Programs/Show.vue +++ b/resources/js/Pages/Client/Programs/Show.vue @@ -106,8 +106,11 @@ export default {
- + + +
+

Направление подготовки

{{ program.data.directionStudy.name }} {{ program.data.directionStudy.code }}

@@ -118,7 +121,9 @@ export default {
- + + +

Срок обучения

@@ -148,7 +153,9 @@ export default {
- + + +

Количество мест на прием

diff --git a/resources/js/Pages/Client/Schedules/Index.vue b/resources/js/Pages/Client/Schedules/Index.vue index c03bb99..699a89d 100644 --- a/resources/js/Pages/Client/Schedules/Index.vue +++ b/resources/js/Pages/Client/Schedules/Index.vue @@ -5,23 +5,34 @@ import { debounce } from "lodash"; import { Link } from "@inertiajs/vue3"; import MainPageNavBar from "@/Navbars/MainPageNavbar.vue"; import BaseIcon from "@/Components/BaseComponents/BaseIcon.vue"; +import ClientScheduleFilter from "@/Components/BuilderUi/Schedules/ClientScheduleFilter.vue"; export default { name: "Index", data() { return { - searchInput: this.searchRequest, - favoriteGroups: JSON.parse(localStorage.getItem('favoriteGroups')) || [], + searchInput: this.filters.search_filter.value, + favoriteGroups: JSON.parse(localStorage.getItem('favoriteGroups')), showFavorites: false, + loading: false, }; }, - components: {BaseIcon, MainPageNavBar, ClientFooterDown, MainNavbar, Link}, - props: [ - 'educationalGroups', - 'mainSections', - 'searchRequest', - 'navigation', - ], + components: {ClientScheduleFilter, BaseIcon, MainPageNavBar, ClientFooterDown, MainNavbar, Link}, + + props: { + navigation: { + type: Object + }, + filters: { + type: Object + }, + forms_education: { + type: Object + }, + schedulesByFaculty: { + type: Object + } + }, methods: { search: debounce(function () { this.$inertia.reload({ @@ -34,7 +45,7 @@ export default { }); }, 300), toggleFavorite(group) { - const index = this.favoriteGroups.findIndex(g => g.id === group.id); + const index = this.favoriteGroups.findIndex(g => g === group); if (index === -1) { this.favoriteGroups.push(group); } else { @@ -43,18 +54,65 @@ export default { localStorage.setItem('favoriteGroups', JSON.stringify(this.favoriteGroups)); }, isFavorite(group) { - return this.favoriteGroups.some(g => g.id === group.id); + return this.favoriteGroups.some(g => g === group); }, toggleShowFavorites() { - this.showFavorites = !this.showFavorites; - }, - }, - computed: { - filteredGroups() { - if (this.showFavorites) { - return this.educationalGroups.data.filter(group => this.favoriteGroups.some(g => g.id === group.id)); + this.loading = true; // Включаем состояние загрузки + + if (this.filters.favorite_filter.value === null) { + this.filterFavorite(() => { + this.loading = false; // Выключаем состояние загрузки после завершения + }); + } else { + this.clearFilterFavorite(() => { + this.loading = false; // Выключаем состояние загрузки после завершения + }); } - return this.educationalGroups.data; + }, + + filterFavorite: debounce(function (callback) { + let url = new URL(window.location.href); + // Создаем массив для хранения всех ключей, которые нужно удалить + const keysToDelete = []; + + // Перебираем все параметры и добавляем ключи, начинающиеся с 'category', в массив + for (const [key] of url.searchParams) { + if (key.startsWith('favorite')) { + keysToDelete.push(key); + } + } + // Удаляем все ключи из массива + keysToDelete.forEach(key => url.searchParams.delete(key)); + let newUrl = url.toString(); + this.$inertia.visit(newUrl, { + method: 'get', + preserveState: true, + data: { + favorite: (this.favoriteGroups.length !== 0) ? this.favoriteGroups : "", + }, + onFinish: callback, // Вызываем колбэк после завершения запроса + }); + }, 500), + clearFilterFavorite(callback) { + let url = new URL(window.location.href); + // Создаем массив для хранения всех ключей, которые нужно удалить + const keysToDelete = []; + + // Перебираем все параметры и добавляем ключи, начинающиеся с 'category', в массив + for (const [key] of url.searchParams) { + if (key.startsWith('favorite')) { + keysToDelete.push(key); + } + } + // Удаляем все ключи из массива + keysToDelete.forEach(key => url.searchParams.delete(key)); + let newUrl = url.toString(); + this.$inertia.visit(newUrl, { + method: 'get', + preserveState: true, + onFinish: callback, // Вызываем колбэк после завершения запроса + + }); }, }, }; @@ -69,14 +127,13 @@ export default {
-
-

+
+

Расписание занятий

- -
-

+

+

Просто введите название группы

@@ -100,7 +157,7 @@ export default { id="hs-search-article-1" class="py-2.5 px-4 block w-full border-transparent rounded-lg disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100" placeholder="Поиск" - :disabled="showFavorites" + :disabled="filters.favorite_filter.value !== null" >
@@ -108,24 +165,20 @@ export default {
- - +

@@ -133,131 +186,59 @@ export default {
-
-
+
+
-