This commit is contained in:
f4ilji
2025-01-30 00:31:40 +05:00
parent bad611eb84
commit 3385dc5807
60 changed files with 1811 additions and 825 deletions
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace App\Dto;
use Carbon\Carbon;
class MainSliderDTO
{
public function __construct(
public ?string $title,
public ?string $content,
public ?string $image,
public ?string $link,
public ?string $link_text,
public ?string $color_theme,
public ?bool $is_active,
public ?Carbon $start_time,
public ?Carbon $end_time,
public ?int $sort,
) {}
// Опционально: метод для создания DTO из массива
public static function fromArray(array $data): self
{
return new self(
title: $data['title'] ?? null,
content: $data['content'] ?? null,
image: $data['image'] ?? null,
link: $data['link'] ?? null,
link_text: $data['link_text'] ?? null,
color_theme: $data['color_theme'] ?? 'white',
is_active: $data['is_active'] ?? true,
start_time: isset($data['start_time']) ? Carbon::parse($data['start_time']) : null,
end_time: isset($data['end_time']) ? Carbon::parse($data['end_time']) : null,
sort: $data['sort'] ?? null,
);
}
// Опционально: метод для преобразования DTO в массив
public function toArray(): array
{
return [
'id' => $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(),
];
}
}
+47 -1
View File
@@ -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'),
]),
])
]);
@@ -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([
//
@@ -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',
];
}
}
@@ -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)
]),
]),
]);
+69 -22
View File
@@ -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),
]),
-2
View File
@@ -44,8 +44,6 @@ class PageResource extends Resource
protected static ?string $pluralLabel = 'Страницы';
public static ?string $label = 'Страница';
protected static ?string $navigationGroup = 'Структура приложения';
+5 -1
View File
@@ -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}'),
];
}
@@ -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 .= "<h" . $block['data']['level'] . ">" . $block['data']['text'] . "</h" . $block['data']['level'] . ">";
break;
case "embded":
$convertedHtml .= "<div><iframe width='560' height='315' src='" . $block['data']['embed'] . "' frameborder='0' allow='autoplay; encrypted-media' allowfullscreen></iframe></div>";
break;
case "paragraph":
$convertedHtml .= "<p>" . $block['data']['text'] . "</p>";
break;
case "delimiter":
$convertedHtml .= "<hr />";
break;
case "image":
$convertedHtml .= "<img class='img-fluid' src='" . $block['data']['file']['url'] . "' title='" . $block['data']['caption'] . "' /><br /><em>" . $block['data']['caption'] . "</em>";
break;
case "list":
$convertedHtml .= "<ul>";
foreach ($block['data']['items'] as $li) {
$convertedHtml .= "<li>" . $li . "</li>";
}
$convertedHtml .= "</ul>";
break;
case "table":
$convertedHtml .= "<table>";
if ($block['data']['withHeadings']) {
$convertedHtml .= "<thead><tr>";
foreach ($block['data']['content'][0] as $th) {
$convertedHtml .= "<th>" . $th . "</th>";
}
$convertedHtml .= "</tr></thead>";
}
$convertedHtml .= "<tbody>";
foreach ($block['data']['content'] as $row) {
$convertedHtml .= "<tr>";
foreach ($row as $td) {
$convertedHtml .= "<td>" . $td . "</td>";
}
$convertedHtml .= "</tr>";
}
$convertedHtml .= "</tbody></table>";
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);
}
@@ -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 [
-1
View File
@@ -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),
@@ -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[] = [
@@ -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)
+27 -9
View File
@@ -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()
+2
View File
@@ -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,
);
}
+2 -1
View File
@@ -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);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class LimitPost
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (auth()->user()->receivedInvitation === null) {
return $next($request);
}
if (auth()->user()->receivedInvitation->post_limit > 0) {
return $next($request);
} else {
throw new HttpException(403, 'Лимит постов исчерпан');
}
}
}
+6
View File
@@ -10,4 +10,10 @@ class MainSlider extends Model
use HasFactory;
protected $guarded = false;
protected $casts = [
'settings' => 'array',
'image' => 'array',
];
}
+5
View File
@@ -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()
{
+1
View File
@@ -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'));
});
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Observers;
use App\Models\MainSlider;
use App\Services\App\Cache\MainSliderCacheService;
class MainSliderObserver
{
protected MainSliderCacheService $cacheService;
public function __construct()
{
$this->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();
}
}
}
+1 -1
View File
@@ -30,7 +30,7 @@ class PostObserver
*/
public function updated(Post $post)
{
$this->postCacheService->clearCache($post);
$this->postCacheService->clearAllCacheByModel();
}
/**
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\ContactWidget;
use Illuminate\Auth\Access\HandlesAuthorization;
class ContactWidgetPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->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');
}
}
+24 -6
View File
@@ -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'));
}
@@ -31,7 +31,7 @@ class AdminPanelProvider extends PanelProvider
->default()
->id('admin')
->path('admin')
->registration()
// ->registration()
->login()
->databaseNotifications()
->databaseNotificationsPolling('5s')
@@ -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([
@@ -0,0 +1,48 @@
<?php
namespace App\Services\App\Cache;
use Illuminate\Support\Facades\Cache;
class MainSliderCacheService extends AbstractCacheService implements CacheInterface
{
/**
* Очищает кеш, связанный с постом.
*
* @param mixed $entity Пост или связанная сущность
* @return void
*/
public function clearCache($entity): void
{
}
public function clearAllCacheByModel(): void
{
$this->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);
}
}
+6 -2
View File
@@ -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*');
}
/**
@@ -0,0 +1,187 @@
<?php
namespace App\Services\Filament\Domain\Posts;
use App\Enums\PostStatus;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class PostDataProcessor
{
/**
* Обрабатывает данные перед созданием поста.
*
* @param array $data
* @return array
*/
public function process(array $data): array
{
// Удаляем ненужные данные
unset($data['publication']);
// Устанавливаем текст для предпросмотра
$data['preview_text'] = $this->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;
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Services\Filament\Domain\Posts;
use App\Filament\Resources\PostResource;
use App\Models\Post;
use App\Models\Role;
use App\Models\User;
use Filament\Notifications\Actions\Action;
use Filament\Notifications\Notification;
class PostNotificationService
{
/**
* Отправляет уведомления редакторам о новом посте.
*
* @param Post $post
* @return void
*/
public function send(Post $post): void
{
// Находим всех пользователей с ролью "editor"
$editors = Role::findByName('editor')->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);
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Services\Filament\Domain\Posts;
use Illuminate\Support\Str;
class PostSeoGenerator
{
/**
* Генерирует SEO-данные для поста.
*
* @param array $data
* @return array
*/
public function generate(array $data): array
{
return [
'title' => $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;
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Services\Filament\Domain\Posts;
use App\Dto\MainSliderDTO;
use App\Models\MainSlider;
use Illuminate\Support\Facades\Log;
class PostSliderService
{
public function __construct(
readonly private MainSliderDTO $dto,
readonly private string $postSlug,
){}
public function create(): void
{
try {
MainSlider::create([
'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 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;
}
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Services\Filament\Domain\Posts;
use App\Enums\PostStatus;
use App\Jobs\CreateVkPost;
use App\Models\Post;
use Illuminate\Support\Facades\Storage;
class VkPostPublisher
{
/**
* Публикует пост в социальных сетях.
*
* @param array $settings
* @param Post $post
* @return void
*/
public function publish(array $settings, Post $post): void
{
if ($post->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);
}
}
+2 -1
View File
@@ -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",
Generated
+70 -1
View File
@@ -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": [
+5 -1
View File
@@ -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, // Включение проверки орфографии
],
]
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up() : void
{
Schema::table('educational_groups', function (Blueprint $table) {
$table->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');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up() : void
{
Schema::table('schedules', function (Blueprint $table) {
$table->dropColumn('is_zaoch');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down() : void
{
Schema::table('schedules', function (Blueprint $table) {
$table->boolean('is_zaoch')->default(false);
});
}
};
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up() : void
{
Schema::table('main_sliders', function (Blueprint $table) {
$table->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']); // Удаление колонок при откате миграции
});
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up() : void
{
Schema::table('main_sliders', function (Blueprint $table) {
$table->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();
});
}
};
+9
View File
@@ -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",
+1
View File
@@ -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"
@@ -1,5 +1,5 @@
<template>
<div class="w-full h-[67px] fixed pointer-events-none" id="visor"></div>
<div class="w-full h-[67px] fixed pointer-events-none bvi-no-styles" id="visor"></div>
<nav class="order-last hidden w-56 shrink-0 lg:block">
<div v-if="headerNavs.length > 0" class="sticky top-[100px] h-[calc(100vh-121px)]">
@@ -45,8 +45,18 @@ export default {
methods: {
filter: debounce(function () {
let url = new URL(window.location.href);
// Создаем массив для хранения всех ключей, которые нужно удалить
const keysToDelete = [];
// Перебираем все параметры и добавляем ключи, начинающиеся с 'category', в массив
for (const [key] of url.searchParams) {
if (key.startsWith('direction')) {
keysToDelete.push(key);
}
}
// Удаляем все ключи из массива
keysToDelete.forEach(key => url.searchParams.delete(key));
url.searchParams.delete('page');
url.searchParams.delete('direction[]');
let newUrl = url.toString();
this.$inertia.visit(newUrl, {
method: 'get',
@@ -58,9 +68,18 @@ export default {
}, 500),
clearFilter() {
let url = new URL(window.location.href);
url.searchParams.delete('direction[]');
// Создаем массив для хранения всех ключей, которые нужно удалить
const keysToDelete = [];
// Перебираем все параметры и добавляем ключи, начинающиеся с 'category', в массив
for (const [key] of url.searchParams) {
if (key.startsWith('direction')) {
keysToDelete.push(key);
}
}
// Удаляем все ключи из массива
keysToDelete.forEach(key => url.searchParams.delete(key));
this.direction = [];
this.searchTerm = ''; // Сбросить поле поиска
let newUrl = url.toString();
this.$inertia.visit(newUrl, {
method: 'get',
@@ -0,0 +1,82 @@
<template>
<!--Фильтр-->
<button type="button"
aria-haspopup="dialog" aria-expanded="false" aria-controls="hs-offcanvas-example" data-hs-overlay="#hs-offcanvas-example"
class="flex w-full py-2 px-4 items-center gap-x-2 text-xs font-medium rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-primaryBlue focus:text-white disabled:opacity-50 disabled:pointer-events-none">
<BaseIcon class="shrink-0 size-4" name="settings" />
<span>Фильтры</span>
</button>
<!--Конец Фильтра-->
<div id="hs-offcanvas-example" class="hs-overlay hs-overlay-open:translate-x-0 hidden -translate-x-full fixed top-0 start-0 transition-all duration-300 transform h-full max-w-xs w-full z-[80] bg-white border-e overflow-auto" role="dialog" tabindex="-1" aria-labelledby="hs-offcanvas-example-label">
<div class="flex justify-between items-center py-3 px-4 border-b">
<h3 id="hs-offcanvas-example-label" class="font-bold text-gray-800">
Фильтры
</h3>
<button type="button" class="size-8 inline-flex justify-center items-center gap-x-2 rounded-full border border-transparent bg-gray-100 text-gray-800 hover:bg-gray-200 focus:outline-none focus:bg-gray-200 disabled:opacity-50 disabled:pointer-events-none" aria-label="Close" data-hs-overlay="#hs-offcanvas-example">
<span class="sr-only">Close</span>
<svg class="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
<div class="p-4">
<div class="hs-accordion-group">
<div class="hs-accordion" :id="'id' + formEdu_filter.type">
<button class="hs-accordion-toggle hs-accordion-active:text-blue-600 py-3 inline-flex items-center gap-x-3 w-full font-semibold text-start text-gray-800 hover:text-gray-500 focus:outline-none focus:text-gray-500 rounded-lg disabled:opacity-50 disabled:pointer-events-none" aria-expanded="false" aria-controls="hs-basic-with-arrow-collapse-two">
<svg class="hs-accordion-active:hidden block size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6"></path>
</svg>
<svg class="hs-accordion-active:block hidden size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m18 15-6-6-6 6"></path>
</svg>
Формат обучения
</button>
<div id="hs-basic-with-arrow-collapse-two" class="hs-accordion-content hidden w-full overflow-hidden transition-[height] duration-300" role="region" :aria-labelledby="'id' + formEdu_filter.type">
<FormEducationalFilter :forms="forms_educational" :formEdu_filter="formEdu_filter" />
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import BaseIcon from "@/Components/BaseComponents/BaseIcon.vue";
import CategoryFilter from "@/Components/BuilderUi/Events/Filters/CategoryFilter.vue";
import TagFilter from "@/Components/BuilderUi/Events/Filters/TagFilter.vue";
import SortingByFilter from "@/Components/BuilderUi/Events/Filters/SortingByFilter.vue";
import FormEducationalFilter from "@/Components/BuilderUi/Programs/Filters/FormEducationalFilter.vue";
import BudgetFilter from "@/Components/BuilderUi/Programs/Filters/BudgetFilter.vue";
import DirectionFilter from "@/Components/BuilderUi/Programs/Filters/DirectionFilter.vue";
export default {
name: "ClientScheduleFilter",
components: {
DirectionFilter,
BudgetFilter, FormEducationalFilter, SortingByFilter, TagFilter, CategoryFilter, BaseIcon},
data() {
return {
}
},
props: {
faculty_filter: {
type: Object,
},
formEdu_filter: {
type: Object
},
forms_educational: {
type: Object
}
},
}
</script>
<style scoped>
</style>
+15 -13
View File
@@ -1,32 +1,34 @@
<template>
<div ref="sliderRef" class="relative z-0 min-h-[calc(100vh)] items-center">
<div class="absolute -z-10 h-full w-full before:absolute before:z-10 before:h-full before:w-full before:bg-black/30">
<a :href="(!slider.settings.link_text) ? slider.link : '#'" v-for="(slider, index) in slidersCarousel.data" :class="`brightness-[${slider.image.shading}]`"
class="absolute -z-10 h-full w-full before:absolute before:z-10 before:h-full before:w-full ">
<img
alt="Thumbnail"
loading="eager"
decoding="async"
data-nimg="fill"
class="object-cover brightness-[0.7] transition-opacity duration-1000 absolute inset-0 h-full w-full"
class="object-cover transition-opacity duration-1000 absolute inset-0 h-full w-full"
sizes="100vw"
v-for="(slider, index) in slidersCarousel.data"
:key="index"
:src="'/storage/' + slider.image"
:src="'/storage/' + slider.image.url"
:class="{ 'opacity-1': currentIndex === index, 'opacity-0': currentIndex !== index }"
/>
</div>
<div v-show="currentIndex === index" v-for="(item, index) in slidersCarousel.data" :key="index" class="mx-auto max-w-screen-md px-5 pt-[150px] pb-0">
<h1 class="text-brand-primary mb-3 mt-2 text-3xl font-semibold tracking-tight text-white lg:text-5xl lg:leading-tight">
</a>
<div v-show="currentIndex === index" v-for="(item, index) in slidersCarousel.data" :key="index" class="mx-auto max-w-screen-md px-5 pt-[150px] pb-0 bvi-no-styles">
<h1 v-if="item.title" :class="`text-${item.settings.text_position}`" class="text-brand-primary mb-3 mt-2 text-3xl font-semibold tracking-tight text-white lg:text-5xl lg:leading-tight">
{{ item.title }}
</h1>
<div class="mt-8 flex space-x-3 text-gray-500 mb-8">
<div class="flex flex-col gap-3 md:flex-row md:items-center">
<div class="flex gap-3">
<p class="text-gray-100 line-clamp-3"><a href="/author/erika-oliver">{{ item.content }}</a></p>
<div v-if="item.content" class="mt-8 space-x-3 text-gray-500 mb-8">
<div class="gap-3 md:flex-row md:items-center">
<div class="gap-3">
<p :class="`text-${item.settings.text_position}`" class="text-gray-100 line-clamp-3">
{{ item.content }}
</p>
</div>
</div>
</div>
<a :href="item.link" class="py-3 px-4 inline-flex items-center gap-x-2 text-sm font-semibold rounded-lg border border-white text-white hover:border-white/70 hover:text-white/70 disabled:opacity-50 disabled:pointer-events-none">
{{ item.link_text }}
<a v-if="item.settings.link_text" :href="item.link" class="py-3 px-4 inline-flex items-center gap-x-2 text-sm font-semibold rounded-lg border border-white text-white hover:border-white/70 hover:text-white/70 disabled:opacity-50 disabled:pointer-events-none">
{{ item.settings.link_text }}
</a>
</div>
<div v-if="slidersCarousel.data.length >= 2" class="mx-auto max-w-screen-md px-5">
@@ -69,8 +69,6 @@
<DirectionFilter :direction_filter="direction_filter" :direction_studies="direction_studies" />
</div>
</div>
</div>
</div>
</div>
+13 -4
View File
@@ -69,10 +69,7 @@ export default {
// stroke_width: 1.5,
// stroke: 'currentColor',
},
home: {
home: {
path: '<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"/>',
viewBox: '0 0 24 24',
fill: 'none',
@@ -81,4 +78,16 @@ home: {
},
settings: {
path: '<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"/>',
viewBox: '0 0 24 24',
fill: 'none',
stroke_width: 1.5,
stroke: 'currentColor',
},
}
+6 -3
View File
@@ -60,9 +60,7 @@
</template>
</template>
<a href="#" class="hover:opacity-70 py-3 className">
<a @click="this.removeCookieBvi()" href="#" class="hover:opacity-70 py-3 open-bvi ">
<svg :class="!underSliderHeader ? 'text-white' : 'text-black'" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6 duration-300 md:block hidden">
@@ -167,6 +165,11 @@ export default {
//
// return hasActiveInPages || hasActiveInSubSections;
},
removeCookieBvi() {
if (this.getCookie('bvi_panelActive') === 'true') {
this.deleteCookiesWithPrefix('bvi')
}
}
},
+4 -66
View File
@@ -36,72 +36,8 @@
</div>
</div>
<div id="navbar-collapse-with-animation"
class="hs-collapse hidden overflow-hidden transition-all duration-300 basis-full grow lg:block">
<div
class="overflow-hidden overflow-y-auto max-h-[75vh] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300">
<div
class="flex flex-col gap-x-0 mt-5 md:flex-row md:items-center md:justify-end md:gap-x-7 md:mt-0 md:ps-7 md:divide-y-0 md:divide-solid">
<template v-for="section in this.sections.data" :key="section.id">
<div
class="hs-dropdown [--strategy:static] md:[--strategy:absolute] [--adaptive:none] md:[--trigger:hover] py-3 md:py-6">
<button type="button"
class="active:text-blue-600 flex items-center w-full text-black hover:text-gray-300 font-medium">
{{ section.title }}
</button>
<div
class="hs-dropdown-menu transition-opacity duration-150 md:duration-500 hs-dropdown-open:opacity-100 opacity-0 w-full hidden z-10 top-full start-0 min-w-[15rem] bg-white md:shadow-2xl rounded-lg py-2 md:p-4 before:absolute before:-top-5 before:start-0 before:w-full before:h-5">
<div class="grid px-5 grid-cols-1 md:grid-cols-10">
<template v-for="subsection in section.subSections" :key="subsection.id">
<div class="md:col-span-3">
<div class="flex flex-col py-6 px-3 md:px-6">
<div class="space-y-4">
<div class="flex items-center mb-2 gap-x-2">
<span class="text-xs font-bold uppercase text-gray-800">{{
subsection.title
}}</span>
</div>
<template v-for="page in subsection.pages" :key="page.id">
<a :class="{'text-secondDarkBlue hover:text-gray-800 font-semibold ' : isSameRoute(page.path), 'text-gray-800 hover:text-gray-500' : !isSameRoute(page.path) }"
class="flex items-center gap-x-2"
:href="(page.is_url) ? page.path : route('page.view', page.path) + '/'">
<div class="grow">
<p>{{ page.title }}</p>
</div>
</a>
</template>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<a href="#" class="hover:opacity-70 py-3 className">
<BaseIcon name="eye" class="w-6 h-6 duration-300 md:block hidden" />
<span class="md:hidden">Режим для слабовидящих</span>
</a>
<Link :href="route('client.schedule')" class="hover:opacity-70 py-3">
<BaseIcon name="schedule" class="w-6 h-6 text-black md:block hidden" />
<span class="md:hidden text-black">Расписание</span>
</Link>
<a class="hover:opacity-70 py-3 cursor-pointer" data-hs-overlay="#hs-full-screen-modal-below-md">
<BaseIcon name="search" class="w-6 h-6 text-black md:block hidden" />
<span class="md:hidden text-black">Поиск</span>
</a>
</div>
</div>
<div>
<DesktopNavBar v-if="sections" :sections="sections" />
</div>
</div>
</nav>
@@ -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,
+24 -19
View File
@@ -44,6 +44,7 @@
</nav>
</header>
<MobileNavbar v-if="sections" :sections="sections" />
@@ -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)
+10 -3
View File
@@ -106,8 +106,11 @@ export default {
<!-- Icon Block -->
<div class="text-center">
<div class="flex justify-center items-center size-12 bg-gray-50 border border-gray-200 rounded-full mx-auto">
<svg class="flex-shrink-0 size-5 text-gray-600" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="10" height="14" x="3" y="8" rx="2"/><path d="M5 4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2h-2.4"/><path d="M8 18h.01"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.26 10.147a60.438 60.438 0 0 0-.491 6.347A48.62 48.62 0 0 1 12 20.904a48.62 48.62 0 0 1 8.232-4.41 60.46 60.46 0 0 0-.491-6.347m-15.482 0a50.636 50.636 0 0 0-2.658-.813A59.906 59.906 0 0 1 12 3.493a59.903 59.903 0 0 1 10.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.717 50.717 0 0 1 12 13.489a50.702 50.702 0 0 1 7.74-3.342M6.75 15a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Zm0 0v-3.675A55.378 55.378 0 0 1 12 8.443m-7.007 11.55A5.981 5.981 0 0 0 6.75 15.75v-1.5" />
</svg>
</div>
<div class="mt-3">
<h3 class="text-lg font-semibold text-gray-800">Направление подготовки</h3>
<p class="mt-1 text-gray-600">{{ program.data.directionStudy.name }} {{ program.data.directionStudy.code }}</p>
@@ -118,7 +121,9 @@ export default {
<!-- Icon Block -->
<div class="text-center">
<div class="flex justify-center items-center size-12 bg-gray-50 border border-gray-200 rounded-full mx-auto">
<svg class="flex-shrink-0 size-5 text-gray-600" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7h-9"/><path d="M14 17H5"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" />
</svg>
</div>
<div class="mt-3">
<h3 class="text-lg font-semibold text-gray-800">Срок обучения</h3>
@@ -148,7 +153,9 @@ export default {
<!-- Icon Block -->
<div class="text-center">
<div class="flex justify-center items-center size-12 bg-gray-50 border border-gray-200 rounded-full mx-auto">
<svg class="flex-shrink-0 size-5 text-gray-600" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9a2 2 0 0 1-2 2H6l-4 4V4c0-1.1.9-2 2-2h8a2 2 0 0 1 2 2v5Z"/><path d="M18 9h2a2 2 0 0 1 2 2v11l-4-4h-6a2 2 0 0 1-2-2v-1"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
</svg>
</div>
<div class="mt-3">
<h3 class="text-lg font-semibold text-gray-800">Количество мест на прием</h3>
+143 -162
View File
@@ -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 {
<article class="w-full min-w-0 mt-4 px-1 md:px-6">
<div class="relative overflow-hidden">
<div class="max-w-[85rem] mx-auto sm:px-6 lg:px-8 py-10 sm:pb-12 sm:py-5">
<div class="text-center">
<h1 class="text-2xl sm:text-4xl font-bold text-gray-800 dark:text-gray-200">
<div class="">
<h1 class="text-2xl sm:text-4xl font-bold text-gray-800 text-center">
Расписание занятий
</h1>
<div class="text-center">
<p class="mt-3 text-gray-600 dark:text-gray-400">
<div class="">
<p class="mt-3 text-gray-600 text-center">
Просто введите название группы
</p>
</div>
@@ -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"
>
</div>
</div>
@@ -108,24 +165,20 @@ export default {
</form>
<div class="">
<div class="grid grid-cols-2 gap-3">
<button @click="toggleShowFavorites" type="button"
:class="showFavorites ? 'bg-primaryBlue text-white hover:bg-secondDarkBlue' : 'text-gray-700 hover:bg-gray-100'"
class="flex w-full py-2 px-4 items-center gap-x-2 text-xs font-medium rounded-lg border border-gray-200 focus:outline-none disabled:opacity-50 disabled:pointer-events-none">
<button
@click="toggleShowFavorites"
type="button"
:disabled="favoriteGroups.length === 0 || loading"
:class="
filters.favorite_filter.value !== null ? 'bg-primaryBlue text-white hover:bg-secondDarkBlue': 'text-gray-700 hover:bg-gray-100',
loading ? 'animate-pulse' : ''
"
class="flex w-full py-2 px-4 items-center gap-x-2 text-xs font-medium rounded-lg border border-gray-200 focus:outline-none disabled:opacity-50 disabled:pointer-events-none"
>
<BaseIcon name="heart" class="shrink-0 size-4"/>
<span>Избранные расписания</span>
</button>
<button type="button"
class="flex w-full py-2 px-4 items-center gap-x-2 text-xs font-medium rounded-lg border border-gray-200 text-gray-700 hover:bg-gray-100 focus:outline-none focus:bg-primaryBlue focus:text-white disabled:opacity-50 disabled:pointer-events-none">
<svg class="shrink-0 size-4" xmlns="http://www.w3.org/2000/svg" width="24" height="24"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<line x1="19" x2="19" y1="8" y2="14"/>
<line x1="22" x2="16" y1="11" y2="11"/>
</svg>
<span>Follow</span>
<span>Избранное</span>
</button>
<ClientScheduleFilter :forms_educational="this.forms_education" :form-edu_filter="this.filters.form_education_filter" />
</div>
</div>
</div>
@@ -133,131 +186,59 @@ export default {
</div>
</div>
<div class="mx-auto max-w-2xl hs-accordion-group grid grid-cols-1 lg:grid-cols-2 gap-3">
<div v-if="!showFavorites">
<div class="mx-auto max-w-2xl hs-accordion-group grid gap-3">
<div class="space-y-4">
<transition-group name="fade">
<template v-for="educationalGroup in filteredGroups" :key="educationalGroup.id">
<div
class="hs-accordion hs-accordion-active:border-gray-200 bg-white border-b dark:hs-accordion-active:border-gray-700 dark:bg-gray-800 dark:border-transparent"
id="hs-active-bordered-heading-one">
<div class="flex">
<button @click="toggleFavorite(educationalGroup)">
<transition name="heart" mode="out-in">
<BaseIcon
v-if="isFavorite(educationalGroup)"
name="heart_filled"
class="shrink-0 size-4 text-red-500"
key="filled"
/>
<BaseIcon
v-else
name="heart"
class="shrink-0 size-4"
key="outline"
/>
</transition>
<template v-for="(faculty, title) in schedulesByFaculty">
<div class="">
<h2 class="text-center text-gray-800 font-medium">{{ title }}</h2>
<hr class="mt-2 mb-4">
<template v-for="educationalGroup in faculty" :key="educationalGroup.data.id">
<div class="hs-accordion hs-accordion-active:border-gray-200 bg-white border border-transparent rounded-xl" id="hs-active-bordered-heading-one">
<div class="flex py-4 px-5 gap-x-3">
<button @click="toggleFavorite(educationalGroup.data.id)">
<transition name="heart" mode="out-in">
<BaseIcon
v-if="isFavorite(educationalGroup.data.id)"
name="heart_filled"
class="shrink-0 size-4 text-red-500"
key="filled"
/>
<BaseIcon
v-else
name="heart"
class="shrink-0 size-4"
key="outline"
/>
</transition>
</button>
</button>
<button class="hs-accordion-toggle hs-accordion-active:text-blue-600 inline-flex justify-between items-center gap-x-3 w-full font-semibold text-start text-gray-800 hover:text-gray-500 disabled:opacity-50 disabled:pointer-events-none" aria-expanded="false" aria-controls="hs-basic-active-bordered-collapse-one">
<span class="flex items-center gap-x-2">{{ educationalGroup.data.title }}</span>
<svg class="hs-accordion-active:hidden block size-3.5" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"></path>
<path d="M12 5v14"></path>
</svg>
<svg class="hs-accordion-active:block hidden size-3.5" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"></path>
</svg>
</button>
</div>
<button
class="hs-accordion-toggle hs-accordion-active:text-blue-600 inline-flex justify-between items-center gap-x-3 w-full font-semibold text-start text-gray-800 py-4 px-5 hover:text-gray-500 disabled:opacity-50 disabled:pointer-events-none dark:hs-accordion-active:text-blue-500 dark:text-gray-200 dark:hover:text-gray-400 dark:focus:outline-none dark:focus:text-gray-400"
aria-controls="hs-basic-active-bordered-collapse-one">
<div class="flex items-center gap-x-2"><span>{{ educationalGroup.title }}</span> <span
class="font-light text-sm uppercase text-gray-500">{{ educationalGroup.faculty }}</span></div>
<svg class="hs-accordion-active:hidden block w-3.5 h-3.5"
xmlns="http://www.w3.org/2000/svg"
width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"/>
<path d="M12 5v14"/>
</svg>
<svg class="hs-accordion-active:block hidden w-3.5 h-3.5"
xmlns="http://www.w3.org/2000/svg"
width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"/>
</svg>
</button>
</div>
<div id="hs-basic-active-bordered-collapse-one"
class="hs-accordion-content hidden w-full overflow-hidden transition-[height] duration-300"
aria-labelledby="hs-active-bordered-heading-one">
<div class="pb-4 px-5 grid gap-3 grid-cols-1">
<template v-for="schedule in educationalGroup.schedules" :key="schedule.id">
<template v-for="file in schedule.file">
<a :href="'storage/' + file.path" target="_blank"
class="py-3 px-4 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-500 shadow-sm hover:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600">
{{ file.title }}
</a>
</template>
</template>
<div id="hs-basic-active-bordered-collapse-one" class="hs-accordion-content hidden w-full overflow-hidden transition-[height] duration-300" role="region" aria-labelledby="hs-active-bordered-heading-one">
<div class="pb-4 px-5 grid gap-3 grid-cols-1">
<template v-for="schedule in educationalGroup.data.schedules" :key="schedule.id">
<template v-for="file in schedule.file">
<a :href="'storage/' + file.path" target="_blank"
class="py-3 px-4 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-500 shadow-sm hover:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600">
{{ file.title }}
</a>
</template>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
</transition-group>
</div>
<div v-else>
<transition-group name="fade">
<template v-for="educationalGroup in favoriteGroups" :key="educationalGroup.id">
<div
class="hs-accordion hs-accordion-active:border-gray-200 bg-white border-b dark:hs-accordion-active:border-gray-700 dark:bg-gray-800 dark:border-transparent"
id="hs-active-bordered-heading-one">
<div class="flex">
<button @click="toggleFavorite(educationalGroup)">
<transition name="heart" mode="out-in">
<BaseIcon
v-if="isFavorite(educationalGroup)"
name="heart_filled"
class="shrink-0 size-4 text-red-500"
key="filled"
/>
<BaseIcon
v-else
name="heart"
class="shrink-0 size-4"
key="outline"
/>
</transition>
</button>
<button
class="hs-accordion-toggle hs-accordion-active:text-blue-600 inline-flex justify-between items-center gap-x-3 w-full font-semibold text-start text-gray-800 py-4 px-5 hover:text-gray-500 disabled:opacity-50 disabled:pointer-events-none dark:hs-accordion-active:text-blue-500 dark:text-gray-200 dark:hover:text-gray-400 dark:focus:outline-none dark:focus:text-gray-400"
aria-controls="hs-basic-active-bordered-collapse-one">
<div class="flex items-center gap-x-2"><span>{{ educationalGroup.title }}</span> <span
class="font-light text-sm uppercase text-gray-500">{{ educationalGroup.faculty }}</span></div>
<svg class="hs-accordion-active:hidden block w-3.5 h-3.5"
xmlns="http://www.w3.org/2000/svg"
width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"/>
<path d="M12 5v14"/>
</svg>
<svg class="hs-accordion-active:block hidden w-3.5 h-3.5"
xmlns="http://www.w3.org/2000/svg"
width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14"/>
</svg>
</button>
</div>
<div id="hs-basic-active-bordered-collapse-one"
class="hs-accordion-content hidden w-full overflow-hidden transition-[height] duration-300"
aria-labelledby="hs-active-bordered-heading-one">
<div class="pb-4 px-5 grid gap-3 grid-cols-1">
<template v-for="schedule in educationalGroup.schedules" :key="schedule.id">
<template v-for="file in schedule.file">
<a :href="'storage/' + file.path" target="_blank"
class="py-3 px-4 inline-flex items-center gap-x-2 text-sm font-medium rounded-lg border border-gray-200 bg-white text-gray-500 shadow-sm hover:bg-gray-50 disabled:opacity-50 disabled:pointer-events-none dark:bg-slate-900 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600">
{{ file.title }}
</a>
</template>
</template>
</div>
</div>
</template>
</div>
</template>
</transition-group>
+9 -6
View File
@@ -4,12 +4,13 @@
<MainPageNavBar :sections="$page.props.navigation" :slider-ref="sliderRef" />
<ClientMainSlider @slider-mounted="setSliderRef" :slidersCarousel="sliders" />
<section class="max-w-screen-xl w-full mx-auto px-4 py-3 pb-10">
<h2 class="text-brand-primary my-6 md:mb-[50px] md:mt-[80px] text-2xl font-semibold tracking-tight text-black lg:text-[32px] lg:leading-tight">Последние новости</h2>
<div class="grid gap-10 pb-10 md:grid-cols-2 lg:gap-10 xl:grid-cols-3">
<template v-for="post in posts.data" :key="post.id">
<ClientPost :post="post" />
</template>
</div>
<h2 class="text-brand-primary my-6 md:mb-[50px] md:mt-[80px] text-2xl font-semibold tracking-tight text-black lg:text-[32px] lg:leading-tight bvi-show">Последние новости</h2>
<div class="grid gap-10 pb-10 md:grid-cols-2 lg:gap-10 xl:grid-cols-3 bvi-no-styles">
<template v-for="post in posts.data" :key="post.id">
<ClientPost :post="post" />
</template>
</div>
<div class="flex justify-center">
@@ -184,6 +185,7 @@ import BaseMetaHead from "@/Components/BaseComponents/BaseMetaHead.vue";
import PageResourceList from "@/Components/BuilderUi/Pages/Blocks/PageResourceList.vue";
import AppHead from "@/Components/AppHead.vue";
import ContactSectionBlock from "@/Components/BaseComponents/BaseBuilderUi/Blocks/Contacts/ContactSectionBlock.vue";
import Cookies from "js-cookie";
@@ -238,6 +240,7 @@ export default {
},
methods: {
Cookies,
setSliderRef(ref) {
this.sliderRef = ref;
},
+1 -1
View File
@@ -5,7 +5,7 @@
</Head>
<MainPageNavBar class="border-b" :sections="$page.props.navigation"></MainPageNavBar>
<div class="w-full h-[67px] fixed pointer-events-none" id="visor"></div>
<div class="w-full h-[67px] fixed pointer-events-none bvi-no-styles" id="visor"></div>
<div class="flex flex-col h-screen">
<main class="flex-grow">
<div class="relative mx-auto mt-[67px] max-w-screen-xl px-4 py-10 md:flex md:flex-row md:py-10">
+2
View File
@@ -7,6 +7,7 @@ import { createInertiaApp } from '@inertiajs/vue3';
import { ZiggyVue } from '../../vendor/tightenco/ziggy/dist/vue.m';
import {linksReform} from "@/mixins/LinksReform.js";
import YSmartCaptcha from 'vue3-yandex-smartcaptcha'
import cookieMixin from "@/mixins/cookieMixin.js";
// const appName = import.meta.env.VITE_APP_NAME || 'НТГСПИ';
@@ -20,6 +21,7 @@ createInertiaApp({
return createSSRApp({ render: () => h(App, props) })
.use(plugin)
.mixin(linksReform)
.mixin(cookieMixin)
.use(ZiggyVue)
.use(YSmartCaptcha, {
siteKey: "ysc1_jxb2IcslgBuHgHbmmKY1F7Nvb8zYHuAeBgjoaTka6886f893",
+43
View File
@@ -0,0 +1,43 @@
export default {
methods: {
// Устанавливаем куки
setCookie(name, value, days) {
let expires = "";
if (days) {
const date = new Date();
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); // Устанавливаем срок действия куки
expires = "; expires=" + date.toUTCString();
}
document.cookie = name + "=" + (value || "") + expires + "; path=/"; // Устанавливаем куки
},
// Получаем куки
getCookie(name) {
const nameEQ = name + "="; // Форматируем имя куки
const ca = document.cookie.split(';'); // Разбиваем куки по точке с запятой
for (let i = 0; i < ca.length; i++) {
let c = ca[i];
while (c.charAt(0) === ' ') c = c.substring(1, c.length); // Убираем пробелы
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); // Если найдено, возвращаем значение
}
return null; // Если кука не найдена
},
// Удаляем куки
deleteCookie(name) {
// Получаем текущий домен
const domain = window.location.hostname.includes('.') ? '.' + window.location.hostname : window.location.hostname;
document.cookie = name + '=; Max-Age=-99999999; path=/; domain=' + domain; // Устанавливаем срок действия в прошлом
},
deleteCookiesWithPrefix(prefix) {
const cookies = document.cookie.split(';'); // Получаем все куки
for (let cookie of cookies) {
const cookieName = cookie.split('=')[0].trim(); // Получаем имя куки
if (cookieName.startsWith(prefix)) { // Проверяем, начинается ли имя куки с заданного префикса
this.deleteCookie(cookieName); // Удаляем куку
}
}
}
}
};
+9
View File
@@ -30,11 +30,20 @@
z-index: 10000000!important;
}
.bvi-active {
padding: 0;
margin: 0;
}
.ce-header {
font-weight: bold;
font-size: 20px;
}
body .bvi-body {
padding: 0;
}
*, html {
scroll-padding: 6rem;
scroll-behavior: smooth !important;
+6
View File
@@ -10,6 +10,7 @@ use App\Http\Controllers\ClientWidgetPageReferenceListController;
use App\Http\Controllers\ClientWidgetPostController;
use App\Http\Controllers\IconController;
use App\Http\Controllers\SearchController;
use App\Http\Controllers\UpdateEduDataApiController;
use App\Http\Controllers\VkAuthController;
use App\Http\Controllers\VkPostController;
use App\Models\AdmissionCampaign;
@@ -25,6 +26,9 @@ Route::get('/getAcademicYear', function () {
})->name('academic.year');
Route::middleware('ensure.browser')->group(function () {
Route::get('/getNavigation', [NavigateController::class, 'index'])->name('client.main.navigate');
@@ -50,6 +54,8 @@ Route::middleware('ensure.browser')->group(function () {
});
Route::middleware(['auth', 'superadmin'])->group(function () {
Route::get('/get-data', [UpdateEduDataApiController::class, 'index']);
Route::get('/login/vk', [VkAuthService::class, 'redirectToProvider'])->name('vk.login');
Route::get('/login/vk/callback', [VkAuthService::class, 'handleProviderCallback'])->name('vk.callback');
Route::get('/vk-get-token', [VkAuthService::class, 'getToken'])->name('vk.getToken');
-2
View File
@@ -86,8 +86,6 @@ Route::middleware('access-check')->group(function () {
Route::get('/divisions', [ClientDivisionController::class, 'index'])->name('client.division.index');
Route::get('/divisions/{slug}', [ClientDivisionController::class, 'show'])->name('client.division.show');
Route::get('/get-data', [UpdateEduDataApiController::class, 'index']);
Route::get('{path}', [PageController::class, 'render'])->where('path', '[0-9,a-z,/,-]+')->name('page.view');
});