$wire.activeLocale || 'en',
+ }),
+
+ selectedImages: [],
+
+ bulkActionsOpen: false,
+
+ draggedIndex: null,
+
+ get hasSelection() { return this.selectedImages.length > 0; },
+
+ get selectedCount() { return this.selectedImages.length; },
+
+ get totalCount() { return this.state ? this.state.length : 0; },
+
+ get allSelected() { return this.totalCount > 0 && this.selectedImages.length === this.totalCount; },
+
+ get someSelected() { return this.selectedImages.length > 0 && this.selectedImages.length < this.totalCount; },
+
+ toggleSelectAll() {
+ if (this.allSelected) {
+ this.selectedImages = [];
+ } else {
+ this.selectedImages = this.state ? this.state.map(img => img.uuid) : [];
+ }
+ this.updateBulkActionsVisibility();
+ },
+
+ toggleImageSelection(uuid) {
+ const index = this.selectedImages.indexOf(uuid);
+ if (index > -1) {
+ this.selectedImages.splice(index, 1);
+ } else {
+ this.selectedImages.push(uuid);
+ }
+ this.updateBulkActionsVisibility();
+ },
+
+ updateBulkActionsVisibility() {
+ this.bulkActionsOpen = this.hasSelection;
+ },
+
+ clearSelection() {
+ this.selectedImages = [];
+ this.bulkActionsOpen = false;
+ },
+
+ async bulkDelete() {
+ if (this.selectedImages.length === 0) return;
+
+ try {
+ await $wire.mountFormComponentAction('{{ $statePath }}', 'bulkDelete', {
+ arguments: { uuids: this.selectedImages }
+ });
+ this.clearSelection();
+ } catch (error) {
+ // Don't clear selection on error so user can retry
+ }
+ }
+ }" wire:key="media-gallery-{{ str_replace('.', '-', $statePath) }}"
+ class="eclipse-media-gallery" style="display: flex; flex-direction: column; gap: 1rem;">
+
+ @if ($getAllowBulkDelete())
+
+
+
+
+ Select All
+ Deselect All
+
+
+
+
+
+
+ Delete
+
+
+
+ @else
+
+ @endif
+
+
+ @if ($getAllowFileUploads() && $getAction('upload'))
+
+ Upload Files
+
+ @endif
+ @if ($getAllowUrlUploads() && $getAction('urlUpload'))
+
+ Add from URL
+
+ @endif
+
+
+
+
+
+
+ @if (str_contains($gridStyle, '@media'))
+
+
+ @else
+
+ @endif
+
+ $isDraggable,
+ 'hover:shadow-md' => !$isDraggable,
+ ])
+ :class="{
+ 'ring-2 ring-primary-600 dark:ring-primary-400 shadow-lg bg-primary-50 dark:bg-primary-900/20': selectedImages
+ .includes(image.uuid),
+ 'opacity-50': draggedIndex === index,
+ 'ring-2 ring-primary-400 bg-primary-50 dark:bg-primary-900/20': draggedIndex !== null &&
+ draggedIndex !== index
+ }"
+ @if ($isDraggable) draggable="true"
+ @dragstart="draggedIndex = index"
+ @dragover.prevent
+ @drop.prevent="
+ if (draggedIndex !== index) {
+ const item = state.splice(draggedIndex, 1)[0];
+ state.splice(index, 0, item);
+ $wire.mountFormComponentAction('{{ $statePath }}', 'reorder', { items: state.map(img => img.uuid) });
+ }
+ draggedIndex = null;
+ "
+ @dragend="draggedIndex = null" @endif>
+
+
+
+
+
+
+
+
+ Set as Cover
+
+
+
+
+ ✓ Cover
+
+
+
+
+
+
+
+
+
+
+ Edit
+
+
+
+ Delete
+
+
+
+ @if ($getAllowBulkDelete())
+
+ @endif
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No images uploaded yet
+
+ @if ($getAllowFileUploads() || $getAllowUrlUploads())
+ Click "Upload Files" or "Add from URL" to add your first image
+ @else
+ No images are currently available for this gallery
+ @endif
+
+
+
+ @if ($getAllowFileUploads() || $getAllowUrlUploads())
+
+ @if ($getAllowFileUploads() && $getAction('upload'))
+ {{ $getAction('upload') }}
+ @endif
+ @if ($getAllowUrlUploads() && $getAction('urlUpload'))
+ {{ $getAction('urlUpload') }}
+ @endif
+
+ @endif
+
+
+
+
+ @if ($hasLightboxPreview)
+ @include('eclipse-common::components.media-lightbox')
+ @endif
+
+
diff --git a/src/CommonServiceProvider.php b/src/CommonServiceProvider.php
index d55c39f..58c4703 100644
--- a/src/CommonServiceProvider.php
+++ b/src/CommonServiceProvider.php
@@ -16,8 +16,8 @@ class CommonServiceProvider extends PackageServiceProvider
public function configurePackage(SpatiePackage|Package $package): void
{
$package->name(static::$name)
- ->hasViews()
->hasTranslations()
+ ->hasViews()
->hasAssets();
}
@@ -36,6 +36,11 @@ public function register(): self
public function bootingPackage(): void
{
+ FilamentAsset::register([
+ Css::make('media-gallery', __DIR__.'/../resources/css/media-gallery.css'),
+ Js::make('media-gallery', __DIR__.'/../resources/js/media-gallery.js'),
+ ], package: static::$name);
+
FilamentAsset::register([
Css::make('slider-column', asset('vendor/eclipse-common/slider-column.css')),
Js::make('slider-column', asset('vendor/eclipse-common/slider-column.js')),
diff --git a/src/Filament/Forms/Components/Concerns/CanManageMediaCollections.php b/src/Filament/Forms/Components/Concerns/CanManageMediaCollections.php
new file mode 100644
index 0000000..c128610
--- /dev/null
+++ b/src/Filament/Forms/Components/Concerns/CanManageMediaCollections.php
@@ -0,0 +1,22 @@
+collection = $collection;
+
+ return $this;
+ }
+
+ public function getCollection(): string
+ {
+ return $this->evaluate($this->collection);
+ }
+}
diff --git a/src/Filament/Forms/Components/Concerns/HasMediaPreview.php b/src/Filament/Forms/Components/Concerns/HasMediaPreview.php
new file mode 100644
index 0000000..916cf3e
--- /dev/null
+++ b/src/Filament/Forms/Components/Concerns/HasMediaPreview.php
@@ -0,0 +1,146 @@
+hasLightbox = $condition;
+
+ return $this;
+ }
+
+ public function preview(): static
+ {
+ $this->hasLightbox = true;
+
+ return $this;
+ }
+
+ public function coverImageSelection(bool|Closure $condition = true): static
+ {
+ $this->hasCoverImageSelection = $condition;
+
+ return $this;
+ }
+
+ public function orderable(bool|Closure $condition = true): static
+ {
+ $this->isDragReorderable = $condition;
+
+ return $this;
+ }
+
+ public function thumbnailHeight(int|Closure $height): static
+ {
+ $this->thumbnailHeight = $height;
+
+ return $this;
+ }
+
+ public function columns(array|string|int|null $columns = 2): static
+ {
+ $this->mediaColumns = $columns;
+
+ return $this;
+ }
+
+ public function gridColumns(int $columns): static
+ {
+ $this->mediaColumns = $columns;
+
+ return $this;
+ }
+
+ public function hasLightbox(): bool
+ {
+ return $this->evaluate($this->hasLightbox);
+ }
+
+ public function hasCoverImageSelection(): bool
+ {
+ return $this->evaluate($this->hasCoverImageSelection);
+ }
+
+ public function isDragReorderable(): bool
+ {
+ return $this->evaluate($this->isDragReorderable);
+ }
+
+ public function getThumbnailHeight(): int
+ {
+ return $this->evaluate($this->thumbnailHeight);
+ }
+
+ public function getColumns(?string $breakpoint = null): array|string|int|null
+ {
+ $columns = $this->evaluate($this->mediaColumns);
+
+ if ($breakpoint && is_array($columns)) {
+ return $columns[$breakpoint] ?? null;
+ }
+
+ return $columns;
+ }
+
+ public function getGridStyle(): string
+ {
+ $columns = $this->getColumns();
+
+ if (is_array($columns)) {
+ $default = $columns['default'] ?? 4;
+ $css = "grid-template-columns: repeat({$default}, 1fr);";
+
+ $breakpoints = [
+ 'sm' => '640px',
+ 'md' => '768px',
+ 'lg' => '1024px',
+ 'xl' => '1280px',
+ '2xl' => '1536px',
+ ];
+
+ foreach ($columns as $breakpoint => $count) {
+ if ($breakpoint === 'default' || ! isset($breakpoints[$breakpoint])) {
+ continue;
+ }
+
+ $css .= " @media (min-width: {$breakpoints[$breakpoint]}) { grid-template-columns: repeat({$count}, 1fr); }";
+ }
+
+ return $css;
+ }
+
+ $columnCount = $columns ?? 4;
+
+ return "grid-template-columns: repeat({$columnCount}, 1fr);";
+ }
+
+ public function getGridClasses(): string
+ {
+ return 'grid gap-3';
+ }
+
+ public function getGridColumns(): int
+ {
+ $columns = $this->getColumns();
+
+ if (is_array($columns)) {
+ return $columns['default'] ?? 4;
+ }
+
+ return (int) ($columns ?? 4);
+ }
+}
diff --git a/src/Filament/Forms/Components/Concerns/HasMediaUploadOptions.php b/src/Filament/Forms/Components/Concerns/HasMediaUploadOptions.php
new file mode 100644
index 0000000..e9fca28
--- /dev/null
+++ b/src/Filament/Forms/Components/Concerns/HasMediaUploadOptions.php
@@ -0,0 +1,128 @@
+acceptedFileTypes = $types;
+
+ return $this;
+ }
+
+ public function allowUrlUploads(): static
+ {
+ $this->allowUrlUploads = true;
+
+ return $this;
+ }
+
+ public function allowFileUploads(): static
+ {
+ $this->allowFileUploads = true;
+
+ return $this;
+ }
+
+ public function allowUploads(): static
+ {
+ $this->allowFileUploads = true;
+ $this->allowUrlUploads = true;
+
+ return $this;
+ }
+
+ public function maxFiles(int|Closure|null $limit): static
+ {
+ $this->maxFiles = $limit;
+
+ return $this;
+ }
+
+ public function maxFileSize(int|Closure|null $size): static
+ {
+ $this->maxFileSize = $size;
+
+ return $this;
+ }
+
+ public function multiple(bool|Closure $condition = true): static
+ {
+ $this->isMultiple = $condition;
+
+ return $this;
+ }
+
+ public function single(): static
+ {
+ $this->multiple(false);
+
+ return $this;
+ }
+
+ public function getAcceptedFileTypes(): array
+ {
+ return $this->evaluate($this->acceptedFileTypes);
+ }
+
+ public function getAllowUrlUploads(): bool
+ {
+ return $this->evaluate($this->allowUrlUploads);
+ }
+
+ public function getAllowFileUploads(): bool
+ {
+ return $this->evaluate($this->allowFileUploads);
+ }
+
+ public function getMaxFiles(): ?int
+ {
+ return $this->evaluate($this->maxFiles);
+ }
+
+ public function getMaxFileSize(): ?int
+ {
+ return $this->evaluate($this->maxFileSize);
+ }
+
+ public function isMultiple(): bool
+ {
+ return $this->evaluate($this->isMultiple);
+ }
+
+ public function bulkDelete(bool|Closure $condition = true): static
+ {
+ $this->allowBulkDelete = $condition;
+
+ return $this;
+ }
+
+ public function disableBulkDelete(): static
+ {
+ $this->allowBulkDelete = false;
+
+ return $this;
+ }
+
+ public function getAllowBulkDelete(): bool
+ {
+ return $this->evaluate($this->allowBulkDelete);
+ }
+}
diff --git a/src/Filament/Forms/Components/MediaGallery.php b/src/Filament/Forms/Components/MediaGallery.php
new file mode 100644
index 0000000..407a4c5
--- /dev/null
+++ b/src/Filament/Forms/Components/MediaGallery.php
@@ -0,0 +1,1309 @@
+configureDefaultState();
+ $this->configureStateLifecycle();
+ $this->configureActions();
+ $this->configureFormBinding();
+ }
+
+ protected function configureDefaultState(): void
+ {
+ $this->default([]);
+ }
+
+ protected function configureStateLifecycle(): void
+ {
+ $this->afterStateHydrated(function (Field $component) {
+ if ($component->getRecord()) {
+ $component->refreshState();
+ }
+ $this->cleanupOldTempFiles();
+ });
+
+ $this->afterStateUpdated(function (Field $component) {
+ if ($component->getRecord()) {
+ $component->refreshState();
+ }
+ });
+ }
+
+ protected function configureActions(): void
+ {
+ $this->registerActions([
+ $this->getUploadAction(),
+ $this->getUrlUploadAction(),
+ $this->getEditAction(),
+ $this->getDeleteAction(),
+ $this->getBulkDeleteAction(),
+ $this->getCoverAction(),
+ $this->getReorderAction(),
+ ]);
+ }
+
+ protected function configureFormBinding(): void
+ {
+ $this->dehydrated(fn (?Model $record): bool => ! $record?->exists);
+ $this->saveRelationshipsUsing(fn (Field $component, ?Model $record) => $this->saveMediaRelationships($component, $record));
+ }
+
+ protected function saveMediaRelationships(Field $component, ?Model $record): void
+ {
+ if (! $record || ! $record->exists) {
+ return;
+ }
+
+ $livewire = $component->getLivewire();
+ if (method_exists($livewire, 'afterCreate') && property_exists($livewire, 'temporaryImages') && $livewire->temporaryImages !== null) {
+ // Let the HandlesImageUploads trait handle this
+ return;
+ }
+
+ $state = $component->getState();
+
+ if (! $state || ! is_array($state)) {
+ return;
+ }
+
+ $this->processStateItems($record, $state);
+ $this->removeDeletedMedia($record, $state);
+ $this->ensureSingleCoverImage($record);
+ $this->cleanupOldTempFiles();
+ }
+
+ protected function processStateItems(Model $record, array $state): void
+ {
+ foreach ($state as $index => $item) {
+ if (isset($item['id']) && $item['id']) {
+ $this->updateExistingMedia($record, $item, $index);
+ } else {
+ $this->createNewMedia($record, $item, $index);
+ }
+ }
+ }
+
+ protected function updateExistingMedia(Model $record, array $item, int $index): void
+ {
+ $media = $record->getMedia($this->getCollection())->firstWhere('id', $item['id']);
+ if ($media) {
+ $media->setCustomProperty('name', $item['name'] ?? []);
+ $media->setCustomProperty('description', $item['description'] ?? []);
+ $media->setCustomProperty('is_cover', $item['is_cover'] ?? false);
+ $media->setCustomProperty('position', $index);
+ $media->save();
+ }
+ }
+
+ protected function createNewMedia(Model $record, array $item, int $index): void
+ {
+ if (isset($item['temp_file'])) {
+ $this->createMediaFromTempFile($record, $item, $index);
+ } elseif (isset($item['temp_url'])) {
+ $this->createMediaFromUrl($record, $item, $index);
+ }
+ }
+
+ protected function createMediaFromTempFile(Model $record, array $item, int $index): void
+ {
+ $tempPath = storage_path('app/public/'.$item['temp_file']);
+ if (file_exists($tempPath)) {
+ $record->addMedia($tempPath)
+ ->usingFileName($this->sanitizeFilename($item['file_name'] ?? basename($tempPath)))
+ ->withCustomProperties($this->getMediaCustomProperties($item, $index))
+ ->toMediaCollection($this->getCollection());
+
+ @unlink($tempPath);
+ }
+ }
+
+ protected function createMediaFromUrl(Model $record, array $item, int $index): void
+ {
+ try {
+ $record->addMediaFromUrl($item['temp_url'])
+ ->usingFileName($this->sanitizeFilename($item['file_name'] ?? basename($item['temp_url'])))
+ ->withCustomProperties($this->getMediaCustomProperties($item, $index))
+ ->toMediaCollection($this->getCollection());
+ } catch (Exception $e) {
+ }
+ }
+
+ protected function getMediaCustomProperties(array $item, int $index): array
+ {
+ return [
+ 'name' => $item['name'] ?? [],
+ 'description' => $item['description'] ?? [],
+ 'is_cover' => $item['is_cover'] ?? false,
+ 'position' => $index,
+ ];
+ }
+
+ protected function removeDeletedMedia(Model $record, array $state): void
+ {
+ $existingIds = collect($state)->pluck('id')->filter()->toArray();
+ $record->getMedia($this->getCollection())
+ ->whereNotIn('id', $existingIds)
+ ->each(fn ($media) => $media->delete());
+ }
+
+ public function getAvailableLocales(): array
+ {
+ $locales = [];
+
+ try {
+ $livewire = $this->getLivewire();
+
+ if ($livewire && method_exists($livewire, 'getTranslatableLocales')) {
+ $plugin = filament('spatie-laravel-translatable');
+ foreach ($livewire->getTranslatableLocales() as $locale) {
+ $locales[$locale] = $plugin->getLocaleLabel($locale) ?? $locale;
+ }
+ }
+ } catch (Exception $e) {
+ }
+
+ if (empty($locales)) {
+ $locales = config('eclipsephp.locales', ['en' => 'English']);
+ }
+
+ return $locales;
+ }
+
+ public function getSelectedLocale(): string
+ {
+ try {
+ $livewire = $this->getLivewire();
+ if ($livewire && property_exists($livewire, 'activeLocale')) {
+ return $livewire->activeLocale;
+ }
+ } catch (Exception $e) {
+ }
+
+ return app()->getLocale();
+ }
+
+ public function imageConversions(array|Closure $conversions): static
+ {
+ $this->imageConversions = $conversions;
+
+ return $this;
+ }
+
+ public function getImageConversions(): array
+ {
+ return $this->evaluate($this->imageConversions);
+ }
+
+ public function getUploadAction(): Action
+ {
+ return Action::make('upload')
+ ->label('Upload Files')
+ ->icon('heroicon-o-arrow-up-tray')
+ ->color('primary')
+ ->modalHeading('Upload Images')
+ ->modalSubmitActionLabel('Upload')
+ ->form([
+ FileUpload::make('files')
+ ->label('Choose files')
+ ->multiple()
+ ->image()
+ ->acceptedFileTypes($this->getAcceptedFileTypes())
+ ->imagePreviewHeight('200')
+ ->required()
+ ->directory('temp-images')
+ ->visibility('public')
+ ->storeFiles(true)
+ ->preserveFilenames(),
+ ])
+ ->action(function (array $data): void {
+ if (! isset($data['files'])) {
+ return;
+ }
+
+ $originalMemoryLimit = ini_get('memory_limit');
+ ini_set('memory_limit', '192M');
+
+ try {
+ $record = $this->getRecord();
+ $maxFiles = $this->getMaxFiles();
+
+ if (! $record) {
+ $currentState = $this->getState() ?: [];
+ $existingCount = count($currentState);
+ $maxPosition = count($currentState) - 1;
+ $allowedCount = $maxFiles ? max(0, $maxFiles - $existingCount) : count($data['files']);
+ $filesToProcess = array_slice($data['files'], 0, $allowedCount);
+
+ if ($allowedCount <= 0) {
+ Notification::make()
+ ->title('Maximum files limit reached')
+ ->body("You can only upload {$maxFiles} file(s) total.")
+ ->warning()
+ ->send();
+
+ return;
+ }
+
+ $initialStateCount = count($currentState);
+ $addedInThisBatch = 0;
+
+ foreach ($filesToProcess as $filePath) {
+ if (is_string($filePath)) {
+ $fullPath = storage_path('app/public/'.$filePath);
+
+ if (file_exists($fullPath)) {
+ $tempId = 'temp_'.uniqid();
+ $fileName = $this->sanitizeFilename(basename($filePath));
+
+ $extension = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
+ $mimeType = match ($extension) {
+ 'jpg', 'jpeg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'gif' => 'image/gif',
+ 'webp' => 'image/webp',
+ default => 'image/jpeg'
+ };
+
+ $currentState[] = [
+ 'id' => null,
+ 'temp_id' => $tempId,
+ 'temp_file' => $filePath,
+ 'uuid' => (string) Str::uuid(),
+ 'url' => \Storage::url($filePath),
+ 'thumb_url' => \Storage::url($filePath),
+ 'preview_url' => \Storage::url($filePath),
+ 'name' => [],
+ 'description' => [],
+ 'is_cover' => $initialStateCount === 0 && $addedInThisBatch === 0,
+ 'position' => ++$maxPosition,
+ 'file_name' => $fileName,
+ 'mime_type' => $mimeType,
+ 'size' => 0,
+ ];
+
+ $addedInThisBatch++;
+ }
+ }
+ }
+
+ $this->state($currentState);
+
+ if (function_exists('gc_collect_cycles')) {
+ gc_collect_cycles();
+ }
+
+ $uploadedCount = count($filesToProcess);
+ $rejectedCount = count($data['files']) - $uploadedCount;
+
+ if ($uploadedCount > 0) {
+ Notification::make()
+ ->title($uploadedCount.' image(s) added successfully')
+ ->success()
+ ->send();
+ }
+
+ if ($rejectedCount > 0) {
+ Notification::make()
+ ->title($rejectedCount.' image(s) rejected')
+ ->body("Maximum files limit ({$maxFiles}) reached.")
+ ->warning()
+ ->send();
+ }
+
+ return;
+ }
+
+ $existingCount = $record->getMedia($this->getCollection())->count();
+ $maxPosition = $record->getMedia($this->getCollection())->max(fn ($m) => $m->getCustomProperty('position', 0)) ?? -1;
+ $allowedCount = $maxFiles ? max(0, $maxFiles - $existingCount) : count($data['files']);
+ $filesToProcess = array_slice($data['files'], 0, $allowedCount);
+ $uploadCount = 0;
+
+ if ($allowedCount <= 0) {
+ Notification::make()
+ ->title('Maximum files limit reached')
+ ->body("You can only upload {$maxFiles} file(s) total.")
+ ->warning()
+ ->send();
+
+ return;
+ }
+
+ foreach ($filesToProcess as $filePath) {
+ if (is_string($filePath)) {
+ $fullPath = storage_path('app/public/'.$filePath);
+
+ if (file_exists($fullPath)) {
+ $record->addMedia($fullPath)
+ ->usingFileName($this->sanitizeFilename(basename($filePath)))
+ ->withCustomProperties([
+ 'name' => [],
+ 'description' => [],
+ 'is_cover' => $existingCount === 0 && $uploadCount === 0,
+ 'position' => ++$maxPosition,
+ ])
+ ->toMediaCollection($this->getCollection());
+
+ $uploadCount++;
+
+ @unlink($fullPath);
+ }
+ }
+ }
+
+ $this->refreshState();
+
+ $rejectedCount = count($data['files']) - $uploadCount;
+
+ if ($uploadCount > 0) {
+ Notification::make()
+ ->title($uploadCount.' image(s) uploaded successfully')
+ ->success()
+ ->send();
+ }
+
+ if ($rejectedCount > 0) {
+ Notification::make()
+ ->title($rejectedCount.' image(s) rejected')
+ ->body("Maximum files limit ({$maxFiles}) reached.")
+ ->warning()
+ ->send();
+ }
+ } catch (Exception $e) {
+ Notification::make()
+ ->title('Upload failed')
+ ->body('An error occurred during image processing. Please try uploading fewer images at once.')
+ ->danger()
+ ->send();
+ } finally {
+ ini_set('memory_limit', $originalMemoryLimit);
+ }
+ })
+ ->modalWidth('lg')
+ ->closeModalByClickingAway(false);
+ }
+
+ public function getUrlUploadAction(): Action
+ {
+ return Action::make('urlUpload')
+ ->label('Add from URL')
+ ->icon('heroicon-o-link')
+ ->color('gray')
+ ->modalHeading('Add Images from URLs')
+ ->modalSubmitActionLabel('Add Images')
+ ->form([
+ Textarea::make('urls')
+ ->label('Image URLs')
+ ->placeholder("https://example.com/image1.jpg\nhttps://example.com/image2.jpg")
+ ->rows(6)
+ ->required()
+ ->helperText('Enter one URL per line. Only direct image URLs (jpg, png, gif, webp) are supported.'),
+ ])
+ ->modalWidth('2xl')
+ ->action(function (array $data): void {
+ if (! isset($data['urls'])) {
+ return;
+ }
+
+ $urls = array_filter(array_map('trim', explode("\n", $data['urls'])));
+ $record = $this->getRecord();
+ $maxFiles = $this->getMaxFiles();
+
+ if (empty($urls)) {
+ Notification::make()
+ ->title('No URLs provided')
+ ->body('Please enter at least one URL.')
+ ->warning()
+ ->send();
+
+ return;
+ }
+
+ $validUrls = [];
+
+ foreach ($urls as $url) {
+ if (filter_var(trim($url), FILTER_VALIDATE_URL)) {
+ $validUrls[] = trim($url);
+ }
+ }
+
+ if (empty($validUrls)) {
+ Notification::make()
+ ->title('No valid URLs found')
+ ->body('Please ensure URLs are properly formatted.')
+ ->danger()
+ ->send();
+
+ return;
+ }
+
+ if (! $record) {
+ $currentState = $this->getState() ?: [];
+ $existingCount = count($currentState);
+ $maxPosition = count($currentState) - 1;
+ $allowedCount = $maxFiles ? max(0, $maxFiles - $existingCount) : count($urls);
+ $urlsToProcess = array_slice($urls, 0, $allowedCount);
+ $successCount = 0;
+ $failedUrls = [];
+
+ if ($allowedCount <= 0) {
+ Notification::make()
+ ->title('Maximum files limit reached')
+ ->body("You can only upload {$maxFiles} file(s) total.")
+ ->warning()
+ ->send();
+
+ return;
+ }
+
+ foreach ($urlsToProcess as $url) {
+ if (filter_var($url, FILTER_VALIDATE_URL)) {
+ $tempId = 'temp_'.uniqid();
+ $currentState[] = [
+ 'id' => null,
+ 'temp_id' => $tempId,
+ 'temp_url' => $url,
+ 'uuid' => (string) Str::uuid(),
+ 'url' => $url,
+ 'thumb_url' => $url,
+ 'preview_url' => $url,
+ 'name' => [],
+ 'description' => [],
+ 'is_cover' => count($currentState) === 0,
+ 'position' => ++$maxPosition,
+ 'file_name' => $this->sanitizeFilename(basename($url)),
+ 'mime_type' => 'image/*',
+ 'size' => 0,
+ ];
+ $successCount++;
+ } else {
+ $failedUrls[] = $url;
+ }
+ }
+
+ $rejectedUrls = array_slice($urls, $allowedCount);
+ $failedUrls = array_merge($failedUrls, $rejectedUrls);
+
+ $this->state($currentState);
+
+ if ($successCount > 0) {
+ Notification::make()
+ ->title($successCount.' image(s) added successfully')
+ ->success()
+ ->send();
+ }
+
+ if (! empty($failedUrls)) {
+ $rejectedCount = count($rejectedUrls ?? []);
+ $invalidCount = count($failedUrls) - $rejectedCount;
+
+ $title = 'Some URLs failed';
+ $body = '';
+
+ if ($rejectedCount > 0) {
+ $body .= "{$rejectedCount} URL(s) rejected (limit reached). ";
+ }
+ if ($invalidCount > 0) {
+ $body .= "{$invalidCount} invalid URL(s). ";
+ }
+
+ Notification::make()
+ ->title($title)
+ ->body(trim($body))
+ ->warning()
+ ->send();
+ }
+
+ return;
+ }
+
+ $existingCount = $record->getMedia($this->getCollection())->count();
+ $maxPosition = $record->getMedia($this->getCollection())->max(fn ($m) => $m->getCustomProperty('position', 0)) ?? -1;
+ $allowedCount = $maxFiles ? max(0, $maxFiles - $existingCount) : count($urls);
+ $urlsToProcess = array_slice($urls, 0, $allowedCount);
+ $successCount = 0;
+ $failedUrls = [];
+ $invalidUrls = [];
+
+ if ($allowedCount <= 0) {
+ Notification::make()
+ ->title('Maximum files limit reached')
+ ->body("You can only upload {$maxFiles} file(s) total.")
+ ->warning()
+ ->send();
+
+ return;
+ }
+
+ foreach ($urlsToProcess as $url) {
+ $url = trim($url);
+
+ if (! filter_var($url, FILTER_VALIDATE_URL)) {
+ $invalidUrls[] = $url;
+
+ continue;
+ }
+
+ try {
+ $fileName = $this->extractImageFileName($url);
+
+ $mediaFile = $record->addMediaFromUrl($url)
+ ->withCustomProperties([
+ 'name' => [],
+ 'description' => [],
+ 'is_cover' => $existingCount === 0 && $successCount === 0,
+ 'position' => ++$maxPosition,
+ ]);
+
+ if ($fileName) {
+ $mediaFile->usingFileName($fileName);
+ }
+
+ $mediaFile->toMediaCollection($this->getCollection());
+ $successCount++;
+ } catch (Exception $e) {
+ $failedUrls[] = $url;
+ }
+ }
+
+ $rejectedUrls = array_slice($urls, $allowedCount);
+
+ $this->refreshState();
+ $this->sendUrlUploadNotifications(
+ $successCount,
+ $failedUrls,
+ $invalidUrls,
+ $rejectedUrls
+ );
+ })
+ ->modalWidth('2xl')
+ ->closeModalByClickingAway(false);
+ }
+
+ protected function isValidImageUrl(string $url): bool
+ {
+ if (! filter_var($url, FILTER_VALIDATE_URL)) {
+ return false;
+ }
+
+ $imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
+ $path = parse_url($url, PHP_URL_PATH);
+ $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
+
+ if (in_array($extension, $imageExtensions)) {
+ return true;
+ }
+
+ $pathLower = strtolower($path);
+ $imageKeywords = ['logo', 'image', 'photo', 'picture', 'img', 'avatar', 'banner', 'thumb'];
+
+ foreach ($imageKeywords as $keyword) {
+ if (str_contains($pathLower, $keyword)) {
+ return true;
+ }
+ }
+
+ $query = parse_url($url, PHP_URL_QUERY);
+ if ($query) {
+ parse_str($query, $params);
+ $imageParams = ['format', 'type', 'ext', 'extension'];
+ foreach ($imageParams as $param) {
+ if (isset($params[$param]) && in_array(strtolower($params[$param]), $imageExtensions)) {
+ return true;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ protected function validateUrlAccessibility(string $url): bool
+ {
+ try {
+ $context = stream_context_create([
+ 'http' => [
+ 'method' => 'HEAD',
+ 'timeout' => 5,
+ 'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ 'follow_location' => true,
+ 'max_redirects' => 3,
+ ],
+ 'https' => [
+ 'method' => 'HEAD',
+ 'timeout' => 5,
+ 'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ 'follow_location' => true,
+ 'max_redirects' => 3,
+ 'verify_peer' => false,
+ 'verify_host' => false,
+ ],
+ ]);
+
+ $headers = @get_headers($url, true, $context);
+
+ if (! $headers) {
+ $getContext = stream_context_create([
+ 'http' => [
+ 'method' => 'GET',
+ 'timeout' => 5,
+ 'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ 'follow_location' => true,
+ 'max_redirects' => 3,
+ ],
+ 'https' => [
+ 'method' => 'GET',
+ 'timeout' => 5,
+ 'user_agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
+ 'follow_location' => true,
+ 'max_redirects' => 3,
+ 'verify_peer' => false,
+ 'verify_host' => false,
+ ],
+ ]);
+
+ $headers = @get_headers($url, true, $getContext);
+ }
+
+ if (! $headers) {
+ return false;
+ }
+
+ $statusLine = $headers[0] ?? '';
+ $isSuccessful = str_contains($statusLine, '200') ||
+ str_contains($statusLine, '302') ||
+ str_contains($statusLine, '301') ||
+ str_contains($statusLine, '304');
+
+ if (! $isSuccessful) {
+ return false;
+ }
+
+ $contentType = $headers['Content-Type'] ?? $headers['content-type'] ?? $headers['Content-type'] ?? '';
+ if (is_array($contentType)) {
+ $contentType = end($contentType);
+ }
+
+ if (empty($contentType)) {
+ return true;
+ }
+
+ return str_starts_with(strtolower($contentType), 'image/');
+ } catch (Exception $e) {
+ return true;
+ }
+ }
+
+ protected function extractImageFileName(string $url): string
+ {
+ $path = parse_url($url, PHP_URL_PATH);
+ $filename = basename($path);
+
+ if (! str_contains($filename, '.')) {
+ $extension = $this->getMimeTypeFromUrl($url) === 'image/jpeg' ? 'jpg' : 'png';
+ $filename = $filename.'.'.$extension;
+ }
+
+ return $this->sanitizeFilename($filename);
+ }
+
+ protected function getMimeTypeFromUrl(string $url): string
+ {
+ $extension = strtolower(pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION));
+
+ return match ($extension) {
+ 'jpg', 'jpeg' => 'image/jpeg',
+ 'png' => 'image/png',
+ 'gif' => 'image/gif',
+ 'webp' => 'image/webp',
+ 'bmp' => 'image/bmp',
+ default => 'image/jpeg'
+ };
+ }
+
+ protected function sendUrlUploadNotifications(int $successCount, array $failedUrls, array $invalidUrls, array $rejectedUrls): void
+ {
+ if ($successCount > 0) {
+ Notification::make()
+ ->title("{$successCount} image(s) added successfully")
+ ->success()
+ ->send();
+ }
+
+ $totalFailed = count($failedUrls) + count($invalidUrls) + count($rejectedUrls);
+
+ if ($totalFailed > 0) {
+ $messages = [];
+
+ if (! empty($failedUrls)) {
+ $messages[] = count($failedUrls).' URL(s) could not be downloaded';
+ }
+
+ if (! empty($invalidUrls)) {
+ $messages[] = count($invalidUrls).' invalid URL(s) (not direct image links)';
+ }
+
+ if (! empty($rejectedUrls)) {
+ $messages[] = count($rejectedUrls).' URL(s) rejected (file limit reached)';
+ }
+
+ Notification::make()
+ ->title('Some images failed to upload')
+ ->body(implode(', ', $messages).'.')
+ ->warning()
+ ->send();
+ }
+ }
+
+ public function getReorderAction(): Action
+ {
+ return Action::make('reorder')
+ ->action(function (array $arguments): void {
+ if (! isset($arguments['items'])) {
+ return;
+ }
+
+ $newOrder = $arguments['items'];
+ $record = $this->getRecord();
+
+ if (! $record) {
+ $state = $this->getState();
+ $orderedState = [];
+
+ foreach ($newOrder as $position => $uuid) {
+ $item = collect($state)->firstWhere('uuid', $uuid);
+ if ($item) {
+ $item['position'] = $position;
+ $orderedState[] = $item;
+ }
+ }
+
+ $this->state($orderedState);
+
+ Notification::make()
+ ->title('Images reordered successfully')
+ ->success()
+ ->send();
+
+ return;
+ }
+
+ $record->load('media');
+ $mediaCollection = $record->getMedia($this->getCollection());
+
+ foreach ($newOrder as $position => $uuid) {
+ $media = $mediaCollection->firstWhere('uuid', $uuid);
+ if ($media) {
+ $media->setCustomProperty('position', $position);
+ $media->save();
+ }
+ }
+
+ $record->load('media');
+
+ $this->refreshState();
+
+ Notification::make()
+ ->title('Images reordered successfully')
+ ->success()
+ ->send();
+ })
+ ->livewireClickHandlerEnabled(false);
+ }
+
+ public function getEditAction(): Action
+ {
+ return Action::make('editImage')
+ ->label('Edit Image')
+ ->modalHeading('Edit Image Details')
+ ->modalSubmitActionLabel('Save Changes')
+ ->form(function (array $arguments) {
+ $args = $arguments['arguments'] ?? $arguments;
+ $uuid = $args['uuid'] ?? null;
+ $selectedLocale = $args['selectedLocale'] ?? $this->getSelectedLocale();
+ $state = $this->getState();
+ $image = collect($state)->firstWhere('uuid', $uuid);
+
+ if (! $image) {
+ return [];
+ }
+
+ $locales = $this->getAvailableLocales();
+
+ $fields = [];
+
+ $fields[] = Placeholder::make('preview')
+ ->label('')
+ ->content(function () use ($image) {
+ return view('eclipse-common::components.media-preview', [
+ 'url' => $image['preview_url'] ?? $image['url'],
+ 'filename' => $image['file_name'],
+ ]);
+ });
+
+ if (count($locales) > 1) {
+ $fields[] = Select::make('edit_locale')
+ ->label('Language')
+ ->options($locales)
+ ->default($selectedLocale)
+ ->live()
+ ->afterStateUpdated(function ($state, $set) use ($image) {
+ $set('name', $image['name'][$state] ?? '');
+ $set('description', $image['description'][$state] ?? '');
+ });
+ }
+
+ $fields[] = TextInput::make('name')
+ ->label('Name')
+ ->default($image['name'][$selectedLocale] ?? '');
+
+ $fields[] = Textarea::make('description')
+ ->label('Description')
+ ->rows(3)
+ ->default($image['description'][$selectedLocale] ?? '');
+
+ return $fields;
+ })
+ ->action(function (array $data, array $arguments): void {
+ $args = $arguments['arguments'] ?? $arguments;
+ $uuid = $args['uuid'] ?? null;
+
+ if (! $uuid) {
+ return;
+ }
+
+ $record = $this->getRecord();
+
+ if (! $record) {
+ $state = $this->getState();
+ $imageIndex = collect($state)->search(fn ($item) => $item['uuid'] === $uuid);
+
+ if ($imageIndex !== false) {
+ $locale = $data['edit_locale'] ?? array_key_first($this->getAvailableLocales());
+ $state[$imageIndex]['name'][$locale] = $data['name'] ?? '';
+ $state[$imageIndex]['description'][$locale] = $data['description'] ?? '';
+
+ $this->state($state);
+
+ Notification::make()
+ ->title('Image details updated')
+ ->success()
+ ->send();
+ }
+
+ return;
+ }
+
+ $media = $record->getMedia($this->getCollection())->firstWhere('uuid', $uuid);
+ if ($media) {
+ $nameTranslations = $media->getCustomProperty('name', []);
+ $descriptionTranslations = $media->getCustomProperty('description', []);
+
+ $locale = $data['edit_locale'] ?? array_key_first($this->getAvailableLocales());
+ $nameTranslations[$locale] = $data['name'] ?? '';
+ $descriptionTranslations[$locale] = $data['description'] ?? '';
+
+ $media->setCustomProperty('name', $nameTranslations);
+ $media->setCustomProperty('description', $descriptionTranslations);
+ $media->save();
+
+ $this->refreshState();
+
+ Notification::make()
+ ->title('Image details updated')
+ ->success()
+ ->send();
+ }
+ })
+ ->modalWidth('lg');
+ }
+
+ public function getCoverAction(): Action
+ {
+ return Action::make('setCover')
+ ->label('Set as Cover')
+ ->requiresConfirmation()
+ ->modalHeading('Set as Cover Image')
+ ->modalDescription('This image will be used as the main product image.')
+ ->modalSubmitActionLabel('Set as Cover')
+ ->action(function (array $arguments): void {
+ $args = $arguments['arguments'] ?? $arguments;
+ $uuid = $args['uuid'] ?? null;
+
+ if (! $uuid) {
+ return;
+ }
+
+ $record = $this->getRecord();
+
+ if (! $record) {
+ $state = $this->getState();
+
+ $newState = collect($state)->map(function ($item) use ($uuid) {
+ $item['is_cover'] = $item['uuid'] === $uuid;
+
+ return $item;
+ })->toArray();
+
+ $this->state($newState);
+
+ Notification::make()
+ ->title('Cover image updated')
+ ->success()
+ ->send();
+
+ return;
+ }
+
+ $record->getMedia($this->getCollection())->each(function ($media) {
+ $media->setCustomProperty('is_cover', false);
+ $media->save();
+ });
+
+ $targetMedia = $record->getMedia($this->getCollection())->firstWhere('uuid', $uuid);
+ if ($targetMedia) {
+ $targetMedia->setCustomProperty('is_cover', true);
+ $targetMedia->save();
+ }
+
+ $this->refreshState();
+
+ Notification::make()
+ ->title('Cover image updated')
+ ->success()
+ ->send();
+ });
+ }
+
+ protected function mediaToArray(Media $media): array
+ {
+ return [
+ 'id' => $media->id,
+ 'uuid' => $media->uuid,
+ 'url' => $media->getUrl(),
+ 'thumb_url' => $media->getUrl('thumb'),
+ 'preview_url' => $media->getUrl('preview'),
+ 'name' => $media->getCustomProperty('name', []),
+ 'description' => $media->getCustomProperty('description', []),
+ 'is_cover' => $media->getCustomProperty('is_cover', false),
+ 'position' => $media->getCustomProperty('position', 0),
+ 'file_name' => $media->file_name,
+ 'mime_type' => $media->mime_type,
+ 'size' => $media->size,
+ ];
+ }
+
+ public function refreshState(): void
+ {
+ $record = $this->getRecord();
+ if (! $record) {
+ $this->state([]);
+
+ return;
+ }
+
+ $record->load('media');
+
+ $media = $record->getMedia($this->getCollection())
+ ->map(fn (Media $media) => $this->mediaToArray($media))
+ ->sortBy('position')
+ ->values()
+ ->toArray();
+
+ $this->state($media);
+ }
+
+ protected function ensureSingleCoverImage(Model $record): void
+ {
+ $coverMedia = $record->getMedia($this->getCollection())
+ ->filter(fn ($media) => $media->getCustomProperty('is_cover', false));
+
+ if ($coverMedia->count() > 1) {
+ $coverMedia->skip(1)->each(function ($media) {
+ $media->setCustomProperty('is_cover', false);
+ $media->save();
+ });
+ }
+
+ if ($coverMedia->count() === 0 && $record->getMedia($this->getCollection())->count() > 0) {
+ $firstMedia = $record->getMedia($this->getCollection())->first();
+ $firstMedia->setCustomProperty('is_cover', true);
+ $firstMedia->save();
+ }
+ }
+
+ protected function sanitizeFilename(string $filename): string
+ {
+ $pathInfo = pathinfo($filename);
+ $name = $pathInfo['filename'] ?? 'image';
+ $extension = isset($pathInfo['extension']) ? '.'.$pathInfo['extension'] : '';
+
+ $sanitizedName = Str::slug($name, '-');
+
+ if (empty($sanitizedName)) {
+ $sanitizedName = 'image-'.time();
+ }
+
+ return $sanitizedName.$extension;
+ }
+
+ protected function cleanupOldTempFiles(): void
+ {
+ $tempDir = storage_path('app/public/temp-images');
+ if (! file_exists($tempDir)) {
+ return;
+ }
+
+ $files = glob($tempDir.'/*');
+ $now = time();
+
+ foreach ($files as $file) {
+ if (is_file($file) && $now - filemtime($file) >= 86400) {
+ @unlink($file);
+ }
+ }
+ }
+
+ public function getBulkDeleteAction(): Action
+ {
+ return Action::make('bulkDelete')
+ ->label('Delete Selected')
+ ->color('danger')
+ ->icon('heroicon-o-trash')
+ ->size('sm')
+ ->requiresConfirmation()
+ ->modalHeading('Delete Images')
+ ->modalDescription('Are you sure you want to delete the selected images? This action cannot be undone.')
+ ->modalSubmitActionLabel('Delete Selected')
+ ->modalIcon('heroicon-o-trash')
+ ->modalIconColor('danger')
+ ->action(function (array $arguments): void {
+ $args = $arguments['arguments'] ?? $arguments;
+ $uuids = $args['uuids'] ?? [];
+
+ if (empty($uuids)) {
+ Notification::make()
+ ->title('No images selected')
+ ->body('Please select images to delete.')
+ ->warning()
+ ->send();
+
+ return;
+ }
+
+ $record = $this->getRecord();
+ $deletedCount = 0;
+ $hadCover = false;
+
+ if (! $record) {
+ $state = $this->getState();
+ $newState = [];
+
+ foreach ($state as $item) {
+ $uuid = $item['uuid'] ?? null;
+
+ if (in_array($uuid, $uuids)) {
+ if ($item['is_cover'] ?? false) {
+ $hadCover = true;
+ }
+
+ if (isset($item['temp_file'])) {
+ $tempPath = storage_path('app/public/'.$item['temp_file']);
+ if (file_exists($tempPath)) {
+ @unlink($tempPath);
+ }
+ }
+
+ $deletedCount++;
+ } else {
+ $newState[] = $item;
+ }
+ }
+
+ if ($hadCover && count($newState) > 0) {
+ $newState[0]['is_cover'] = true;
+ }
+
+ $this->state($newState);
+
+ Notification::make()
+ ->title($deletedCount.' image(s) removed')
+ ->success()
+ ->send();
+
+ return;
+ }
+
+ $mediaCollection = $record->getMedia($this->getCollection());
+ $mediaToDelete = [];
+
+ foreach ($uuids as $uuid) {
+ $media = $mediaCollection->firstWhere('uuid', $uuid);
+ if ($media) {
+ $mediaToDelete[] = $media;
+ if ($media->getCustomProperty('is_cover', false)) {
+ $hadCover = true;
+ }
+ }
+ }
+
+ foreach ($mediaToDelete as $media) {
+ $media->delete();
+ $deletedCount++;
+ }
+
+ if ($deletedCount > 0) {
+ $record->load('media');
+
+ if ($hadCover) {
+ $remainingMedia = $record->getMedia($this->getCollection());
+ if ($remainingMedia->count() > 0) {
+ $firstMedia = $remainingMedia->first();
+ $firstMedia->setCustomProperty('is_cover', true);
+ $firstMedia->save();
+ }
+ }
+
+ $this->refreshState();
+
+ Notification::make()
+ ->title($deletedCount.' image(s) deleted')
+ ->success()
+ ->send();
+ } else {
+ Notification::make()
+ ->title('No images were deleted')
+ ->warning()
+ ->send();
+ }
+ });
+ }
+
+ public function getDeleteAction(): Action
+ {
+ return Action::make('deleteImage')
+ ->label('Delete')
+ ->color('danger')
+ ->requiresConfirmation()
+ ->modalHeading('Delete Image')
+ ->modalDescription('Are you sure you want to delete this image? This action cannot be undone.')
+ ->modalSubmitActionLabel('Delete')
+ ->action(function (array $arguments): void {
+ $args = $arguments['arguments'] ?? $arguments;
+ $uuid = $args['uuid'] ?? null;
+
+ if (! $uuid) {
+ return;
+ }
+
+ $record = $this->getRecord();
+
+ if (! $record) {
+ $state = $this->getState();
+ $imageIndex = collect($state)->search(fn ($item) => $item['uuid'] === $uuid);
+
+ if ($imageIndex !== false) {
+ $wasCover = $state[$imageIndex]['is_cover'] ?? false;
+
+ if (isset($state[$imageIndex]['temp_file'])) {
+ $tempPath = storage_path('app/public/'.$state[$imageIndex]['temp_file']);
+ if (file_exists($tempPath)) {
+ @unlink($tempPath);
+ }
+ }
+
+ $newState = collect($state)->reject(fn ($item) => $item['uuid'] === $uuid)->values()->toArray();
+
+ if ($wasCover && count($newState) > 0) {
+ $newState[0]['is_cover'] = true;
+ }
+
+ $this->state($newState);
+
+ Notification::make()
+ ->title('Image removed')
+ ->success()
+ ->send();
+ }
+
+ return;
+ }
+
+ $media = $record->getMedia($this->getCollection())->firstWhere('uuid', $uuid);
+
+ if (! $media) {
+ Notification::make()
+ ->title('Could not find image to delete')
+ ->warning()
+ ->send();
+
+ return;
+ }
+
+ $wasCover = $media->getCustomProperty('is_cover', false);
+
+ $media->delete();
+
+ $record->load('media');
+
+ if ($wasCover) {
+ $remainingMedia = $record->getMedia($this->getCollection());
+ if ($remainingMedia->count() > 0) {
+ $firstMedia = $remainingMedia->first();
+ $firstMedia->setCustomProperty('is_cover', true);
+ $firstMedia->save();
+ }
+ }
+
+ $this->refreshState();
+
+ Notification::make()
+ ->title('Image deleted')
+ ->success()
+ ->send();
+ });
+ }
+}
diff --git a/tests/Feature/MediaGalleryTest.php b/tests/Feature/MediaGalleryTest.php
new file mode 100644
index 0000000..73f104e
--- /dev/null
+++ b/tests/Feature/MediaGalleryTest.php
@@ -0,0 +1,180 @@
+collection('test-images');
+
+ expect($field->getCollection())->toBe('test-images');
+});
+
+test('media gallery can be configured with preview options', function () {
+ $field = MediaGallery::make('images')
+ ->columns(6)
+ ->thumbnailHeight(200)
+ ->lightbox(false)
+ ->orderable(false);
+
+ expect($field->getColumns())->toBe(6)
+ ->and($field->getThumbnailHeight())->toBe(200)
+ ->and($field->hasLightbox())->toBeFalse()
+ ->and($field->isDragReorderable())->toBeFalse();
+});
+
+test('media gallery can be configured with upload options', function () {
+ $field = MediaGallery::make('images')
+ ->maxFiles(5)
+ ->maxFileSize(2048)
+ ->allowUrlUploads()
+ ->allowFileUploads()
+ ->single()
+ ->acceptedFileTypes(['image/jpeg', 'image/png']);
+
+ expect($field->getMaxFiles())->toBe(5)
+ ->and($field->getMaxFileSize())->toBe(2048)
+ ->and($field->getAllowUrlUploads())->toBeTrue()
+ ->and($field->getAllowFileUploads())->toBeTrue()
+ ->and($field->isMultiple())->toBeFalse()
+ ->and($field->getAcceptedFileTypes())->toBe(['image/jpeg', 'image/png']);
+});
+
+test('media gallery has preview and orderable methods', function () {
+ $previewField = MediaGallery::make('images')->preview();
+ $orderableField = MediaGallery::make('images')->orderable();
+
+ expect($previewField->hasLightbox())->toBeTrue()
+ ->and($orderableField->isDragReorderable())->toBeTrue();
+});
+
+test('media gallery has correct default values', function () {
+ $field = MediaGallery::make('images');
+
+ expect($field->hasLightbox())->toBeFalse()
+ ->and($field->isDragReorderable())->toBeFalse()
+ ->and($field->getColumns())->toBe(4)
+ ->and($field->getThumbnailHeight())->toBe(150)
+ ->and($field->getAllowFileUploads())->toBeFalse()
+ ->and($field->getAllowUrlUploads())->toBeFalse();
+});
+
+test('media gallery upload methods work', function () {
+ $fileOnlyField = MediaGallery::make('images')->allowFileUploads();
+ $urlOnlyField = MediaGallery::make('images')->allowUrlUploads();
+ $bothField = MediaGallery::make('images')->allowUploads();
+
+ expect($fileOnlyField->getAllowFileUploads())->toBeTrue()
+ ->and($fileOnlyField->getAllowUrlUploads())->toBeFalse()
+ ->and($urlOnlyField->getAllowFileUploads())->toBeFalse()
+ ->and($urlOnlyField->getAllowUrlUploads())->toBeTrue()
+ ->and($bothField->getAllowFileUploads())->toBeTrue()
+ ->and($bothField->getAllowUrlUploads())->toBeTrue();
+});
+
+test('media gallery has action methods', function () {
+ $field = MediaGallery::make('images');
+
+ expect($field->getUploadAction())->toBeInstanceOf(Action::class)
+ ->and($field->getUrlUploadAction())->toBeInstanceOf(Action::class)
+ ->and($field->getEditAction())->toBeInstanceOf(Action::class)
+ ->and($field->getDeleteAction())->toBeInstanceOf(Action::class)
+ ->and($field->getCoverAction())->toBeInstanceOf(Action::class)
+ ->and($field->getReorderAction())->toBeInstanceOf(Action::class);
+});
+
+test('media gallery supports closure configuration', function () {
+ $field = MediaGallery::make('images')
+ ->collection(fn () => 'dynamic-collection')
+ ->maxFiles(fn () => 10);
+
+ expect($field->getCollection())->toBe('dynamic-collection')
+ ->and($field->getMaxFiles())->toBe(10);
+});
+
+test('media gallery upload action has correct properties', function () {
+ $field = MediaGallery::make('images');
+ $uploadAction = $field->getUploadAction();
+
+ expect($uploadAction->getName())->toBe('upload')
+ ->and($uploadAction->getLabel())->toBe('Upload Files')
+ ->and($uploadAction->getIcon())->toBe('heroicon-o-arrow-up-tray')
+ ->and($uploadAction->getColor())->toBe('primary');
+});
+
+test('media gallery url upload action has correct properties', function () {
+ $field = MediaGallery::make('images');
+ $urlUploadAction = $field->getUrlUploadAction();
+
+ expect($urlUploadAction->getName())->toBe('urlUpload')
+ ->and($urlUploadAction->getLabel())->toBe('Add from URL')
+ ->and($urlUploadAction->getIcon())->toBe('heroicon-o-link')
+ ->and($urlUploadAction->getColor())->toBe('gray');
+});
+
+test('media gallery delete action has correct properties', function () {
+ $field = MediaGallery::make('images');
+ $deleteAction = $field->getDeleteAction();
+
+ expect($deleteAction->getName())->toBe('deleteImage')
+ ->and($deleteAction->getLabel())->toBe('Delete')
+ ->and($deleteAction->getColor())->toBe('danger');
+});
+
+test('media gallery cover action has correct properties', function () {
+ $field = MediaGallery::make('images');
+ $coverAction = $field->getCoverAction();
+
+ expect($coverAction->getName())->toBe('setCover')
+ ->and($coverAction->getLabel())->toBe('Set as Cover');
+});
+
+test('media gallery reorder action has correct properties', function () {
+ $field = MediaGallery::make('images');
+ $reorderAction = $field->getReorderAction();
+
+ expect($reorderAction->getName())->toBe('reorder');
+});
+
+test('media gallery supports responsive columns', function () {
+ $field = MediaGallery::make('images')
+ ->columns([
+ 'default' => 2,
+ 'sm' => 3,
+ 'lg' => 4,
+ 'xl' => 6,
+ ]);
+
+ expect($field->getColumns())->toBe([
+ 'default' => 2,
+ 'sm' => 3,
+ 'lg' => 4,
+ 'xl' => 6,
+ ])
+ ->and($field->getGridColumns())->toBe(2)
+ ->and($field->getGridClasses())->toBe('grid gap-3')
+ ->and($field->getGridStyle())->toContain('grid-template-columns: repeat(2, 1fr)')
+ ->and($field->getGridStyle())->toContain('@media (min-width: 640px)')
+ ->and($field->getGridStyle())->toContain('@media (min-width: 1024px)');
+});
+
+test('media gallery columns method works with simple integer', function () {
+ $field = MediaGallery::make('images')->columns(5);
+
+ expect($field->getColumns())->toBe(5)
+ ->and($field->getGridColumns())->toBe(5)
+ ->and($field->getGridClasses())->toBe('grid gap-3')
+ ->and($field->getGridStyle())->toBe('grid-template-columns: repeat(5, 1fr);');
+});
+
+test('media gallery has bulk delete action', function () {
+ $field = MediaGallery::make('images');
+ $bulkDeleteAction = $field->getBulkDeleteAction();
+
+ expect($bulkDeleteAction->getName())->toBe('bulkDelete')
+ ->and($bulkDeleteAction->getLabel())->toBe('Delete Selected')
+ ->and($bulkDeleteAction->getColor())->toBe('danger')
+ ->and($bulkDeleteAction->getIcon())->toBe('heroicon-o-trash')
+ ->and($bulkDeleteAction->getModalHeading())->toBe('Delete Images')
+ ->and($bulkDeleteAction->getModalSubmitActionLabel())->toBe('Delete Selected');
+});