Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Voir cette série
Mon CMS - Créer un article
Jeudi 19 septembre 2024 18:11

Dans le précédent article, on a mis en place le tableau des articles du CMS qui affiche les principales informations les concernant et ajoute quelques actions. À présent, nous allons coder une partie importante du CMS qui concerne la création des articles, ce qui est une tâche fondamentale de ce genre d'application.

Pour rappel, la table des matières est ici.

Un composant pour le formulaire

On a à nouveau besoin d'un composant Volt pour gérer le formulaire et le code PHP qui va faire tout le traitement :

php artisan make:volt admin/posts/create --class

On va tout de suite ajouter la route pour l'atteindre (administrateurs et rédacteurs) :

Route::middleware('auth')->group(function () {
	...
	Route::middleware(IsAdminOrRedac::class)->prefix('admin')->group(function () {
		...
		Volt::route('/posts/create', 'admin.posts.create')->name('posts.create');
	});
});

Et dans la foulée un item dans la barre latérale (admin.sidebar) :

<x-menu-sub title="{{ __('Posts') }}" icon="s-document-text">
    <x-menu-item title="{{ __('All posts') }}" link="{{ route('posts.index') }}" />
    <x-menu-item title="{{ __('Add a post') }}" link="{{ route('posts.create') }}" />
</x-menu-sub>

TinyMCE

Pour renseigner le contenu de nos articles, on va avoir besoin d'un éditeur performant. On va s'en sortir élégamment parce que MaryUI intègre TinyMCE dans un composant. Il existe deux façons de l'utiliser :

La première option est plus simple à mettre en place, mais le plan gratuit est limité à 1000 chargements mensuels de l'éditeur. La seconde option est plus longue à mettre en place, mais vous n'avez aucune limitation de chargement. On va donc choisir la seconde possibilité. Évidemment, les mises à jour dans ce cas ne seront pas automatiques, contrairement à la première option.

Télécharger et copiez tous les fichiers ici :

Il faut ensuite configurer l'éditeur en fonction de nos besoins. En particulier les commendes à prévoir dans la barre d'outils et les plugins utiles. Il y a plusieurs façons pour le faire, le plus logique me semble d'utiliser un fichier de configuration :

Avec ce code :

<?php

return [
	'config' => [
		'language'       => env('APP_TINYMCE_LOCALE', 'en_US'),
		'plugins'        => 'codesample fullscreen',
		'toolbar'        => 'undo redo style | fontfamily fontsize | alignleft aligncenter alignright alignjustify | bullist numlist | copy cut paste pastetext | hr | codesample | link image quicktable | fullscreen',
		'toolbar_sticky' => true,
		'min_height'     => 1000,
		'license_key'    => 'gpl',
		'valid_elements' => '*[*]',
	],
];

On voit que la locale pour le langage est récupéré dans le fichier .env, on l'ajoute dans ce fichier :

APP_TINYMCE_LOCALE=fr_FR

Mais pour que ça fonctionne, il faut aussi charger les traductions, vous trouvez les gratuites sur cette page. Chargez le français et mettez le fichier ici :

On prévoit deux plugins :

Pour la barre d'outils, les éléments indispensables (copie, police, alignement, puces, images, accès aux plugins...)

On prévoit aussi la barre d'outils qui reste collée (sticky) et une hauteur minimale de 1000 pixels.

Rien ne vous empêche de compléter ou modifier tout ça, la documentation est très riche.

Par défaut, les images sont enregistrées dans le disque local. On va changer ça parce qu'on veut nos images dans le dossier photos. Pour éviter d'avoir toutes les images dans un seul dossier, on va adopter le même principe que Wordpress avec des dossiers par années et par mois.

Pour intégrer l'éditeur dans notre composant, ça sera du coup très simple :

<x-editor wire:model="body" label="{{ __('Content') }}" :config="config('tinymce.config')" folder="{{ 'photos/' . now()->format('Y/m') }}" />

Mais pour que ça fonctionne, il faudra charger les librairies Javascript de TinyMCE. Dans notre layout admin, on ajoute ce chargement ;

<head>
    ...

    <script src="{{ asset('storage/scripts/tinymce.min.js') }}" ></script>

    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>

Si vous optez pour le CDN de TinyMCE, alors ça sera plutôt ça :

<script src="https://cdn.tiny.cloud/1/YOUR-KEY-HERE/tinymce/6/tinymce.min.js" referrerpolicy="origin"></script>

Le formulaire

On va coder le formulaire progressivement pour bien comprendre comment ça se passe. Au niveau du PHP, on a besoin de récupérer les catégories disponibles puisqu'un article doit appartenir à une catégorie.

<?php

use Livewire\Volt\Component;
use App\Models\Category;
use Livewire\Attributes\Layout;

new #[Layout('components.layouts.admin')] 
class extends Component {

    public function with(): array
	{
		return [
			'categories' => Category::orderBy('title')->get(),
		];
	}
    
}; ?>

Dans la partie HTML, on place le formulaire :

<div>
    <x-header title="{{ __('Add a post') }}" separator progress-indicator>
        <x-slot:actions>
            <x-button icon="s-building-office-2" label="{{ __('Dashboard') }}" class="btn-outline lg:hidden"
                link="{{ route('admin') }}" />
        </x-slot:actions>
    </x-header>
    <x-card>
        <x-form wire:submit="save">
            <x-select label="{{ __('Category') }}" option-label="title" :options="$categories" wire:model="category_id"
                wire:change="$refresh" />
            <br>
            <div class="flex gap-6">
                <x-checkbox label="{{ __('Published') }}" wire:model="active" />
                <x-checkbox label="{{ __('Pinned') }}" wire:model="pinned" />
            </div>
            <x-input type="text" wire:model="title" label="{{ __('Title') }}"
                placeholder="{{ __('Enter the title') }}" wire:change="$refresh" />
            <x-input type="text" wire:model="slug" label="{{ __('Slug') }}" />
            <x-editor wire:model="body" label="{{ __('Content') }}" :config="config('tinymce.config')"
                folder="{{ 'photos/' . now()->format('Y/m') }}" />
            <x-card title="{{ __('SEO') }}" shadow separator>
                <x-input placeholder="{{ __('Title') }}" wire:model="seo_title" hint="{{ __('Max 70 chars') }}" />
                <br>
                <x-textarea label="{{ __('META Description') }}" wire:model="meta_description"
                    hint="{{ __('Max 160 chars') }}" rows="2" inline />
                <br>
                <x-textarea label="{{ __('META Keywords') }}" wire:model="meta_keywords"
                    hint="{{ __('Keywords separated by comma') }}" rows="1" inline />
            </x-card>
            <hr>
            <x-slot:actions>
                <x-button label="{{ __('Save') }}" icon="o-paper-airplane" spinner="save" type="submit"
                    class="btn-primary" />
            </x-slot:actions>
        </x-form>
    </x-card>
</div>

On a besoin de quelques traductions :

"Select a category": "Sélectionnez une catégorie",
"Pinned": "Épinglé",
"Content": "Contenu",
"META Keywords": "META mots-clefs",
"Keywords separated by comma": "Mots-clefs séparés par une virgule",
"Max 70 chars": "Max 70 caractères",
"Max 160 chars": "Max 160 caractères",
"Enter the title": "Entrez le titre",
"Post added with success.": "Article ajouté avec succès.",

Dans la partie supérieure, on a le choix de la catégorie :

Puis la saisie du titre et du slug :

Ensuite, on a le bloc de l'éditeur TinyMCE (vérifiez qu'il est bien en français) :

Enfin tout en bas les zones pour le SEO :

Et évidemment un bouton pour soumettre le formulaire :

Tout ça est bien joli, mais ça ne fonctionne pas encore...

Les propriétés

On a besoin de nombreuses propriétés pour toutes les valeurs à enregistrer, sans oublier leur validation.

use Livewire\Attributes\{Layout, Validate};

...

public int $category_id;

#[Validate('required|string|max:16777215')]
public string $body = '';

#[Validate('required|string|max:255')]
public string $title = '';

#[Validate('required|max:255|unique:posts,slug|regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/')]
public string $slug = '';

#[Validate('required')]
public bool $active = false;

#[Validate('required')]
public bool $pinned = false;

#[Validate('required|max:70')]
public string $seo_title = '';

#[Validate('required|max:160')]
public string $meta_description = '';

#[Validate('required|regex:/^[A-Za-z0-9-éèàù]{1,50}?(,[A-Za-z0-9-éèàù]{1,50})*$/')]
public string $meta_keywords = '';

public function mount(): void
{
    $category          = Category::first();
    $this->category_id = $category->id;
}

Lorsqu'on va créer ou modifier le titre, il est judicieux de créer automatiquement le slug, ce qui facilite grandement la saisie :

public function updatedTitle($value)
{
    $this->slug      = Str::slug($value);
    $this->seo_title = $value;
}

L'enregistrement de l'article

On ajoute la fonction pour l'enregistrement :

...

use App\Models\{Category, Post};
use Mary\Traits\Toast;

new #[Layout('components.layouts.admin')] 
class extends Component {
    use Toast;

	...

    public function save()
	{
		$data = $this->validate();

		Post::create(
			$data + [
				'user_id'     => Auth::id(),
				'category_id' => $this->category_id,
			],
		);

		$this->success(__('Post added with success.'), redirectTo: '/admin/posts/index');
	}

Vérifiez les validations :

Et évidement l'enregistrement dans la base ! Si tout se passe bien, vous êtes dirigé vers la liste des articles et vous devriez le trouver :

L'image en exergue

Un article doit comporter aussi une image en exergue pour la page d'accueil et pour faire joli en haut de l'article. Il faut donc compléter le formulaire avec ce choix de l'image. Livewire est bien équipé pour faire ça et en plus MaryUI nous propose un composant bien pratique.

On commence par ajouter le trait de Livewire et une propriété pour récupérer le lien de la photo :

use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;

new #[Layout('components.layouts.admin')] 
class extends Component {
    use WithFileUploads, Toast;

    #[Rule('required|image|max:2000')]
	public ?TemporaryUploadedFile $photo = null;

Ensuite, on place le composant de MaryUI dans le formulaire :

            <x-file wire:model="photo" label="{{ __('Featured image') }}"
                hint="{{ __('Click on the image to modify') }}" accept="image/png, image/jpeg">
                <img src="{{ $photo == '' ? '/storage/ask.jpg' : $photo }}" class="h-40" />
            </x-file>
            <x-slot:actions>
                ...
            </x-slot:actions>
        </x-form>
    </x-card>
</div>

Remarquez que par défaut, on montre une photo située en /storage/ask.jpg. Vous pouvez récupérer la photo que j'utilise dans le zip associé à cet article.

On a besoin de deux traductions :

"Featured image": "Image mise en avant",
"Click on the image to modify": "Cliquez sur cette image pour la modifier",

Et voilà le résultat :

Lorsque vous cliquez sur l'image, vous avez l'ouverture de gestionnaire de fichier, vous pouvez alors sélectionner une image qui va apparaître à la place de l'image par défaut :

Pour le moment, cette image se trouve dans un fichier temporaire créé par Livewire :

Il faut compléter la fonction save pour gérer cette image :

public function save()
{
    $data = $this->validate();

    $date = now()->format('Y/m');
    $path = $date . '/' . basename($this->photo->store('photos/' . $date, 'public'));

    Post::create(
        $data + [
            'user_id'     => Auth::id(),
            'category_id' => $this->category_id,
            'image'       => $path,
        ],
    );

    $this->success(__('Post added with success.'), redirectTo: '/admin/posts/index');
}

Maintenant lorsque je crée un article, l'image vient prendre sa place dans le dossier de l'année et du mois :

Et j'ai le bon lien dans la base :

Réflexion sur les liens des images

Lorsque vous insérez une image dans un article avec TinyMCE, cette image va au bon endroit dans le dossier storage, comme on l'a vu. Le fonctionnement est automatique grâce à MaryUI. Mais il y a une chose qu'on peut améliorer. Les liens sont absolus et on obtient par exemple pour une image le lien <img src="http://moncms.oo/storage/photos/2024/09/3ltcjND1gbM59IeEZ6SSIF8Gx6jN4Buhi96JAZFy.jpg">.

Ça fonctionne très bien tant que la base de l'URL ne change pas, sinon ça casse tout. Si vous déménagez votre CMS avec un autre domaine, vous devrez changer tous ces liens dans tous vos articles, ce qui peut être fastidieux. L'idéal serait d'avoir des adresses relatives du genre <img src="/storage/photos/2024/09/3ltcjND1gbM59IeEZ6SSIF8Gx6jN4Buhi96JAZFy.jpg">.

Le meilleur moment pour effectuer ce changement est lors de l'enregistrement de l'article. On crée un helper (dans app/helpers.php) pour automatiser le travail :

if (!function_exists('replaceAbsoluteUrlsWithRelative')) {
	function replaceAbsoluteUrlsWithRelative(string $content)
	{
		$baseUrl = url('/');

		if ('/' !== substr($baseUrl, -1)) {
			$baseUrl .= '/';
		}
		
		$pattern     = '/<img\s+[^>]*src="(?:https?:\/\/)?' . preg_quote(parse_url($baseUrl, PHP_URL_HOST), '/') . '\/([^"]+)"/i';
		$replacement = '<img src="/$1"';

		return preg_replace($pattern, $replacement, $content);
	}
}

Et enfin, on ajoute le traitement dans la méthode save de notre composant :

public function save()
{
   ...

    $data['body'] = replaceAbsoluteUrlsWithRelative($data['body']);
    
    Post::create(

    ...

}

Créez un article et vérifiez que tout fonctionne correctement.

Conclusion

On a bien avancé avec la création des articles. Il nous faudra, dans un deuxième temps, autoriser leur modification, mais on réutilisera beaucoup de code déjà existant pour la création.

Pour vous simplifier la vie, vous pouvez charger le projet dans son état à l’issue de ce chapitre.



Par bestmomo

Aucun commentaire

Article précédent : Mon CMS - Tableau des articles
Article suivant : Mon CMS - Modifier un article