Xây dựng website bán hàng bằng Laravel - Tạo và quản lý thư viện Media cho Sản phẩm

TQH 2023-08-30 17:11:59

Hướng dẫn xây dựng tính năng quản lý thư viện Ảnh/Video cho Sản phẩm với Livewire và Spatie Media Library.

E-commerce với Laravel là loạt bài ghi lại toàn bộ quá trình xây dựng hệ thống thương mại điện tử được thực hiện bởi Transmoni team nhắm hướng dẫn mọi người làm quen với Laravel, Livewire, Alpine.js và Tailwind CSS. Hiện dự án đang được cập nhật liên tục, toàn bộ mã nguồn của dự án sẽ được công khai miễn phí theo hình thức mã nguồn mở trên trang Github của Transmoni sau khi hoàn tất loạt bài hướng dẫn này.

Hình ảnh hay video clip của sản phẩm là hình thức để mô tả một cách trực quan về sản phẩm tới khách hàng. Để có thể đăng tải và liên kết ảnh/video (gọi tắt là media) cho từng sản phẩm chúng ta sẽ cùng xây dựng một tính năng để thực hiện điều này.

Livewire cung cấp cho chúng ta một trait WithFileUploads phục vụ cho việc upload file hết sức đơn giản và trơn tru. Với Trait này sau khi file input của chúng ta được cập nhật (thêm file) thì phần javascript của Livewire sẽ tự động thực hiện upload các files này lên hệ thống dưới một thư mục tạm trong storage là livewire-tmp. Sau khi các files đã được upload thành công vào thư mục tạm này bạn có thể tiến hành xử lý validate hoặc di chuyển đến các thư mục chỉ định. Bạn có thể tìm hiểu thêm về phần upload của Livewire tại https://laravel-livewire.com/docs/2.x/file-uploads.

Để tiến hành liên kết các file đã được upload bằng Livewire với product Model chúng ta sẽ cần sử dụng đến package Media Library được phát hành bởi Spatie. Với gói mở rộng này chúng ta có thể thực hiện các thao tác quản lý như thay đổi tên, tạo các phiên bản kích cỡ khác nhau của file ảnh, tạo thumbnail cho file PDF hoặc file Video...và rất nhiều các tính năng khác nữa. Bạn có thể tìm hiểu thêm về package này tại https://spatie.be/docs/laravel-medialibrary/v10/introduction.

Trước hết chúng ta sẽ bắt tay vào việc xây dựng tính năng này bằng việc cài đặt package Media Library với composer:

composer require "spatie/laravel-medialibrary:^10.0.0"

tiếp đến publish file migration của Media Library:

php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="migrations"

và chạy lệnh khởi tạo bảng trên cơ sở dữ liệu:

php artisan migrate

tại Product model lúc này chúng ta tiến hành khai báo việc sử dụng Media Library như sau:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;

class Product extends Model implements HasMedia
{
    use HasFactory;
    use InteractsWithMedia;
}

Vậy là xong phần cài đặt cho package Media Library, bạn có thể tìm hiểu thêm về quy trình cài đặt cũng như cách thức cấu hình cho package này tại https://spatie.be/docs/laravel-medialibrary/v10/installation-setup.

Sau đó hãy tạo một Livewire component mới với tên gọi là ProductMediaManager với lệnh livewire:make:

php artisan livewire:make Admin\\ProductMediaManager

Tiếp đến như thường lệ chúng ta tiến hành khai báo biến công khai $product:

<?php

namespace App\Http\Livewire\Admin;

use App\Models\Product;
use Livewire\Component;
use Livewire\WithFileUploads;

class ProductMediaManager extends Component
{
    use WithFileUploads;

    public Product $product;
    public array $media = [];

    public function render()
    {
        return view('livewire.admin.product-media-manager');
    }
}

Trên đây tôi khai báo thêm một biến khác với định dạng mảng là $media vì chúng ta sẽ cho phép upload nhiều file cùng một lúc, và đừng quên sử dụng trait WithFileUploads của Livewire nhé!

Tiếp đến chúng từ phần view của ProductManager component chúng ta nhúng view của ProductMediaManager ngay phía dưới của ProductInformationForm như sau:

<div class="grid grid-cols-3 gap-6">
        <div class="col-span-3 xl:col-span-2">
            <livewire:admin.product-information-form :product="$product" />

            <livewire:admin.product-media-manager :product="$product" />
        </div>

        <div class="col-span-3 xl:col-span-1">
            
        </div>
    </div>

Và chúng ta tiến hành xây dựng cho phần view của ProductMediaManager với một file input:

<div>
    <form wire:submit.prevent="save">
        <x-card class="-mx-4 mt-5 sm:-mx-0">
            <x-slot name="header">
                <div class="ml-4 mt-2">
                    <h3 class="text-lg leading-6 font-medium text-gray-900">Media</h3>
                </div>
            </x-slot>
            <x-slot name="content">
                <label for="mediaUpload" class="relative flex items-center justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-gray-400 hover:bg-gray-50 cursor-pointer transition group">
                    <div class="space-y-1 text-center">
                        <svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
                            <path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
                        </svg>
                        <div class="flex text-sm text-gray-600">
                            <span class="font-medium text-indigo-600 group-hover:underline">Upload</span>
                            <x-input wire:model="media" type="file" id="mediaUpload" class="sr-only" multiple />
                        </div>
                    </div>
                </label>
            </x-slot>
            <x-slot name="footer">
                <div class="flex items-center justify-end">
                    <x-action-message on="saved" class="mr-2" />
                    <x-primary-button wire:loading.attr="disabled">
                        {{ __('Save') }}
                    </x-primary-button>
                </div>
            </x-slot>
        </x-card>
    </form>
</div>

Ok! hãy ngó thử phần chỉnh sửa của một sản phẩm bất kỳ xem thế nào
 

Tạm ổn rồi nhỉ, ngay lúc này nếu bấm vào khung upload bạn đã có thể tải file lên được rồi nhé, chỉ cần chọn file và Livewire sẽ lo phần còn lại. Tuy nhiên các file lúc này mới chỉ được lưu trữ ở thư mục tạm của Livewire mà thôi, chưa có bất kỳ liên kết nào với sản phẩm này của bạn cả. Bạn có thể thử upload vài file, sau đó truy cập đến thư mục storage/app/livewire-tmp sẽ thấy các file được tải lên đang nằm ngay ngắn trong đó.

Chúng ta sẽ cần đến một phương thức để xử lý việc liên kết và di chuyển vào thư mục riêng của các file đã tải lên tại ProductMediaManager như sau:

class ProductMediaManager extends Component
{
    use WithFileUploads;

    ...

    public function save()
    {
        $this->validate([
            'media.*' => 'file|image',
        ]);
        collect($this->media)->each(
            fn($medium) => $this->product
                ->addMedia($medium->getRealPath())
                ->setName($medium->getClientOriginalName())
                ->setFileName($medium->getClientOriginalName())
                ->toMediaCollection('media')
        );
    }
}

Với phương thức này, sau khi upload file và nhấn save chúng ta mới bắt đầu tiến hành kiểm tra, như trên đây tôi đặt yêu cầu cho các file tải lên phải là định dạng hình ảnh. Tiếp đến chúng ta sẽ lần lượt thực hiện liên kết các file này với sản phẩm thông qua phương thức addMedia() và gom vào bộ sưu tập có tên là media. Vì các file sau khi được tải lên với Livewire tên của chúng sẽ được mã hóa do đó chúng ta cũng cần đến 2 phương thức khác là setName() và setFileName() để cập nhật lại tên.

Hãy thử upload lại xem sao nhé! Có thấy thiếu gì không? Đúng rồi, chưa cho hiển thị các file đã upload, hãy cùng làm tiếp tại phần view của ProductMediaManager component như sau:

<div class="grid grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-fr">
    @foreach($product->media as $medium)
        <div @class(['relative overflow-hidden border border-gray-300 group rounded-md flex items-center justify-center', 'col-start-1 col-span-2 row-span-2'=> $loop->first])>
            <img src="{{ $medium->getUrl() }}" alt="{{ $medium->name }}" class="group-hover:scale-125 transition">
        </div>
    @endforeach
</div>

Với đoạn code trên tôi sử dụng grid để chia làm 4 cột trong đó file đầu tiên sẽ có chiếm 2 cột ngang và 2 cột dọc (2x2), các file còn lại sẽ lần lượt sử dụng 1x1. Hãy đặt đoạn code này ngay phía trước của nút bấm upload và reload lại trang của chúng ta xem thế nào nhé!
 

Vậy là xong phẩn upload, hãy tiếp tục đến phần xóa file thôi nhỉ! Tại ProductMediaManager component chúng ta tiếp tục khai báo một biến công khai khác theo định dạng mảng là $selected để nhận thông tin các file đã chọn và một phương thức delete() để thực hiện thao tác xóa bỏ chúng.

class ProductMediaManager extends Component
{
    use WithFileUploads;

    public array $selected = [];

    public function delete()
    {
        $media = $this->product->media()->whereIn('id', $this->selected)->get();
        $media->each(fn ($medium) => $medium->delete());
        $this->confirmingMediaDeletion = false;
        $this->reset('selected');
        $this->emitSelf('refresh');
    }
}

TIếp theo tại phần view của component này chúng ta thêm một checkbox tương ứng với mỗi file như sau:

<div class="grid grid-cols-3 lg:grid-cols-4 gap-4 auto-rows-fr">
    @foreach($product->media as $medium)
        <div @class(['relative overflow-hidden border border-gray-300 group rounded-md flex items-center justify-center', 'col-start-1 col-span-2 row-span-2'=> $loop->first])>
            <img src="{{ $medium->getUrl() }}" alt="{{ $medium->name }}" class="group-hover:scale-125 transition">
            <x-input wire:model="selected" type="checkbox" class="absolute top-2 left-2 rounded" x-bind:class="{ 'opacity-0 group-hover:opacity-100': !selected.length }" value="{{ $medium->id }}" />
        </div>
    @endforeach
</div>

Và ở phần header của component chúng ta thêm một nút bấm để gọi đến thao tác delete()

<div class="ml-4 mt-2 flex-shrink-0">
    <button type="button" x-show="selected.length" x-cloak wire:click="delete" class="sm:text-sm text-red-600">
        Delete
    </button>
</div>

Và hãy reload lại để xem thành quả
 

Kết luận

Vậy là chúng ta đã hoàn tất việc xây dựng tính năng quản lý thư viện media cho sản phẩm. Trên đây chúng ta mới chỉ cho phép tải lên các file dưới dạng hình ảnh, bạn có thể cấu hình lại một chút trong phần validate để có thể tải lên các file có dạng video nhé! Nếu gặp khó khăn hãy để lại comment ở phía dưới để được hỗ trợ, hẹn gặp lại các bạn ở bài sau với tính năng quản lý thuộc tính sản phẩm và các biến thể!