From d3f83d2ccca82c65ad62ba8432e968361331a1bb Mon Sep 17 00:00:00 2001 From: Noah Lancien Date: Fri, 24 Nov 2023 11:11:32 +0100 Subject: [PATCH] Added news CRUD --- app/Http/Controllers/Bo/NewsController.php | 166 +++++++ .../Requests/Bo/News/StoreNewsRequest.php | 90 ++++ .../Requests/Bo/News/UpdateNewsRequest.php | 7 + app/Models/News.php | 11 +- app/Policies/NewsPolicy.php | 120 ++++++ app/Providers/RouteServiceProvider.php | 8 +- app/Traits/Requests/HasPicture.php | 180 ++++++++ resources/lang/fr/crud.php | 34 +- resources/lang/fr/models.php | 5 +- resources/lang/fr/validation.php | 12 +- resources/views/bo/layouts/nav-list.blade.php | 8 + .../views/bo/pages/news/create.blade.php | 36 ++ resources/views/bo/pages/news/edit.blade.php | 52 +++ resources/views/bo/pages/news/form.blade.php | 407 ++++++++++++++++++ .../views/bo/pages/news/index-list.blade.php | 77 ++++ resources/views/bo/pages/news/index.blade.php | 21 + routes/breadcrumbs-bo.php | 21 + routes/web-bo.php | 15 + 18 files changed, 1248 insertions(+), 22 deletions(-) create mode 100644 app/Http/Controllers/Bo/NewsController.php create mode 100644 app/Http/Requests/Bo/News/StoreNewsRequest.php create mode 100644 app/Http/Requests/Bo/News/UpdateNewsRequest.php create mode 100644 app/Policies/NewsPolicy.php create mode 100644 app/Traits/Requests/HasPicture.php create mode 100644 resources/views/bo/pages/news/create.blade.php create mode 100644 resources/views/bo/pages/news/edit.blade.php create mode 100644 resources/views/bo/pages/news/form.blade.php create mode 100644 resources/views/bo/pages/news/index-list.blade.php create mode 100644 resources/views/bo/pages/news/index.blade.php diff --git a/app/Http/Controllers/Bo/NewsController.php b/app/Http/Controllers/Bo/NewsController.php new file mode 100644 index 0000000..a79fa28 --- /dev/null +++ b/app/Http/Controllers/Bo/NewsController.php @@ -0,0 +1,166 @@ +authorizeResource(News::class); + } + + /** + * Display a listing of the resource. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Contracts\View\View + */ + public function index(Request $request): \Illuminate\Contracts\View\View + { + /** @var \Illuminate\Database\Eloquent\Builder $newsModels */ + $newsModels = News::query(); + + /** @var string $search Search field */ + $search = $request->search; + if ($search) { + $this->searchQuery( + $newsModels, + $search, + null, + 'title', + 'introduction', + 'content', + ); + } + $searchFields = \implode(', ', [ + Str::ucfirst(trans('validation.attributes.title')), + trans('validation.custom.introduction'), + trans('validation.attributes.content'), + ]); + + /** Sort columns with a query */ + $this->sortQuery($newsModels); + + /** Custom pagination */ + $newsModels = $this->paginate($newsModels); + + return view('bo.pages.news.index', compact('newsModels', 'search', 'searchFields')); + } + + + /** + * Show the form for creating a new resource. + * + * @param \App\Models\News $news + * @return \Illuminate\Contracts\View\View + */ + public function create(News $news): \Illuminate\Contracts\View\View + { + return view('bo.pages.news.create', ['newsModel' => $news]); + } + + /** + * Store a newly created resource in storage. + * + * @param \App\Http\Requests\Bo\News\StoreNewsRequest $request + * @return \Illuminate\Http\RedirectResponse + */ + public function store(StoreNewsRequest $request): \Illuminate\Http\RedirectResponse + { + return DB::transaction(function () use ($request) { + $news = new News(); + + if ($news->fill($request->validated())->saveOrFail()) { + return redirect()->route('bo.news.edit', $news) + ->with('success', __('crud.messages.has_been_created', [ + 'model' => Str::ucfirst(__('models.classes.news')) + ])); + } + return redirect()->back() + ->with('error', __('crud.messages.cannot_be_created', [ + 'model' => Str::ucfirst(__('models.classes.news')) + ])); + }); + } + + /** + * Show the form for editing the specified resource. + * + * @param App\Models\News $news + * @return \Illuminate\Contracts\View\View + */ + public function edit(News $news): \Illuminate\Contracts\View\View + { + return view('bo.pages.news.edit', ['newsModel' => $news]); + } + + /** + * Update the specified resource in storage. + * + * @param \App\Http\Requests\Bo\News\UpdateNewsRequest $request + * @param \App\Models\News $news + * @return \Illuminate\Http\RedirectResponse + */ + public function update(UpdateNewsRequest $request, News $news): \Illuminate\Http\RedirectResponse + { + return DB::transaction(function () use ($request, $news) { + if ($news->fill($request->validated())->saveOrFail()) { + return redirect()->route('bo.news.edit', $news) + ->with('success', __('crud.messages.has_been_updated', [ + 'model' => Str::ucfirst(__('models.classes.news')) + ])); + } + return redirect()->route('bo.news.edit', $news) + ->with('error', __('crud.messages.cannot_be_updated', [ + 'model' => Str::ucfirst(__('models.classes.news')) + ])); + }); + } + + /** + * Remove the specified resource from storage. + * + * @param \App\Models\News $news + * @return \Illuminate\Http\RedirectResponse + */ + public function destroy(News $news): \Illuminate\Http\RedirectResponse + { + if ($news->deleteOrFail()) { + return redirect()->route('bo.news.index') + ->with('success', __('crud.messages.has_been_deleted', [ + 'model' => Str::ucfirst(__('models.classes.news')) + ])); + } + return redirect()->back() + ->with('error', __('crud.messages.cannot_be_deleted', [ + 'model' => Str::ucfirst(__('models.classes.news')) + ])); + } + + /** + * Duplicate the specified resource. + * + * @param \App\Models\News $news + * @return \Illuminate\Contracts\View\View + */ + public function duplicate(News $news): \Illuminate\Contracts\View\View + { + return $this->create($news->replicate()); + } +} diff --git a/app/Http/Requests/Bo/News/StoreNewsRequest.php b/app/Http/Requests/Bo/News/StoreNewsRequest.php new file mode 100644 index 0000000..783316b --- /dev/null +++ b/app/Http/Requests/Bo/News/StoreNewsRequest.php @@ -0,0 +1,90 @@ +check(); + } + + /** + * Prepare the data for validation. + * + * @return void + */ + protected function prepareForValidation() + { + $this->merge([ + 'published' => $this->published ? true : false + ]); + $this->mergePicture('picture'); + $this->mergePicture('thumbnail'); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules(): array + { + $rules = [ + 'seo_title' => 'required|between:45,70', + 'seo_description' => 'required|between:50,160', + 'title' => 'required|string|between:3,100', + 'picture_alt' => 'required|string|between:3,255', + 'picture_title' => 'required|string|between:3,255', + 'thumbnail_alt' => 'required|string|between:3,255', + 'thumbnail_title' => 'required|string|between:3,255', + 'introduction' => 'required|string|min:15', + 'content' => 'nullable|string|min:15', + 'linkedin_url' => 'nullable|url', + 'published' => 'required|boolean', + 'publish_at' => 'nullable|date', + 'unpublish_at' => 'nullable|date|after:publish_at', + + ]; + return \array_merge( + $rules, + // FIXME: Mettre à jour les tailles. + $this->pictureRules('picture', 100, 100, 100, 100, false), + $this->pictureRules('thumbnail', 100, 100, 100, 100, false), + ); + } + + /** + * Get custom attributes for validator errors. + * + * @return array + */ + public function attributes(): array + { + return [ + 'seo_title' => trans('validation.custom.seo_title'), + 'seo_description' => trans('validation.custom.seo_description'), + 'title' => trans('validation.attributes.title'), + 'introduction' => trans('validation.attributes.content'), + 'content' => trans('validation.attributes.content'), + 'linkedin_url' => trans('validation.attributes.content'), + 'picture_alt' => trans('validation.custom.picture_alt'), + 'picture_title' => trans('validation.custom.picture_title'), + 'thumbnail_alt' => trans('validation.custom.thumbnail_alt'), + 'thumbnail_title' => trans('validation.custom.thumbnail_title'), + 'published' => trans('validation.custom.activated'), + 'publish_at' => trans('validation.custom.publish_at'), + 'unpublish_at' => trans('validation.custom.unpublish_at'), + ]; + } +} diff --git a/app/Http/Requests/Bo/News/UpdateNewsRequest.php b/app/Http/Requests/Bo/News/UpdateNewsRequest.php new file mode 100644 index 0000000..3e4e0b2 --- /dev/null +++ b/app/Http/Requests/Bo/News/UpdateNewsRequest.php @@ -0,0 +1,7 @@ +title); - self::assertFieldIsUnique('slug', Str::slug($model->title)); + self::assertFieldIsUnique('title', $model->title, $model->id); + self::assertFieldIsUnique('slug', Str::slug($model->title), $model->id); + + ToolboxHelper::assertNoInternalBoUrls('introduction', $model->introduction); + $model->introduction = clean($model->introduction); + if ($model->content) { + ToolboxHelper::assertNoInternalBoUrls('content', $model->content); + $model->content = clean($model->content); + } // * TRANSFORMATIONS self::setSlug($model, $model->title); diff --git a/app/Policies/NewsPolicy.php b/app/Policies/NewsPolicy.php new file mode 100644 index 0000000..475e697 --- /dev/null +++ b/app/Policies/NewsPolicy.php @@ -0,0 +1,120 @@ +json('csp-report')) - ->flatMap(function ($v, $k) { - return [$k => is_array($v) ? collect($v)->flatten()->implode(', ') : $v]; - })->all(); + ->flatMap(function ($v, $k) { + return [$k => is_array($v) ? collect($v)->flatten()->implode(', ') : $v]; + })->all(); // * Filter array keys $cspReport = collect(array_intersect_key($cspReport, [ 'blocked-uri' => '', 'document-uri' => '', 'original-policy' => '', diff --git a/app/Traits/Requests/HasPicture.php b/app/Traits/Requests/HasPicture.php new file mode 100644 index 0000000..f79aa60 --- /dev/null +++ b/app/Traits/Requests/HasPicture.php @@ -0,0 +1,180 @@ + $minWith ?? $this->minWith, + 'minHeight' => $minHeight ?? $this->minHeight, + 'maxWith' => $maxWith ?? $this->maxWith, + 'maxHeight' => $maxHeight ?? $this->maxHeight + ]; + return [ + $name => [ + !$nullable ? 'required' : 'nullable', + (strlen($requiredWith)) ? 'required_with:' . $requiredWith : '', + ($size) ? 'max:' . $size : '', + function (string $attribute, $value, callable $fail) use ($dimensions) { + return ToolboxHelper::validatePicture($attribute, $value, $fail, $dimensions); + } + ] + ]; + } + + /** + * Images rules + * + * @param string $name + * @param integer|null $minWith + * @param integer|null $minHeight + * @param integer|null $maxWith + * @param integer|null $maxHeight + * @return array + */ + public function picturesRules( + string $name = 'pictures', + ?int $minWith = null, + ?int $minHeight = null, + ?int $maxWith = null, + ?int $maxHeight = null + ): array { + $sizes = (object)[ + 'minWith' => $minWith ?? $this->minWith, + 'minHeight' => $minHeight ?? $this->minHeight, + 'maxWith' => $maxWith ?? $this->maxWith, + 'maxHeight' => $maxHeight ?? $this->maxHeight + ]; + return [ + $name => 'sometimes|array', + "{$name}Validate" => "required_with:{$name}|array", + "{$name}Validate.*" => [ + 'required', + function (string $attribute, $value, callable $fail) use ($sizes) { + return $this->validatePicture($attribute, $value, $fail, $sizes); + } + ] + ]; + } + + /** + * Merges products pictures fields with request data + * + * @param string $name + * @return void + */ + protected function mergePicture(string $name = 'picture'): void + { + $picturePath = $this->{$name}; + if (is_string($picturePath)) { + $this->merge(["{$name}Validate" => []]); + // ! Empty picture. + if ($picturePath === 'false') { + // * Empty picture and exit + $this->merge([$name => []]); + return; + } + $decodedPath = \urldecode($picturePath); + $this->merge([ + "{$name}Validate" => array_merge( + $this->{"{$name}Validate"}, + [\public_path(ltrim($decodedPath, '/'))] + ) + ]); + $changedPicture = ltrim($decodedPath, '/'); + $this->merge([ + $name => $changedPicture + ]); + } //end if + } + + /** + * Merges products pictures fields with request data + * + * @param string $name + * @return void + */ + protected function mergePictures(string $name = 'pictures'): void + { + $pictures = $this->{$name}; + if (is_array($pictures)) { + $this->merge(["{$name}Validate" => []]); + foreach ($pictures as $key => $picturePath) { + // ! Empty picture. + if ($picturePath === 'false') { + // * Empty picture and exit + $this->merge([$name => []]); + return; + } + $decodedPath = \urldecode($picturePath); + $this->merge([ + "{$name}Validate" => array_merge( + $this->{"{$name}Validate"}, + [\public_path(ltrim($decodedPath, '/'))] + ) + ]); + $changedPicture = $this->{$name}; + $changedPicture[$key] = ltrim($decodedPath, '/'); + $this->merge([ + $name => $changedPicture + ]); + } + } //end if + } +} diff --git a/resources/lang/fr/crud.php b/resources/lang/fr/crud.php index f3dc47b..34d8196 100644 --- a/resources/lang/fr/crud.php +++ b/resources/lang/fr/crud.php @@ -13,21 +13,27 @@ return [ ], 'helpers' => [ - 'list' => 'Liste', - 'list_all' => 'Lister tous les :model', - 'create_model' => 'Créer :model', - 'view_model' => 'Voir :model', - 'update_model' => 'Éditer :model', - 'duplicate_model' => 'Dupliquer :model', - 'delete_model' => 'Supprimer :model', - 'save_model' => 'Sauvegarder :model', - 'edit_model' => 'Mettre à jour :model', - 'no_results' => 'Aucun résultat pour :model', - 'publish' => 'Publié', - 'unpublish' => 'Dépublié', - 'seo_title' => 'Doit décrire précisément la page, recommandation pour un positionnement + 'list' => 'Liste', + 'list_all' => 'Lister tous les :model', + 'create_model' => 'Créer :model', + 'view_model' => 'Voir :model', + 'update_model' => 'Éditer :model', + 'duplicate_model' => 'Dupliquer :model', + 'delete_model' => 'Supprimer :model', + 'save_model' => 'Sauvegarder :model', + 'edit_model' => 'Mettre à jour :model', + 'no_results' => 'Aucun résultat pour :model', + 'publish' => 'Publié', + 'unpublish' => 'Dépublié', + 'seo_title' => 'Doit décrire précisément la page, recommandation pour un positionnement optimal dans les moteurs de recherche (balise ).', - 'seo_description' => 'Doit décrire précisément le contenu de la page (balise <meta> description).', + 'seo_description' => 'Doit décrire précisément le contenu de la page (balise <meta> description).', + 'required_fields' => 'Champs obligatoires', + 'visibility_with_dates' => 'Ce champ n\'aura d\'effet que si aucune date n\'est renseignée.', + 'publish_at' => 'Le :model sera publié à partir de cette date.', + 'unpublish_at' => 'Le :model sera dépublié à partir de cette date.', + 'url' => 'Doit être une URL valide.', + ], 'libs' => [ diff --git a/resources/lang/fr/models.php b/resources/lang/fr/models.php index cc24db9..92d30c3 100644 --- a/resources/lang/fr/models.php +++ b/resources/lang/fr/models.php @@ -4,7 +4,8 @@ return [ 'classes' => [ 'user' => 'utilisateur', 'activity_log' => 'journal d\'activité|journaux d\'activité', - 'static_page' => 'page statique|pages statiques', - 'section' => 'section', + 'static_page' => 'page statique|pages statiques', + 'section' => 'section', + 'news' => 'actualité', ] ]; diff --git a/resources/lang/fr/validation.php b/resources/lang/fr/validation.php index fd9a539..ddab3ef 100644 --- a/resources/lang/fr/validation.php +++ b/resources/lang/fr/validation.php @@ -227,10 +227,20 @@ return [ 'activated' => 'activé', 'target' => 'cible', 'static_page_type' => 'type de page', + 'seo' => 'Référencement', 'seo_title' => 'titre SEO', 'seo_description' => 'description SEO', 'h2_title' => 'titre h2', 'first_content' => 'premier contenu', - 'specific_content' => 'contenu spécifique' + 'specific_content' => 'contenu spécifique', + 'linkedin_url' => 'lien du post Linkedin', + 'introduction' => 'introduction', + 'alt_attribute' => 'attribut alt', + 'title_attribute' => 'attribut title', + 'thumbnail' => 'vignette', + 'publish_at' => 'date de publication', + 'unpublish_at' => 'date de fin de publication', + + ], ]; diff --git a/resources/views/bo/layouts/nav-list.blade.php b/resources/views/bo/layouts/nav-list.blade.php index c2e738e..8e38b7a 100644 --- a/resources/views/bo/layouts/nav-list.blade.php +++ b/resources/views/bo/layouts/nav-list.blade.php @@ -53,6 +53,14 @@ </div> </li> @endcan + @can('viewAny', \App\Models\News::class) + <li class="nav-item"> + <a class="nav-link @if (request()->routeIs('bo.news.*')) active @endif" data-bs-toggle="tooltip" href="{{ route('bo.news.index') }}" + title="{{ __('crud.helpers.list_all', ['model' => Str::of(trans_choice('models.classes.news', '*'))->title()]) }}"> + {{ Str::of(trans_choice('models.classes.news', '*'))->title() }} + </a> + </li> + @endcan @canAny(['isConceptor', 'viewAny'], \App\Models\User::class) <li class="nav-item"> <div class="dropdown"> diff --git a/resources/views/bo/pages/news/create.blade.php b/resources/views/bo/pages/news/create.blade.php new file mode 100644 index 0000000..c28a4f7 --- /dev/null +++ b/resources/views/bo/pages/news/create.blade.php @@ -0,0 +1,36 @@ +@extends('bo.layout', ['brParam' => $newsModel]) + +@section('title', __('Création d\'une :model', ['model' => __('models.classes.news')])) +@section('description', __('Création d\'une toute nouvelle :model', ['model' => __('models.classes.news')])) +@section('breadcrumb', + request()->route()->getName()) + +@section('content') + <div class="d-flex justify-content-between flex-md-nowrap align-items-center flex-wrap pb-3"> + <div class="d-flex align-items-start flex-row"> + <a class="btn btn-primary text-decoration-none m-0" data-bs-toggle="tooltip" href="{{ route('bo.news.index') }}" + title="{{ __('crud.helpers.list_all', ['model' => __('models.classes.news')]) }}"> + <i class="fa-solid fa-arrow-left"></i> + </a> + @include('modules.breadcrumbs.breadcrumb-body', ['brParam' => $newsModel]) + </div> + <div class="mb-md-0 mb-2"> + <button class="btn btn-primary" id="formSubmitClone" data-bs-toggle="tooltip" type="submit" + title="{{ __('crud.helpers.save_model', ['model' => __('models.classes.news')]) }}"> + {{ Str::ucfirst(__('crud.actions.save')) }} + </button> + </div> + </div> + <form action="{{ route('bo.news.store') }}" method="POST" enctype="multipart/form-data"> + @csrf + @include('bo.pages.news.form') + <div class="row mt-3"> + <div class="col text-center"> + <button class="btn btn-primary" id="formSubmit" data-bs-toggle="tooltip" type="submit" + title="{{ __('crud.helpers.save_model', ['model' => __('models.classes.news')]) }}"> + {{ Str::ucfirst(__('crud.actions.save')) }} + </button> + </div> + </div> + </form> +@endsection diff --git a/resources/views/bo/pages/news/edit.blade.php b/resources/views/bo/pages/news/edit.blade.php new file mode 100644 index 0000000..c5225ea --- /dev/null +++ b/resources/views/bo/pages/news/edit.blade.php @@ -0,0 +1,52 @@ +@extends('bo.layout', ['brParam' => $newsModel]) + +@section('title', __('Édition d\'une :model', ['model' => __('models.classes.news')])) +@section('description', __('Édition d\'une :model déjà existante', ['model' => __('models.classes.news')])) +@section('breadcrumb', + request()->route()->getName()) + +@section('content') + <div class="d-flex justify-content-between flex-md-nowrap align-items-center flex-wrap pb-3"> + <div class="d-flex align-items-start flex-row"> + <a class="btn btn-primary text-decoration-none m-0" data-bs-toggle="tooltip" href="{{ route('bo.news.index') }}" + title="{{ __('crud.helpers.list_all', ['model' => Str::plural(__('models.classes.news'))]) }}"> + <i class="fa-solid fa-arrow-left"></i> + </a> + @include('modules.breadcrumbs.breadcrumb-body', ['brParam' => $newsModel]) + </div> + <div class="mb-md-0 mb-2"> + <form class="btn-group confirmDeleteTS" action="{{ route('bo.news.destroy', $newsModel) }}" method="POST" novalidate> + @can('duplicate', $newsModel) + <a class="btn btn-secondary" data-bs-toggle="tooltip" href="{{ route('bo.news.duplicate', ['news' => $newsModel]) }}" + title="{{ Str::ucfirst(__('crud.actions.duplicate')) }}"> + <i class="fa-solid fa-copy"></i> + </a> + @endcan + <button class="btn btn-primary" id="formSubmitClone" data-bs-toggle="tooltip" type="submit" + title="{{ __('crud.helpers.save_model', ['model' => __('models.classes.news')]) }}"> + {{ Str::ucfirst(__('crud.actions.save')) }} + </button> + @can('delete', $newsModel) + @csrf + @method('DELETE') + <button class="btn btn-danger" data-bs-toggle="tooltip" type="submit" title="{{ Str::ucfirst(__('crud.actions.delete')) }}"> + <i class="fa-solid fa-trash"></i> + </button> + @endcan + </form> + </div> + </div> + <form action="{{ route('bo.news.update', $newsModel) }}" method="POST" enctype="multipart/form-data"> + @csrf + @method('PUT') + @include('bo.pages.news.form') + <div class="row"> + <div class="col text-center"> + <button class="btn btn-primary" id="formSubmit" data-bs-toggle="tooltip" type="submit" + title="{{ __('crud.helpers.save_model', ['model' => __('models.classes.news')]) }}"> + {{ Str::ucfirst(__('crud.actions.save')) }} + </button> + </div> + </div> + </form> +@endsection diff --git a/resources/views/bo/pages/news/form.blade.php b/resources/views/bo/pages/news/form.blade.php new file mode 100644 index 0000000..745951a --- /dev/null +++ b/resources/views/bo/pages/news/form.blade.php @@ -0,0 +1,407 @@ +<div class="row"> + {{-- SEO --}} + <div class="col-12 pb-3"> + <fieldset class="h-100 border p-3"> + <legend>{{ Str::ucfirst(__('validation.custom.seo')) }}</legend> + <div class="row mb-3"> + {{-- SEO_TITLE --}} + <div class="col-12 col-md-6 m-md-0 mb-3"> + @include('bo.partials.inputs.varchar', [ + 'id' => 'newsSeoTitle', + 'label' => __(':field :inter:model', [ + 'field' => Str::ucfirst(__('validation.custom.seo_title')), + 'inter' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + 'hasWordCounter' => true, + 'name' => 'seo_title', + 'required' => true, + 'model' => $newsModel ?? null, + 'helper' => sprintf( + '%s %s', + __('crud.helpers.seo_title'), + __('validation.between.string', [ + 'attribute' => __(':field :inter:model', [ + 'field' => __('validation.custom.seo_title'), + 'inter' => __('validation.custom.of_the.female'), + 'model' => __('models.classes.news'), + ]), + 'min' => 45, + 'max' => 70, + ])), + 'placeholder' => __(':field :inter:model', [ + 'field' => Str::ucfirst(__('validation.custom.seo_title')), + 'inter' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + ]) + </div> + {{-- SEO_DESCRIPTION --}} + <div class="col-12 col-md-6 m-md-0 mb-3"> + @include('bo.partials.inputs.varchar-multiline', [ + 'id' => 'newsSeoDescription', + 'label' => __(':field :inter:model', [ + 'field' => Str::ucfirst(__('validation.custom.seo_description')), + 'inter' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + 'hasWordCounter' => true, + 'name' => 'seo_description', + 'required' => true, + 'model' => $newsModel ?? null, + 'helper' => sprintf( + '%s %s', + __('crud.helpers.seo_description'), + __('validation.between.string', [ + 'attribute' => __(':field :inter:model', [ + 'field' => __('validation.custom.seo_description'), + 'inter' => __('validation.custom.of_the.female'), + 'model' => __('models.classes.news'), + ]), + 'min' => 50, + 'max' => 160, + ])), + 'placeholder' => __(':field :inter:model', [ + 'field' => Str::ucfirst(__('validation.custom.seo_description')), + 'inter' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + ]) + </div> + </div> + </fieldset> + </div> + {{-- TITLE --}} + <div class="col-12 col-md-6 mb-3"> + <fieldset class="h-100 border p-3"> + <legend>{{ Str::ucfirst(__('validation.attributes.title')) }}</legend> + <div class="row"> + <div class="col-12"> + @include('bo.partials.inputs.varchar', [ + 'id' => 'newsTitle', + 'label' => __(':field :inter:model', [ + 'field' => Str::ucfirst(__('validation.attributes.title')), + 'inter' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + 'hasWordCounter' => true, + 'name' => 'title', + 'required' => true, + 'model' => $newsModel ?? null, + 'helper' => __('validation.between.string', [ + 'attribute' => __(':field :inter:model', [ + 'field' => __('validation.attributes.title'), + 'inter' => __('validation.custom.of_the.female'), + 'model' => __('models.classes.news'), + ]), + 'min' => 3, + 'max' => 100, + ]), + 'placeholder' => __('Un titre clair et concis'), + ]) + </div> + </div> + </fieldset> + </div> + {{-- LINKEDIN_URL --}} + <div class="col-12 col-md-6 mb-3"> + <fieldset class="h-100 border p-3"> + <legend>{{ Str::ucfirst(__('validation.custom.linkedin_url')) }}</legend> + <div class="row"> + <div class="col-12"> + @include('bo.partials.inputs.varchar', [ + 'id' => 'newsLinkedinUrl', + 'label' => __(':field :inter:model', [ + 'field' => Str::ucfirst(__('validation.custom.linkedin_url')), + 'inter' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + 'hasWordCounter' => true, + 'name' => 'linkedin_url', + 'required' => false, + 'model' => $newsModel ?? null, + 'helper' => sprintf( + '%s %s', + __('validation.between.string', [ + 'attribute' => __(':field :inter:model', [ + 'field' => Str::ucfirst(__('validation.custom.linkedin_url')), + 'inter' => __('validation.custom.of_the.female'), + 'model' => __('models.classes.news'), + ]), + 'min' => 3, + 'max' => 255, + ]), + __('crud.helpers.url'), + ), + 'placeholder' => __('Lien du post linkedin'), + ]) + </div> + </div> + </fieldset> + </div> + {{-- INTRODUCTION --}} + <div class="col-12 mb-3"> + <fieldset class="border p-3"> + <legend>{{ Str::ucfirst(__('validation.custom.introduction')) }}</legend> + <div class="row mb-3"> + <div class="col-12"> + @include('bo.partials.inputs.text-html', [ + 'id' => 'newsIntroduction', + 'label' => __(':field :inter:model', [ + 'field' => Str::ucfirst(__('validation.custom.introduction')), + 'inter' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + 'name' => 'introduction', + 'model' => $newsModel ?? null, + 'required' => true, + 'helper' => __('crud.minicrud.text_warning'), + ]) + </div> + </div> + </fieldset> + </div> + {{-- CONTENT --}} + <div class="col-12 mb-3"> + <fieldset class="border p-3"> + <legend>{{ Str::ucfirst(__('validation.attributes.content')) }}</legend> + <div class="row mb-3"> + <div class="col-12"> + @include('bo.partials.inputs.text-html', [ + 'id' => 'newsContent', + 'label' => __(':field :inter:model', [ + 'field' => Str::ucfirst(__('validation.attributes.content')), + 'inter' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + 'name' => 'content', + 'model' => $newsModel ?? null, + 'editorConfig' => \App\Enums\CkEditorConfigName::advanced->name(), + 'required' => false, + 'helper' => __('crud.minicrud.text_warning'), + ]) + </div> + </div> + </fieldset> + </div> + {{-- PICTURE --}} + <div class="col-12 pb-3"> + <fieldset class="h-100 border p-3"> + <legend>{{ Str::ucfirst(__('validation.attributes.image')) }}</legend> + <div class="row mb-3"> + {{-- PICTURE_ALT --}} + <div class="col-12 col-md-6 m-md-0 mb-3"> + @include('bo.partials.inputs.varchar', [ + 'id' => 'newsPictureAlt', + 'label' => __(':field1 :inter1:field2 :inter2:model', [ + 'field1' => Str::ucfirst(__('validation.custom.alt_attribute')), + 'inter1' => __('validation.custom.of_the.vowel'), + 'field2' => __('validation.attributes.image'), + 'inter2' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + 'hasWordCounter' => true, + 'name' => 'picture_alt', + 'required' => true, + 'model' => $newsModel ?? null, + 'helper' => __('crud.minicrud.picture_alt_helper'), + 'placeholder' => __(':field1 :inter1:field2 :inter2:model', [ + 'field1' => Str::ucfirst(__('validation.custom.alt_attribute')), + 'inter1' => __('validation.custom.of_the.vowel'), + 'field2' => __('validation.attributes.image'), + 'inter2' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + ]) + </div> + {{-- PICTURE_TITLE --}} + <div class="col-12 col-md-6 mb-3"> + @include('bo.partials.inputs.varchar', [ + 'id' => 'newsPictureTitle', + 'label' => __(':field1 :inter1:field2 :inter2:model', [ + 'field1' => Str::ucfirst(__('validation.custom.title_attribute')), + 'inter1' => __('validation.custom.of_the.vowel'), + 'field2' => __('validation.attributes.image'), + 'inter2' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + 'hasWordCounter' => true, + 'name' => 'picture_title', + 'required' => true, + 'model' => $newsModel ?? null, + 'helper' => __('crud.minicrud.picture_title_helper'), + 'placeholder' => __(':field1 :inter1:field2 :inter2:model', [ + 'field1' => Str::ucfirst(__('validation.custom.title_attribute')), + 'inter1' => __('validation.custom.of_the.vowel'), + 'field2' => __('validation.attributes.image'), + 'inter2' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + ]) + </div> + {{-- PICTURE --}} + <div class="col-12"> + @include('bo.partials.inputs.varchar-picture', [ + 'id' => 'newsPicture', + 'name' => 'picture', + 'width' => 100, + 'height' => 100, + 'model' => $newsModel ?? null, + 'helper' => __('crud.minicrud.dimensions_must_be', [ + 'widthpx' => '100px', + 'heightpx' => '100px', + ]), + 'required' => true, + 'preview' => true, + ]) + </div> + </div> + </fieldset> + </div> + {{-- PICTURE_THUMBNAIL --}} + <div class="col-12 pb-3"> + <fieldset class="h-100 border p-3"> + <legend>{{ Str::ucfirst(__('validation.custom.thumbnail')) }}</legend> + <div class="row mb-3"> + {{-- PICTURE_THUMBNAIL_ALT --}} + <div class="col-12 col-md-6 m-md-0 mb-3"> + @include('bo.partials.inputs.varchar', [ + 'id' => 'newsPictureThumbnailAlt', + 'label' => __(':field1 :inter1:field2 :inter2:model', [ + 'field1' => Str::ucfirst(__('validation.custom.alt_attribute')), + 'inter1' => __('validation.custom.of_the.female'), + 'field2' => __('validation.custom.thumbnail'), + 'inter2' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + 'hasWordCounter' => true, + 'name' => 'thumbnail_alt', + 'required' => true, + 'model' => $newsModel ?? null, + 'helper' => __('crud.minicrud.picture_alt_helper'), + 'placeholder' => __(':field1 :inter1:field2 :inter2:model', [ + 'field1' => Str::ucfirst(__('validation.custom.alt_attribute')), + 'inter1' => __('validation.custom.of_the.female'), + 'field2' => __('validation.custom.thumbnail'), + 'inter2' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + ]) + </div> + {{-- PICTURE_THUMBNAIL_TITLE --}} + <div class="col-12 col-md-6 mb-3"> + @include('bo.partials.inputs.varchar', [ + 'id' => 'newsPictureThumbnailTitle', + 'label' => __(':field1 :inter1:field2 :inter2:model', [ + 'field1' => Str::ucfirst(__('validation.custom.title_attribute')), + 'inter1' => __('validation.custom.of_the.female'), + 'field2' => __('validation.custom.thumbnail'), + 'inter2' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + 'hasWordCounter' => true, + 'name' => 'thumbnail_title', + 'required' => true, + 'model' => $newsModel ?? null, + 'helper' => __('crud.minicrud.picture_title_helper'), + 'placeholder' => __(':field1 :inter1:field2 :inter2:model', [ + 'field1' => Str::ucfirst(__('validation.custom.title_attribute')), + 'inter1' => __('validation.custom.of_the.female'), + 'field2' => __('validation.custom.thumbnail'), + 'inter2' => __('validation.custom.of_the.vowel'), + 'model' => __('models.classes.news'), + ]), + ]) + </div> + {{-- PICTURE_THUMBNAIL --}} + <div class="col-12"> + @include('bo.partials.inputs.varchar-picture', [ + 'id' => 'newsPictureThumbnail', + 'name' => 'thumbnail', + 'width' => 100, + 'height' => 100, + 'model' => $newsModel ?? null, + 'helper' => __('crud.minicrud.dimensions_must_be', [ + 'widthpx' => '100px', + 'heightpx' => '100px', + ]), + 'required' => true, + 'preview' => true, + ]) + </div> + </div> + </fieldset> + </div> + {{-- PUBLISHED --}} + <div class="col-12 col-md-6 mb-3"> + <fieldset class="h-100 border p-3"> + <legend>{{ __('crud.minicrud.visibility_label') }}</legend> + <div class="row"> + <div class="col-12"> + @include('bo.partials.inputs.tinyint-boolean', [ + 'id' => 'newsPublished', + 'name' => 'published', + 'model' => $newsModel ?? null, + 'label' => __('crud.minicrud.visibility_label'), + 'helper' => sprintf('%s %s', __('crud.minicrud.visibility_helper'), __('crud.helpers.visibility_with_dates')), + ]) + </div> + </div> + </fieldset> + </div> + {{-- PUBLICATION --}} + <div class="col-12 mb-3"> + <fieldset class="h-100 border p-3"> + <legend>{{ __('Publication') }}</legend> + <div class="row mb-3"> + <div class="col"> + <div class="form-group"> + @include('bo.partials.inputs.timestamp-datetime', [ + 'label' => Str::ucfirst(__('validation.custom.publish_at')) . __(' (Europe/Paris)'), + 'id' => 'newsPublishedAt', + 'name' => 'publish_at', + 'model' => $newsModel ?? null, + 'helper' => __('crud.helpers.publish_at', [ + 'model' => __('models.classes.news'), + ]), + ]) + </div> + </div> + <div class="col"> + <div class="form-group"> + @include('bo.partials.inputs.timestamp-datetime', [ + 'label' => Str::ucfirst(__('validation.custom.unpublish_at')) . __(' (Europe/Paris)'), + 'id' => 'newsUnPublishedAt', + 'name' => 'unpublish_at', + 'model' => $newsModel ?? null, + 'helper' => __('crud.helpers.unpublish_at', [ + 'model' => __('models.classes.news'), + ]), + ]) + </div> + </div> + </div> + </fieldset> + </div> + +</div> + +<div class="row"> + <div class="col text-center"> + <p><b>{{ __('* Champs obligatoires') }}</b></p> + </div> +</div> + +@push('scripts') + <script nonce="{{ $nonce }}"> + document.addEventListener("DOMContentLoaded", () => { + // Submit button clone. + const submit = document.getElementById('formSubmit') + submitClone = document.getElementById('formSubmitClone'); + submitClone.addEventListener('click', (event) => { + event.preventDefault(); + submit.click(''); + }) + }); + </script> +@endpush diff --git a/resources/views/bo/pages/news/index-list.blade.php b/resources/views/bo/pages/news/index-list.blade.php new file mode 100644 index 0000000..1f2d7e7 --- /dev/null +++ b/resources/views/bo/pages/news/index-list.blade.php @@ -0,0 +1,77 @@ +<table class="table-hover table-fix-action m-0 table"> + @if (count($newsModels) > 0) + <thead> + @include('bo.modules.table-col-sorter', [ + 'cols' => [ + 'title' => Str::ucfirst(__('validation.attributes.title')), + 'order' => Str::ucfirst(__('validation.custom.order')), + 'published' => Str::ucfirst(__('validation.custom.activated')), + 'updated_at' => Str::ucfirst(__('validation.attributes.updated_at')), + ], + ]) + </thead> + <tbody> + @foreach ($newsModels as $newsModel) + <tr class="border-bottom list-item"> + <td class="text-center align-middle">{{ $newsModel->title }}</td> + {{-- <td class="text-center align-middle">{!! Str::limit($newsModel->introduction, 50, '...') !!}</td> --}} + @php + $routeName = request() + ->route() + ->getName(); + @endphp + @if (empty(request()->search) && Session::get("$routeName.sort_col") === 'order') + @include('bo.modules.change-model-order', [ + 'routeName' => 'news', + 'model' => $newsModel, + 'models' => $newsModels, + ]) + @endif + @include('bo.modules.change-published-status', [ + 'routeName' => 'news', + 'model' => $newsModel, + ]) + <td class="text-center align-middle"> + <span class="badge bg-secondary">{{ $newsModel->updated_at->isoFormat('LLLL') }}</span> + </td> + <td class="text-end align-middle"> + @canAny(['delete', 'duplicate', 'update'], $newsModel) + <form class="btn-group confirmDeleteTS" action="{{ route('bo.news.destroy', $newsModel) }}" method="POST" novalidate> + @can('duplicate', $newsModel) + <a class="btn btn-sm btn-secondary" data-bs-toggle="tooltip" + href="{{ route('bo.news.duplicate', ['news' => $newsModel]) }}" + title="{{ Str::ucfirst(__('crud.actions.duplicate')) }}"> + <i class="fa-solid fa-copy"></i> + </a> + @endcan + @can('update', $newsModel) + <a class="btn btn-sm btn-primary" data-bs-toggle="tooltip" + href="{{ route('bo.news.edit', ['news' => $newsModel]) }}" + title="{{ Str::ucfirst(__('crud.actions.update')) }}"> + <i class="fa-solid fa-pencil"></i> + </a> + @endcan + @can('delete', $newsModel) + @csrf + @method('DELETE') + <button class="btn btn-sm btn-danger" data-bs-toggle="tooltip" type="submit" + title="{{ Str::ucfirst(__('crud.actions.delete')) }}"> + <i class="fa-solid fa-trash"></i> + </button> + @endcan + </form> + @else + <span class="text-danger" data-bs-toggle="tooltip" title="{{ __('list.right') }}"> + <i class="fa-solid fa-ban"></i> + </span> + @endcan + </td> + </tr> + @endforeach + </tbody> + @else + <tr> + <td class="border-0">{{ __('crud.other.no_model_found', ['model' => __('models.classes.news')]) }}</td> + </tr> + @endif +</table> diff --git a/resources/views/bo/pages/news/index.blade.php b/resources/views/bo/pages/news/index.blade.php new file mode 100644 index 0000000..a5251a0 --- /dev/null +++ b/resources/views/bo/pages/news/index.blade.php @@ -0,0 +1,21 @@ +@extends('bo.layout') + +@section('title', Str::ucfirst(__('models.classes.news'))) +@section('description', __('Liste de tous les :model', ['model' => Str::plural(__('models.classes.news'))])) + +@section('content') + <div class="d-flex justify-content-between flex-md-nowrap align-items-center flex-wrap"> + @include('modules.breadcrumbs.breadcrumb-body') + @can('create', \App\Models\News::class) + <a class="btn btn-primary float-right" data-bs-toggle="tooltip" href="{{ route('bo.news.create') }}" + title="{{ __('crud.helpers.create_model', ['model' => __('models.classes.news')]) }}"> + <i class="fa-solid fa-plus"></i> + </a> + @endcan + </div> + @include('bo.modules.search-bar') + <div class="table-responsive mb-3"> + @include('bo.pages.news.index-list') + </div> + {{ $newsModels->links() }} +@endsection diff --git a/routes/breadcrumbs-bo.php b/routes/breadcrumbs-bo.php index 26c9490..647ef93 100644 --- a/routes/breadcrumbs-bo.php +++ b/routes/breadcrumbs-bo.php @@ -1,6 +1,7 @@ <?php use App\Models\ActivityLog; +use App\Models\News; use App\Models\Section; use App\Models\StaticPage; use App\Models\User; @@ -52,6 +53,26 @@ Breadcrumbs::for('bo.static_pages.edit', function (Generator $trail, StaticPage $trail->push(Str::ucfirst(trans('crud.actions.edit')), route('bo.static_pages.edit', $static_page)); }); +// * NEWS +Breadcrumbs::for('bo.news.index', function (Generator $trail) { + $trail->push( + Str::of(trans('models.classes.news'))->plural()->ucfirst(), + route('bo.news.index', ['sort_col' => 'updated_at', 'sort_way' => 'desc']) + ); +}); +Breadcrumbs::for('bo.news.create', function (Generator $trail) { + $trail->parent('bo.news.index'); + $trail->push(Str::ucfirst(trans('crud.actions.create')), route('bo.news.create')); +}); +Breadcrumbs::for('bo.news.edit', function (Generator $trail, News $news) { + $trail->parent('bo.news.index'); + $trail->push(Str::ucfirst(trans('crud.actions.edit')), route('bo.news.edit', $news)); +}); +Breadcrumbs::for('bo.news.duplicate', function (Generator $trail) { + $trail->parent('bo.news.index'); + $trail->push(Str::ucfirst(trans('crud.actions.duplicate'))); +}); + // * SECTIONS Breadcrumbs::for('bo.sections.index', function (Generator $trail) { $trail->push( diff --git a/routes/web-bo.php b/routes/web-bo.php index a5d2a28..48bea97 100644 --- a/routes/web-bo.php +++ b/routes/web-bo.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Bo\ActivityLogController; use App\Http\Controllers\Bo\DashboardController; +use App\Http\Controllers\Bo\NewsController; use App\Http\Controllers\Bo\SectionController; use App\Http\Controllers\Bo\StaticPageController; use App\Http\Controllers\Bo\UploadController; @@ -48,6 +49,20 @@ Route::prefix('bo') ])->parameters(['pages' => 'static_page']) ->only(['index', 'edit', 'update']); + // * NEWS. + Route::resource('news', NewsController::class)->except('show') + ->withoutMiddleware('htmlsanitize') + ->middleware('htmlsanitize:content'); + Route::patch('/news/{news}/change-published', [ + NewsController::class, 'changePublished' + ])->name('news.change-published'); + Route::patch('/news/{news}/change-order/{direction}', [ + RangeController::class, 'changeOrder' + ])->where('direction', 'up|down')->name('news.change-order'); + Route::get('/news/{news}/duplicate', [ + NewsController::class, 'duplicate' + ])->name('news.duplicate'); + // * SECTIONS. Route::resource('sections', SectionController::class) ->only(['index', 'edit', 'update'])