Laravel 5.7 par la pratique – Les langues

Dans ce chapitre on va s’intéresser à l’aspect multi-langage. Pour le moment notre galerie est en français mais on a fait en sorte que les textes soient faciles à traduire en utilisant dans le code les helpers de Laravel. On va donc ajouter maintenant l’anglais à notre galerie. Ça ne concernera évidemment que l’interface et pas les données, ce qui serait une autre histoire…

La configuration

Dans le fichier config/app.php on a des réglages pour les langues :

'locale' => 'fr',

'fallback_locale' => 'en',

On a fixé la locale au français (fr) et la langue par défaut en cas d’absence de traduction à l’anglais (en).

On va ajouter un réglage avec les langues qu’on va mettre en œuvre et qui devront avoir les traductions présentes :

'locales' => ['fr', 'en',],

On va ajouter un fichier de configuration pour les locales :

Ce fichier est issu de ce dépôt Github. Il permet d’avoir par pays le code pour Carbon et celui pour setLocale().

Route, contrôleur et middleware

Route

On ajoute la route pour le changement de la langue :

Route::name('language')->get('language/{lang}', 'HomeController@language');

Contrôleur

Et on ajoute la fonction dans HomeController :

public function language(String $locale)
{
    $locale = in_array($locale, config('app.locales')) ? $locale : config('app.fallback_locale');

    session(['locale' => $locale]);

    return back();
}

On reçoit une locale en paramètre. Si elle est présente dans le tableau de la configuration on fixe cette locale en session, sinon on se rabat sur la locale par défaut.

Middleware

Il nous faut maintenant un middleware qui va vérifier si on a une locale en session pour réellement l’affecter. Si ce n’est pas le cas essayer de définir la langue de l’utilisateur. On va aussi fixer la locale pour les dates.

php artisan make:middleware Locale

Et on code ainsi :

public function handle($request, Closure $next)
{
    if (!session ()->has ('locale')) {
        session (['locale' => $request->getPreferredLanguage (config ('app.locales'))]);
    }
    $locale = session ('locale');
    app ()->setLocale ($locale);
    setlocale (LC_TIME, app()->environment('local') ? $locale : config('locale.languages')[$locale][1]);
    return $next ($request);
}

Et on le référence dans app/Http/Kernel :

protected $middlewareGroups = [
    'web' => [
        ... 

        \App\Http\Middleware\Locale::class,
        \App\Http\Middleware\Settings::class,
    ],

    ...
];

Le menu

On va ajouter un menu déroulant pour le choix de la locale dans views/layouts/app :

<ul class="navbar-nav mr-auto">
    <li class="nav-item dropdown">
        <a class="nav-link" href="#" id="navbarDropdownFlag" role="button" data-toggle="dropdown"
            aria-haspopup="true" aria-expanded="false">
            <img width="32" height="32" alt="{{ session('locale') }}"
                    src="{!! asset('images/flags/' . session('locale') . '-flag.png') !!}"/>
        </a>
        <div id="flags" class="dropdown-menu" aria-labelledby="navbarDropdownFlag">
            @foreach(config('app.locales') as $locale)
                @if($locale != session('locale'))
                    <a class="dropdown-item" href="{{ route('language', $locale) }}">
                        <img width="32" height="32" alt="{{ session('locale') }}"
                                src="{!! asset('images/flags/' . $locale . '-flag.png') !!}"/>
                    </a>
                @endif
            @endforeach
        </div>
    </li>

Et on va voir le résultat :

Je ne trouve pas trop élégant la largueur de la zone pour le drapeau.On va arranger ça dans resources/sass/_variables.scss. On va en profiter pour changer la typographie et un peu la pagination :

// Body
$body-bg: #343a40;

// Menu
$dropdown-min-width: 5rem;

// Typography
$Raleway", sans-serif;
$font-size-base: 0.9rem;
$line-height-base: 1.6;
$text-color: #636b6f;

// Card
$card-border-width: 4px;
$card-border-radius: .3rem;
$card-border-color: rgba(255, 242, 242, .3);

// Pagination
$pagination-active-bg: grey;
$pagination-active-border-color: grey;

On lance npm…

Et c’est maintenant plus équilibré :

Pour le moment le changement de langue ne se voit que dans les validations :

Un package

Il nous faut créer un fichier JSON avec toutes les traductions pour l’anglais. On pourrait faire ça en explorant tout le code avec des copier/coller, ça serait vraiment laborieux !

On va plutôt utiliser un package pour nous aider. Comme je n’en ai pas vraiment trouvé un qui me plaise j’en ai créé un. On va commencer par l’installer :

composer require bestmomo/laravel5-artisan-language --dev

On a maintenant 4 commandes de plus dans artisan :

On va utiliser la deuxième :

php artisan language:make en

Le fichier JSON a été créé et on a tous les textes par ordre alphabétique qui attendent leur traduction :

{
    "A l'url :": "",
    "A propos": "",
    "Administration": "",
    "Adresse email": "",
    "Adresse web :": "",
    "Adulte": ""

    ...

On ajoute donc les traductions. Comme le fichier est assez gros vous pouvez le récupérer sur github.

Maintenant si on passe à l’anglais on a bien les textes dans cette langue :

Vous pourrez aussi remarquer que les dates sont maintenant correctes.

C’est le dernier chapitre de cette série. Il reste quelques éléments dont je n’ai pas parlé, comme les pages d’information, mais que vous pouvez retrouver dans le dépôt Github.

Conclusion

Dans ce chapitre on a :

  • prévu la configuration pour les locales
  • ajouté la route, la fonction du contrôleur et un middleware pour le changement de locale
  • ajouté un menu déroulant avec les drapeaux des langues disponibles
  • installé un package pour créer le fichier de la nouvelle langue et ajouté ainsi les traductions

 




Laravel 5.7 par la pratique – Les notifications

Notre galerie est désormais bien équipée avec ses catégories, ses albums, son administration, sa gestion du profil utilisateur, la notation des photos… Dans ce chapitre nous allons voir les notifications. Les utilisateurs seront prévenus si on a noté leurs photos. L’administrateur sera aussi prévenu lors de l’inscription d’un nouvel utilisateur. Dans le premier cas ce sera avec la présence d’une icône avec le nombre de notifications dans la barre de menu. Dans le second cas avec l’envoi d’un mail.

Notification par la base de données

La table des notifications

Lorsqu’on veut stocker les notifications dans la base de données en attendant que l’utilisateur en ait connaissance il faut commencer par créer une table spécifique :

php artisan notifications:table
php artisan migrate

Voyons les champs de cette table :

  • id : un identifiant unique
  • notifiable_type : le modèle en relation (dans notre cas de notation d’image ça sera User)
  • notifiable_id : l’id du modèle en relation (donc l’id de l’utilisateur notifié)
  • data : les données qui seront sous forme JSON (là on met ce qu’on veut)
  • read_at : le moment où la notification est marquée comme étant lue
  • created_at : le moment où la notification est créée
  • updated_at : le moment où la notification est modifiée

Histoire d’avoir déjà des données on va créer ce seeder :

Avec ce code :

<?php

use Illuminate\Database\Seeder;

class NotificationsTableSeeder extends Seeder
{
    public function run()
    {
        \DB::table('notifications')->insert([
            0 => [
                'id' => '6bd79182-0d88-48b7-8e4e-59dbf3371763',
                'type' => 'App\Notifications\ImageRated',
                'notifiable_type' => 'App\Models\User',
                'notifiable_id' => '2',
                'data' => '{"image":"hVCKABCaItIPhop9nQZBoZb7CFFwgGCYYTLgQEvE.jpeg","image_id":31,"rate":3,"user":3}'
            ],
            1 => [
                'id' => '6c7b833c-4a12-44d5-8fbe-f542e688b865',
                'type' => 'App\Notifications\ImageRated',
                'notifiable_type' => 'App\Models\User',
                'notifiable_id' => '2',
                'data' => '{"image":"RvlsdZqwNw6fIWoQCsb13uFw1W4DiDRHuU4tZONT.jpeg","image_id":32,"rate":5,"user":3}'
            ],
        ]);
    }
}

Mettez à jour DatabaseSeeder :

public function run()
{
    ...

    $this->call(NotificationsTableSeeder::class);
}

Puis rafraichissez la base :

php artisan migrate:fresh --seed

On a maintenant deux notifications :

La notification

On crée maintenant la notification :

php artisan make:notification ImageRated

On change ainsi le code :

<?php

namespace App\Notifications;
use Illuminate\Notifications\Notification;

class ImageRated extends Notification
{
    protected $image;
    protected $rate;
    protected $user_id;

    public function __construct($image, $rate, $user_id)
    {
        $this->image = $image;
        $this->rate = $rate;
        $this->user_id = $user_id;
    }

    public function via()
    {
        return ['database'];
    }

    public function toArray()
    {
        return [
            'image' => $this->image->name,
            'image_id' => (integer)$this->image->id,
            'rate' => (integer)$this->rate,
            'user' => (integer)$this->user_id
        ];
    }
}

On va transmettre à la notification 3 valeurs :

  • l’image concernée ($image)
  • la note donnée ($rate)
  • le propriétaire de l’image notée ($user_id)

Dans la fonction via on définit le canal, donc dans notre cas la base de données (database).

Dans la fonction toArray on définit les données transmises dans la base.

L’envoi de la notification

Dans le contrôleur ImageController, lorsqu’on reçoit une note pour une photo on doit créer la notification :

use App\Repositories\ { ImageRepository, NotificationRepository, AlbumRepository, CategoryRepository };
use App\Notifications\ImageRated;

    ...

public function rate(Request $request, Image $image)
{
    ...
    $this->imageRepository->setImageRate ($image);

    // Notification
    $notificationRepository->deleteDuplicate($user, $image);
    $image->user->notify(new ImageRated($image, $request->value, $user->id));

    return ...
}

On envoie la notification avec la méthode notify appliquée sur l’instance de User. On sait que User est déjà équipé du trait Notifiable sinon il aurait fallu l’ajouter.

On fait appel à un repository (NotificationRepository) qui n’existe pas encore pour accomplir une action : si on reçoit une note pour une photo déjà notifiée et non lue on supprime le doublon. On crée ce repository :

Avec ce code :

<?php

namespace App\Repositories;

use Illuminate\Support\Facades\DB;

class NotificationRepository
{
    public function deleteDuplicate($user, $image)
    {
        DB::table('notifications')
            ->whereNotifiableId($image->user->id)
            ->whereNull('read_at')
            ->where('data->image_id', $image->id)
            ->where('data->user', $user->id)
            ->delete();
    }
}

L’affichage des notifications

On va ajouter deux routes :

Route::middleware ('auth', 'verified')->group (function () {

    ...

    Route::name ('notification.')->prefix('notification')->group(function () {
        Route::name ('index')->get ('/', 'NotificationController@index');
        Route::name ('update')->patch ('{notification}', 'NotificationController@update');
    });
});

Dans la vue layouts.app on ajoute ce code :

@endmaintenance
@unless(auth()->user()->unreadNotifications->isEmpty())
    <li class="nav-item">
        <a class="nav-link" href="{{ route('notification.index') }}">
            <span class="fa-layers fa-fw">
                <span style="color: yellow" class="fas fa-bell fa-lg" data-fa-transform="grow-2"></span>
                <span class="fa-layers-text fa-inverse" data-fa-transform="shrink-4 up-2 left-1" style="color: black; font-weight:900">{{ auth()->user()->unreadNotifications->count() }}</span>
            </span>
        </a>
    </li>
@endunless

Maintenant si on se connecte avec Dupont on a la petite icône et le nombre 2 associé :

On crée le nouveau contrôleur :

php artisan make:controller NotificationController

Avec ces deux méthodes :

<?php

namespace App\Http\Controllers;

use Illuminate\ {
    Http\Request,
    Notifications\DatabaseNotification
};

class NotificationController extends Controller
{
    public function index(Request $request)
    {
        $user = $request->user();

        return view('notifications.index', compact('user'));
    }

    public function update(Request $request, DatabaseNotification $notification)
    {
        $notification->markAsRead();

        if($request->user()->unreadNotifications->isEmpty()) {
            return redirect()->route('home');
        }

        return back();
    }
}

La première (index) sert à afficher la page des notifications. Créons la vue :

Codée ainsi :

@extends('layouts.app')

@section('content')
    <main class="container-fluid">
        <h1>@lang('Notation de vos photos')</h1>
        <div class="card">
            <div class="card-body">
                <div class="table-responsive">
                    <table class="table" style="margin-bottom: 140px">
                        <thead>
                            <tr>
                                <th>@lang('Photo')</th>
                                <th>@lang('Note')</th>
                                <th></th>
                            </tr>
                        </thead>
                        <tbody>
                            @foreach ($user->unreadNotifications as $notification)
                                <tr>
                                    <td>
                                        <div class="hover_img">
                                            <a href="{{ url('images/' . $notification->data['image']) }}" target="_blank">{{ url('images/' . $notification->data['image']) }}<span><img src="{{ url('thumbs/' . $notification->data['image']) }}" alt="image" height="150" /></span></a>
                                        </div>
                                    </td>
                                    <td>{{ $notification->data['rate'] }}</td>
                                    <td>
                                        <form action="{{ route('notification.update', $notification->id) }}" method="POST">
                                            @csrf
                                            @method('PATCH')
                                            <input type="submit" class="btn btn-success btn-sm" value="@lang('Marquer comme lu')">
                                        </form>
                                    </td>
                                </tr>
                            @endforeach
                        </tbody>
                    </table>
                </div>
            </div>
        </div>
        <br>
    </main>
@endsection

On a le lien des photos notées ,la note, et un bouton pour dire qu’on a bien lu la notification. Le survol du lien fait apparaître une miniature de l’image.

Notification par mail

L’administrateur est averti de l’inscription d’un nouvel utilisateur par mail. On crée une nouvelle notification :

On va compléter le code :

<?php

namespace App\Notifications;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;

class UserCreated extends Notification
{
    protected $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }

    public function via($notifiable)
    {
        return ['mail'];
    }

    public function toMail($notifiable)
    {
        return (new MailMessage)
                    ->subject(__('Nouvel utilisateur'))
                    ->line(__("Un nouvel utilisateur s'est enregistré."))
                    ->line(__('Nom : ') . $this->user->name)
                    ->line(__('Email : ') . $this->user->email);
    }
}

On transmet juste l’utilisateur à notifier, donc l’administrateur.

Cette fois dans la méthode via on précise par mail.

On a une méthode toMail pour définir ce que doit contenir le mail.

Comment savoir quand un utilisateur est créé ? Laravel va nous en informer. On commence par créer un événement :

php artisan make:event UserCreated

On le code ainsi :

<?php

namespace App\Events;

use Illuminate\Queue\SerializesModels;
use App\Models\User;

class UserCreated
{
    use  SerializesModels;

    public $user;

    public function __construct(User $user)
    {
        $this->user = $user;
    }
}

On crée ensuite un listener :

php artisan make:listener UserCreated

Avec ce code :

<?php

namespace App\Listeners;

use App\ {
    Events\UserCreated as UserCreatedEvent,
    Notifications\UserCreated as SendNotificationUserCreated,
    Models\User
};
use Illuminate\Support\Facades\Notification;

class UserCreated
{
    public function handle(UserCreatedEvent $event)
    {
        Notification::send(User::whereRole('admin')->first(), new SendNotificationUserCreated($event->user));
    }
}

C’est ici qu’on envoie la notification, donc le mail.

On établit la liaison entre les deux dans EventServiceProvider :

protected $listen = [
    ...

    'App\Events\UserCreated' => ['App\Listeners\UserCreated',],
];

Il ne reste plus qu’à déclencher l’événement, on va le faire dans le modèle User :

use App\Events\UserCreated;

    ...

protected $dispatchesEvents = [
    'created' => UserCreated::class,
];

Et maintenant quand un nouvel utilisateur est créé l’administrateur (ou les administrateurs) reçoit un mail :

Pour gagner en efficacité on peut établir une file d’attente (queue) parce que l’envoi d’un mail prend du temps. On ajoute ce trait dans la notification (et on implémente ShouldQueue) :

class UserCreated extends Notification implements ShouldQueue
{
    use Queueable;

Dans le fichier .env on choisit un driver, par exemple redis :

QUEUE_CONNECTION=redis

Il faut également ajouter ce package :

composer require predis/predis

Plus qu’à lancer :

php artisan queue:work

En résumé

Dans ce chapitre on a créé deux sortes de notification :

  • avec la base de données pour mémoriser la notation des photos et en informer le propriétaire
  • avec un mail pour avertir l’administrateur de l’inscription des nouveaux utilisateurs

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




Laravel 5.7 par la pratique – Notation des photos

Dans ce chapitre on va permettre aux utilisateurs authentifier de noter les photos des autres utilisateurs. On va adopter une approche visuelle classique avec une série de 5 étoiles.

La base

Pour mémoriser les notes on va créer une nouvelle table. Un utilisateur peut noter plusieurs photos et une photo peut être notée par plusieurs utilisateurs, on a donc une relation de type ManyToMany avec une table pivot. Créons cette table :

php artisan make:migration create_image_user_table

On complète le code :

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;

class CreateImageUserTable extends Migration
{
    public function up()
    {
        Schema::create('image_user', function (Blueprint $table) {
            $table->increments('id');
            $table->timestamps();
            $table->integer('rating');
            $table->unsignedInteger('user_id')->index();
            $table->unsignedInteger('image_id')->index();
            $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
            $table->foreign('image_id')->references('id')->on('images')->onDelete('cascade');
        });
    }

    public function down()
    {
        Schema::drop('image_user');
    }
}

Histoire d’avoir déjà des notes on crée un seeder :

Avec ce code :

<?php

use Illuminate\Database\Seeder;

class RatingsTableSeeder extends Seeder
{
    public function run()
    {
        \DB::table('image_user')->insert([
            0 => [
                'image_id' => 39,
                'user_id' => 3,
                'rating' => 1,
            ],
            1 => [
                'image_id' => 40,
                'user_id' => 3,
                'rating' => 2,
            ],
            2 => [
                'image_id' => 37,
                'user_id' => 3,
                'rating' => 2,
            ],
            3 => [
                'image_id' => 43,
                'user_id' => 3,
                'rating' => 2,
            ],
            4 => [
                'image_id' => 39,
                'user_id' => 2,
                'rating' => 5,
            ],
            5 => [
                'image_id' => 37,
                'user_id' => 2,
                'rating' => 5,
            ],
            6 => [
                'image_id' => 41,
                'user_id' => 2,
                'rating' => 3,
            ],
            7 => [
                'image_id' => 36,
                'user_id' => 2,
                'rating' => 2,
            ],
            7 => [
                'image_id' => 31,
                'user_id' => 3,
                'rating' => 3,
            ],
            8 => [
                'image_id' => 32,
                'user_id' => 3,
                'rating' => 3,
            ]
        ]);
    }
}

On complète le code de DatabaseSeeder :

public function run()
{
    ...

    $this->call(RatingsTableSeeder::class);
}

On n’a plus qu’à rafraîchir la base :

php artisan migrate:fresh --seed

Vous aurez peut-être besoin de lancer un composer dumpautoload pour charger la classe.

Les relations

Dans le modèle User on ajoute la relation :

public function imagesRated()
{
    return $this->belongsToMany (Image::class);
}

Ainsi que dans le modèle Image :

public function users()
{
    return $this->belongsToMany (User::class)->withPivot('rating');
}

Route et contrôleur

On va ajouter une route pour gérer la notation :

Route::name ('image.')->middleware ('ajax')->group (function () {
    Route::prefix('image')->group(function () {

        ...

    });
    Route::name ('rating')->put ('rating/{image}', 'ImageController@rate');
});

On va crée une méthode dans ImageController :

public function rate(Request $request, Image $image)
{
    $user = $request->user();

    // Is user image owner ?
    if($this->imageRepository->isOwner ($user, $image)) {
        return response()->json(['status' => 'no']);
    }

    // Rating
    $rate = $this->imageRepository->rateImage ($user, $image, $request->value);
    $this->imageRepository->setImageRate ($image);

    return [
        'status' => 'ok',
        'id' => $image->id,
        'value' => $image->rate,
        'count' => $image->users->count(),
        'rate' => $rate
    ];
}

On vérifie que celui qui note n’est pas le propriétaire de la photo, parce qu’il n’a pas le droit de noter ses propres photos.

Comme d’habitude on délègue au repository (ImageRepository) la gestion des données :

public function rateImage($user, $image, $value)
{
    $rate = $image->users()->where('users.id', $user->id)->pluck('rating')->first();

    if($rate) {
        if($rate !== $value) {
            $image->users ()->updateExistingPivot ($user->id, ['rating' => $value]);
        }
    } else {
        $image->users ()->attach ($user->id, ['rating' => $value]);
    }

    return $rate;
}

public function isOwner($user, $image)
{
    return $image->user()->where('users.id', $user->id)->exists();
}

On vérifie si l’utilisateur en question a déjà noté la photo, dans ce cas il faut mettre à jour sa notation, sinon il faut la créer.

On a ainsi la gestion d’une note qui arrive mais il faut aussi envoyer la note de chaque image qu’on affiche pour savoir combien d’étoiles prévoir !

Ajouter la note aux photos

Donc chaque fois qu’on envoie les informations d’une photos à partir du serveur il faut maintenant ajouter la note. Ça va se passer dans ImageRepository. On commence par créer ces 3 fonctions :

public function paginateAndRate($query)
{
    $images = $query->paginate (config ('app.pagination'));
    return $this->setRating ($images);
}

public function setRating($images)
{
    $images->transform(function ($image) {
        $this->setImageRate ($image);
        return $image;
    });
    return $images;
}

public function setImageRate($image)
{
    $number = $image->users->count();
    $image->rate = $number ? $image->users->pluck ('pivot.rating')->sum () / $number : 0;
}

Et partout où on se contentait de paginer on va ajouter la note aux images :

public function getAllImages()
{
    return $this->paginateAndRate (Image::latestWithUser());
}

public function getImagesForCategory($slug)
{
    $query = Image::latestWithUser ()->whereHas ('category', function ($query) use ($slug) {
        $query->whereSlug ($slug);
    });
    return $this->paginateAndRate ($query);
}

public function getImagesForUser($id)
{
    $query = Image::latestWithUser ()->whereHas ('user', function ($query) use ($id) {
        $query->whereId ($id);
    });
    return $this->paginateAndRate ($query);
}

public function getImagesForAlbum($slug)
{
    $query = Image::latestWithUser ()->whereHas ('albums', function ($query) use ($slug) {
        $query->whereSlug ($slug);
    });
    return $this->paginateAndRate ($query);
}

Vous n’allez encore rien remarquer à l’affichage de la galerie mais les données sont bien là :

La vue

Il ne nous reste plus qu’à gérer le côté client…

On commence par placer les étoiles dans la vue home :

<div class="star-rating" id="{{ $image->id }}">
    <span class="count-number">({{ $image->users->count() }})</span>
    <div id="{{ $image->id . '.5' }}" data-toggle="tooltip" title="5" @if($image->rate > 4) class="star-yellow" @endif>
        <i class="fas fa-star"></i>
    </div>
    <div id="{{ $image->id . '.4' }}" data-toggle="tooltip" title="4" @if($image->rate > 3) class="star-yellow" @endif>
        <i class="fas fa-star"></i>
    </div>
    <div id="{{ $image->id . '.3' }}" data-toggle="tooltip" title="3" @if($image->rate > 2) class="star-yellow" @endif>
        <i class="fas fa-star"></i>
    </div>
    <div id="{{ $image->id . '.2' }}" data-toggle="tooltip" title="2" @if($image->rate > 1) class="star-yellow" @endif>
        <i class="fas fa-star"></i>
    </div>
    <div id="{{ $image->id . '.1' }}" data-toggle="tooltip" title="1" @if($image->rate > 0) class="star-yellow" @endif>
        <i class="fas fa-star"></i>
    </div>
    <span class="pull-right">
        @adminOrOwner($image->user_id)

Bon, ce n’est pas encore très fun :

On va ajouter quelques règles CSS dans resources/sass/app.scss :

.star-rating div {
  display: inline-block;
  font-size: 15px;
  -webkit-transition: all .3s ease-in-out;
  transition: all .3s ease-in-out;
  cursor: pointer;
}
.star-yellow,
.star-rating div:hover,
.star-rating div:hover ~ div {
  color: #f2b600
}
.hover_img a { position:relative; }
.hover_img a span { position:absolute; display:none; z-index:99; }
.hover_img a:hover span { display:block; }

On relance la compilation avec npm run dev.

Ça s’est un peu arrangé :

On ajoute le Javascript pour gérer tout ça dans home :

let memoStars = []

$('.star-rating div').click((e) => {
    @auth
        let element = $(e.currentTarget)
        let values = element.attr('id').split('.')
        element.addClass('fa-spin')
        $.ajax({
            url: "{{ url('rating') }}" + '/' + values[0],
            type: 'PUT',
            data: {value: values[1]}
        })
        .done((data) => {
            if (data.status === 'ok') {
                let image = $('#' + data.id)
                memoStars = []
                image.children('div')
                    .removeClass('star-yellow')
                    .each(function (index, element) {
                        if (data.value > 4 - index) {
                            $(element).addClass('star-yellow')
                            memoStars.push(true)
                        }
                        memoStars.push(false)
                    })
                    .end()
                    .find('span.count-number')
                    .text('(' + data.count + ')')
                if(data.rate) {
                    if(data.rate == values[1]) {
                        title = '@lang("Vous avez déjà donné cette note !")'
                    } else {
                        title = '@lang("Votre vote a été modifié !")'
                    }
                } else {
                    title = '@lang("Merci pour votre vote !")'
                }
                swal({
                    title: title,
                    type: 'warning'
                })
            } else {
                swal({
                    title: '@lang('Vous ne pouvez pas voter pour vos photos !')',
                    type: 'error'
                })
            }
            element.removeClass('fa-spin')
        })
        .fail(() => {
            swallAlertServer()
            element.removeClass('fa-spin')
        })
    @else
        swal({
            title: '@lang('Vous devez être connecté pour pouvoir voter !')',
            type: 'error'
        })
    @endauth
})

$('.star-rating').hover(
    (e) => {
        memoStars = []
        $(e.currentTarget).children('div')
            .each((index, element) => {
                memoStars.push($(element).hasClass('star-yellow'))
            })
            .removeClass('star-yellow')
    }, (e) => {
    $.each(memoStars, (index, value) => {
        if(value) {
            $(e.currentTarget).children('div:eq(' + index + ')').addClass('star-yellow')
        }
    })
})

Maintenant au survol on a les étoiles qui réagissent correctement. Su un utilisateur non connecté clique il a ce message :

Si on essaie de voter pour ses photos :

Si on change son vote :

Et si on ajoute un vote :

Le chiffre entre parenthèses indique le nombre de votes et évidemment on fait la moyenne des votes pour afficher la bonne étoile.

Le nombre de vues

On va terminer ce chapitre en ajoutant le nombre de fois qu’une photo a été cliquée. On n’a pas beosin de toucher à la base parce qu’on a déjà prévu un champ clicks dans la table images.

On ajoute cette route :

Route::middleware('ajax')->name('image.click')->patch('image/{image}/click', 'ImageController@click');

On ajoute cette fonction dans ImageController :

public function click(Request $request, Image $image)
{
    if ($request->session()->has('images') && in_array ($image->id, session ('images'))) {
        return response ()->json (['increment' => false]);
    }
    $request->session()->push('images', $image->id);
    $image->increment('clicks');
    return ['increment' => true];
}

On mémorise le clic en session pour ne pas comptabiliser plusieurs fois le clic d’un même utilisateur.

Dans la vue home on ajoute la valeur à côté de la date :

<div class="pull-right">
    <em>
        (<span class="image-click">{{ $image->clicks }}</span> {{ trans_choice(__('vue|vues'), $image->clicks) }}) {{ $image->created_at->formatLocalized('%x') }}
    </em>
</div>

On gère le pluriel avec trans_choice.

On ajoute le Javascript pour gérer ça :

$('a.image-link').click((e) => {
    e.preventDefault()
    let that = $(e.currentTarget)
    $.ajax({
        method: 'patch',
        url: that.attr('data-link')
    }).done((data) => {
        if(data.increment) {
            let numberElement = that.siblings('div.card-footer').find('.image-click')
            numberElement.text(parseInt(numberElement.text()) + 1)
        }
    })
})

Pour récupérer la bonne url on ajoute une référence pour chaque image (data-link) :

@foreach($images as $image)
    <div class="card @if($image->adult) border-danger @endif" id="image{{ $image->id }}">
        <a href="{{ url('images/' . $image->name) }}" class="image-link" data-link="{{ route('image.click', $image->id) }}">

Et maintenant ça devrait fonctionner :

En résumé

Dans ce chapitre on a :

  • ajouté la notation des images
  • ajouté le nombre de vues

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




Laravel 5.7 par la pratique – L’administration

La galerie est maintenant bien avancée. Les utilisateurs peuvent gérer des albums personnels, changer leur profil, modifier toutes les caractéristiques de leurs images. Dans ce chapitre nous allons mettre en place des outils d’administrations : suppression des images orphelines, galerie en mode maintenance et gestion des utilisateurs.

Les images orphelines

Lorsqu’on supprime une image ça a pour effet de supprimer la ligne dans la table images mais les deux versions de la photo (haute et basse résolution) restent sur le disque. Ce n’est pas vraiment gênant mais ça pourrait le devenir en cas de nombreuses suppressions et puis ça serait quand même plus élégant de s’en occuper.

On pourrait ajouter cette action systématiquement quand on supprime une photo mais j’ai préféré créer une partie maintenance réservée à l’administrateur.

On va créer deux nouvelles routes :

Route::middleware ('admin')->group (function () {

    ...

    Route::name ('orphans.')->prefix('orphans')->group(function () {
        Route::name ('index')->get ('/', 'AdminController@orphans');
        Route::name ('destroy')->delete ('/', 'AdminController@destroy');
    });
});

On ajoute un item au menu de l’administration (views/layouts/app) :

@admin
<li class="nav-item dropdown">
    <a class="nav-link dropdown-toggle{{ currentRoute(
                            route('category.create'),
                            route('category.index'),
                            route('category.edit', request()->category?: 0),
                            route('orphans.index'),
                        )}}" href="#" id="navbarDropdownGestCat" role="button" data-toggle="dropdown"
        aria-haspopup="true" aria-expanded="false">
        @lang('Administration')
    </a>
    <div class="dropdown-menu" aria-labelledby="navbarDropdownGestCat">
        <a class="dropdown-item" href="{{ route('category.create') }}">
            <i class="fas fa-plus fa-lg"></i> @lang('Ajouter une catégorie')
        </a>
        <a class="dropdown-item" href="{{ route('category.index') }}">
            <i class="fas fa-wrench fa-lg"></i> @lang('Gérer les catégories')
        </a>
        <a class="dropdown-item" href="{{ route('orphans.index') }}">
            <i class="fas fa-images fa-lg"></i> @lang('Photos orphelines')
        </a>
    </div>
</li>
@endadmin

On crée un nouveau contrôleur :

php artisan make:controller AdminController --resource

Affichage des orphelines

Pour l’affichage des orphelines on crée une méthode orphans dans AdminController et on déclare le repository ImageRepository :

<?php

namespace App\Http\Controllers;

use App\Repositories\ImageRepository;
use Illuminate\Http\Request;

class AdminController extends Controller
{
    protected $repository;

    public function __construct(ImageRepository $repository)
    {
        $this->repository = $repository;
    }

    public function orphans()
    {
        $orphans = $this->repository->getOrphans ();
        $orphans->count = count($orphans);

        return view ('maintenance.orphans', compact ('orphans'));
    }
}

Et dans ImageRepository :

public function getOrphans()
{
    return collect (Storage::files ('images'))->transform(function ($item) {
        return basename($item);
    })->diff (Image::select ('name')->pluck ('name'));
}

On en profite pour voir la puissance des collections de Laravel !

On crée la vue :

Avec ce code :

@extends('layouts.app')

@section('content')

    <main class="container-fluid">
        <h1>
            {{ $orphans->count }} {{ trans_choice(__('image orpheline|images orphelines'), $orphans->count) }}
            @if($orphans->count)
                <a class="btn btn-danger pull-right" href="{{ route('orphans.destroy') }}"
                   role="button">@lang('Supprimer')</a>
            @endif
        </h1>

        <div class="card-columns">
            @foreach($orphans as $orphan)
                <div class="card">
                    <img class="img-fluid" src="{{ url('thumbs/' . $orphan) }}" alt="image">
                </div>
            @endforeach
        </div>
    </main>

@endsection

@section('script')

    @include('partials.script-delete', ['text' => __('Vraiment supprimer toutes les photos orphelines ?'), 'return' => 'reload'])

@endsection

Suppression des orphelines

On a un bouton pour la suppression :

On code AdminController :

    
    ...

public function __construct(ImageRepository $repository)
{
    $this->repository = $repository;
    $this->middleware('ajax')->only('destroy');
}

public function destroy()
{
    $this->repository->destroyOrphans ();
    return response ()->json ();
}

    ...

Et ImageRepository :

public function destroyOrphans()
{
    $orphans = $this->getOrphans ();

    foreach ($orphans as $orphan) {
        Storage::delete ([
            'images/' . $orphan,
            'thumbs/' . $orphan,
        ]);
    }
}

Si on supprime on se retrouve avec ça :

Remarquez la gestion du pluriel au niveau de la vue :

{{ trans_choice(__('image orpheline|images orphelines'), $orphans->count) }}

Le mode maintenance

Maintenant voyons comment mettre en place le code pour pouvoir mettre notre galerie en maintenance et qu’ainsi elle ne soit plus accessible que par l’administrateur en se fondant sur son adresse IP.

On va créer deux nouvelles routes :

Route::middleware ('admin')->group (function () {

    ...

    Route::name ('maintenance.')->prefix('maintenance')->group(function () {
        Route::name ('index')->get ('/', 'AdminController@edit');
        Route::name ('update')->put ('/', 'AdminController@update');
    });

});

On ajoute un item au menu de l’administration (views/layouts/app) :

@admin
<li class="nav-item dropdown">
    <a class="nav-link dropdown-toggle{{ currentRoute(
                            route('category.create'),
                            route('category.index'),
                            route('category.edit', request()->category?: 0),
                            route('orphans.index'),
                            route('maintenance.index')
                        )}}" href="#" id="navbarDropdownGestCat" role="button" data-toggle="dropdown"
        aria-haspopup="true" aria-expanded="false">
        @lang('Administration')
    </a>
    <div class="dropdown-menu" aria-labelledby="navbarDropdownGestCat">
        <a class="dropdown-item" href="{{ route('category.create') }}">
            <i class="fas fa-plus fa-lg"></i> @lang('Ajouter une catégorie')
        </a>
        <a class="dropdown-item" href="{{ route('category.index') }}">
            <i class="fas fa-wrench fa-lg"></i> @lang('Gérer les catégories')
        </a>
        <a class="dropdown-item" href="{{ route('orphans.index') }}">
            <i class="fas fa-images fa-lg"></i> @lang('Photos orphelines')
        </a>
        <a class="dropdown-item" href="{{ route('maintenance.index') }}">
            <i class="fas fa-cogs fa-lg"></i> @lang('Mode maintenance')
        </a>
    </div>
</li>
@endadmin

Affichage du formulaire

On ajoute cette méthode dans AdminController :

use Symfony\Component\HttpFoundation\IpUtils;
use Illuminate\Contracts\Foundation\Application;

    ...

public function edit(Request $request, Application $app)
{
    $maintenance = $app->isDownForMaintenance();
    $ipChecked = true;
    $ip = $request->ip();

    if($maintenance) {
        $data = json_decode(file_get_contents($app->storagePath().'/framework/down'), true);
        $ipChecked = isset($data['allowed']) && IpUtils::checkIp($ip, (array) $data['allowed']);
    }

    return view ('maintenance.maintenance', compact ('maintenance', 'ip', 'ipChecked'));
}

On crée la vue :

Avec ce code :

@extends('layouts.form')

@section('card')

    @component('components.card')

        @slot('title')
            @lang('Mode maintenance')
        @endslot

        <form method="POST" action="{{ route('maintenance.update') }}">
            @csrf
            @method('PUT')

            @component('components.checkbox', [
                    'name' => 'maintenance',
                    'label' => __('Mode maintenance'),
                    'checked' => $maintenance ? 'checked' : ''
                ])
            @endcomponent

            @component('components.checkbox', [
                    'name' => 'ip',
                    'label' => __('Autoriser mon IP ') . '(' . $ip . ')',
                    'checked' => $ipChecked ? 'checked' : ''
                ])
            @endcomponent

            @component('components.button')
                @lang('Envoyer')
            @endcomponent

        </form>

    @endcomponent            

@endsection

On ajoute un composant pour les cases à cocher :

Avec ce code :

<div class="form-group">
    <div class="custom-control custom-checkbox">
        <input type="checkbox" class="custom-control-input" id="{{ $name }}" name="{{ $name }}" @if($checked) checked @endif>
        <label class="custom-control-label" for="{{ $name }}"> {{ $label }}</label>
    </div>
</div>

Et le formulaire devrait apparaître :

Traitement du formulaire

On ajoute la méthode update dans AdminController :

use Illuminate\Support\Facades\Artisan;

    ...

public function update(Request $request)
{
    if($request->maintenance) {
        Artisan::call ('down', $request->ip ? ['--allow' => $request->ip()] : []);
    } else {
        Artisan::call ('up');
    }

    return redirect()->route('maintenance.index')->with ('ok', __ ('Le mode a bien été actualisé.'));
}

Une alerte nous informe du succès de l’opération :

Une alerte

Maintenant ce qui serait bien serait de disposer de quelque chose qui nous rappelle qu’on est en mode maintenance parce qu’on risque d’oublier !

On va créer une nouvelle commande Blade dans AppServiceProvider :

public function boot()
{
   
    ...

    Blade::if ('maintenance', function () {
        return auth ()->check () && auth ()->user ()->admin && app()->isDownForMaintenance();
    });

    ...

}

Et on ajoute une petite icône dans layouts.app :

<ul class="navbar-nav ml-auto">
    @guest
    <li class="nav-item{{ currentRoute(route('login')) }}"><a class="nav-link" href="{{ route('login') }}">@lang('Connexion')</a></li>
    <li class="nav-item{{ currentRoute(route('register')) }}"><a class="nav-link" href="{{ route('register') }}">@lang('Inscription')</a></li>
    @else
        @maintenance
            <li class="nav-item">
                <a class="nav-link" href="{{ route('maintenance.index') }}" data-toggle="tooltip" title="@lang('Mode maintenance')">
                    <span class="fas fa-exclamation-circle  fa-lg" style="color: red;">

                    </span>
                </a>
            </li>
        @endmaintenance
        <li class="nav-item{{ currentRoute(
                    route('profile.edit', auth()->id()),
                    route('profile.show', auth()->id())
                )}}">
            <a class="nav-link" href="{{ route('profile.edit', auth()->id()) }}">@lang('Profil')</a>
        </li>
        <li class="nav-item">
            <a id="logout" class="nav-link" href="{{ route('logout') }}">@lang('Déconnexion')</a>
            <form id="logout-form" action="{{ route('logout') }}" method="POST" class="hide">
                {{ csrf_field() }}
            </form>
        </li>
    @endguest
</ul>

Maintenant on risque moins d’oublier !

On en profite pour qu’un clic sur l’icône renvoie dans la page de maintenance.

La gestion des utilisateurs

Pour gérer les utilisateurs on va encore créer des routes :

Route::middleware ('admin')->group (function () {

    ...

    Route::resource ('user', 'UserController', [
        'only' => ['index', 'edit', 'update', 'destroy']
    ]);

    ...

});

On crée le contrôleur associé :

php artisan make:controller UserController --resource

Avec ce code de base :

<?php

namespace App\Http\Controllers;

use App\Repositories\UserRepository;
use App\Models\User;

class UserController extends Controller
{
    protected $repository;

    public function __construct(UserRepository $repository)
    {
        $this->repository = $repository;
    }
}

 

On crée aussi un repository :

Avec ce code de base :

<?php

namespace App\Repositories;

use App\Models\User;

class UserRepository extends BaseRepository
{
    public function __construct(User $user)
    {
        $this->model = $user;
    }
}

Le menu

On ajoute encore un item au menu (layouts.app) :

@admin
<li class="nav-item dropdown">
    <a class="nav-link dropdown-toggle{{ currentRoute(
                            route('category.create'),
                            route('category.index'),
                            route('category.edit', request()->category?: 0),
                            route('orphans.index'),
                            route('maintenance.index'),
                            route('user.index')
                        )}}" href="#" id="navbarDropdownGestCat" role="button" data-toggle="dropdown"
        aria-haspopup="true" aria-expanded="false">
        @lang('Administration')
    </a>
    <div class="dropdown-menu" aria-labelledby="navbarDropdownGestCat">
        <a class="dropdown-item" href="{{ route('category.create') }}">
            <i class="fas fa-plus fa-lg"></i> @lang('Ajouter une catégorie')
        </a>
        <a class="dropdown-item" href="{{ route('category.index') }}">
            <i class="fas fa-wrench fa-lg"></i> @lang('Gérer les catégories')
        </a>
        <a class="dropdown-item" href="{{ route('orphans.index') }}">
            <i class="fas fa-images fa-lg"></i> @lang('Photos orphelines')
        </a>
        <a class="dropdown-item" href="{{ route('maintenance.index') }}">
            <i class="fas fa-cogs fa-lg"></i> @lang('Mode maintenance')
        </a>
        <a class="dropdown-item" href="{{ route('user.index') }}">
            <i class="fas fa-users fa-lg"></i> @lang('Utilisateurs')
        </a>
    </div>
</li>
@endadmin

Afficher les utilisateurs

On va commencer par afficher une page avec la liste des utilisateurs et de leurs renseignements ainsi que des boutons pour modifier leurs données ou même les supprimer. On commence par coder UserController :

public function index()
{
    $users = $this->repository->getAllWithPhotosCount();
    return view ('users.index', compact('users'));
}

Et dans UserRepository :

public function getAllWithPhotosCount()
{
    return User::withCount('images')->oldest('name')->get();
}

On crée la vue :

Avec ce code :

@extends('layouts.form-wide')

@section('css')

    <style>
        .fa-check { color: green; }
    </style>

@endsection


@section('card')

    @component('components.card')

        @slot('title')
            @lang('Gestion des utilisateurs (administrateurs en rouge)')
        @endslot

        <div class="table-responsive">
            <table class="table table-dark text-white">
                <thead>
                    <tr>
                        <th scope="col">@lang('Nom')</th>
                        <th scope="col">@lang('Email')</th>
                        <th scope="col">@lang('Inscription')</th>
                        <th scope="col">@lang('Vérifié')</th>
                        <th scope="col">@lang('Adulte')</th>
                        <th scope="col">@lang('Photos')</th>
                        <th></th>
                        <th></th>
                    </tr>
                </thead>
                <tbody>
                @foreach($users as $user)
                    <tr @if($user->admin) style="color: red" @endif>
                        <td>{{ $user->name }}</td>
                        <td>{{ $user->email }}</td>
                        <td>{{ $user->created_at->formatLocalized('%x') }}</td>
                        <td>@if($user->email_verified_at){{ $user->email_verified_at->formatLocalized('%x') }}@endif</td>
                        <td>@if($user->adult)<i class="fas fa-check fa-lg"></i>@endif</td>
                        <td>{{ $user->images_count }}</td>
                        <td>
                            <a type="button" href="{{ route('user.edit', $user->id) }}"
                               class="btn btn-warning btn-sm pull-right mr-2 invisible" data-toggle="tooltip"
                               title="@lang("Modifier l'utilisateur") {{ $user->name }}"><i
                                        class="fas fa-edit fa-lg"></i></a>
                        </td>
                        <td>
                            @unless($user->admin)
                            <a type="button" href="{{ route('user.destroy', $user->id) }}"
                               class="btn btn-danger btn-sm pull-right invisible" data-toggle="tooltip"
                               title="@lang("Supprimer l'utilisateur") {{ $user->name }}"><i
                                        class="fas fa-trash fa-lg"></i></a>
                            @endunless
                        </td>
                    </tr>
                @endforeach
                </tbody>
            </table>
        </div>

    @endcomponent

@endsection

@section('script')

    <script>
        $(() => {
            $('a').removeClass('invisible')
        })
    </script>

    @include('partials.script-delete', ['text' => __('Vraiment supprimer cet utilisateur ?'), 'return' => 'removeTr'])

@endsection

Pour l’occasion on crée un nouveau layout pour ce formulaire plus large que les autres parce qu’on a beaucoup d’informations à y faire tenir :

Avec ce code :

@extends('layouts.app')

@section('content')
    <div class="container py-5">
        <div class="row">
            <div class="col">
                @yield('card')
            </div>
        </div>
    </div>
@endsection

Pour afficher correctement les dates on va compléter ainsi le modèle User :

protected $dates = [
    'created_at',
    'updated_at',
    'email_verified_at',
];

Ainsi email_verified_at sera lui aussi automatiquement converti en instance de Carbon.

Et enfin on a la page :

Modifier un utilisateur

On code la méthode update de UserController :

public function edit(User $user)
{
    return view ('users.edit', compact ('user'));
}

On crée la vue pour le formulaire de modification :

Avec ce code :

@extends('layouts.form')

@section('card')

    @component('components.card')

        @slot('title')
            @lang('Modifier un utilisateur')
        @endslot

        <form method="POST" action="{{ route('user.update', $user->id) }}">
            @csrf
            @method('PUT')

            @include('partials.form-group', [
                'title' => __('Nom'),
                'type' => 'text',
                'name' => 'name',
                'value' => $user->name,
                'required' => true,
                ])

            @include('partials.form-group', [
                'title' => __('Email'),
                'type' => 'email',
                'name' => 'email',
                'value' => $user->email,
                'required' => true,
                ])

            <div class="form-group">
                <div class="custom-control custom-checkbox">
                    <input type="checkbox" class="custom-control-input" id="adult" name="adult" {{ $user->settings->adult ? 'checked' : '' }}>
                    <label class="custom-control-label" for="adult"> @lang('Adulte')</label>
                </div>
            </div>

            <div class="form-group">
                <div class="custom-control custom-checkbox">
                    <input type="checkbox" class="custom-control-input" id="verified" name="verified" {{ $user->hasVerifiedEmail() ? 'checked' : '' }}>
                    <label class="custom-control-label" for="verified"> @lang('Vérifié')</label>
                </div>
            </div>

            @component('components.button')
                @lang('Envoyer')
            @endcomponent

        </form>

    @endcomponent            

@endsection

Pour la validation on crée une requête de formulaire :

hp artisan make:request UserRequest

On complète le code :

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class UserRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        return $rules = [
            'name' => 'required|string|max:255|unique:users,name,' . $this->user->id,
            'email' => 'required|string|email|max:255|unique:users,email,' . $this->user->id,
        ];
    }
}

On code la méthode update dans UserController :

use App\Http\Requests\UserRequest;

    ...

public function update(UserRequest $request, User $user)
{
    $this->repository->update ($user, $request);

    return redirect ()->route('user.index')->with ('ok', __ ("L'utilisateur a bien été modifié"));
}

Le traitement se fait dans UserRepository :

use Illuminate\Http\Request;
use Carbon\Carbon;

    ...

public function update(User $user, Request $request)
{
    if($user->hasVerifiedEmail() && !$request->verified) {
        $request->merge(['email_verified_at' => null]);
    }

    if(!$user->hasVerifiedEmail() && $request->verified) {
        $request->merge(['email_verified_at' => new Carbon]);
    }

    $user->adult = $request->adult;
    $user->update ($request->all());
}

Pour le changement du statut adulte on crée un mutateur dans le modèle User :

public function setAdultAttribute($value)
{
    $this->attributes['settings'] = json_encode ([
        'adult' => $value,
        'pagination' => $this->settings->pagination
    ]);
}

Ca clarifie bien la syntaxe !

Une petite alerte pour rassurer :

Supprimer un utilisateur

Pour terminer on va ajouter la possibilité de supprimer un utilisateur.

On code la méthode destroy dans UserController :

    ...

public function __construct(UserRepository $repository)
{
    $this->repository = $repository;

    $this->middleware('ajax')->only('destroy');
}

public function destroy(User $user)
{
    $user->delete ();

    return response ()->json ();
}

Evidemment on affiche une alerte avant modification :

Et si on dit oui alors il disparaît de la liste et de la base ainsi que toutes ses photos !

En résumé

Dans ce chapitre on a :

  • ajouté la gestion des images orphelines
  • ajouté le mode maintenance
  • mis en place la gestion des utilisateurs

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

 




Laravel 5.7 par la pratique – Les albums 2/2

Dans le précédant chapitre on a commencé à voir la gestion des albums pour notre galerie photos. On sait maintenant ajouter un album. Maintenant on va voir comment modifier et supprimer un album. On va créer deux vues : une qui liste toutes les catégories avec des boutons pour modifier et supprimer, et une pour le formulaire de modification. Enfin il faudra aussi prévoir dans la barre un menu pour ces albums !

Le menu

On va compléter le menu pour qu’on puisse accéder aux nouvelles vues. Dans dans notre layout (views/layouts/app) dans la partie concernant le menu déroulant pour les utilisateurs authentifiés on va avoir ce code :

@auth
    <li class="nav-item dropdown">
        <a class="nav-link dropdown-toggle{{ currentRoute(
                            route('album.create'),
                            route('image.create'),
                            route('album.index')
                        )}}"
        href="#" id="navbarDropdownGestAlbum" role="button" data-toggle="dropdown"
        aria-haspopup="true" aria-expanded="false">
            @lang('Gestion')
        </a>
        <div class="dropdown-menu" aria-labelledby="navbarDropdownGestAlbum">
            <a class="dropdown-item" href="{{ route('image.create') }}">
                <i class="fas fa-images fa-lg"></i> @lang('Ajouter une image')
            </a>
            <a class="dropdown-item" href="{{ route('album.create') }}">
                <i class="fas fa-folder-open fa-lg"></i> @lang('Ajouter un album')
            </a>
            <a class="dropdown-item" href="{{ route('album.index') }}">
                <i class="fas fa-wrench fa-lg"></i> @lang('Gérer les albums')
            </a>
        </div>
    </li>
@endauth

Avec ce résultat :

La liste des albums

On va créer maintenant la vue pour lister les albums et afficher des boutons de commande. On crée donc cette vue ici :

Avec ce code :

@extends('layouts.form')

@section('card')

    @component('components.card')

        @slot('title')
            @lang('Gestion des albums')
        @endslot


        <table class="table table-dark text-white">
            <tbody>
            @if($userAlbums->isEmpty())
                <p class="text-center">@lang("Vous n'avez aucun album pour le moment")</p>
            @else
                @foreach($userAlbums as $album)
                    <tr>
                        <td>{{ $album->name }}</td>
                        <td>
                            <a type="button" href="{{ route('album.destroy', $album->id) }}"
                               class="btn btn-danger btn-sm pull-right invisible" data-toggle="tooltip"
                               title="@lang("Supprimer l'album") {{ $album->name }}"><i
                                        class="fas fa-trash fa-lg"></i></a>
                            <a type="button" href="{{ route('album.edit', $album->id) }}"
                               class="btn btn-warning btn-sm pull-right mr-2 invisible" data-toggle="tooltip"
                               title="@lang("Modifier l'album") {{ $album->name }}"><i
                                        class="fas fa-edit fa-lg"></i></a>
                        </td>
                    </tr>
                @endforeach
            @endif
            </tbody>
        </table>

    @endcomponent

@endsection

@section('script')

    <script>
        $(() => {
            $('a').removeClass('invisible')
        })
    </script>

    @include('partials.script-delete', ['text' => __('Vraiment supprimer cet album ?'), 'return' => 'removeTr'])

@endsection

On a un code équivalent à celui vu pour les catégories.

Pour activer ces vues on va utiliser la fonction index du contrôleur AlbumController :

public function index(Request $request)
{
    $userAlbums = $this->repository->getAlbums ($request->user ());
    return view ('albums.index', compact('userAlbums'));
}

Il nous faut évidemment les albums de l’utilisateur connecté. On délègue cette tâche au repository AlbumRepository :

public function getAlbums($user)
{
    return $user->albums()->get();
}

Normalement en cliquant maintenant dans le menu vous devez obtenir la liste des albums (à condition évidemment d’en avoir créé !) :

Vérifiez que les popups fonctionnent :

C’est d’ailleurs tout ce qui fonctionne pour le moment !

Supprimer un album

Pour la suppression d’un album j’ai prévu une alerte pour éviter une suppression accidentelle, comme pour les catégories :

Si on clique sur Non ça se referme et rien ne se passe.

On complète le code dans le contrôleur :

public function destroy(Album $album)
{
    $this->authorize('manage', $album);
    $album->delete ();
    return response ()->json ();
}

Remarquez la liaison implicite avec le modèle au niveau du paramètre. Maintenant une suppression va être effective.

Mais il serait sans doute judicieux de prévoir un filtre pour être sûr que seules des requêtes Ajax effectuent cette action. On complète AlbumController :

public function __construct(AlbumRepository $repository)
{
    $this->repository = $repository;
    $this->middleware('ajax')->only('destroy');
}

Évidemment que pour la méthode destroy.

On autorise que le propriétaire de l’album (et els administrateurs) à faire cette action, on va créer cette autorisation :

php artisan make:policy AlbumPolicy

Avec ce code :

<?php

namespace App\Policies;

use App\Models\ { User, Album };
use Illuminate\Auth\Access\HandlesAuthorization;

class AlbumPolicy
{
    use HandlesAuthorization;

    public function before(User $user)
    {
        if ($user->admin) {
            return true;
        }
    }

    public function manage(User $user, Album $album)
    {
        return $user->id === $album->user_id;
    }
}

On déclare ça dans AuthServiceProvider :

use App\Policies\{ AlbumPolicy, ImagePolicy, UserPolicy };
use App\Models\ { Image, User, Album };

    ...

protected $policies = [
    Image::class => ImagePolicy::class,
    User::class => UserPolicy::class,
    Album::class => AlbumPolicy::class,
];

Et maintenant la suppression devrait bien se passer !

Modifier un album

On crée le formulaire pour la modification qui est pratiquement identique à celui pour la création :

Avec ce code :

@extends('layouts.form')

@section('card')

    @component('components.card')

        @slot('title')
            @lang('Modifier un album')
        @endslot

        <form method="POST" action="{{ route('album.update', $album->id) }}">
            @csrf
            @method('PUT')

            @include('partials.form-group', [
                'title' => __('Nom'),
                'type' => 'text',
                'name' => 'name',
                'value' => $album->name,
                'required' => true,
                ])

            @component('components.button')
                @lang('Envoyer')
            @endcomponent

        </form>

    @endcomponent            

@endsection

On utilise la fonction edit du contrôleur AlbumController :

public function edit(Album $album)
{
    return view ('albums.edit', compact ('album'));
}

Maintenant quand on clique sur un bouton de modification dans la liste on a bien le formulaire :

On utilise maintenant la fonction update dans le contrôleur :

public function update(AlbumRequest $request, Album $album)
{
    $this->authorize('manage', $album);
    $album->update ($request->all ());
    return redirect ()->route('album.index')->with ('ok', __ ("L'album a bien été modifié"));
}

On a dans le précédent chapitre mis en place un événement pour le slug qui sera aussi actif dans la modification, on a donc pas à nous en inquiéter ici.

Quand l’album a été modifiée on a une alerte (c’est le même code que celui qu’on a vu pour la création au niveau du layout) :

Afficher les albums

Il nous faut maintenant les afficher ces albums !

On va ajouter le menu dans la barre (layouts/app) :

@isset($albums)
    <li class="nav-item dropdown">
        <a class="nav-link dropdown-toggle
            @isset($album)
                {{ currentRoute(route('album', $album->slug))}}
            @endisset
            " href="#" id="navbarDropdownAlbum" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
            @lang('Albums')
        </a>
        <div class="dropdown-menu" aria-labelledby="navbarDropdownAlbum">
            @foreach($albums as $album)
                <a class="dropdown-item"
                href="{{ route('album', $album->slug) }}">{{ $album->name }}</a>
            @endforeach
        </div>
    </li>
@endisset
@admin

On voit qu’on attend une variable $albums. Comme on en aura besoin dans toutes les pages on va utiliser un composeur de vue dans AppServiceProvider :

use App\Repositories\ { CategoryRepository, AlbumRepository };

    ...

if (request ()->server ("SCRIPT_NAME") !== 'artisan') {

    view ()->share ('categories', resolve(CategoryRepository::class)->getAll());

    view ()->composer('layouts.app', function ($view)
    {
        if(auth()->check()) {
            $albums = resolve (AlbumRepository::class)->getByUser(auth()->id());
            if($albums->isNotEmpty()) {
                $view->with('albums', $albums);
            }
        }
    });
}

On ajoute la route pour les slugs :

Route::name ('album')->get ('album/{slug}', 'ImageController@album');

Dans ImageController on ajoute la fonction pour aller chercher le bon album et ses images :

....

use App\Repositories\ {
    ImageRepository, AlbumRepository, CategoryRepository
};

    ...

protected $albumRepository;

public function __construct(
    ImageRepository $imageRepository,
    AlbumRepository $albumRepository,
    CategoryRepository $categoryRepository)
{
    $this->imageRepository = $imageRepository;
    $this->albumRepository = $albumRepository;
    $this->categoryRepository = $categoryRepository;
}

    ...

public function album($slug)
{
    $album = $this->albumRepository->getBySlug ($slug);
    $images = $this->imageRepository->getImagesForAlbum ($slug);
    return view ('home', compact ('album', 'images'));
}

Et on ajoute le traitement dans ImageRepository :

public function getImagesForAlbum($slug)
{
    return Image::latestWithUser ()->whereHas ('albums', function ($query) use ($slug) {
        $query->whereSlug ($slug);
    })->paginate(config('app.pagination'));
}

Maintenant le menu doit fonctionner mais on n’a pas encore mis des photos dans des albums ! Pour voir si ça fonctionne vous pouvez directement renseigner la table pivot album_image.

On va juste ajouter le nom de l’album en cours dans la vue home :

@isset($album)
    <h2 class="text-title mb-3">{{ $album->name }}</h2>
@endif

On remplit les albums

Maintenant qu’on sait créer des albums et les afficher il faut pouvoir les garnir avec des photos !

On va ajouter une icône dans le menu des images dans la vue home :

<a class="albums-manage"
   href="{{ route('image.albums', $image->id) }}"
   data-toggle="tooltip"
   title="@lang('Gérer les albums')">
   <i class="fa fa-folder-open"></i>
</a>

Et pour que ça fonctionne on ajoute la route :

Route::middleware ('auth', 'verified')->group (function () {

    ...

    Route::name ('image.')->middleware ('ajax')->group (function () {
        Route::prefix('image')->group(function () {

            ...

            Route::name('albums')->get('{image}/albums', 'ImageController@albums');
        });
    });
});

Dans un premier temps on met en place le Javascript dans la vue home pour envoyer la demande des albums disponibles :

$('a.albums-manage').click((e) => {
    e.preventDefault()
    let that = $(e.currentTarget)
    that.tooltip('hide')
    that.children().removeClass('fa-folder-open').addClass('fa-cog fa-spin')
    e.preventDefault()
    $.get(that.attr('href'))
        .done((data) => {
            that.children().addClass('fa-folder-open').removeClass('fa-cog fa-spin')
            $('#listeAlbums').html(data)
            $('#manageAlbums').attr('action', that.attr('href'))
            $('#editAlbums').modal('show')
        })
        .fail(() => {
            that.children().addClass('fa-folder-open').removeClass('fa-cog fa-spin')
            swallAlertServer()
        })
})

La demande arrive dans ImageController :

public function albums(Request $request,  Image $image)
{
    $this->authorize ('manage', $image);
    $albums = $this->albumRepository->getAlbumsWithImages ($request->user ());
    return view ('images.albums', compact('albums', 'image'));
}

On traite ça dans AlbumRepository :

public function getAlbumsWithImages($user)
{
    return $user->albums()->with('images')->get();
}

On voit qu’on renvoie une vue images.albums, créons cette vue :

Avec ce code :

@foreach($albums as $album)
    <div class="form-check">
        <label class="form-check-label">
            <input 
                class="form-check-input" 
                name="albums[]" 
                value="{{ $album->id }}" 
                type="checkbox" 
                @if ($album->images->contains('id', $image->id)) checked @endif
            >
            {{ $album->name }}
        </label>
    </div>        
@endforeach

Dans la vue home on prévoit une feuille modale pour afficher la liste des albums sous forme de formulaire avec cases à cocher :

<div class="modal fade" id="editAlbums" tabindex="-1" role="dialog" aria-labelledby="albumLabel" aria-hidden="true">
    <div class="modal-dialog" role="document">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title" id="albumLabel">@lang("Gestion des albums pour l'image")</h5>
                <button type="button" class="close" data-dismiss="modal" aria-label="Close">
                    <span aria-hidden="true">&times;</span>
                </button>
            </div>
            <div class="modal-body">
                <form id="manageAlbums" action="" method="POST">
                    <div class="form-group" id="listeAlbums"></div>
                    <button type="submit" class="btn btn-primary">@lang('Envoyer')</button>
                </form>
            </div>
        </div>
    </div>
</div>

On gère la soumission en Ajax dans la vue home :

$('#manageAlbums').submit((e) => {
    e.preventDefault()
    let that = $(e.currentTarget)
    $.ajax({
        method: 'put',
        url: that.attr('action'),
        data: that.serialize()
    })
        .done((data) => {
            if(data === 'reload') {
                location.reload();
            } else {
                $('#editAlbums').modal('hide')
            }
        })
        .fail(() => {
            swallAlertServer()
        })
})

On crée la route :

Route::name ('image.')->middleware ('ajax')->group (function () {
    Route::prefix('image')->group(function () {
        Route::name ('albums.update')->put ('{image}/albums', 'ImageController@albumsUpdate');

        ...

    });
});

Ça arrive dans ImageController :

public function albumsUpdate(Request $request, Image $image)
{
    $this->authorize ('manage', $image);
    
    $image->albums()->sync($request->albums);

    $path = pathinfo (parse_url(url()->previous())['path']);

    if($path['dirname'] === '/album') {

        $album = $this->albumRepository->getBySlug ($path['basename']);

        if($this->imageRepository->isNotInAlbum ($image, $album)) {
            return response ()->json('reload');
        }
    }

    return response ()->json();
}

On a pas la même réponse selon que l’image se trouvait à l’origine dans un album affiché, auquel cas il faut recharger la page, sinon on ne change rien. Pour savoir si une image est dans un certain album on ajoute cette fonction dans ImageRepository :

public function isNotInAlbum($image, $album)
{
    return $image->albums()->where('albums.id', $album->id)->doesntExist();
}

On peut maintenant gérer complètement les albums !

Conclusion

Dans ce chapitre on a :

  • modifié le menu de la barre de navigation pour ajouter un item pour la gestion des albums
  • créé une vue pour lister les albums avec des boutons pour la modification et la suppression
  • créé le code pour la suppression des albums
  • créé la vue et le code pour la modification des albums
  • ajouté l’affichage des albums avec un menu
  • ajouté la possibilité d’affecter les images aux albums à l’aide d’une icône dans leur menu

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

 




Laravel 5.7 par la pratique – Les albums 1/2

On va poursuivre la création de notre galerie photos en nous intéressant dans ce chapitre aux albums. On a vu qu’on organise les photos de la galerie avec des catégories et seul un administrateur peut créer, modifier ou supprimer une catégorie.

Maintenant on va permettre à chaque utilisateur inscrit d’organiser ses photos dans des albums personnels.

On va voir dans ce chapitre comment on crée un album et forcément le code est très proche de celui utilisé pour les catégories. Mais on va devoir ajouter 2 tables à la base de données et installer quelque relations. Il faudra également compléter le menu de la barre de navigation, créer un formulaire, les routes, le contrôleur, un événement…

La base de données

Il nous faut compléter notre base de données pour intégrer les albums. Une table pour les informations des albums : nom, slug, dates. D’autre part un album sera relié à un utilisateur, il faudra donc établir une relation avec User de type One To Many.

On doit aussi établir une relation entre les albums et les images : un album contient plusieurs images et une image peut être dans plusieurs albums, on aura donc une relation Many To Many.

Les migrations

On va ajouter une migration pour la table albums :

php artisan make:migration create_albums_table --create=albums

Changez ainsi le code :

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateAlbumsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('albums', function (Blueprint $table) {
            $table->increments('id');
            $table->string('name')->unique();
            $table->string('slug')->unique();
            $table->integer('user_id')->unsigned();
            $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('albums');
    }
}

On ajoute aussi une migration pour la table pivot entre les albums et les images :

php artisan make:migration create_album_image_table

Avec ce code :

<?php

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateAlbumImageTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('album_image', function (Blueprint $table) {
            $table->increments('id');
            $table->unsignedInteger('album_id');
            $table->unsignedInteger('image_id');
            $table->foreign('album_id')->references('id')->on('albums')->onDelete('cascade');
            $table->foreign('image_id')->references('id')->on('images')->onDelete('cascade');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('album_image');
    }
}

On peut maintenant rafraîchir la base :

php artisan migrate:fresh --seed

On se retrouve avec cette organisation :

Les modèles

Dans le modèle Image on ajoute la relation :

public function albums()
{
    return $this->belongsToMany (Album::class);
}

Dans le modèle User on ajoute la relation :

public function albums()
{
    return $this->hasMany (Album::class);
}

On crée un modèle Album :

Avec ce code :

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use App\Events\NameSaving;

class Album extends Model
{
    protected $fillable = [
        'name', 'slug',
    ];

    protected $dispatchesEvents = [
        'saving' => NameSaving::class,
    ];

    public function images()
    {
        return $this->belongsToMany (Image::class);
    }

    public function user()
    {
        return $this->belongsTo (User::class);
    }
}

On a mis en place les deux relations. D’autre part on a prévu le déclenchement de l’événement NameSaving pour la création du slug comme on l’avait fait pour les catégories.

Le contrôleur

Pour gérer les albums on va créer un contrôleur :

php artisan make:controller AlbumController --resource

Le fait d’utiliser l’option –resource a généré les 7 méthodes de base. On va toutes les conserver sauf show.

Les routes

Pour les routes on va ajouter ça :

Route::middleware ('auth', 'verified')->group (function () {
    Route::resource ('album', 'AlbumController', [
        'except' => 'show'
    ]);

    ...

});

On vérifie :

php artisan route:list

On a bien nos 6 routes pour notre contrôleur.

Le menu

Pour accéder au formulaire de création d’un album on va devoir compléter la barre de navigation dans views/layouts/app :

@auth
    <li class="nav-item dropdown">
        <a class="nav-link dropdown-toggle{{ currentRoute(
                            route('album.create'),
                            route('image.create')
                        )}}"
        href="#" id="navbarDropdownGestAlbum" role="button" data-toggle="dropdown"
        aria-haspopup="true" aria-expanded="false">
            @lang('Gestion')
        </a>
        <div class="dropdown-menu" aria-labelledby="navbarDropdownGestAlbum">
            <a class="dropdown-item" href="{{ route('image.create') }}">
                <i class="fas fa-images fa-lg"></i> @lang('Ajouter une image')
            </a>
            <a class="dropdown-item" href="{{ route('album.create') }}">
                <i class="fas fa-folder-open fa-lg"></i> @lang('Ajouter un album')
            </a>
        </div>
    </li>
@endauth

Si on a affaire à un utilisateur authentifié (@auth) on crée le menu déroulant. On l’avait créé pour les catégories, là on ajoute l’album :

La vue de création

On crée un dossier pour les albums et une vue pour la création :

Pour cette vue on utilise l’intendance qu’on a précédemment mise en place :

@extends('layouts.form')

@section('card')

    @component('components.card')

        @slot('title')
            @lang('Ajouter un album')
        @endslot

        <form method="POST" action="{{ route('album.store') }}">
            @csrf

            @include('partials.form-group', [
                'title' => __('Nom'),
                'type' => 'text',
                'name' => 'name',
                'required' => true,
                ])

            @component('components.button')
                @lang('Envoyer')
            @endcomponent

        </form>

    @endcomponent

@endsection

L’affichage du formulaire

Il nous faut maintenant coder la gestion de tout ça dans le contrôleur CategoryController.

Déjà il faut afficher le formulaire :

public function create()
{
    return view('albums.create');
}

On vérifie avec le menu que ça marche (il faut se connecter comme administrateur) :

La validation

Pour la validation on crée une requête de formulaire :

php artisan make:request AlbumRequest

Et on change ainsi le code :

<?php

namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;

class AlbumRequest extends FormRequest
{
    public function authorize()
    {
        return true;
    }

    public function rules()
    {
        $id = $this->album ? ',' . $this->album->id : '';

        return $rules = [
            'name' => 'required|string|max:255|unique:albums,name' . $id,
        ];
    }
}

C’est déjà préparé pour gérer la validation de la modification. Dans ce cas on sait qu’il faut arranger un peu la règle d’unicité.

La soumission

A la soumission on arrive dans la méthode store du contrôleur. On va la coder ainsi :

<?php

namespace App\Http\Controllers;

use App\Models\Album;
use App\Http\Requests\AlbumRequest;
use App\Repositories\AlbumRepository;
use Illuminate\Http\Request;

class AlbumController extends Controller
{
    protected $repository;

    public function __construct(AlbumRepository $repository)
    {
        $this->repository = $repository;
    }

    public function store(AlbumRequest $request)
    {
        $this->repository->create ($request->user(), $request->all ());

        return back()->with ('ok', __ ("L'album a bien été enregistré"));
    }
}

Un repository

On voit qu’on passe par un repository histoire de bien organiser le code. Créons ce repository :

Avec ce code :

<?php

namespace App\Repositories;

use App\Models\Album;

class AlbumRepository extends BaseRepository
{
    public function __construct(Album $album)
    {
        $this->model = $album;
    }

    public function create($user, array $inputs)
    {
        $user->albums ()->create($inputs);
    }
}

On voit qu’on étend encore le repository de base parce qu’on va avoir des méthodes communes à tous nos repositories.

Maintenant on doit pouvoir ajouter un album :

On verra dans le prochain chapitre la gestion des albums : modification et supression. On ajoutera aussi une icône sur les images pour gérer directement leur affectation aux albums.

Conclusion

Dans ce chapitre on a :

  • créé les migrations pour les albums
  • créé un modèle pour les albums
  • créé les relations entre les modèles
  • créé routes, contrôleur, repository et requête de formulaire pour la création d’un album
  • complété la barre de navigation

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