From 28286ca5a7d7c7196051c41790edf83340c06fd2 Mon Sep 17 00:00:00 2001 From: Alexis Gousseau Date: Mon, 12 Jan 2026 12:07:17 +0100 Subject: [PATCH 1/2] feat: :sparkles: set search page with meilisearch --- .env.example | 7 +- app/Console/Commands/MeilisearchUpdate.php | 78 ++++ app/Http/Controllers/Front/HomeController.php | 2 +- .../Controllers/Front/SearchController.php | 63 +++ app/Http/Middleware/HandleInertiaRequests.php | 5 +- app/Http/Requests/Front/SearchRequest.php | 34 ++ app/Models/Document.php | 49 ++- app/Models/DynamicPage.php | 100 +++-- app/Models/News.php | 67 +++- app/Models/SearchIndex.php | 152 +++++++ app/Traits/Models/HasMeilisearchIndex.php | 113 ++++++ composer.json | 3 + composer.lock | 379 +++++++++++++++++- config/app.php | 1 + config/scout.php | 110 +++++ lang/en/front.php | 15 +- lang/en/routes.php | 3 +- lang/fr/front.php | 15 +- lang/fr/routes.php | 3 +- manifest.yaml | 4 +- public/assets/css/media.css | 4 + public/assets/css/style.css | 37 +- .../ts/back/Components/Layout/AppSidebar.tsx | 6 +- .../front/search-pagination.blade.php | 28 ++ .../front/search-result-card.blade.php | 19 - .../breadcrumbs/breadcrumb-layout.blade.php | 2 +- .../views/front/layouts/header.blade.php | 6 +- .../views/front/layouts/mobile-menu.blade.php | 10 +- .../views/front/pages/news/show.blade.php | 3 +- resources/views/front/pages/search.blade.php | 122 +++--- routes/breadcrumbs-front.php | 180 ++------- routes/web-front.php | 17 +- 32 files changed, 1319 insertions(+), 318 deletions(-) create mode 100644 app/Console/Commands/MeilisearchUpdate.php create mode 100644 app/Http/Controllers/Front/SearchController.php create mode 100644 app/Http/Requests/Front/SearchRequest.php create mode 100644 app/Models/SearchIndex.php create mode 100644 app/Traits/Models/HasMeilisearchIndex.php create mode 100644 config/scout.php create mode 100644 resources/views/components/front/search-pagination.blade.php delete mode 100644 resources/views/components/front/search-result-card.blade.php diff --git a/.env.example b/.env.example index e0895926..4ff52b74 100644 --- a/.env.example +++ b/.env.example @@ -69,4 +69,9 @@ SAML2_CONTACT_TECHNICAL_EMAIL= SAML2_CONTACT_SUPPORT_EMAIL= # GOOGLE -GOOGLE_MAPS_KEY= \ No newline at end of file +GOOGLE_MAPS_KEY= + +# MEILISEARCH +SCOUT_PREFIX= +MEILISEARCH_HOST= +MEILISEARCH_KEY= diff --git a/app/Console/Commands/MeilisearchUpdate.php b/app/Console/Commands/MeilisearchUpdate.php new file mode 100644 index 00000000..f98a8946 --- /dev/null +++ b/app/Console/Commands/MeilisearchUpdate.php @@ -0,0 +1,78 @@ +map(function (string $locale) use ($client) { + $localeName = Str::upper($locale); + $indexName = config('scout.prefix') . $locale; + $index = $client->index($indexName); + + $index->updateSearchableAttributes(['title', 'content']); + $index->updateFilterableAttributes(['type']); + + $this->info("Meilisearch indexes configured for locale: {$localeName}"); + + app()->setLocale($locale); + + SearchIndex::removeAllFromSearch(); + + collect([ + News::class, + DynamicPage::class, + Document::class, + ])->map(function(string $class) { + $class::chunk(100, function ($items) { + $items + ->map + ->toSearchDocument() + ->filter() + ->each + ->searchable(); + }); + }); + + $this->info("Meilisearch indexes rebuilt for locale: {$localeName}"); + }); + } +} diff --git a/app/Http/Controllers/Front/HomeController.php b/app/Http/Controllers/Front/HomeController.php index c35a8f46..52d2fe03 100644 --- a/app/Http/Controllers/Front/HomeController.php +++ b/app/Http/Controllers/Front/HomeController.php @@ -17,7 +17,7 @@ class HomeController extends Controller /** * unsubscribeNewsletter method * - * @param \App\Http\Requests\Fo\UnsubscribeNewsletterRequest $request + * @param \App\Http\Requests\Front\UnsubscribeNewsletterRequest $request * @return \Illuminate\Contracts\View\View */ public function unsubscribeNewsletter(UnsubscribeNewsletterRequest $request): \Illuminate\Contracts\View\View diff --git a/app/Http/Controllers/Front/SearchController.php b/app/Http/Controllers/Front/SearchController.php new file mode 100644 index 00000000..981b03c0 --- /dev/null +++ b/app/Http/Controllers/Front/SearchController.php @@ -0,0 +1,63 @@ +validated()[$queryKey]; + $currentPage = $request->validated()[$pageKey]; + $perPage = 10; + + $searchQuery = $this->buildExactSearchQuery($searchTerm); + + $resultModels = SearchIndex::search($searchQuery) + ->paginateRaw(perPage: $perPage, page: $currentPage); + + $resultModels->appends([ + $queryKey => $searchTerm, + $pageKey => $currentPage, + 'query' => null, + ]); + + return view('front.pages.search', compact('searchTerm', 'resultModels')); + } + + /** + * Set strict search term. + * + * @return string + */ + private function buildExactSearchQuery(string $searchTerm): string + { + $words = preg_split('/\s+/', trim($searchTerm), -1, PREG_SPLIT_NO_EMPTY); + + if (count($words) === 1) { + return '"' . $words[0] . '"'; + } + + $exactTerms = array_map(function($word) { + return $word . '*'; + }, $words); + + return '+' . implode(' +', $exactTerms); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f371e796..c7d4454e 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -21,6 +21,7 @@ use Diglactic\Breadcrumbs\Breadcrumbs; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Inertia\Middleware; +use Mcamara\LaravelLocalization\Facades\LaravelLocalization; use Uwil\LaravelBackend\Enums\RoleEnum; use Uwil\LaravelBackend\Models\User; use Uwil\LaravelCommon\Enums\ItemsPerPaginationEnum; @@ -53,6 +54,8 @@ class HandleInertiaRequests extends Middleware ]), ]); + $locales = LaravelLocalization::getSupportedLocales(); + $props = [ ...parent::share($request), 'appEnv' => [ @@ -60,7 +63,7 @@ class HandleInertiaRequests extends Middleware 'name' => config('app.name'), 'url' => config('app.url'), 'locale' => app()->getLocale(), - 'locales' => config('app.locales'), + 'locales' => array_keys($locales), 'fallbackLocale' => app()->getFallbackLocale(), ], 'auth' => [ diff --git a/app/Http/Requests/Front/SearchRequest.php b/app/Http/Requests/Front/SearchRequest.php new file mode 100644 index 00000000..d6ee5b0b --- /dev/null +++ b/app/Http/Requests/Front/SearchRequest.php @@ -0,0 +1,34 @@ +merge(['page' => (int) $this->page ?: 1]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + $queryKey = trans('front.search.query'); + $pageKey = trans('front.search.page'); + + return [ + $queryKey => 'required|string|between:3,255', + $pageKey => 'required|integer|min:1', + ]; + } +} diff --git a/app/Models/Document.php b/app/Models/Document.php index d66bc8f8..16aba6d0 100644 --- a/app/Models/Document.php +++ b/app/Models/Document.php @@ -2,19 +2,21 @@ namespace App\Models; -use Carbon\Carbon; -use App\Enums\YearsEnum; -use Illuminate\Support\Arr; -use App\Traits\Models\HasCache; -use App\Helpers\TranslationHelper; -use Illuminate\Support\Facades\App; use App\Contracts\TranslatableModel; -use App\Traits\Models\HasUwilImages; -use App\Traits\Models\HasTranslations; -use Illuminate\Database\Eloquent\Model; use App\Enums\Document\DocumentStatusEnum; -use Uwil\LaravelActivityLogs\Traits\ActivityLog; +use App\Enums\YearsEnum; +use App\Helpers\TranslationHelper; +use App\Traits\Models\HasCache; +use App\Traits\Models\HasMeilisearchIndex; +use App\Traits\Models\HasTranslations; +use App\Traits\Models\HasUwilImages; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\App; +use Illuminate\Support\Str; +use Uwil\LaravelActivityLogs\Traits\ActivityLog; class Document extends Model implements TranslatableModel { @@ -25,6 +27,7 @@ class Document extends Model implements TranslatableModel use HasUwilImages { HasUwilImages::inertiaFill as traitInertiaFill; } + use HasMeilisearchIndex; /** * Champs contenant des images dynamiques à gérer. @@ -96,6 +99,8 @@ class Document extends Model implements TranslatableModel }); } + // * METHODS + /** * Adjusts model status using current status and publish dates. * Case 1: publish model when all null -> set published_at date. @@ -164,7 +169,6 @@ class Document extends Model implements TranslatableModel } return DocumentStatusEnum::unpublished; } - // * METHODS /** * Manage model fields. @@ -212,6 +216,29 @@ class Document extends Model implements TranslatableModel return sprintf('/%s', $targetModel->path); } + /** + * Set value of meilisearch indexes. + * + * @return \App\Models\SearchIndex|null + */ + public function toSearchDocument(): ?\App\Models\SearchIndex + { + $locale = app()->getLocale(); + + // Keep only published models. + if (!$this->status->equals(DocumentStatusEnum::published) || !$this->hasTranslation('path', $locale)) { + return null; + } + + return new SearchIndex([ + 'type' => Str::snake(class_basename($this)), + 'model_id' => $this->getKey(), + 'slug' => $this->getTranslation('path', $locale), + 'title' => $this->getTranslation('name', $locale), + 'content' => null, + ]); + } + // * RELATIONSHIPS /** diff --git a/app/Models/DynamicPage.php b/app/Models/DynamicPage.php index bcced317..d8aa046b 100644 --- a/app/Models/DynamicPage.php +++ b/app/Models/DynamicPage.php @@ -4,11 +4,14 @@ namespace App\Models; use App\Contracts\TranslatableModel; use App\Enums\DynamicFieldTypes; +use App\Enums\DynamicPages; use App\Enums\DynamicPages\PageStatusEnum; use App\Helpers\DynamicPageHelper; use App\Helpers\RouteHelper; use App\Helpers\TranslationHelper; +use App\Models\SearchIndex; use App\Traits\Models\HasCache; +use App\Traits\Models\HasMeilisearchIndex; use App\Traits\Models\HasTranslations; use App\Traits\Models\HasUwilImages; use Illuminate\Database\Eloquent\Collection; @@ -44,6 +47,7 @@ class DynamicPage extends Model implements TranslatableModel use HasTranslations; use HasUwilImages; use SoftDeletes; + use HasMeilisearchIndex; /** * The attributes that are mass assignable. @@ -316,6 +320,72 @@ class DynamicPage extends Model implements TranslatableModel return parse_url($url, PHP_URL_PATH) ?? "/"; } + /** + * Set value of meilisearch indexes. + * + * @return \App\Models\SearchIndex|null + */ + public function toSearchDocument(): ?\App\Models\SearchIndex + { + $locale = app()->getLocale(); + + // Keep only published models. + if (!$this->status->equals(PageStatusEnum::published) || !$this->hasTranslation('slug', $locale)) { + return null; + } + + // Clean content (no tags, no paths, no numbers, only string). + $content = strip_tags(collect($this->getTranslation('content', $locale)) + ->flatten() + ->filter(fn($value, $key) => + is_string($value) && + !empty($value) && + !is_numeric($value) && + !in_array($key, ['order', 'id']) && + !str_starts_with($key, 'image_') && + !preg_match('/storage.*\.(?:png|jpe?g|gif|pdf|webp)/i', $value) + ) + ->values() + ->implode(' ')); + + return new SearchIndex([ + 'type' => Str::snake(class_basename($this)), + 'model_id' => $this->getKey(), + 'id_key' => $this->id_key, + 'slug' => $this->getTranslation('slug', $locale), + 'title' => $this->getTranslation('title', $locale), + 'content' => $content + ]); + } + + /** + * Get localized path from the locale. + * + * @param string $locale + * @return string|null + */ + public function getLocalizedPath(string $locale): ?string + { + $slug = $this->getTranslation('slug', $locale); + + if (! $slug) { + return null; + } + + $pages = static::getModelsCached() + ->where('status', PageStatusEnum::published) + ->load('parentPage') + ->keyBy('id'); + + $segments = DynamicPageHelper::getAllDynamicPageSegments($this, $pages, $locale); + + $segmentsPath = implode('/', $segments); + + return $segmentsPath + ? "{$segmentsPath}/{$slug}" + : $slug; + } + // * RELATIONSHIPS /** @@ -347,34 +417,4 @@ class DynamicPage extends Model implements TranslatableModel { return $this->belongsTo(PageConfig::class, 'config_id'); } - - /** - * Get localized path from the locale - * - * @param string $locale - * @return string|null - */ - public function getLocalizedPath(string $locale): ?string - { - $slug = $this->getTranslation('slug', $locale); - - if (! $slug) { - return null; - } - - // Récupère toutes les pages publiées (cache déjà existant chez toi) - $pages = static::getModelsCached() - ->where('status', PageStatusEnum::published) - ->load('parentPage') - ->keyBy('id'); - - $segments = DynamicPageHelper::getAllDynamicPageSegments($this, $pages, $locale); - - $segmentsPath = implode('/', $segments); - - return $segmentsPath - ? "{$segmentsPath}/{$slug}" - : $slug; - } - } diff --git a/app/Models/News.php b/app/Models/News.php index a23ee9cc..3c9758a6 100644 --- a/app/Models/News.php +++ b/app/Models/News.php @@ -2,23 +2,25 @@ namespace App\Models; -use Carbon\Carbon; -use App\Models\NewsTag; -use Illuminate\Support\Arr; -use Illuminate\Support\Str; -use App\Traits\Models\HasCache; +use App\Contracts\TranslatableModel; use App\Enums\News\NewsStatusEnum; use App\Helpers\TranslationHelper; -use Illuminate\Support\Facades\App; -use App\Contracts\TranslatableModel; -use App\Traits\Models\HasUwilImages; +use App\Models\NewsTag; +use App\Models\SearchIndex; +use App\Traits\Models\HasCache; +use App\Traits\Models\HasMeilisearchIndex; use App\Traits\Models\HasTranslations; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\SoftDeletes; -use Uwil\LaravelActivityLogs\Traits\ActivityLog; +use App\Traits\Models\HasUwilImages; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\App; +use Illuminate\Support\Str; use Mcamara\LaravelLocalization\Interfaces\LocalizedUrlRoutable; +use Uwil\LaravelActivityLogs\Traits\ActivityLog; /** * @property string $seo_title SEO title of the news. @@ -48,6 +50,7 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable use HasUwilImages { HasUwilImages::inertiaFill as traitInertiaFill; } + use HasMeilisearchIndex; /** * Get the route key for the model. @@ -317,6 +320,44 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable TranslationHelper::validateFallbackTranslations($model); } + /** + * Set value of meilisearch indexes. + * + * @return \App\Models\SearchIndex|null + */ + public function toSearchDocument(): ?\App\Models\SearchIndex + { + $locale = app()->getLocale(); + + // Keep only published models. + if (!$this->status->equals(NewsStatusEnum::published) || !$this->hasTranslation('slug', $locale)) { + return null; + } + + return new SearchIndex([ + 'type' => Str::snake(class_basename($this)), + 'model_id' => $this->getKey(), + 'slug' => $this->getTranslation('slug', $locale), + 'title' => $this->getTranslation('title', $locale), + 'content' => strip_tags($this->getTranslation('content', $locale)), + ]); + } + + /** + * Get the url to access a specific resource. + * + * @param integer $id Id field of the target model. + * @return string + */ + public static function getAccessModel(int $id): string + { + $targetModel = self::getModelsCached()->firstWhere('id', $id); + if (!$targetModel) { + return null; + } + return route('front.news.show', $targetModel); + } + // * RELATIONSHIPS /** @@ -324,7 +365,7 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ - public function categories(): BelongsToMany + public function categories(): \Illuminate\Database\Eloquent\Relations\BelongsToMany { return $this->morphedByMany( NewsCategory::class, @@ -340,7 +381,7 @@ class News extends Model implements TranslatableModel, LocalizedUrlRoutable * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ - public function tags(): BelongsToMany + public function tags(): \Illuminate\Database\Eloquent\Relations\BelongsToMany { return $this->morphedByMany( NewsTag::class, diff --git a/app/Models/SearchIndex.php b/app/Models/SearchIndex.php new file mode 100644 index 00000000..2027a14e --- /dev/null +++ b/app/Models/SearchIndex.php @@ -0,0 +1,152 @@ +getLocale(); + } + + /** + * Unique primary key. + * + * @return string + */ + public function getScoutKey(): mixed + { + return "{$this->type}_{$this->model_id}"; + } + + /** + * Indexed data. + * + * @return array + */ + public function toSearchableArray(): array + { + return [ + 'id' => $this->getScoutKey(), + 'type' => $this->type, + 'model_id' => $this->model_id, + 'id_key' => $this->id_key ?? null, + 'slug' => $this->slug, + 'title' => $this->title, + 'content' => $this->content, + ]; + } + + /** + * Highlights all search terms in a text. + * + * @param string $text + * @param string $searchTerm + * @return string + */ + public static function highlightTerms(string $text, string $searchTerm, int $contextLength = 150): string + { + if (trim($searchTerm) === '') { + return $text; + } + + // Clean terms list. + $words = preg_split('/\s+/', $searchTerm, -1, PREG_SPLIT_NO_EMPTY); + if (empty($words)) { + return $text; + } + + $fullText = strip_tags($text); + $textLength = strlen($fullText); + $ranges = []; + + // Collection of relevant ranges around each word. + foreach ($words as $word) { + $pattern = '/(? max(0, $matchPos - $contextLength), + 'end' => min($textLength, $matchPos + $matchLen + $contextLength), + ]; + } + } + } + + // Return the entire text if there isn't matches. + if (empty($ranges)) { + return $text; + } + + // Merging overlapping intervals. + usort($ranges, fn($a, $b) => $a['start'] <=> $b['start']); + $merged = []; + foreach ($ranges as $r) { + if (empty($merged) || $r['start'] > $merged[array_key_last($merged)]['end']) { + $merged[] = $r; + } else { + $merged[array_key_last($merged)]['end'] = max( + $merged[array_key_last($merged)]['end'], + $r['end'] + ); + } + } + + $snippets = []; + + foreach ($merged as $r) { + $snippet = substr($fullText, $r['start'], $r['end'] - $r['start']); + + // Cut cleanly without breaking the words + if ($r['start'] > 0) { + $snippet = preg_replace('/^\S*\s/', '…', $snippet, 1); + } + if ($r['end'] < $textLength) { + $snippet = preg_replace('/\s\S*$/', '…', $snippet, 1); + } + + // Highlighting searched words + foreach ($words as $word) { + $pattern = '/(?$0', + $snippet + ); + } + + $snippets[] = trim($snippet); + if (count($snippets) >= 3) break; + } + + $result = implode('… ', $snippets); + $result = preg_replace('/\s*…\s*(?:…\s*)+/', ' … ', $result); + + return trim($result); + } +} diff --git a/app/Traits/Models/HasMeilisearchIndex.php b/app/Traits/Models/HasMeilisearchIndex.php new file mode 100644 index 00000000..5384c165 --- /dev/null +++ b/app/Traits/Models/HasMeilisearchIndex.php @@ -0,0 +1,113 @@ +wasChanged('status'); + $originalLocale = app()->getLocale(); + $locales = $statusChanged + ? array_keys(LaravelLocalization::getSupportedLocales()) + : [$originalLocale]; + + // Loop on all locales to update indexes when global fields like status is updated, + // Otherwise, on current locale. + collect($locales)->map(function($locale) use ($keyPrefix, $model, $usesSoftDeletes) { + // Set the locale to alter the right ms index. + app()->setLocale($locale); + + $indexId = "{$keyPrefix}_{$model->getKey()}"; + + // Remove model from meilisearch index. + if ($usesSoftDeletes && method_exists($model, 'trashed') && $model->trashed()) { + SearchIndex::make([ + 'type' => $keyPrefix, + 'model_id' => $model->getKey(), + ])->unsearchable(); + return; + } + $msIndex = $model->toSearchDocument(); + // Add model to the meilisearch index if conditions are true, remove from it if not. + if ($msIndex) { + $msIndex->searchable(); + } else { + SearchIndex::make([ + 'type' => $keyPrefix, + 'model_id' => $model->getKey(), + ])->unsearchable(); + } + }); + + // Reset the locale with the original. + app()->setLocale($originalLocale); + }); + + static::deleted(function ($model) use ($keyPrefix) { + $originalLocale = app()->getLocale(); + $locales = array_keys(LaravelLocalization::getSupportedLocales()); + + // Loop on all locales to update indexes when model is deleted (softdeleted or not). + collect($locales)->map(function($locale) use ($keyPrefix, $model) { + // Set the locale to alter the right ms index. + app()->setLocale($locale); + + $indexId = "{$keyPrefix}_{$model->getKey()}"; + // Remove model from meilisearch index. + SearchIndex::make([ + 'type' => $keyPrefix, + 'model_id' => $model->getKey(), + ])->unsearchable(); + }); + + // Reset the locale with the original. + app()->setLocale($originalLocale); + }); + + if ($usesSoftDeletes) { + static::restored(function ($model) { + $originalLocale = app()->getLocale(); + $locales = array_keys(LaravelLocalization::getSupportedLocales()); + + // Loop on all locales to update indexes when model is restored. + collect($locales)->map(function($locale) use ($model) { + // Set the locale to alter the right ms index. + app()->setLocale($locale); + + // Add model to the meilisearch index if conditions are true. + $msIndex = $model->toSearchDocument(); + if ($msIndex) { + $msIndex->searchable(); + } + }); + + // Reset the locale with the original. + app()->setLocale($originalLocale); + }); + } + } +} diff --git a/composer.json b/composer.json index 7cf6abf9..7036732b 100644 --- a/composer.json +++ b/composer.json @@ -12,12 +12,15 @@ "php": "^8.2", "24slides/laravel-saml2": "^2.4", "diglactic/laravel-breadcrumbs": "^10.0", + "http-interop/http-factory-guzzle": "^1.2", "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.0", + "laravel/scout": "^10.23", "laravel/telescope": "^5.7", "laravel/tinker": "^2.10.1", "ldc/ldccrmv4client": "^4.1", "mcamara/laravel-localization": "^2.3.0", + "meilisearch/meilisearch-php": "^1.16", "sentry/sentry-laravel": "^4.18", "spatie/laravel-translatable": "^6.11", "tightenco/ziggy": "^2.5", diff --git a/composer.lock b/composer.lock index 68069c16..c1ae247c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f9cb87942c7b3c93516b54b61c4bf66d", + "content-hash": "b1d9d1034b8cd27b5f5de54e9fce077a", "packages": [ { "name": "24slides/laravel-saml2", @@ -1311,6 +1311,64 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "http-interop/http-factory-guzzle", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/http-interop/http-factory-guzzle.git", + "reference": "c2c859ceb05c3f42e710b60555f4c35b6a4a3995" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/http-interop/http-factory-guzzle/zipball/c2c859ceb05c3f42e710b60555f4c35b6a4a3995", + "reference": "c2c859ceb05c3f42e710b60555f4c35b6a4a3995", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^1.7||^2.0", + "php": ">=7.3", + "psr/http-factory": "^1.0" + }, + "provide": { + "psr/http-factory-implementation": "^1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "guzzlehttp/psr7": "Includes an HTTP factory starting in version 2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Factory\\Guzzle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "An HTTP Factory using Guzzle PSR7", + "keywords": [ + "factory", + "http", + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/http-interop/http-factory-guzzle/issues", + "source": "https://github.com/http-interop/http-factory-guzzle/tree/1.2.1" + }, + "time": "2025-12-15T11:28:16+00:00" + }, { "name": "inertiajs/inertia-laravel", "version": "v2.0.10", @@ -1925,6 +1983,86 @@ }, "time": "2025-09-19T13:47:56+00:00" }, + { + "name": "laravel/scout", + "version": "v10.23.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/scout.git", + "reference": "fb6d94cfc5708e4202dc00d46e61af0b9f35b03c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/scout/zipball/fb6d94cfc5708e4202dc00d46e61af0b9f35b03c", + "reference": "fb6d94cfc5708e4202dc00d46e61af0b9f35b03c", + "shasum": "" + }, + "require": { + "illuminate/bus": "^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^9.0|^10.0|^11.0|^12.0", + "illuminate/database": "^9.0|^10.0|^11.0|^12.0", + "illuminate/http": "^9.0|^10.0|^11.0|^12.0", + "illuminate/pagination": "^9.0|^10.0|^11.0|^12.0", + "illuminate/queue": "^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0", + "php": "^8.0", + "symfony/console": "^6.0|^7.0" + }, + "conflict": { + "algolia/algoliasearch-client-php": "<3.2.0|>=5.0.0" + }, + "require-dev": { + "algolia/algoliasearch-client-php": "^3.2|^4.0", + "meilisearch/meilisearch-php": "^1.0", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.31|^8.36|^9.15|^10.8", + "php-http/guzzle7-adapter": "^1.0", + "phpstan/phpstan": "^1.10", + "typesense/typesense-php": "^4.9.3" + }, + "suggest": { + "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", + "meilisearch/meilisearch-php": "Required to use the Meilisearch engine (^1.0).", + "typesense/typesense-php": "Required to use the Typesense engine (^4.9)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Scout\\ScoutServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "10.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Scout\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Scout provides a driver based solution to searching your Eloquent models.", + "keywords": [ + "algolia", + "laravel", + "search" + ], + "support": { + "issues": "https://github.com/laravel/scout/issues", + "source": "https://github.com/laravel/scout" + }, + "time": "2025-12-16T15:43:03+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.6", @@ -2800,6 +2938,86 @@ ], "time": "2025-02-26T06:38:01+00:00" }, + { + "name": "meilisearch/meilisearch-php", + "version": "v1.16.1", + "source": { + "type": "git", + "url": "https://github.com/meilisearch/meilisearch-php.git", + "reference": "f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/meilisearch/meilisearch-php/zipball/f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6", + "reference": "f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "php-http/discovery": "^1.7", + "psr/http-client": "^1.0", + "symfony/polyfill-php81": "^1.33" + }, + "require-dev": { + "http-interop/http-factory-guzzle": "^1.2.0", + "php-cs-fixer/shim": "^3.59.3", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.5 || ^10.5", + "symfony/http-client": "^5.4|^6.0|^7.0" + }, + "suggest": { + "guzzlehttp/guzzle": "Use Guzzle ^7 as HTTP client", + "http-interop/http-factory-guzzle": "Factory for guzzlehttp/guzzle", + "symfony/http-client": "Use Symfony Http client" + }, + "type": "library", + "autoload": { + "psr-4": { + "MeiliSearch\\": "src/", + "Meilisearch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Clémentine Urquizar", + "email": "clementine@meilisearch.com" + }, + { + "name": "Bruno Casali", + "email": "bruno@meilisearch.com" + }, + { + "name": "Laurent Cazanove", + "email": "lau.cazanove@gmail.com" + }, + { + "name": "Tomas Norkūnas", + "email": "norkunas.tom@gmail.com" + } + ], + "description": "PHP wrapper for the Meilisearch API", + "keywords": [ + "api", + "client", + "instant", + "meilisearch", + "php", + "search" + ], + "support": { + "issues": "https://github.com/meilisearch/meilisearch-php/issues", + "source": "https://github.com/meilisearch/meilisearch-php/tree/v1.16.1" + }, + "time": "2025-09-18T10:15:45+00:00" + }, { "name": "mews/purifier", "version": "3.4.3", @@ -3527,6 +3745,85 @@ ], "time": "2025-05-25T14:28:00+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", @@ -6344,6 +6641,86 @@ ], "time": "2025-01-02T08:10:11+00:00" }, + { + "name": "symfony/polyfill-php81", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "symfony/polyfill-php83", "version": "v1.33.0", diff --git a/config/app.php b/config/app.php index 3ffccc9c..849f6840 100644 --- a/config/app.php +++ b/config/app.php @@ -80,6 +80,7 @@ return [ 'locale' => env('APP_LOCALE', 'fr'), + // FIXME: need this because of uwil packages 'locales' => [ 'en', 'fr', diff --git a/config/scout.php b/config/scout.php new file mode 100644 index 00000000..794a607e --- /dev/null +++ b/config/scout.php @@ -0,0 +1,110 @@ + env('SCOUT_DRIVER', 'meilisearch'), + + /* + |-------------------------------------------------------------------------- + | Index Prefix + |-------------------------------------------------------------------------- + | + | Here you may specify a prefix that will be applied to all search index + | names used by Scout. This prefix may be useful if you have multiple + | "tenants" or applications sharing the same search infrastructure. + | + */ + + 'prefix' => env('SCOUT_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Queue Data Syncing + |-------------------------------------------------------------------------- + | + | This option allows you to control if the operations that sync your data + | with your search engines are queued. When this is set to "true" then + | all automatic data syncing will get queued for better performance. + | + */ + + 'queue' => env('SCOUT_QUEUE', false), + + /* + |-------------------------------------------------------------------------- + | Database Transactions + |-------------------------------------------------------------------------- + | + | This configuration option determines if your data will only be synced + | with your search indexes after every open database transaction has + | been committed, thus preventing any discarded data from syncing. + | + */ + + 'after_commit' => true, + + /* + |-------------------------------------------------------------------------- + | Chunk Sizes + |-------------------------------------------------------------------------- + | + | These options allow you to control the maximum chunk size when you are + | mass importing data into the search engine. This allows you to fine + | tune each of these chunk sizes based on the power of the servers. + | + */ + + 'chunk' => [ + 'searchable' => 500, + 'unsearchable' => 500, + ], + + /* + |-------------------------------------------------------------------------- + | Soft Deletes + |-------------------------------------------------------------------------- + | + | This option allows to control whether to keep soft deleted records in + | the search indexes. Maintaining soft deleted records can be useful + | if your application still needs to search for the records later. + | + */ + + 'soft_delete' => true, + + /* + |-------------------------------------------------------------------------- + | Meilisearch Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure your Meilisearch settings. Meilisearch is an open + | source search engine with minimal configuration. Below, you can state + | the host and key information for your own Meilisearch installation. + | + | See: https://www.meilisearch.com/docs/learn/configuration/instance_options#all-instance-options + | + */ + + 'meilisearch' => [ + 'host' => env('MEILISEARCH_HOST', 'http://localhost:7700'), + 'key' => env('MEILISEARCH_KEY'), + 'index-settings' => [], + ], +]; diff --git a/lang/en/front.php b/lang/en/front.php index 3c95255e..3f1a6851 100644 --- a/lang/en/front.php +++ b/lang/en/front.php @@ -21,7 +21,6 @@ return [ "mieux-vivre-ensemble" => "living and working together", "respecter-la-terre" => "respecting the nature", "bien-nourrir" => "providing healthy food", - 'search' => 'search', "news" => [ "index" => "news", "read_article" => "read more", @@ -124,5 +123,17 @@ return [ "3_clicks" => 'I apply in 3 clicks', "contact_details" => 'Your contact details', 'contact_our' => 'contact our', - 'press_department' => 'press department' + 'press_department' => 'press department', + 'search' => [ + 'seo_title' => 'You searched for :terms', + 'seo_description' => 'Search for a word on the entire LDC.fr website', + 'placeholder' => 'Your search', + 'results_total' => ':nbr results for ":terms"', + 'no_results' => 'No results found.', + 'trans' => 'search', + 'query' => 'text', + 'page' => 'page', + 'previous' => 'Previous page', + 'next' => 'Next page', + ], ]; diff --git a/lang/en/routes.php b/lang/en/routes.php index aecbb573..9e74e7f4 100644 --- a/lang/en/routes.php +++ b/lang/en/routes.php @@ -3,5 +3,6 @@ return [ 'contact' => 'contact-us/{slug}', 'contact_index' => 'contact-us', - 'news' => 'news/{news}' + 'news' => 'news/{news}', + 'search' => 'search', ]; diff --git a/lang/fr/front.php b/lang/fr/front.php index 577aebbd..e07f3315 100644 --- a/lang/fr/front.php +++ b/lang/fr/front.php @@ -34,7 +34,6 @@ return [ 'order' => 'afficher', ], "finance" => "finance", - 'search' => 'rechercher', 'espace-presse' => 'espace presse', 'next_events' => 'prochains évènements', 'buttons' => [ @@ -125,5 +124,17 @@ return [ "3_clicks" => 'Je postule en 3 clics', "contact_details" => 'Vos coordonnées', 'contact_our' => 'contactez notre', - 'press_department' => 'service presse' + 'press_department' => 'service presse', + 'search' => [ + 'seo_title' => 'Vous avez cherché :terms', + 'seo_description' => 'Recherche de mot sur la globalité du site LDC.fr', + 'placeholder' => 'Votre recherche', + 'results_total' => ':nbr résultats pour ":terms"', + 'no_results' => 'Aucun résultat trouvé.', + 'trans' => 'rechercher', + 'query' => 'texte', + 'page' => 'page', + 'previous' => 'Page précédente', + 'next' => 'Page suivante', + ], ]; diff --git a/lang/fr/routes.php b/lang/fr/routes.php index 9b9a657f..426c61a6 100644 --- a/lang/fr/routes.php +++ b/lang/fr/routes.php @@ -3,5 +3,6 @@ return [ 'contact' => 'nous-contacter/{slug}', 'contact_index' => 'nous-contacter', - 'news' => 'actualites/{news}' + 'news' => 'actualites/{news}', + 'search' => 'recherche', ]; diff --git a/manifest.yaml b/manifest.yaml index 128fabef..4942053b 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -33,7 +33,9 @@ data: GOOGLE_RECAPTCHA_PUBLIC: "${APP_GOOGLE_RECAPTCHA_PUBLIC}" GOOGLE_RECAPTCHA_VERSION: "${APP_GOOGLE_RECAPTCHA_VERSION}" GOOGLE_MAPS_KEY: "${APP_GOOGLE_MAPS_KEY}" - + SCOUT_PREFIX: "${APP_SCOUT_PREFIX}" + MEILISEARCH_HOST: "meilisearch.common-services:7700" + MEILISEARCH_KEY: "${APP_MEILISEARCH_KEY}" SAML2_SP_CERT_x509: "${APP_SAML2_SP_CERT_x509}" SAML2_SP_CERT_PRIVATEKEY: "${APP_SAML2_SP_CERT_PRIVATEKEY}" SAML2_CONTACT_TECHNICAL_EMAIL: "${APP_SAML2_CONTACT_TECHNICAL_EMAIL}" diff --git a/public/assets/css/media.css b/public/assets/css/media.css index 44fce83b..72641113 100644 --- a/public/assets/css/media.css +++ b/public/assets/css/media.css @@ -2027,6 +2027,10 @@ margin: 0 -15px 40px; padding: 30px 20px; } + .search-form__wrapper { + display: flex; + flex-direction: column; + } .search-form__btn { width: 100%; padding: 15px; diff --git a/public/assets/css/style.css b/public/assets/css/style.css index 8170fd79..83119b1e 100644 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -371,6 +371,18 @@ iframe { margin-bottom: 0; } +.no-top-padding-important { + padding-top: 0 !important; +} + +.no-bottom-padding-important { + padding-bottom: 0 !important; +} + +.no-bottom-margin-important { + margin-bottom: 0 !important; +} + .half-bottom-padding { padding-bottom: 50px; } @@ -688,14 +700,19 @@ form .wpcf7-form-control-wrap .wpcf7-list-item { .search-form { display: flex; + flex-direction: column; background: #333333; padding: 30px; margin-bottom: 50px; } +.search-form__wrapper { + display: flex; + flex-direction: row; +} + .search-form__field { - width: 890px; - max-width: 100%; + width: 100%; } input.search-form__input { @@ -1331,11 +1348,14 @@ textarea:focus { .breadcrumbs { font-size: 13px; - padding: 15px 0; font-weight: 500; color: #6a6a6a; - margin-bottom: 5px; +} + +.breadcrumbs_base_margined { + padding: 15px 0; margin-top: -60px; + margin-bottom: 5px; } .breadcrumbs_margined { @@ -3232,7 +3252,7 @@ a.copyright__item:hover { /* page search START */ -.search-page .breadcrumbs { +.search-page > .breadcrumbs { margin-top: 60px; margin-bottom: 70px; } @@ -3266,7 +3286,7 @@ div.search-results { } .search-results__item-bc { - margin: 0 0 10px !important; + margin: 0 0 10px; } .search-noresults__title { @@ -3275,6 +3295,11 @@ div.search-results { margin-bottom: 50px; } +.search-highlight { + color: #ff4e4e; + font-weight: 700; +} + /* page search END */ /* pagination START */ diff --git a/resources/ts/back/Components/Layout/AppSidebar.tsx b/resources/ts/back/Components/Layout/AppSidebar.tsx index ef8ddf56..c5de9e42 100644 --- a/resources/ts/back/Components/Layout/AppSidebar.tsx +++ b/resources/ts/back/Components/Layout/AppSidebar.tsx @@ -102,10 +102,8 @@ export default function AppSidebar() { }, { title: useTrans("back.your_website"), - link: route().has("front.dynamic-pages.accueil." + appEnv.locale) - ? route("front.dynamic-pages.accueil." + appEnv.locale) - : "", - show: route().has("front.dynamic-pages.accueil." + appEnv.locale), + link: "/", + show: true, icon: SquareArrowOutUpRightIcon, }, { diff --git a/resources/views/components/front/search-pagination.blade.php b/resources/views/components/front/search-pagination.blade.php new file mode 100644 index 00000000..629294a0 --- /dev/null +++ b/resources/views/components/front/search-pagination.blade.php @@ -0,0 +1,28 @@ +@if ($models->lastPage() > 1) +
+
+ {{-- Page précédente --}} + @if (!$models->onFirstPage()) + + @endif + {{-- Pages --}} + @for ($page = 1; $page <= $models->lastPage(); $page++) + @if ($page == $models->currentPage()) + {{ $page }} + @else + + {{ $page }} + + @endif + @endfor + {{-- Page suivante --}} + @if ($models->hasMorePages()) + + @endif +
+
+@endif \ No newline at end of file diff --git a/resources/views/components/front/search-result-card.blade.php b/resources/views/components/front/search-result-card.blade.php deleted file mode 100644 index 9c890df6..00000000 --- a/resources/views/components/front/search-result-card.blade.php +++ /dev/null @@ -1,19 +0,0 @@ -{{-- # FIXME: ajouter une class pour remplacer le style inline sur les concordances de résultats --}} - - @if (isset($title)) -
{!! $title !!}
- @endif - - {{-- # FIXME: voir comment seront passés et récpérés les breadcrumb --}} - - @if (isset($description)) -
-

{!! $description !!}

-
- @endif -
diff --git a/resources/views/front/breadcrumbs/breadcrumb-layout.blade.php b/resources/views/front/breadcrumbs/breadcrumb-layout.blade.php index d1df278e..d7b818b3 100644 --- a/resources/views/front/breadcrumbs/breadcrumb-layout.blade.php +++ b/resources/views/front/breadcrumbs/breadcrumb-layout.blade.php @@ -1,5 +1,5 @@ @if ($breadcrumbs->isNotEmpty() && request()->route()->getName() !== 'front.dynamic-pages.accueil.' . app()->getLocale()) -