Laravel

Un framework qui rend heureux

Voir cette catégorie
Vers le bas
Voir cette série
Mon CMS - Les médias (partie 1)
Lundi 7 octobre 2024 15:58

Notre CMS est déjà bien avancé et nous avons codé l'essentiel. Nous allons à présent nous intéresser aux médias. Si un CMS doit savoir gérer du texte, il doit aussi être à l'aise avec les médias, en particulier les images. On a déjà vu comment ajouter une image mise en avant pour les articles. On peut également ajouter des images dans un article facilement grâce à TinyMCE. Pour mémoire, toutes ces images sont placées dans des dossiers organisés en années et en mois. On pourrait se contenter de cette situation, mais on va aller plus loin en permettant de gérer ces images enregistrées.

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

Un composant pour gérer les images

On a à nouveau besoin d'un composant Volt pour gérer les images :

php artisan make:volt admin/images/index --class

On va ajouter la route pour l'atteindre (réservée aux administrateurs) :

Route::middleware('auth')->group(function () {
	...
	Route::middleware(IsAdminOrRedac::class)->prefix('admin')->group(function () {
		...
		Route::middleware(IsAdmin::class)->group(function () {
			...
			Volt::route('/images/index', 'admin.images.index')->name('images.index');

On ajoute un item dans la barre latérale (admin.sidebar) :

@if (Auth::user()->isAdmin())
    ...
    <x-menu-item icon="c-photo" title="{{ __('Images') }}" link="{{ route('images.index') }}" />
@endif

On peut désormais atteindre le nouveau composant, il ne nous reste plus qu'à le compléter.

La liste des images

Voilà le code complet du composant images.index, je vais commenter plus loin les points essentiels :

<?php

use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\{Layout, Title};
use Livewire\Volt\Component;
use Livewire\WithPagination;
use Mary\Traits\Toast;

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

	public array $allImages = [];
	public Collection $years;
	public Collection $months;
	public string $selectedYear;
	public string $selectedMonth;
	public int $perPage = 10;
	public int $page    = 1;

	// Définir les en-têtes de table.
	public function headers(): array
	{
		return [['key' => 'url', 'label' => ''], 
		['key' => 'path', 'label' => __('Path')]];
	}

	public function mount(): void
	{
		$this->years  = $this->getYears();
		$this->months = $this->getMonths($this->selectedYear);
		$this->getImages();
	}

	public function updating($property, $value): void
	{
		if ('selectedYear' == $property) {
			$this->months = $this->getMonths($value);
		}
	}

	public function getImages(): LengthAwarePaginator
	{
		$imagesPath = "public/photos/{$this->selectedYear}/{$this->selectedMonth}";
		$allFiles   = Storage::files($imagesPath);

		$this->allImages = collect($allFiles)
			->map(function ($file) {
				return [
					'path'  => $file,
					'url'   => Storage::url($file),
				];
			})
			->toArray();

		$this->page = LengthAwarePaginator::resolveCurrentPage('page');
		$total      = count($this->allImages);
		$images     = array_slice($this->allImages, ($this->page - 1) * $this->perPage, $this->perPage, true);

		return new LengthAwarePaginator($images, $total, $this->perPage, $this->page, [
			'path'     => LengthAwarePaginator::resolveCurrentPath(),
			'pageName' => 'page',
		]);
	}

	public function deleteImage($index): void
	{
		$path = $this->allImages[$index]['path'];
		Storage::delete($path);
		$this->success(__('Image deleted with success.'));
		$this->getImages();
	}

	public function with(): array
	{
		return [
			'headers' => $this->headers(),
			'images'  => $this->getImages(),
		];
	}

    private function getYears(): Collection
    {
        return $this->getDirectories('public/photos', function ($years) {
            $this->selectedYear = $years->first()['id'] ?? null;
            return $years;
        });
    }

    private function getMonths($year): Collection
    {
        return $this->getDirectories("public/photos/{$year}", function ($months) {
            $this->selectedMonth = $months->first()['id'] ?? null;
            $this->getImages();
            return $months;
        });
    }

    private function getDirectories(string $basePath, Closure $callback): Collection
    {
        $directories = Storage::directories($basePath);

        $items = collect($directories)->map(function ($path) {
            $name = basename($path);
            return ['id' => $name, 'name' => $name];
        })->sortByDesc('id');

        return $callback($items);
    }

}; ?>

<div>
    <x-header title="{{ __('Images') }}" separator progress-indicator>
        <x-slot:actions class="lg:hidden">
            <x-button icon="s-building-office-2" label="{{ __('Dashboard') }}" class="btn-outline"
                link="{{ route('admin') }}" />
        </x-slot:actions>
    </x-header>

    <x-card title="{!! __('Select year and month') !!}" class="shadow-md">
        <x-select label="{{ __('Year') }}" :options="$years" wire:model="selectedYear" wire:change="$refresh" />
        <br>
        <x-select label="{{ __('Month') }}" :options="$months" wire:model="selectedMonth" wire:change="$refresh" />
    </x-card>

    <x-card>
        <x-table striped :headers="$headers" :rows="$images" with-pagination>
            @scope('cell_url', $image)
                <img src="{{ $image['url'] }}" width="100" alt="">
            @endscope
            @scope('actions', $image, $selectedYear, $selectedMonth, $perPage, $page, $loop)
                <div class="flex gap-2">
                    <x-popover>
                        <x-slot:trigger>
                            <x-button icon="s-briefcase" data-url="{{ $image['url'] }}" onclick="copyUrl(this)"
                                class="text-blue-500 btn-ghost btn-sm" spinner />
                        </x-slot:trigger>
                        <x-slot:content class="pop-small">
                            @lang('Copy url')
                        </x-slot:content>
                    </x-popover>
                    <x-popover>
                        <x-slot:trigger>
                            <x-button icon="c-wrench"
                                link="#"
                                class="text-blue-500 btn-ghost btn-sm" spinner />
                        </x-slot:trigger>
                        <x-slot:content class="pop-small">
                            @lang('Manage image')
                        </x-slot:content>
                    </x-popover>
                    <x-popover>
                        <x-slot:trigger>
                            <x-button icon="o-trash" wire:click="deleteImage({{ $loop->index }})"
                                wire:confirm="{{ __('Are you sure to delete this image?') }}" spinner
                                class="text-red-500 btn-ghost btn-sm" />
                        </x-slot:trigger>
                        <x-slot:content class="pop-small">
                            @lang('Delete image')
                        </x-slot:content>
                    </x-popover>
                </div>
            @endscope
        </x-table>
    </x-card>
    <script>
        function copyUrl(button) {
            const url = button.dataset.url;
            const textArea = document.createElement('textarea');
            textArea.value = url;
            document.body.appendChild(textArea);
            textArea.select();
            try {
                document.execCommand('copy');
                alert('URL copiée: ' + url);
            } catch (err) {
                console.error('Erreur lors de la copie de l\'URL: ', err);
            }
            document.body.removeChild(textArea);
        }
    </script>

</div>

On a encore besoin de traductions :

"Select year and month": "Sélectionner l'année et le mois",
"Year": "Année",
"Month": "Mois",
"Path": "Chemin",
"Copy url": "Copier l'URL",
"Manage image": "Gérer l'image",
"Delete image": "Supprimer l'image",
"Image deleted with success.": "Image supprimée avec succès."

Voici l'aspect de cette liste :

On choisit les images pour une année et un mois pour éviter d'en charger trop à la fois. Le résultat est paginé si on dépasse la valeur de la variable $perPage qui est ici fixée à 10.

Pour chaque image, on a :

  • une vue miniature
  • le chemin
  • la possibilité de copier l'url
  • la possibilité de la modifier (pas encore codée)
  • la possibilité de la supprimer

Les années et les mois

Il nous faut connaître les années et les mois pour lesquels on a des images stockées. Il faut donc explorer les dossiers existants pour le déterminer. Ces trois fonctions sont destinées à cette gestion :

private function getYears(): Collection
{
	return $this->getDirectories('public/photos', function ($years) {
		$this->selectedYear = $years->first()['id'] ?? null;
		return $years;
	});
}

private function getMonths($year): Collection
{
	return $this->getDirectories("public/photos/{$year}", function ($months) {
		$this->selectedMonth = $months->first()['id'] ?? null;
		$this->getImages();
		return $months;
	});
}

private function getDirectories(string $basePath, Closure $callback): Collection
{
	$directories = Storage::directories($basePath);

	$items = collect($directories)->map(function ($path) {
		$name = basename($path);
		return ['id' => $name, 'name' => $name];
	})->sortByDesc('id');

	return $callback($items);
}

La fonction "getDirectories"

  • Cette fonction prend deux paramètres :
    • $basePath : le chemin du dossier à explorer
    • $callback : une fonction à exécuter sur les résultats
  • Storage::directories($basePath) récupère tous les dossiers dans le chemin spécifié.
  • collect($directories) transforme la liste des dossiers en une Collection Laravel.
  • map(function ($path) { ... }) parcourt chaque chemin de dossier et le transforme :
    • basename($path) extrait le nom du dossier du chemin complet
    • on crée un tableau avec 'id' et 'name' tous deux égaux au nom du dossier
  • sortByDesc('id') trie les résultats par ordre décroissant.
  • return $callback($items); exécute la fonction callback fournie et retourne son résultat.

      La fonction getYears

      Cette fonction appelle getDirectories avec le chemin 'public/photos'. Elle passe une fonction anonyme (callback) qui :

      • définit $this->selectedYear comme l'ID du premier élément (ou null si vide)
      • retourne la collection des années

      La fonction getMonths

      Similaire à getYears, mais utilise un chemin qui inclut l'année : "public/photos/{$year}". Le callback :

      • définit $this->selectedMonth comme l'ID du premier mois (ou null si vide)
      • appelle $this->getImages() (pour charger les images du mois sélectionné)
      • retourne la collection des mois

      La récupération des images

      Lorsqu'on a sélectionné une année et un mois, il faut récupérer les images concernées et les paginer. C'est la fonction getImages qui est chargée de ce travail.

      En premier, on crée le chemin vers le dossier contenant les images, basé sur l'année et le mois sélectionnés :

      $imagesPath = "public/photos/{$this->selectedYear}/{$this->selectedMonth}";

      On utilise la façade Storage pour obtenir tous les fichiers dans le dossier spécifié :

      $allFiles = Storage::files($imagesPath);

      Ensuite, on traite les fichiers :

      $this->allImages = collect($allFiles)
          ->map(function ($file) {
              return [
                  'path'  => $file,
                  'url'   => Storage::url($file),
              ];
          })
          ->toArray();
      • on convertit la liste des fichiers en une collection.
      • on transforme chaque fichier en un tableau avec 'path' (chemin du fichier) et 'url' (URL publique du fichier).
      • on convertit le résultat en tableau et le stocke dans $this->allImages.

      On détermine la page courante :

      $this->page = LengthAwarePaginator::resolveCurrentPage('page');

      On compte le nombre total d'images :

      $total = count($this->allImages);

      On sélectionne des images pour la page courante :

      $images = array_slice($this->allImages, ($this->page - 1) * $this->perPage, $this->perPage, true);

      Enfin on retourne un paginateur :

      return new LengthAwarePaginator($images, $total, $this->perPage, $this->page, [
          'path'     => LengthAwarePaginator::resolveCurrentPath(),
          'pageName' => 'page',
      ]);

      Ça crée un nouveau LengthAwarePaginator avec :

      • les images de la page courante
      • le nombre total d'images
      • le nombre d'images par page
      • le numéro de la page courante
      • des options supplémentaires (chemin et nom du paramètre de page)

      La copie de l'url

      On a prévu un bouton pour copier l'URL d'une image. On ne peut pas réaliser cette action avec le PHP et il faut ajouter une fonction Javascript. J'ai opté pour un code simple qui marchera à coup sûr.

      On a valeur de l'attribut data-url du bouton. dataset est une propriété qui donne accès à tous les attributs data-* d'un élément HTML :

      const url = button.dataset.url;

      On crée un nouvel élément <textarea> dans le DOM. Ce textarea sera utilisé comme un conteneur temporaire pour l'URL à copier :

      textArea.value = url;
      

      On ajoute le textarea nouvellement créé au corps (body) du document HTML. Cela est nécessaire pour que le texte puisse être sélectionné :

      document.body.appendChild(textArea);
      

      On sélectionne tout le texte dans le textarea :

      textArea.select();

      Ensuite, on tente la copie avec la méthode document.execCommand('copy') :

      try {
          document.execCommand('copy');
          alert('URL copiée: ' + url);
      } catch (err) {
          console.error('Erreur lors de la copie de l\'URL: ', err);
      }
      

      Enfin, on supprime le textarea temporaire du document, nettoyant ainsi le DOM :

       document.body.removeChild(textArea);
      

      Pour réaliser cette copie, on pourrait aussi utiliser l'API Clipboard.

      Conclusion

      Nous disposons maintenant dans l'administration d'un outil efficace pour visualiser les images enregistrées par année et par mois. On peut aussi récupérer facilement leur URL et aussi les supprimer. La prochaine étape consistera à créer un outil pour modifier les caractéristiques d'une image.

      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 - Les menus (partie 2)
      Article suivant : Mon CMS - Les médias (partie 2)