Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Voir cette série
Mon CMS - La page d'accueil
Samedi 24 août 2024 15:25

Poursuivons notre création du CMS en mettant l'accent sur la mise en place d'une interface utilisateur conviviale et attrayante. Nous allons créer la page d'accueil, en intégrant différents éléments importants pour l'expérience utilisateur.

Commençons par l'en-tête qui contiendra une image d'arrière-plan représentant la thématique du site, ainsi que le titre du site en surimpression. Le choix de couleurs et de design doit refléter l'identité du site et son public cible.

Ensuite, implémentons une barre de navigation responsive, qui se transformera en barre latérale sur les écrans de petite taille, tout en gardant une hiérarchie claire des menus. Cela facilitera la navigation sur le site, quel que soit le type d'appareil utilisé par les visiteurs.

En ce qui concerne le contenu, nous allons mettre en place un système de pavés pour chaque article. Chaque pavé devra contenir une image et un résumé de l'article, avec un lien pour accéder à la page complète. Afin de faciliter la lecture, nous ajouterons également une pagination pour les longues listes d'articles.

À mesure que nous construisons la page d'accueil, il est essentiel de garder à l'esprit l'expérience utilisateur, en assurant une navigation intuitive et un design esthétique. De cette façon, nous pourrons créer un CMS qui répondra à la fois aux besoins fonctionnels et aux attentes esthétiques des utilisateurs.

La page d'accueil va être gérée par un composant Volt, commençons par le créer (on en profite au passage pour supprimer le dossier users qui a été créé par MaryUI avec son composant) :

php artisan make:volt index --class  

On change aussi la route pour pointer ce composant :

Volt::route('/', 'index');

Vous obtenez quelque chose comme ça :

Le composant prend par défaut le layout components/layouts/app.blade.php qui a été créé par MaryUI. On va devoir le modifier pour qu'il corresponde à nos besoins

Les images

On va avoir pas mal d'images dans un CMS, il faut les placer quelque part. 

Le disque public mentionné dans le fichier de configuration des systèmes de fichiers de Laravel est destiné aux fichiers qui doivent être accessibles publiquement. Par défaut, ce disque public utilise le pilote local et stocke ses fichiers dans le répertoire storage/app/public.

Pour rendre ces fichiers accessibles depuis le web, vous devez créer un lien symbolique de public/storage vers storage/app/public. En utilisant cette convention de dossiers, vous garderez vos fichiers accessibles publiquement dans un seul répertoire, ce qui facilite leur partage lors des déploiements.

Pour créer ce lien symbolique il y a une commande artisan :

php artisan storage:link

On va donc placer toutes les images dans le dossier storage/app/public. Mais elles seront accessibles à partir de public/storage

Pour les besoins du développement, allez chercher les images qui sont dans le fichier à télécharger dans un lien à la fin de cet article. Vous devez vous retrouver avec des dossiers et images :

Le layout

L'en-tête

Pour le moment, on ne va pas toucher au header :

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>{{ isset($title) ? $title.' - '.config('app.name') : config('app.name') }}</title>

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

Dans le body, on commence par s'occuper de l'image d'arrière-plan avec titre et sous-titre :

<body class="min-h-screen font-sans antialiased bg-base-200/50 dark:bg-base-200">
    {{-- HERO --}}
    <div class="min-h-[35vw] hero" style="background-image: url({{ asset('storage/hero.jpg') }});">
        <div class="bg-opacity-60 hero-overlay"></div>
        <a href="{{ '/' }}">
            <div class="text-center hero-content text-neutral-content">
                <div>
                    <h1 class="mb-5 text-4xl font-bold sm:text-5xl md:text-6xl lg:text-7xl xl:text-8xl">
                        Mon Titre
                    </h1>
                    <p class="mb-5 text-lg sm:text-xl md:text-2xl lg:text-3xl xl:text-4xl">
                        Mon sous-tire
                    </p>
                </div>                
            </div>
        </a>
    </div>

Maintenant en haut de la page, on a bien l'image qui prend toute la largeur et le titre accompagné de son sous-titre qui s'adapte à la largeur de l'écran.

Si vous avez quelques lacunes concernant l'aspect responsive du titre voilà quelque sexplications : 

    mb-5 :
        Signification : mb signifie "margin-bottom".
        Valeur : 5 est une unité de mesure spécifique à un framework CSS comme Tailwind CSS. Cela signifie que l'élément aura une marge en bas de 1.25rem (car chaque unité représente 0.25rem dans Tailwind CSS).

    text-4xl :
        Signification : text indique que cette classe affecte la taille du texte.
        Valeur : 4xl signifie "extra large 4". Cela correspond à une taille de police spécifique dans le framework CSS utilisé (par exemple, Tailwind CSS).

    font-bold :
        Signification : font indique que cette classe affecte le style de la police.
        Valeur : bold signifie que le texte sera en gras.

    sm:text-5xl :
        Signification : sm est un préfixe qui signifie "small screens" (petits écrans). text-5xl affecte la taille du texte.
        Valeur : 5xl signifie "extra large 5". Cela signifie que sur les petits écrans, la taille du texte sera plus grande que 4xl.

    md:text-6xl :
        Signification : md est un préfixe qui signifie "medium screens" (écrans de taille moyenne). text-6xl affecte la taille du texte.
        Valeur : 6xl signifie "extra large 6". Cela signifie que sur les écrans de taille moyenne, la taille du texte sera encore plus grande.

   et ainsi de suite pour les textes plus grands...

La barre de navigation

On crée un composant pour la barre de navigation :

php artisan make:volt navigation/navbar --class

On ajoute ce code :

<?php

use Illuminate\Support\Facades\{Auth, Session};
use Livewire\Volt\Component;

new class extends Component {

    public function logout(): void
    {
        Auth::guard('web')->logout();

        Session::invalidate();
        Session::regenerateToken();

        $this->redirect('/');
    }
};
?>

<x-nav sticky full-width >
    <x-slot:brand>
        <label for="main-drawer" class="mr-3 lg:hidden">
            <x-icon name="o-bars-3" class="cursor-pointer" />
        </label>
    </x-slot:brand>

    <x-slot:actions>
        <span class="hidden lg:block">
            @if ($user = auth()->user())
                <x-dropdown>
                    <x-slot:trigger>
                        <x-button label="{{ $user->name }}" class="btn-ghost" />
                    </x-slot:trigger>
                    <x-menu-item title="{{ __('Logout') }}" wire:click="logout" />
                </x-dropdown>
            @else
                <x-button label="{{ __('Login') }}" link="/login" class="btn-ghost" />
            @endif
        </span>
    </x-slot:actions>
</x-nav>

Dans le layout, vous retirez ce code qui concerne la navigation :

{{-- NAVBAR mobile only --}}
<x-nav sticky class="lg:hidden">
    <x-slot:brand>
        <x-app-brand />
    </x-slot:brand>
    <x-slot:actions>
        <label for="main-drawer" class="lg:hidden me-3">
            <x-icon name="o-bars-3" class="cursor-pointer" />
        </label>
    </x-slot:actions>
</x-nav>

Et vous insérez notre nouveau composant à la place :

{{-- NAVBAR --}}
<livewire:navigation.navbar  />

Sur écran étroit, on affiche juste le bouton pour ouvrir le menu latéral (qu'on n'a pas encore modifié) :

Si vous connectez un utilisateur on aura son nom et un simple menu qui propose la déconnexion (qui fonctionne) :

La barre latérale

Sur petits écrans, on doit gérer la barre de navigation latérale. On crée aussi un composant pour celle-ci :

php artisan make:volt navigation/sidebar --class

On va évidemment trouver un code très proche de la barre qu'on a vue ci-dessus :

<?php

use Illuminate\Support\Facades\{Auth, Session};
use Livewire\Volt\Component;

new class() extends Component {

	public function logout(): void
	{
		Auth::guard('web')->logout();
		Session::invalidate();
		Session::regenerateToken();
		$this->redirect('/');
	}
};
?>

<div>
    <x-menu activate-by-route>
        @if($user = auth()->user())
            <x-menu-separator />
                <x-list-item :item="$user" value="name" sub-value="email" no-separator no-hover class="-mx-2 !-my-2 rounded">
                    <x-slot:actions>
                        <x-button icon="o-power" wire:click="logout" class="btn-circle btn-ghost btn-xs" tooltip-left="{{ __('Logout') }}" no-wire-navigate />
                    </x-slot:actions>
                </x-list-item>
            <x-menu-separator />
        @else
            <x-menu-item title="{{ __('Login') }}" link="/login" />
        @endif
    </x-menu>
</div>

Dans le layout, remplacez tout le x-main par celui-ci :

{{-- MAIN --}}
<x-main full-width>

    {{-- SIDEBAR --}}
    <x-slot:sidebar drawer="main-drawer" collapsible class="bg-base-100 lg:bg-inherit lg:hidden">
        <livewire:navigation.sidebar />
    </x-slot:sidebar>

    {{-- SLOT --}}
    <x-slot:content>
        {{ $slot }}
    </x-slot:content>

</x-main>

Vérifiez que la barre latérale fonctionne correctement.

Un repository pour les articles

Comme on va avoir plusieurs façons de récupérer les articles et pour ne pas trop charger les composants, on va créer un repository :

On va commencer avec ce code :

<?php

namespace App\Repositories;

use App\Models\{Category, Post};
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Exception;

class PostRepository
{
	public function getPostsPaginate(?Category $category): LengthAwarePaginator
	{
		$query = $this->getBaseQuery()->orderBy('pinned', 'desc')->latest();

		if ($category) {
			$query->whereBelongsTo($category);
		}

		return $query->paginate(config('app.pagination'));
	}

	protected function getBaseQuery(): Builder
	{
		$specificReqs = [
			'mysql'  => "LEFT(body, LOCATE(' ', body, 700))",
			'sqlite' => 'substr(body, 1, 700)',
			'pgsql'  => 'substring(body from 1 for 700)',
		];

		$usedDbSystem = env('DB_CONNECTION', 'mysql');

		if (!isset($specificReqs[$usedDbSystem])) {
			throw new Exception("Base de données non supportée: {$usedDbSystem}");
		}

		$adaptedReq = $specificReqs[$usedDbSystem];

		return Post::select('id', 'slug', 'image', 'title', 'user_id', 'category_id', 'created_at', 'pinned')
			->selectRaw(
				"CASE
                    WHEN LENGTH(body) <= 300 THEN body
                    ELSE {$adaptedReq}
                END AS excerpt",
			)
			->with('user:id,name', 'category')
			->whereActive(true);
	}
}

La fonction getBaseQuery() est chargée de démarrer la requête, on détermine les données à récupérer (on évite le body, inutile sur la page d'accueil, pour ne pas surcharger la réponse). Pour le résumé de l'article, on prend juste le début du texte (on adapte la requête selon la base utilisée). On charge également le nom de l'auteur et la catégorie de chaque article. On ne prend en compte évidemment que les articles actifs.

La fonction getPostsPaginate() est appelée pour récupérer les articles paginés. On tient compte du paramètre de la catégorie si on veut limiter la requête à cette catégorie.

La quantité d'articles par page est définie par une donnée issue de la configuration app.pagination. Il faut qu'on l'ajoute dans le fichier de configuration config/app.php :

return [

	...

	'pagination' => 6,

On complètera plus tard tout ça, mais pour le moment ça va suffire pour générer notre page d'accueil.

Le tableau associatif $specificReqs contient des requêtes SQL spécifiques pour différents systèmes de bases de données (MySQL, SQLite, PostgreSQL). Chaque requête extrait les 700 premiers caractères du champ body.

$usedDbSystem récupère le type de base de données utilisé à partir des variables d'environnement. Si la variable DB_CONNECTION n'est pas définie, elle utilise mysql par défaut.

Post::select(...) : Sélectionne les colonnes id, slug, image, title, user_id, category_id, created_at, et pinned de la table posts.
selectRaw(...) : Ajoute une colonne calculée excerpt qui contient soit le contenu complet de body si sa longueur est inférieure ou égale à 300 caractères, soit les 700 premiers caractères de body en utilisant la requête spécifique adaptée.
with('user:id,name', 'category') : Charge les relations user et category avec les colonnes spécifiées.
whereActive(true) : Filtre les enregistrements pour ne sélectionner que ceux qui sont actifs.

La page d'accueil

Pour le moment, notre composant index pour la page d'accueil est vide.

Partie PHP

<?php

use App\Models\Category;
use Livewire\Volt\Component;
use Livewire\WithPagination;
use App\Repositories\PostRepository;
use Illuminate\Pagination\LengthAwarePaginator;

new class extends Component {
    use WithPagination;

    public ?Category $category = null;

    public function mount(string $slug = ''): void
	{
		if (request()->is('category/*')) {
			$this->category = $this->getCategoryBySlug($slug);
		} 
	}

	public function getPosts(): LengthAwarePaginator
	{
		$postRepository = new PostRepository();

		return $postRepository->getPostsPaginate($this->category);
	}

    protected function getCategoryBySlug(string $slug): ?Category
	{
		return 'category' === request()->segment(1) ? Category::whereSlug($slug)->firstOrFail() : null;
	}


    public function with(): array
	{
		return ['posts' => $this->getPosts()];
    }

}; ?>

On déclare une propriété publique $category qui peut être de type Category ou null. Elle est initialisée à null.
La méthode mount est appelée lorsque le composant est monté (initialisé). Elle prend un paramètre $slug qui est une chaîne de caractères.
Si l'URL actuelle correspond à un motif de catégorie (category/*), elle appelle la méthode getCategoryBySlug pour récupérer la catégorie correspondante au slug et l'assigne à la propriété $category.
La méthode getPosts retourne un objet LengthAwarePaginator contenant les posts paginés.
    Elle crée une instance de PostRepository.
    Elle appelle la méthode getPostsPaginate de PostRepository en passant la catégorie actuelle ($this->category).
La méthode with retourne un tableau associatif contenant les posts paginés.
    Elle appelle la méthode getPosts pour obtenir les posts paginés et les assigne à la clé posts.

On complète les routes pour tenir compte de la possibilité de sélectionner une catégorie :

Volt::route('/', 'index');
Volt::route('/category/{slug}', 'index');

Partie HTML

Voici le code :

<div class="relative grid items-center w-full py-5 mx-auto md:px-12 max-w-7xl">

    @if ($category)
        <x-header title="{{ __('Posts for category ') }} {{ $category->title }}" size="text-2xl sm:text-3xl md:text-4xl" />
    @endif

    <div class="mb-4 mary-table-pagination">
        <div class="mb-5 border border-t-0 border-x-0 border-b-1 border-b-base-300"></div>
        {{ $posts->links() }}
    </div>

    <div class="container mx-auto">
        <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
            @forelse($posts as $post)
                <x-card
                    class="w-full transition duration-500 ease-in-out shadow-md shadow-gray-500 hover:shadow-xl hover:shadow-gray-500"
                    title="{!! $post->title !!}">
    
                    <div class="text-justify">{!! str(strip_tags($post->excerpt))->words(config('app.excerptSize')) !!}</div>
                    <br>
                    <hr>
                    <div class="flex justify-between">
                        <p wire:click="" class="text-left cursor-pointer">{{ $post->user->name }}</p>
                        <p class="text-right"><em>{{ $post->created_at->isoFormat('LL') }}</em></p>
                    </div>
                    @if($post->image)
                        <x-slot:figure>
                            <a href="{{ url('/posts/' . $post->slug) }}">
                                <img src="{{ asset('storage/photos/' . $post->image) }}" alt="{{ $post->title }}" />
                            </a>
                        </x-slot:figure>
                    @endif
    
                    <x-slot:menu>
                        @if ($post->pinned)
                            <x-badge value="{{ __('Pinned') }}" class="p-3 badge-warning" />
                        @endif
                    </x-slot:menu>
    
                    <x-slot:actions>
                        <div class="flex flex-col items-end space-y-2 sm:items-start sm:flex-row sm:space-y-0 sm:space-x-2">
                            <x-popover>
                                <x-slot:trigger>
                                    <x-button label="{{ $post->category->title }}"
                                        link="{{ url('/category/' . $post->category->slug) }}" class="mt-1 btn-outline btn-sm" />
                                </x-slot:trigger>
                                <x-slot:content class="pop-small">
                                    @lang('Show this category')
                                </x-slot:content>
                            </x-popover>
        
                            <x-popover>
                                <x-slot:trigger>
                                    <x-button label="{{ __('Read') }}" link="{{ url('/posts/' . $post->slug) }}"
                                        class="mt-1 btn-outline btn-sm" />
                                </x-slot:trigger>
                                <x-slot:content class="pop-small">
                                    @lang('Read this post')
                                </x-slot:content>
                            </x-popover>
                        </div>
                    </x-slot:actions>
                </x-card>
            @empty
                <div class="col-span-3">
                    <x-card title="{{ __('Nothing to show !') }}">
                        {{ __('No Post found with these criteria') }}
                    </x-card>
                </div>
            @endforelse
        </div>
    </div>
    

    <!-- Pagination inférieure -->
    <div class="mb-4 mary-table-pagination">
        <div class="mb-5 border border-t-0 border-x-0 border-b-1 border-b-base-300"></div>
        {{ $posts->links() }}
    </div>

</div>

On doit ajouter la longueur de la partie de texte en exergue (nombre de mots) pour chaque article dans config/app.php :

'excerptSize' => 30,

On obtient cet aspect :

L'aspect "responsive" (3 colonnes sur grand écran, 2 colonnes sur écran moyen et une seule sur petit écran) est fourni par ce code :

<div class="container mx-auto">
    <div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">

container : Cette classe applique des marges et des paddings pour centrer le contenu et le rendre responsive.
mx-auto : Cette classe centre horizontalement le conteneur en appliquant des marges automatiques sur les côtés gauche et droit.
grid : Cette classe transforme l'élément en un conteneur de grille CSS.
grid-cols-1 : Cette classe définit une grille avec une seule colonne par défaut.
gap-6 : Cette classe ajoute un espace (gap) de 1.5rem (6 unités de taille) entre les éléments de la grille.
sm:grid-cols-2 : Cette classe modifie la grille pour avoir deux colonnes sur les écrans de taille sm (petits) et plus grands.
lg:grid-cols-3 : Cette classe modifie la grille pour avoir trois colonnes sur les écrans de taille lg (grands) et plus grands.

    Par Défaut (Écrans Très Petits)
        La grille a une seule colonne (grid-cols-1).
        Les éléments sont empilés verticalement.

    Écrans Moyens (sm et plus grands)
        La grille a deux colonnes (sm:grid-cols-2).
        Les éléments sont répartis sur deux colonnes.

    Écrans Grands (lg et plus grands)
        La grille a trois colonnes (lg:grid-cols-3).
        Les éléments sont répartis sur trois colonnes.

On a un petit souci avec la pagination. Normalement, on devrait avoir des boutons avec les numéros de page sur écran large et juste les boutons "précédent" et "suivant" sur les écrans plus petits. Et là, on a seulement la deuxième option dans tous les cas. On va arranger ça. Ouvrez le fichier tailwind.config.js et ajoutez cette ligne :

export default {
    content: [
        ...
        "./vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php",
    ],

Maintenant, c'est correct :

Chaque pavé pour les articles est stylisé avec ces classes :

class="w-full transition duration-500 ease-in-out shadow-md shadow-gray-500 hover:shadow-xl hover:shadow-gray-500"

w-full
    Cette classe applique une largeur de 100% à l'élément, ce qui signifie qu'il occupera toute la largeur de son conteneur parent.

transition
    Cette classe active les transitions CSS sur l'élément, permettant des animations fluides lorsque certaines propriétés changent.

duration-500
    Cette classe définit la durée de la transition à 500 millisecondes (0.5 seconde). Cela signifie que toute transition appliquée à l'élément prendra 0.5 seconde pour se compléter.

ease-in-out
        Cette classe définit la fonction de temporisation (timing function) de la transition. ease-in-out signifie que la transition commence lentement, accélère au milieu, puis ralentit à la fin, créant un effet de transition doux.

shadow-md
        Cette classe applique une ombre de taille moyenne à l'élément. shadow-md est une classe prédéfinie de Tailwind CSS qui ajoute une ombre avec une certaine taille et une certaine opacité.

shadow-gray-500
        Cette classe définit la couleur de l'ombre à gray-500, qui est une nuance de gris dans la palette de couleurs de Tailwind CSS.

hover:shadow-xl
        Cette classe applique une ombre de taille extra-large (shadow-xl) lorsque l'élément est survolé par la souris. Cela crée un effet visuel plus prononcé lors du survol.

hover:shadow-gray-500
        Cette classe définit la couleur de l'ombre à gray-500 lorsque l'élément est survolé par la souris. Cela garantit que la couleur de l'ombre reste cohérente avec l'ombre par défaut.

On va ajouter quelques traductions :

"Show this category": "Voir cette catégorie",
"Read this post": "Lire cet article",
"Posts for category ": "Articles pour la catégorie ",
"Read": "Lire",
"Nothing to show !": "Rien à montrer !",
"No Post found with these criteria": "Aucun article trouvé avec ces critères"

On va aussi ajouter une règle CSS dans le fichier resources/css/app.css :

.pop-small {
    @apply !p-1 !px-2 text-sm border-info text-center
}

On obtient ainsi un popup plus esthétique :

Il y a une partie du code HTML qui ne sert pas encore et qui est activé si aucun article n'est trouvé, mais il servira bientôt !

Conclusion

On a mis en place notre page d'accueil avec nos articles paginés avec la possibilité de les afficher par catégorie. Pour chaque article, on a l'image, le titre, l'auteur, la date, la catégorie, un court extrait du début du texte.

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 - L'authentification
Article suivant : Mon CMS - Les articles