diff --git a/app/Livewire/Forms/OutletForm.php b/app/Livewire/Forms/OutletForm.php index 2ea2e90..d7ed0b9 100644 --- a/app/Livewire/Forms/OutletForm.php +++ b/app/Livewire/Forms/OutletForm.php @@ -5,11 +5,15 @@ use App\Enums\OutletStatus; use App\Models\Outlet; use App\Rules\PhoneNumber; +use App\Traits\WithMediaHandler; +use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Livewire\Form; class OutletForm extends Form { + use WithMediaHandler; + public ?Outlet $outlet; public string $name = ''; @@ -38,6 +42,10 @@ class OutletForm extends Form public ?string $status = null; + public array $featured_image = []; + + public array $images = []; + public function rules(): array { $days = ['senin', 'selasa', 'rabu', 'kamis', 'jumat', 'sabtu', 'minggu']; @@ -57,6 +65,8 @@ public function rules(): array 'facilities.*' => ['required', 'string', 'max:20'], 'opened_date' => ['required', 'date'], 'status' => ['required', Rule::in(OutletStatus::cases())], + 'featured_image' => ['required', 'array', 'max:1'], + 'images' => ['required', 'array', 'max:5'], ]; foreach ($days as $day) { @@ -79,6 +89,8 @@ public function validationAttributes(): array 'opening_hours.*' => 'jam beroperasi', 'facilities.*' => 'fasilitas', 'opened_date' => 'tanggal buka', + 'featured_image' => 'gambar utama', + 'images' => 'gambar', ]; } @@ -95,6 +107,9 @@ public function setOutlet(Outlet $outlet) $this->facilities = $outlet->facilities; $this->opened_date = $outlet->opened_date; $this->status = $outlet->status->value; + + $this->featured_image = $this->mapMediaCollection($outlet->getMedia('featured_image')); + $this->images = $this->mapMediaCollection($outlet->getMedia('images')); } public function store() @@ -109,7 +124,12 @@ public function store() $data['closed_date'] = null; } - Outlet::create($data); + DB::transaction(function () use ($data) { + $outlet = Outlet::create($data); + + $this->uploadMedia($this->featured_image, $outlet, 'featured_image'); + $this->uploadMedia($this->images, $outlet, 'images'); + }); } public function update() @@ -118,12 +138,20 @@ public function update() $data = $this->all(); + $this->syncMedia($data['featured_image'], $this->outlet, 'featured_image'); + $this->syncMedia($data['images'], $this->outlet, 'images'); + if ($data['status'] == OutletStatus::TERMINATED->value) { $data['closed_date'] = now()->toDateString(); } else { $data['closed_date'] = null; } - $this->outlet->update($data); + DB::transaction(function () use ($data) { + $this->outlet->update($data); + + $this->uploadMedia($this->featured_image, $this->outlet, 'featured_image'); + $this->uploadMedia($this->images, $this->outlet, 'images'); + }); } } diff --git a/app/Models/Outlet.php b/app/Models/Outlet.php index b698a9c..3abf158 100644 --- a/app/Models/Outlet.php +++ b/app/Models/Outlet.php @@ -7,12 +7,14 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Sluggable\HasSlug; use Spatie\Sluggable\SlugOptions; -class Outlet extends Model +class Outlet extends Model implements HasMedia { - use HasFactory, HasSlug, SoftDeletes; + use HasFactory, HasSlug, InteractsWithMedia, SoftDeletes; protected $guarded = ['id']; diff --git a/app/Traits/WithMediaHandler.php b/app/Traits/WithMediaHandler.php new file mode 100644 index 0000000..2098f7b --- /dev/null +++ b/app/Traits/WithMediaHandler.php @@ -0,0 +1,49 @@ +map(fn ($media) => [ + 'id' => $media->id, + 'extension' => pathinfo($media->file_name, PATHINFO_EXTENSION), + 'temporaryUrl' => $media->getFullUrl(), + 'name' => Str::limit($media->file_name, 15), + 'size' => $media->size, + 'tmpFilename' => $media->getPath(), + ])->toArray(); + } + + protected function syncMedia(array $newMedia, $model, string $collectionName): void + { + $existingMediaIds = $model->getMedia($collectionName)->pluck('id')->toArray(); + + $newMediaIds = collect($newMedia)->pluck('id')->filter()->toArray(); + + $toDeleteIds = array_diff($existingMediaIds, $newMediaIds); + + foreach ($toDeleteIds as $id) { + $media = Media::find($id); + if ($media) { + $media->delete(); + } + } + } + + protected function uploadMedia(array $mediaArray, $model, string $collectionName): void + { + foreach ($mediaArray as $file) { + if (isset($file['path'])) { + $model->addMedia($file['path']) + ->preservingOriginal() + ->toMediaCollection($collectionName); + } + } + } +} diff --git a/composer.json b/composer.json index d4429f4..c01f245 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "livewire/flux-pro": "2.2.5", "livewire/livewire": "^3.6", "rappasoft/laravel-livewire-tables": "^3.7", + "spatie/laravel-medialibrary": "^11.15", "spatie/laravel-sluggable": "^3.7" }, "repositories": [ diff --git a/config/media-library.php b/config/media-library.php new file mode 100644 index 0000000..c100c80 --- /dev/null +++ b/config/media-library.php @@ -0,0 +1,291 @@ + env('MEDIA_DISK', 'public'), + + /* + * The maximum file size of an item in bytes. + * Adding a larger file will result in an exception. + */ + 'max_file_size' => 1024 * 1024 * 10, // 10MB + + /* + * This queue connection will be used to generate derived and responsive images. + * Leave empty to use the default queue connection. + */ + 'queue_connection_name' => env('QUEUE_CONNECTION', 'sync'), + + /* + * This queue will be used to generate derived and responsive images. + * Leave empty to use the default queue. + */ + 'queue_name' => env('MEDIA_QUEUE', ''), + + /* + * By default all conversions will be performed on a queue. + */ + 'queue_conversions_by_default' => env('QUEUE_CONVERSIONS_BY_DEFAULT', true), + + /* + * Should database transactions be run after database commits? + */ + 'queue_conversions_after_database_commit' => env('QUEUE_CONVERSIONS_AFTER_DB_COMMIT', true), + + /* + * The fully qualified class name of the media model. + */ + 'media_model' => Spatie\MediaLibrary\MediaCollections\Models\Media::class, + + /* + * The fully qualified class name of the media observer. + */ + 'media_observer' => Spatie\MediaLibrary\MediaCollections\Models\Observers\MediaObserver::class, + + /* + * When enabled, media collections will be serialised using the default + * laravel model serialization behaviour. + * + * Keep this option disabled if using Media Library Pro components (https://medialibrary.pro) + */ + 'use_default_collection_serialization' => false, + + /* + * The fully qualified class name of the model used for temporary uploads. + * + * This model is only used in Media Library Pro (https://medialibrary.pro) + */ + 'temporary_upload_model' => Spatie\MediaLibraryPro\Models\TemporaryUpload::class, + + /* + * When enabled, Media Library Pro will only process temporary uploads that were uploaded + * in the same session. You can opt to disable this for stateless usage of + * the pro components. + */ + 'enable_temporary_uploads_session_affinity' => true, + + /* + * When enabled, Media Library pro will generate thumbnails for uploaded file. + */ + 'generate_thumbnails_for_temporary_uploads' => true, + + /* + * This is the class that is responsible for naming generated files. + */ + 'file_namer' => Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer::class, + + /* + * The class that contains the strategy for determining a media file's path. + */ + 'path_generator' => Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator::class, + + /* + * The class that contains the strategy for determining how to remove files. + */ + 'file_remover_class' => Spatie\MediaLibrary\Support\FileRemover\DefaultFileRemover::class, + + /* + * Here you can specify which path generator should be used for the given class. + */ + 'custom_path_generators' => [ + // Model::class => PathGenerator::class + // or + // 'model_morph_alias' => PathGenerator::class + ], + + /* + * When urls to files get generated, this class will be called. Use the default + * if your files are stored locally above the site root or on s3. + */ + 'url_generator' => Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator::class, + + /* + * Moves media on updating to keep path consistent. Enable it only with a custom + * PathGenerator that uses, for example, the media UUID. + */ + 'moves_media_on_update' => false, + + /* + * Whether to activate versioning when urls to files get generated. + * When activated, this attaches a ?v=xx query string to the URL. + */ + 'version_urls' => false, + + /* + * The media library will try to optimize all converted images by removing + * metadata and applying a little bit of compression. These are + * the optimizers that will be used by default. + */ + 'image_optimizers' => [ + Spatie\ImageOptimizer\Optimizers\Jpegoptim::class => [ + '-m85', // set maximum quality to 85% + '--force', // ensure that progressive generation is always done also if a little bigger + '--strip-all', // this strips out all text information such as comments and EXIF data + '--all-progressive', // this will make sure the resulting image is a progressive one + ], + Spatie\ImageOptimizer\Optimizers\Pngquant::class => [ + '--force', // required parameter for this package + ], + Spatie\ImageOptimizer\Optimizers\Optipng::class => [ + '-i0', // this will result in a non-interlaced, progressive scanned image + '-o2', // this set the optimization level to two (multiple IDAT compression trials) + '-quiet', // required parameter for this package + ], + Spatie\ImageOptimizer\Optimizers\Svgo::class => [ + '--disable=cleanupIDs', // disabling because it is known to cause troubles + ], + Spatie\ImageOptimizer\Optimizers\Gifsicle::class => [ + '-b', // required parameter for this package + '-O3', // this produces the slowest but best results + ], + Spatie\ImageOptimizer\Optimizers\Cwebp::class => [ + '-m 6', // for the slowest compression method in order to get the best compression. + '-pass 10', // for maximizing the amount of analysis pass. + '-mt', // multithreading for some speed improvements. + '-q 90', // quality factor that brings the least noticeable changes. + ], + Spatie\ImageOptimizer\Optimizers\Avifenc::class => [ + '-a cq-level=23', // constant quality level, lower values mean better quality and greater file size (0-63). + '-j all', // number of jobs (worker threads, "all" uses all available cores). + '--min 0', // min quantizer for color (0-63). + '--max 63', // max quantizer for color (0-63). + '--minalpha 0', // min quantizer for alpha (0-63). + '--maxalpha 63', // max quantizer for alpha (0-63). + '-a end-usage=q', // rate control mode set to Constant Quality mode. + '-a tune=ssim', // SSIM as tune the encoder for distortion metric. + ], + ], + + /* + * These generators will be used to create an image of media files. + */ + 'image_generators' => [ + Spatie\MediaLibrary\Conversions\ImageGenerators\Image::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Webp::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Avif::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Pdf::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Svg::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Video::class, + ], + + /* + * The path where to store temporary files while performing image conversions. + * If set to null, storage_path('media-library/temp') will be used. + */ + 'temporary_directory_path' => null, + + /* + * The engine that should perform the image conversions. + * Should be either `gd` or `imagick`. + */ + 'image_driver' => env('IMAGE_DRIVER', 'gd'), + + /* + * FFMPEG & FFProbe binaries paths, only used if you try to generate video + * thumbnails and have installed the php-ffmpeg/php-ffmpeg composer + * dependency. + */ + 'ffmpeg_path' => env('FFMPEG_PATH', '/usr/bin/ffmpeg'), + 'ffprobe_path' => env('FFPROBE_PATH', '/usr/bin/ffprobe'), + + /* + * Here you can override the class names of the jobs used by this package. Make sure + * your custom jobs extend the ones provided by the package. + */ + 'jobs' => [ + 'perform_conversions' => Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob::class, + 'generate_responsive_images' => Spatie\MediaLibrary\ResponsiveImages\Jobs\GenerateResponsiveImagesJob::class, + ], + + /* + * When using the addMediaFromUrl method you may want to replace the default downloader. + * This is particularly useful when the url of the image is behind a firewall and + * need to add additional flags, possibly using curl. + */ + 'media_downloader' => Spatie\MediaLibrary\Downloaders\DefaultDownloader::class, + + /* + * When using the addMediaFromUrl method the SSL is verified by default. + * This is option disables SSL verification when downloading remote media. + * Please note that this is a security risk and should only be false in a local environment. + */ + 'media_downloader_ssl' => env('MEDIA_DOWNLOADER_SSL', true), + + /* + * The default lifetime in minutes for temporary urls. + * This is used when you call the `getLastTemporaryUrl` or `getLastTemporaryUrl` method on a media item. + */ + 'temporary_url_default_lifetime' => env('MEDIA_TEMPORARY_URL_DEFAULT_LIFETIME', 5), + + 'remote' => [ + /* + * Any extra headers that should be included when uploading media to + * a remote disk. Even though supported headers may vary between + * different drivers, a sensible default has been provided. + * + * Supported by S3: CacheControl, Expires, StorageClass, + * ServerSideEncryption, Metadata, ACL, ContentEncoding + */ + 'extra_headers' => [ + 'CacheControl' => 'max-age=604800', + ], + ], + + 'responsive_images' => [ + /* + * This class is responsible for calculating the target widths of the responsive + * images. By default we optimize for filesize and create variations that each are 30% + * smaller than the previous one. More info in the documentation. + * + * https://docs.spatie.be/laravel-medialibrary/v9/advanced-usage/generating-responsive-images + */ + 'width_calculator' => Spatie\MediaLibrary\ResponsiveImages\WidthCalculator\FileSizeOptimizedWidthCalculator::class, + + /* + * By default rendering media to a responsive image will add some javascript and a tiny placeholder. + * This ensures that the browser can already determine the correct layout. + * When disabled, no tiny placeholder is generated. + */ + 'use_tiny_placeholders' => true, + + /* + * This class will generate the tiny placeholder used for progressive image loading. By default + * the media library will use a tiny blurred jpg image. + */ + 'tiny_placeholder_generator' => Spatie\MediaLibrary\ResponsiveImages\TinyPlaceholderGenerator\Blurred::class, + ], + + /* + * When enabling this option, a route will be registered that will enable + * the Media Library Pro Vue and React components to move uploaded files + * in a S3 bucket to their right place. + */ + 'enable_vapor_uploads' => env('ENABLE_MEDIA_LIBRARY_VAPOR_UPLOADS', false), + + /* + * When converting Media instances to response the media library will add + * a `loading` attribute to the `img` tag. Here you can set the default + * value of that attribute. + * + * Possible values: 'lazy', 'eager', 'auto' or null if you don't want to set any loading instruction. + * + * More info: https://css-tricks.com/native-lazy-loading/ + */ + 'default_loading_attribute_value' => null, + + /* + * You can specify a prefix for that is used for storing all media. + * If you set this to `/my-subdir`, all your media will be stored in a `/my-subdir` directory. + */ + 'prefix' => env('MEDIA_PREFIX', ''), + + /* + * When forcing lazy loading, media will be loaded even if you don't eager load media and you have + * disabled lazy loading globally in the service provider. + */ + 'force_lazy_loading' => env('FORCE_MEDIA_LIBRARY_LAZY_LOADING', true), +]; diff --git a/database/migrations/2025_09_19_132017_create_media_table.php b/database/migrations/2025_09_19_132017_create_media_table.php new file mode 100644 index 0000000..47a4be9 --- /dev/null +++ b/database/migrations/2025_09_19_132017_create_media_table.php @@ -0,0 +1,32 @@ +id(); + + $table->morphs('model'); + $table->uuid()->nullable()->unique(); + $table->string('collection_name'); + $table->string('name'); + $table->string('file_name'); + $table->string('mime_type')->nullable(); + $table->string('disk'); + $table->string('conversions_disk')->nullable(); + $table->unsignedBigInteger('size'); + $table->json('manipulations'); + $table->json('custom_properties'); + $table->json('generated_conversions'); + $table->json('responsive_images'); + $table->unsignedInteger('order_column')->nullable()->index(); + + $table->nullableTimestamps(); + }); + } +}; diff --git a/resources/views/livewire/studio/master/outlet/form.blade.php b/resources/views/livewire/studio/master/outlet/form.blade.php index 69f6b49..be5df31 100644 --- a/resources/views/livewire/studio/master/outlet/form.blade.php +++ b/resources/views/livewire/studio/master/outlet/form.blade.php @@ -13,42 +13,93 @@
-
- -
- +
+
+
+ +
+ - + -
- -
+
+ +
- + - + - + - - @foreach (\App\Enums\OutletStatus::cases() as $status) - - {{ $status->label() }} - - @endforeach - + + @foreach (\App\Enums\OutletStatus::cases() as $status) + + {{ $status->label() }} + + @endforeach + +
+
- + + +
+

Gambar Utama

+
+ + @error('form.featured_image') + + @enderror +
+
+
+ + +
+

Gambar

+
+ + @error('form.images') + + @enderror +
+
+
+
@@ -76,7 +127,8 @@ placeholder="Masukkan fasilitas" class="flex-1" autocomplete="off" clearable /> @if ($index != 0) + wire:click="removeFacility({{ $index }})" + class="cursor-pointer" /> @endif
@endforeach diff --git a/resources/views/vendor/livewire-dropzone/livewire/dropzone.blade.php b/resources/views/vendor/livewire-dropzone/livewire/dropzone.blade.php index 8231a10..5e9125b 100644 --- a/resources/views/vendor/livewire-dropzone/livewire/dropzone.blade.php +++ b/resources/views/vendor/livewire-dropzone/livewire/dropzone.blade.php @@ -1,169 +1,197 @@ -
-
- @if(! is_null($error)) +
+
+ @if (!is_null($error))
- - + +

{{ $error }}

@endif -
+
-
- - - +
+ + + -

Drop here or Browse files

+

Drop here or Browse files

-
- - - +
+ + +

Drop here to upload

- accept)) accept="{{ $this->accept }}" @endif - @if($multiple === true) multiple @endif - > + accept)) accept="{{ $this->accept }}" @endif + @if ($multiple === true) multiple @endif>
@php - $hasMaxFileSize = ! is_null($this->maxFileSize); - $hasMimes = ! empty($this->mimes); + $hasMaxFileSize = !is_null($this->maxFileSize); + $hasMimes = !empty($this->mimes); @endphp - @if($hasMaxFileSize) -

{{ __('Up to :size', ['size' => \Illuminate\Support\Number::fileSize($this->maxFileSize * 1024)]) }}

+ @if ($hasMaxFileSize) +

{{ __('Up to :size', ['size' => \Illuminate\Support\Number::fileSize($this->maxFileSize * 1024)]) }} +

@endif - @if($hasMaxFileSize && $hasMimes) + @if ($hasMaxFileSize && $hasMimes) ยท @endif - @if($hasMimes) + @if ($hasMimes)

{{ Str::upper($this->mimes) }}

@endif
- Loading... -
Cancel upload
+
+ Cancel upload
- @if(isset($files) && count($files) > 0) -
- @foreach($files as $file) -
-
- @if($this->isImageMime($file['extension'])) -
- {{ $file['name'] }} + @if (isset($files) && count($files) > 0) +
+ @foreach ($files as $file) +
+
+ @if ($this->isImageMime($file['extension'])) +
+ {{ $file['name'] }} +
+ @else +
+ + + +
+ @endif +
+
+ {{ $file['name'] }}
+
+ {{ \Illuminate\Support\Number::fileSize($file['size']) }}
- @else -
- - +
+
+
- @endif -
-
{{ $file['name'] }}
-
{{ \Illuminate\Support\Number::fileSize($file['size']) }}
+
-
- -
-
- @endforeach -
+ @endforeach +
@endif
@script - + }, + removeUpload(tmpFilename) { + // Dispatch an event to remove the temporarily uploaded file + _this.dispatch(uuid + ':fileRemoved', { + tmpFilename + }) + }, + }); + }) + @endscript