diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 3855a08..337c133 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -16,7 +16,8 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.3' + extensions: sockets coverage: none - name: Install composer dependencies diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 7c60f1f..3656254 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,13 +13,13 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, windows-latest] - php: [8.2, 8.1] - laravel: [10.*] + php: [8.3] + laravel: [11.*] stability: [prefer-lowest, prefer-stable] include: - - laravel: 10.* - testbench: 8.* - carbon: 2.* + - laravel: 11.* + testbench: 9.* + carbon: 3.* name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} @@ -31,7 +31,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo, sockets coverage: none - name: Setup problem matchers @@ -42,7 +42,7 @@ jobs: - name: Install dependencies run: | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" "nesbot/carbon:${{ matrix.carbon }}" --no-interaction --no-update - composer update --${{ matrix.stability }} --prefer-dist --no-interaction + composer update --${{ matrix.stability }} --prefer-dist --no-interaction --ignore-platform-req=ext-pcntl - name: List Installed Dependencies run: composer show -D diff --git a/README.md b/README.md index b54ec11..db1ce90 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ You can publish and run the migrations with: ```bash php artisan vendor:publish --tag="media-migrations" +php artisan vendor:publish --provider="Backstage\Translations\Laravel\TranslationServiceProvider" +php artisan migrate ``` > [!NOTE] @@ -228,8 +230,8 @@ Please review [our security policy](../../security/policy) on how to report secu ## Credits -- [Baspa](https://github.com/vormkracht10) -- [All Contributors](../../contributors) +- [Baspa](https://github.com/vormkracht10) +- [All Contributors](../../contributors) ## License diff --git a/composer.json b/composer.json index 73ec7bb..ec95c71 100644 --- a/composer.json +++ b/composer.json @@ -20,10 +20,12 @@ } ], "require": { - "php": "^8.1", + "php": "^8.3", + "backstage/fields": "^0.8.0", + "backstage/laravel-translations": "^0.3.2", "filament/filament": "^4.0", - "spatie/laravel-package-tools": "^1.15.0", - "backstage/fields": "^0.8.0" + "phiki/phiki": "^2.0", + "spatie/laravel-package-tools": "^1.15.0" }, "require-dev": { "laravel/pint": "^1.0", @@ -59,6 +61,7 @@ "sort-packages": true, "allow-plugins": { "pestphp/pest-plugin": true, + "php-http/discovery": true, "phpstan/extension-installer": true } }, @@ -73,17 +76,13 @@ } }, "repositories": { - "backstage/fields": { - "type": "git", - "url": "git@github.com:backstagephp/fields.git" - }, "backstage/cms": { "type": "git", "url": "https://github.com/backstagephp/core.git" }, - "saade/filament-adjacency-list": { + "backstage/fields": { "type": "git", - "url": "git@github.com:backstagephp/filament-adjacency-list.git" + "url": "https://github.com/backstagephp/fields.git" } }, "minimum-stability": "dev", diff --git a/config/backstage/media.php b/config/backstage/media.php index 1fa9091..9dae345 100644 --- a/config/backstage/media.php +++ b/config/backstage/media.php @@ -21,7 +21,7 @@ 'directory' => 'media', - 'disk' => env('FILAMENT_FILESYSTEM_DISK', 'public'), + 'disk' => config('filesystems.default', 'public'), 'should_preserve_filenames' => false, diff --git a/database/migrations/add_alt_column_to_media_table.php.stub b/database/migrations/add_alt_column_to_media_table.php.stub new file mode 100644 index 0000000..5b11c9a --- /dev/null +++ b/database/migrations/add_alt_column_to_media_table.php.stub @@ -0,0 +1,20 @@ +getTable(), function (Blueprint $table) { + $table->text('alt')->after('height'); + }); + } + + public function down(): void + { + Schema::dropIfExists(app(config('backstage.media.model'))->getTable()); + } +}; \ No newline at end of file diff --git a/phpstan.neon.dist b/phpstan.neon.dist index a91953b..7980f31 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -10,5 +10,6 @@ parameters: tmpDir: build/phpstan checkOctaneCompatibility: true checkModelProperties: true - checkMissingIterableValueType: false + ignoreErrors: + - identifier: trait.unused diff --git a/src/Concerns/HasMedia.php b/src/Concerns/HasMedia.php index 20faa6f..2c76f64 100644 --- a/src/Concerns/HasMedia.php +++ b/src/Concerns/HasMedia.php @@ -6,6 +6,9 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Support\Collection; +/** + * @mixin Model + */ trait HasMedia { /** diff --git a/src/Media.php b/src/Media.php index c550a57..6aad62f 100644 --- a/src/Media.php +++ b/src/Media.php @@ -51,14 +51,15 @@ public static function create(array | string $data): array } } - $media[] = Model::updateOrCreate([ - 'site_ulid' => Filament::getTenant()->ulid, + $tenant = Filament::getTenant(); + $mediaModel = Model::updateOrCreate([ + 'site_ulid' => $tenant && property_exists($tenant, 'ulid') ? $tenant->ulid : null, 'disk' => config('backstage.media.disk'), 'original_filename' => pathinfo($filename, PATHINFO_FILENAME), 'checksum' => md5_file($fullPath), ], [ 'filename' => $filename, - 'uploaded_by' => auth()->user()->id, + 'uploaded_by' => auth()->user()?->id, 'extension' => $extension, 'mime_type' => $mimeType, 'size' => $fileSize, @@ -66,6 +67,8 @@ public static function create(array | string $data): array 'height' => $fileInfo['height'] ?? null, 'public' => config('backstage.media.visibility') === 'public', ]); + + $media[] = $mediaModel; } return $media; diff --git a/src/MediaPlugin.php b/src/MediaPlugin.php index 72a4cd3..a846645 100644 --- a/src/MediaPlugin.php +++ b/src/MediaPlugin.php @@ -200,7 +200,7 @@ public function getTenantModel(): ?string } /** - * @return class-string + * @return class-string */ public function getModelItem(): string { diff --git a/src/MediaServiceProvider.php b/src/MediaServiceProvider.php index 07d7af3..927e2c4 100644 --- a/src/MediaServiceProvider.php +++ b/src/MediaServiceProvider.php @@ -81,7 +81,7 @@ public function packageBooted(): void } Relation::enforceMorphMap([ - 'media' => 'Backstage\Media\Models\Media', + 'media' => config('backstage.media.model'), ]); // Testing @@ -146,6 +146,7 @@ protected function getMigrations(): array 'create_media_table', 'create_media_relationships_table', 'add_tenant_aware_column_to_media_table', + 'add_alt_column_to_media_table', ]; } } diff --git a/src/Models/Media.php b/src/Models/Media.php index 7c272e1..e7a9d14 100644 --- a/src/Models/Media.php +++ b/src/Models/Media.php @@ -12,6 +12,24 @@ use Illuminate\Support\Facades\Storage; use Symfony\Component\HttpFoundation\StreamedResponse; +/** + * @property string $ulid + * @property string $filename + * @property string $path + * @property string $mime_type + * @property int $size + * @property int|null $width + * @property int|null $height + * @property string|null $alt + * @property array|null $metadata + * @property int|null $uploaded_by + * @property string|null $tenant_ulid + * @property \Illuminate\Support\Carbon|null $created_at + * @property \Illuminate\Support\Carbon|null $updated_at + * @property \Illuminate\Support\Carbon|null $deleted_at + * @property-read string $humanReadableSize + * @property-read string $src + */ class Media extends Model { use HasUlids; @@ -68,7 +86,7 @@ protected static function booted(): void if ($tenantRelationship && class_exists($tenantModel)) { $currentTenant = Filament::getTenant(); - if ($currentTenant) { + if ($currentTenant && property_exists($currentTenant, 'ulid')) { $model->{$tenantRelationship . '_ulid'} = $currentTenant->ulid; } } diff --git a/src/Pages/Media/Library.php b/src/Pages/Media/Library.php index c4b40b3..a26218c 100644 --- a/src/Pages/Media/Library.php +++ b/src/Pages/Media/Library.php @@ -14,7 +14,6 @@ use Filament\Schemas\Schema; use Filament\Support\Enums\Width; use Illuminate\Support\Collection; -use Illuminate\Support\Str; use Livewire\WithPagination; class Library extends Page implements HasForms @@ -90,7 +89,7 @@ protected function getActions(): array public static function getNavigationLabel(): string { - return MediaPlugin::get()->getNavigationLabel() ?? Str::title(static::getPluralModelLabel()) ?? Str::title(static::getModelLabel()); + return MediaPlugin::get()->getNavigationLabel() ?? __('Media Library'); } public static function getNavigationIcon(): string diff --git a/src/Resources/MediaResource.php b/src/Resources/MediaResource.php index 7980de6..1aae135 100644 --- a/src/Resources/MediaResource.php +++ b/src/Resources/MediaResource.php @@ -9,13 +9,20 @@ use Backstage\Media\Resources\MediaResource\ListMedia; use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; +use Filament\Actions\ViewAction; use Filament\Facades\Filament; +use Filament\Infolists\Components\CodeEntry; +use Filament\Infolists\Components\IconEntry; +use Filament\Infolists\Components\ImageEntry; +use Filament\Infolists\Components\TextEntry; use Filament\Resources\Resource; +use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Illuminate\Support\Str; +use Phiki\Grammar\Grammar; class MediaResource extends Resource { @@ -46,7 +53,7 @@ public static function getPluralModelLabel(): string public static function getNavigationLabel(): string { - return MediaPlugin::get()->getNavigationLabel() ?? Str::title(static::getPluralModelLabel()) ?? Str::title(static::getModelLabel()); + return MediaPlugin::get()->getNavigationLabel() ?: (Str::title(static::getPluralModelLabel()) ?: Str::title(static::getModelLabel())); } public static function getNavigationIcon(): string @@ -71,12 +78,16 @@ public static function getNavigationBadge(): ?string } if (Filament::hasTenancy() && config('backstage.media.is_tenant_aware')) { - return static::getEloquentQuery() - ->where(config('backstage.media.tenant_relationship') . '_ulid', Filament::getTenant()->id) + $tenant = Filament::getTenant(); + $tenantId = $tenant && property_exists($tenant, 'id') ? $tenant->id : null; + $count = static::getEloquentQuery() + ->where(config('backstage.media.tenant_relationship') . '_ulid', $tenantId) ->count(); + + return (string) $count; } - return number_format(static::getModel()::count()); + return (string) static::getModel()::count(); } public static function shouldRegisterNavigation(): bool @@ -116,6 +127,13 @@ public static function table(Table $table): Table ]) ->recordActions([ + ViewAction::make() + ->hiddenLabel() + ->tooltip(__('View')) + ->slideOver() + ->schema([ + ...self::getFormSchema(), + ]), DeleteAction::make() ->hiddenLabel() ->tooltip(__('Delete')), @@ -129,6 +147,124 @@ public static function table(Table $table): Table ->recordUrl(false); } + public static function getFormSchema(): array + { + $schema = [ + Section::make(__('File Information')) + ->schema([ + TextEntry::make('original_filename') + ->label(__('Original Filename')) + ->copyable(), + TextEntry::make('filename') + ->label(__('Filename')) + ->copyable(), + TextEntry::make('extension') + ->label(__('Extension')) + ->badge(), + TextEntry::make('mime_type') + ->label(__('MIME Type')) + ->badge(), + TextEntry::make('size') + ->label(__('File Size')) + ->formatStateUsing(function ($state) { + if (! $state) { + return null; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $bytes = (int) $state; + + for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) { + $bytes /= 1024; + } + + return round($bytes, 2) . ' ' . $units[$i]; + }), + IconEntry::make('public') + ->label(__('Public')) + ->boolean(), + ]) + ->columns(2), + + Section::make(__('File Preview')) + ->schema([ + ImageEntry::make('url') + ->label(__('Preview')) + ->imageHeight(200) + ->visible(fn ($record) => $record && $record->mime_type && str_starts_with($record->mime_type, 'image/')), + TextEntry::make('url') + ->label(__('File URL')) + ->copyable() + ->url(fn ($state) => $state) + ->openUrlInNewTab(), + ]), + + Section::make(__('Technical Details')) + ->schema([ + TextEntry::make('disk') + ->label(__('Storage Disk')) + ->badge(), + TextEntry::make('checksum') + ->label(__('Checksum')) + ->copyable() + ->visible(fn ($record) => $record && $record->checksum), + TextEntry::make('width') + ->label(__('Width')) + ->visible(fn ($record) => $record && $record->width) + ->suffix('px'), + TextEntry::make('height') + ->label(__('Height')) + ->visible(fn ($record) => $record && $record->height) + ->suffix('px'), + TextEntry::make('created_at') + ->label(__('Created At')) + ->dateTime(), + TextEntry::make('updated_at') + ->label(__('Updated At')) + ->dateTime(), + ]) + ->columns(2) + ->collapsible(), + + Section::make(__('Metadata')) + ->schema([ + CodeEntry::make('metadata') + ->label(__('Metadata')) + ->hiddenLabel() + ->formatStateUsing(function ($state) { + if (! $state) { + return null; + } + + // If it's already a string, try to decode it + if (is_string($state)) { + $decoded = json_decode($state, true); + if (json_last_error() === JSON_ERROR_NONE) { + $state = $decoded; + } else { + // If it's not valid JSON, return as-is + return $state; + } + } + + if (empty($state)) { + return null; + } + + // Ensure proper JSON formatting with indentation + return json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + }) + ->grammar(Grammar::Json) + ->visible(fn ($record) => $record && $record->metadata) + ->columnSpanFull() + ->copyable(), + ]) + ->collapsible(), + ]; + + return $schema; + } + public static function getPages(): array { return [ diff --git a/src/Resources/MediaResource/CreateMedia.php b/src/Resources/MediaResource/CreateMedia.php index a121895..b29a234 100644 --- a/src/Resources/MediaResource/CreateMedia.php +++ b/src/Resources/MediaResource/CreateMedia.php @@ -19,6 +19,8 @@ public static function getResource(): string public function handleRecordCreation(array $data): Model { + $firstMedia = null; + foreach ($data['media'] as $file) { // Get the full path on the configured disk $fullPath = Storage::disk(config('backstage.media.disk'))->path($file); @@ -53,8 +55,9 @@ public function handleRecordCreation(array $data): Model } } - $first = Media::create([ - 'site_ulid' => Filament::getTenant()->ulid, + $tenant = Filament::getTenant(); + $media = Media::create([ + 'site_ulid' => $tenant && property_exists($tenant, 'ulid') ? $tenant->ulid : null, 'disk' => config('backstage.media.disk'), 'uploaded_by' => auth()->id(), 'filename' => $filename, @@ -66,9 +69,13 @@ public function handleRecordCreation(array $data): Model 'checksum' => md5_file($fullPath), 'public' => config('backstage.media.visibility') === 'public', // TODO: Should be configurable in the form itself ]); + + if ($firstMedia === null) { + $firstMedia = $media; + } } - return $first; + return $firstMedia ?? Media::first(); // return static::getModel()::create($data); } } diff --git a/src/Resources/MediaResource/EditMedia.php b/src/Resources/MediaResource/EditMedia.php index ea17b6d..81e5933 100644 --- a/src/Resources/MediaResource/EditMedia.php +++ b/src/Resources/MediaResource/EditMedia.php @@ -23,7 +23,7 @@ public function getHeaderActions(): array Action::make('preview') ->label(__('Preview')) ->color('gray') - ->url($this->record->url, shouldOpenInNewTab: true), + ->url(fn () => (is_object($this->record) && property_exists($this->record, 'url')) ? $this->record->url : null, shouldOpenInNewTab: true), DeleteAction::make(), ]; } diff --git a/tests/TestCase.php b/tests/TestCase.php index e4fca90..70bb390 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -20,6 +20,8 @@ class TestCase extends Orchestra { + protected static $latestResponse; + protected function setUp(): void { parent::setUp();