From 1da0eab3aec23fddfc487cff01f1855389ff80f2 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Fri, 26 Sep 2025 14:00:41 +0545 Subject: [PATCH 1/5] feat: add MediaGallery form field component --- docs/MediaGallery.md | 174 +++ .../forms/components/media-gallery.blade.php | 244 +++ .../Concerns/HasMediaUploadOptions.php | 128 ++ .../Forms/Components/MediaGallery.php | 1309 +++++++++++++++++ tests/Feature/MediaGalleryTest.php | 180 +++ 5 files changed, 2035 insertions(+) create mode 100644 docs/MediaGallery.md create mode 100644 resources/views/filament/forms/components/media-gallery.blade.php create mode 100644 src/Filament/Forms/Components/Concerns/HasMediaUploadOptions.php create mode 100644 src/Filament/Forms/Components/MediaGallery.php create mode 100644 tests/Feature/MediaGalleryTest.php diff --git a/docs/MediaGallery.md b/docs/MediaGallery.md new file mode 100644 index 0000000..12397f4 --- /dev/null +++ b/docs/MediaGallery.md @@ -0,0 +1,174 @@ +# MediaGallery Form Field + +A powerful image gallery form field for Filament with drag & drop reordering, multiple upload options, and preview features. + +## Basic Usage + +```php +use Eclipse\Common\Filament\Forms\Components\MediaGallery; + +MediaGallery::make('images') + ->collection('gallery') + ->required() +``` + +## Collection Configuration + +```php +MediaGallery::make('images') + ->collection('product-gallery') // Media collection name + ->collection(fn () => 'dynamic-' . $this->category) // Dynamic collection +``` + +## Upload Options + +```php +MediaGallery::make('images') + ->maxFiles(10) // Maximum number of files + ->maxFileSize(2048) // Max size in KB + ->acceptedFileTypes(['image/jpeg', 'image/png', 'image/webp']) + ->allowUploads() // Enable both upload methods + ->single() // Single file mode + ->multiple() // Multiple files (default) +``` + +## Preview & Layout + +```php +MediaGallery::make('images') + ->columns(6) // Number of columns (default: 4) + ->thumbnailHeight(200) // Thumbnail height in pixels (default: 150) + ->preview() // Enable lightbox preview (disabled by default) + ->orderable() // Enable drag & drop reordering (disabled by default) +``` + +### Responsive Columns + +```php +MediaGallery::make('images') + ->columns([ + 'default' => 2, + 'sm' => 3, + 'lg' => 4, + 'xl' => 6 + ]) +``` + +## Methods Available + +### Layout Control +- `columns(int|array)` - Set number of grid columns or responsive column configuration (default: 4) +- `thumbnailHeight(int)` - Set thumbnail image height in pixels (default: 150) + +### Interactive Features +- `preview()` - Enable lightbox modal for image preview (disabled by default) +- `orderable()` - Enable drag & drop reordering (disabled by default) + +### Upload Configuration +- `collection(string)` - Set media collection name +- `maxFiles(int)` - Maximum number of uploadable files +- `maxFileSize(int)` - Maximum file size in KB +- `acceptedFileTypes(array)` - Allowed file MIME types +- `allowFileUploads()` - Enable file upload button (disabled by default) +- `allowUrlUploads()` - Enable URL upload button (disabled by default) +- `allowUploads()` - Enable both file and URL upload buttons +- `single()` - Single file mode +- `multiple()` - Multiple file mode (default) + +## Actions Available + +The MediaGallery provides these built-in actions: + +- **Upload**: File upload with drag & drop interface +- **URL Upload**: Add images from external URLs +- **Edit**: Edit image details and metadata +- **Delete**: Remove images with confirmation +- **Set Cover**: Mark image as cover/featured +- **Reorder**: Drag & drop reordering (when `orderable()` is enabled) + +## Examples + +### Basic Gallery (View Only) +```php +MediaGallery::make('images') + ->collection('products') +``` + +### Gallery with Uploads +```php +MediaGallery::make('images') + ->collection('gallery') + ->allowUploads() // Enable both upload methods + ->maxFiles(20) +``` + +### Gallery with Preview +```php +MediaGallery::make('images') + ->collection('gallery') + ->allowUploads() + ->preview() // Enables lightbox + ->columns(3) + ->thumbnailHeight(180) +``` + +### Orderable Gallery +```php +MediaGallery::make('images') + ->collection('portfolio') + ->allowUploads() + ->orderable() // Enables drag & drop + ->columns(5) + ->maxFiles(50) +``` + +### File Upload Only +```php +MediaGallery::make('images') + ->collection('secure-documents') + ->allowFileUploads() // Only file uploads + ->maxFiles(10) +``` + +### URL Upload Only +```php +MediaGallery::make('images') + ->collection('external-media') + ->allowUrlUploads() // Only URL uploads + ->columns(6) +``` + +### Complete Configuration +```php +MediaGallery::make('images') + ->collection('products') + ->allowUploads() // Enable both upload methods + ->maxFiles(20) + ->columns(4) + ->thumbnailHeight(180) + ->preview() // Enable lightbox + ->orderable() // Enable reordering + ->acceptedFileTypes(['image/jpeg', 'image/png']) +``` + +### Dynamic Configuration +```php +MediaGallery::make('images') + ->collection(fn () => $this->record?->category . '-images') + ->maxFiles(fn () => $this->record?->isPremium() ? 50 : 10) + ->columns(fn () => $this->getColumnCount()) +``` + +## Default Behaviors + +- **Upload Buttons**: Disabled by default (use `->allowUploads()`, `->allowFileUploads()`, or `->allowUrlUploads()`) +- **Lightbox**: Disabled by default (use `->preview()` to enable) +- **Drag & Drop**: Disabled by default (use `->orderable()` to enable) +- **Grid Columns**: 4 columns by default +- **File Types**: Images only (jpeg, png, gif, webp) + +## Requirements + +- Spatie Media Library package +- Model must implement `HasMedia` interface +- Images are stored in configured media collections \ No newline at end of file diff --git a/resources/views/filament/forms/components/media-gallery.blade.php b/resources/views/filament/forms/components/media-gallery.blade.php new file mode 100644 index 0000000..83b667d --- /dev/null +++ b/resources/views/filament/forms/components/media-gallery.blade.php @@ -0,0 +1,244 @@ +@php + $isDraggable = $isDragReorderable(); + $hasLightboxPreview = $hasLightbox(); + $gridClasses = $getGridClasses(); + $gridStyle = $getGridStyle(); + $thumbnailHeight = $getThumbnailHeight(); + $statePath = $getStatePath(); + $gridId = 'media-gallery-grid-' . str_replace(['.', '[', ']'], '-', $statePath); +@endphp + + +
$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()) +
+ + +
+ + 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 + +
+
+ +
+
+
+ +
+
+

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/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'); +}); From 23fd334036515472a8abd0981ce0de02a3101ff6 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Fri, 26 Sep 2025 14:14:16 +0545 Subject: [PATCH 2/5] feat: add missing MediaGallery trait dependencies --- .../Concerns/CanManageMediaCollections.php | 22 ++++++++ .../Components/Concerns/HasMediaPreview.php | 50 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 src/Filament/Forms/Components/Concerns/CanManageMediaCollections.php create mode 100644 src/Filament/Forms/Components/Concerns/HasMediaPreview.php diff --git a/src/Filament/Forms/Components/Concerns/CanManageMediaCollections.php b/src/Filament/Forms/Components/Concerns/CanManageMediaCollections.php new file mode 100644 index 0000000..247d3aa --- /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); + } +} \ No newline at end of file diff --git a/src/Filament/Forms/Components/Concerns/HasMediaPreview.php b/src/Filament/Forms/Components/Concerns/HasMediaPreview.php new file mode 100644 index 0000000..964bd8d --- /dev/null +++ b/src/Filament/Forms/Components/Concerns/HasMediaPreview.php @@ -0,0 +1,50 @@ +previewConversions = $conversions; + + return $this; + } + + public function previewHeight(int|Closure $height): static + { + $this->previewHeight = $height; + + return $this; + } + + public function previewWidth(int|Closure $width): static + { + $this->previewWidth = $width; + + return $this; + } + + public function getPreviewConversions(): array + { + return $this->evaluate($this->previewConversions); + } + + public function getPreviewHeight(): int + { + return $this->evaluate($this->previewHeight); + } + + public function getPreviewWidth(): int + { + return $this->evaluate($this->previewWidth); + } +} \ No newline at end of file From e90bb9eabf8422bbdd5ac0b3ca91dbfb78137feb Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Fri, 26 Sep 2025 14:16:57 +0545 Subject: [PATCH 3/5] feat: add missing MediaGallery methods for thumbnailHeight, columns, orderable, preview --- .../Components/Concerns/HasMediaPreview.php | 28 +++++++++++++++++++ .../Concerns/HasMediaUploadOptions.php | 28 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/Filament/Forms/Components/Concerns/HasMediaPreview.php b/src/Filament/Forms/Components/Concerns/HasMediaPreview.php index 964bd8d..60024ba 100644 --- a/src/Filament/Forms/Components/Concerns/HasMediaPreview.php +++ b/src/Filament/Forms/Components/Concerns/HasMediaPreview.php @@ -12,6 +12,10 @@ trait HasMediaPreview protected int|Closure $previewWidth = 200; + protected int|Closure $thumbnailHeight = 150; + + protected bool|Closure $preview = true; + public function previewConversions(array|Closure $conversions): static { $this->previewConversions = $conversions; @@ -33,6 +37,20 @@ public function previewWidth(int|Closure $width): static return $this; } + public function thumbnailHeight(int|Closure $height): static + { + $this->thumbnailHeight = $height; + + return $this; + } + + public function preview(bool|Closure $preview = true): static + { + $this->preview = $preview; + + return $this; + } + public function getPreviewConversions(): array { return $this->evaluate($this->previewConversions); @@ -47,4 +65,14 @@ public function getPreviewWidth(): int { return $this->evaluate($this->previewWidth); } + + public function getThumbnailHeight(): int + { + return $this->evaluate($this->thumbnailHeight); + } + + public function getPreview(): bool + { + return $this->evaluate($this->preview); + } } \ No newline at end of file diff --git a/src/Filament/Forms/Components/Concerns/HasMediaUploadOptions.php b/src/Filament/Forms/Components/Concerns/HasMediaUploadOptions.php index e9fca28..7c95414 100644 --- a/src/Filament/Forms/Components/Concerns/HasMediaUploadOptions.php +++ b/src/Filament/Forms/Components/Concerns/HasMediaUploadOptions.php @@ -20,6 +20,10 @@ trait HasMediaUploadOptions protected bool|Closure $allowBulkDelete = true; + protected array|Closure $columns = ['default' => 1]; + + protected bool|Closure $orderable = false; + public function acceptedFileTypes(array|Closure $types): static { $this->acceptedFileTypes = $types; @@ -77,6 +81,20 @@ public function single(): static return $this; } + public function columns(array|Closure $columns): static + { + $this->columns = $columns; + + return $this; + } + + public function orderable(bool|Closure $condition = true): static + { + $this->orderable = $condition; + + return $this; + } + public function getAcceptedFileTypes(): array { return $this->evaluate($this->acceptedFileTypes); @@ -125,4 +143,14 @@ public function getAllowBulkDelete(): bool { return $this->evaluate($this->allowBulkDelete); } + + public function getColumns(): array + { + return $this->evaluate($this->columns); + } + + public function getOrderable(): bool + { + return $this->evaluate($this->orderable); + } } From 21ac543268297475b97e56002a88084441315e71 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Fri, 26 Sep 2025 14:29:25 +0545 Subject: [PATCH 4/5] fix: adding missing files --- resources/dist/media-gallery.css | 153 ++++++++++++++++++ resources/dist/media-gallery.js | 80 +++++++++ .../views/components/media-lightbox.blade.php | 39 +++++ .../views/components/media-preview.blade.php | 8 + src/CommonServiceProvider.php | 21 ++- .../Concerns/CanManageMediaCollections.php | 4 +- .../Components/Concerns/HasMediaPreview.php | 110 ++++++++++--- .../Concerns/HasMediaUploadOptions.php | 28 ---- 8 files changed, 391 insertions(+), 52 deletions(-) create mode 100644 resources/dist/media-gallery.css create mode 100644 resources/dist/media-gallery.js create mode 100644 resources/views/components/media-lightbox.blade.php create mode 100644 resources/views/components/media-preview.blade.php diff --git a/resources/dist/media-gallery.css b/resources/dist/media-gallery.css new file mode 100644 index 0000000..77be8dc --- /dev/null +++ b/resources/dist/media-gallery.css @@ -0,0 +1,153 @@ +[x-cloak] { + display: none !important; +} + +.eclipse-media-gallery [draggable="true"] { + cursor: move; +} + +.eclipse-media-gallery [draggable="true"]:active { + cursor: grabbing; +} + +.eclipse-image-lightbox-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 9999!important; + background-color: rgba(0, 0, 0, 0.95); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.eclipse-image-lightbox-container { + position: relative; + max-width: 90vw; + max-height: 90vh; + display: flex; + align-items: center; + justify-content: center; + z-index: 9999999999 !important; +} + +.eclipse-image-lightbox-close { + position: absolute; + top: -50px; + right: 0; + color: white; + background: none; + border: none; + cursor: pointer; + padding: 10px; + opacity: 0.8; + transition: opacity 0.2s; +} + +.eclipse-image-lightbox-close:hover { + opacity: 1; +} + +.eclipse-image-lightbox-close svg { + width: 32px; + height: 32px; +} + +.eclipse-image-lightbox-image-wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; + background-color: #1f2937; + border-radius: 8px; + overflow: hidden; + max-width: 90vw; + max-height: 85vh; +} + +.eclipse-image-lightbox-image { + max-width: 100%; + max-height: 85vh; + width: auto; + height: auto; + object-fit: contain; + display: block; +} + +.eclipse-image-lightbox-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + background-color: rgba(255, 255, 255, 0.1); + color: white; + border: none; + border-radius: 50%; + width: 48px; + height: 48px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s; +} + +.eclipse-image-lightbox-nav:hover { + background-color: rgba(255, 255, 255, 0.2); +} + +.eclipse-image-lightbox-nav.prev { + left: -60px; +} + +.eclipse-image-lightbox-nav.next { + right: -60px; +} + +.eclipse-image-lightbox-nav svg { + width: 24px; + height: 24px; +} + +.eclipse-image-lightbox-info { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.9), transparent); + padding: 24px; + color: white; + border-radius: 0 0 8px 8px; +} + +.eclipse-image-lightbox-title { + font-size: 18px; + font-weight: 600; + margin: 0 0 8px 0; +} + +.eclipse-image-lightbox-description { + font-size: 14px; + opacity: 0.9; + margin: 0; + line-height: 1.5; +} + +.eclipse-image-card-container { + background-color: #f3f4f6; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.eclipse-image-card-img { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + object-fit: contain; +} \ No newline at end of file diff --git a/resources/dist/media-gallery.js b/resources/dist/media-gallery.js new file mode 100644 index 0000000..2db60d9 --- /dev/null +++ b/resources/dist/media-gallery.js @@ -0,0 +1,80 @@ +window.mediaGallery = function({ state, getLocale }) { + return { + state: state || [], + getLocale: getLocale, + + init() { + if (!Array.isArray(this.state)) { + this.state = []; + } + + this.lightboxOpen = false; + this.lightboxIndex = 0; + this.lightboxImage = ''; + this.lightboxAlt = ''; + this.lightboxName = ''; + this.lightboxDescription = ''; + + document.addEventListener('keydown', (e) => { + if (this.lightboxOpen) { + if (e.key === 'ArrowLeft') { + e.preventDefault(); + this.previousImage(); + } else if (e.key === 'ArrowRight') { + e.preventDefault(); + this.nextImage(); + } + } + }); + }, + + getLocalizedName(image) { + const currentLocale = this.getLocale(); + if (!image.name || typeof image.name !== 'object') { + return image.file_name || ''; + } + return image.name[currentLocale] || image.name['en'] || image.file_name || ''; + }, + + getLocalizedDescription(image) { + const currentLocale = this.getLocale(); + if (!image.description || typeof image.description !== 'object') { + return ''; + } + return image.description[currentLocale] || image.description['en'] || ''; + }, + + openImageModal(index) { + this.lightboxIndex = index; + const image = this.state[index]; + this.lightboxImage = image.url; + this.lightboxAlt = image.file_name; + this.lightboxName = this.getLocalizedName(image); + this.lightboxDescription = this.getLocalizedDescription(image); + this.lightboxOpen = true; + }, + + previousImage() { + this.lightboxIndex = (this.lightboxIndex - 1 + this.state.length) % this.state.length; + this.updateLightboxImage(); + }, + + nextImage() { + this.lightboxIndex = (this.lightboxIndex + 1) % this.state.length; + this.updateLightboxImage(); + }, + + updateLightboxImage() { + const image = this.state[this.lightboxIndex]; + this.lightboxImage = image.url; + this.lightboxAlt = image.file_name; + this.lightboxName = this.getLocalizedName(image); + this.lightboxDescription = this.getLocalizedDescription(image); + }, + + handleSetCover(image) { + if (image.is_cover) return; + this.$wire.mountFormComponentAction('setCover', { arguments: { uuid: image.uuid } }); + } + }; +}; \ No newline at end of file diff --git a/resources/views/components/media-lightbox.blade.php b/resources/views/components/media-lightbox.blade.php new file mode 100644 index 0000000..628c4a9 --- /dev/null +++ b/resources/views/components/media-lightbox.blade.php @@ -0,0 +1,39 @@ +
+ +
+ + +
+ +
+

+

+
+
+ +
+
\ No newline at end of file diff --git a/resources/views/components/media-preview.blade.php b/resources/views/components/media-preview.blade.php new file mode 100644 index 0000000..c8a2147 --- /dev/null +++ b/resources/views/components/media-preview.blade.php @@ -0,0 +1,8 @@ +
+ {{ $filename }} +

{{ $filename }}

+
\ No newline at end of file diff --git a/src/CommonServiceProvider.php b/src/CommonServiceProvider.php index ff470d4..1d671db 100644 --- a/src/CommonServiceProvider.php +++ b/src/CommonServiceProvider.php @@ -3,6 +3,10 @@ namespace Eclipse\Common; use Eclipse\Common\Foundation\Providers\PackageServiceProvider; +use Filament\Support\Assets\Css; +use Filament\Support\Assets\Js; +use Filament\Support\Facades\FilamentAsset; +use Filament\Support\Facades\FilamentView; use Spatie\LaravelPackageTools\Package as SpatiePackage; class CommonServiceProvider extends PackageServiceProvider @@ -12,7 +16,9 @@ class CommonServiceProvider extends PackageServiceProvider public function configurePackage(SpatiePackage|Package $package): void { $package->name(static::$name) - ->hasTranslations(); + ->hasTranslations() + ->hasViews() + ->hasAssets(); } public function register(): self @@ -27,4 +33,17 @@ public function register(): self return $this; } + + public function bootingPackage(): void + { + FilamentAsset::register([ + Css::make('media-gallery', asset('vendor/eclipse-common/media-gallery.css')), + Js::make('media-gallery', asset('vendor/eclipse-common/media-gallery.js')), + ], 'eclipse-common'); + + FilamentView::registerRenderHook( + 'panels::body.end', + fn (): string => view('eclipse-common::components.media-lightbox')->render() + ); + } } diff --git a/src/Filament/Forms/Components/Concerns/CanManageMediaCollections.php b/src/Filament/Forms/Components/Concerns/CanManageMediaCollections.php index 247d3aa..c128610 100644 --- a/src/Filament/Forms/Components/Concerns/CanManageMediaCollections.php +++ b/src/Filament/Forms/Components/Concerns/CanManageMediaCollections.php @@ -6,7 +6,7 @@ trait CanManageMediaCollections { - protected string|Closure $collection = 'default'; + protected string|Closure $collection = 'images'; public function collection(string|Closure $collection): static { @@ -19,4 +19,4 @@ public function getCollection(): string { return $this->evaluate($this->collection); } -} \ No newline at end of file +} diff --git a/src/Filament/Forms/Components/Concerns/HasMediaPreview.php b/src/Filament/Forms/Components/Concerns/HasMediaPreview.php index 60024ba..916cf3e 100644 --- a/src/Filament/Forms/Components/Concerns/HasMediaPreview.php +++ b/src/Filament/Forms/Components/Concerns/HasMediaPreview.php @@ -6,33 +6,40 @@ trait HasMediaPreview { - protected array|Closure $previewConversions = ['thumb', 'preview']; + protected bool|Closure $hasLightbox = false; - protected int|Closure $previewHeight = 200; + protected bool|Closure $hasCoverImageSelection = true; - protected int|Closure $previewWidth = 200; + protected bool|Closure $isDragReorderable = false; protected int|Closure $thumbnailHeight = 150; - protected bool|Closure $preview = true; + protected array|string|int|null $mediaColumns = 4; - public function previewConversions(array|Closure $conversions): static + public function lightbox(bool|Closure $condition = true): static { - $this->previewConversions = $conversions; + $this->hasLightbox = $condition; return $this; } - public function previewHeight(int|Closure $height): static + public function preview(): static { - $this->previewHeight = $height; + $this->hasLightbox = true; return $this; } - public function previewWidth(int|Closure $width): static + public function coverImageSelection(bool|Closure $condition = true): static { - $this->previewWidth = $width; + $this->hasCoverImageSelection = $condition; + + return $this; + } + + public function orderable(bool|Closure $condition = true): static + { + $this->isDragReorderable = $condition; return $this; } @@ -44,26 +51,33 @@ public function thumbnailHeight(int|Closure $height): static return $this; } - public function preview(bool|Closure $preview = true): static + public function columns(array|string|int|null $columns = 2): static { - $this->preview = $preview; + $this->mediaColumns = $columns; return $this; } - public function getPreviewConversions(): array + public function gridColumns(int $columns): static { - return $this->evaluate($this->previewConversions); + $this->mediaColumns = $columns; + + return $this; } - public function getPreviewHeight(): int + public function hasLightbox(): bool { - return $this->evaluate($this->previewHeight); + return $this->evaluate($this->hasLightbox); } - public function getPreviewWidth(): int + public function hasCoverImageSelection(): bool { - return $this->evaluate($this->previewWidth); + return $this->evaluate($this->hasCoverImageSelection); + } + + public function isDragReorderable(): bool + { + return $this->evaluate($this->isDragReorderable); } public function getThumbnailHeight(): int @@ -71,8 +85,62 @@ public function getThumbnailHeight(): int return $this->evaluate($this->thumbnailHeight); } - public function getPreview(): bool + 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 { - return $this->evaluate($this->preview); + $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); } -} \ No newline at end of file +} diff --git a/src/Filament/Forms/Components/Concerns/HasMediaUploadOptions.php b/src/Filament/Forms/Components/Concerns/HasMediaUploadOptions.php index 7c95414..e9fca28 100644 --- a/src/Filament/Forms/Components/Concerns/HasMediaUploadOptions.php +++ b/src/Filament/Forms/Components/Concerns/HasMediaUploadOptions.php @@ -20,10 +20,6 @@ trait HasMediaUploadOptions protected bool|Closure $allowBulkDelete = true; - protected array|Closure $columns = ['default' => 1]; - - protected bool|Closure $orderable = false; - public function acceptedFileTypes(array|Closure $types): static { $this->acceptedFileTypes = $types; @@ -81,20 +77,6 @@ public function single(): static return $this; } - public function columns(array|Closure $columns): static - { - $this->columns = $columns; - - return $this; - } - - public function orderable(bool|Closure $condition = true): static - { - $this->orderable = $condition; - - return $this; - } - public function getAcceptedFileTypes(): array { return $this->evaluate($this->acceptedFileTypes); @@ -143,14 +125,4 @@ public function getAllowBulkDelete(): bool { return $this->evaluate($this->allowBulkDelete); } - - public function getColumns(): array - { - return $this->evaluate($this->columns); - } - - public function getOrderable(): bool - { - return $this->evaluate($this->orderable); - } } From 886bd9b5671db305dea10455d41054e09aca6625 Mon Sep 17 00:00:00 2001 From: thapacodes4u Date: Wed, 8 Oct 2025 14:19:03 +0545 Subject: [PATCH 5/5] refactor: align asset registration with catalogue plugin pattern --- resources/{dist => css}/media-gallery.css | 0 resources/{dist => js}/media-gallery.js | 0 src/CommonServiceProvider.php | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) rename resources/{dist => css}/media-gallery.css (100%) rename resources/{dist => js}/media-gallery.js (100%) diff --git a/resources/dist/media-gallery.css b/resources/css/media-gallery.css similarity index 100% rename from resources/dist/media-gallery.css rename to resources/css/media-gallery.css diff --git a/resources/dist/media-gallery.js b/resources/js/media-gallery.js similarity index 100% rename from resources/dist/media-gallery.js rename to resources/js/media-gallery.js diff --git a/src/CommonServiceProvider.php b/src/CommonServiceProvider.php index 1d671db..859a9e2 100644 --- a/src/CommonServiceProvider.php +++ b/src/CommonServiceProvider.php @@ -37,9 +37,9 @@ public function register(): self public function bootingPackage(): void { FilamentAsset::register([ - Css::make('media-gallery', asset('vendor/eclipse-common/media-gallery.css')), - Js::make('media-gallery', asset('vendor/eclipse-common/media-gallery.js')), - ], 'eclipse-common'); + Css::make('media-gallery', __DIR__.'/../resources/css/media-gallery.css'), + Js::make('media-gallery', __DIR__.'/../resources/js/media-gallery.js'), + ], package: static::$name); FilamentView::registerRenderHook( 'panels::body.end',