diff --git a/.gitmodules b/.gitmodules index b22a1a9b..ffdef8b5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,7 @@ [submodule "packages/adrema-plugin"] path = packages/adrema-plugin url = https://git.zoomyboy.de/silva/adrema-plugin.git +[submodule "packages/medialibrary-helper"] + path = packages/medialibrary-helper + url = https://git.zoomyboy.de/zoomyboy/medialibrary-helper.git + branch = version2 diff --git a/app/Form/Actions/FormStoreAction.php b/app/Form/Actions/FormStoreAction.php index 14d0e284..c6fa7498 100644 --- a/app/Form/Actions/FormStoreAction.php +++ b/app/Form/Actions/FormStoreAction.php @@ -28,6 +28,7 @@ class FormStoreAction 'registration_until' => 'present|nullable|date', 'mail_top' => 'nullable|string', 'mail_bottom' => 'nullable|string', + 'header_image' => 'required|exclude', ]; } @@ -36,7 +37,10 @@ class FormStoreAction */ public function handle(array $attributes): Form { - return Form::create($attributes); + return tap( + Form::create($attributes), + fn ($form) => $form->setDeferredUploads(request()->input('header_image')) + ); } /** @@ -48,6 +52,7 @@ class FormStoreAction ...$this->globalValidationAttributes(), 'from' => 'Start', 'to' => 'Ende', + 'header_image' => 'Bild', ]; } diff --git a/app/Form/Models/Form.php b/app/Form/Models/Form.php index 71eaed87..26b31426 100644 --- a/app/Form/Models/Form.php +++ b/app/Form/Models/Form.php @@ -7,11 +7,16 @@ use Cviebrock\EloquentSluggable\Sluggable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; +use Zoomyboy\MedialibraryHelper\DefersUploads; -class Form extends Model +class Form extends Model implements HasMedia { use HasFactory; use Sluggable; + use InteractsWithMedia; + use DefersUploads; public $guarded = []; @@ -26,6 +31,14 @@ class Form extends Model ]; } + public function registerMediaCollections(): void + { + $this->addMediaCollection('headerImage') + ->singleFile() + ->maxWidth(fn () => 500) + ->forceFileName(fn (Form $model, string $name) => $model->slug); + } + /** @var array */ public $dates = ['from', 'to', 'registration_from', 'registration_until']; diff --git a/composer.json b/composer.json index 9b335c23..5e9f4851 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,13 @@ "options": { "symlink": true } + }, + { + "type": "path", + "url": "./packages/medialibrary-helper", + "options": { + "symlink": true + } } ], "license": "MIT", @@ -53,7 +60,8 @@ "zoomyboy/laravel-nami": "dev-master", "zoomyboy/osm": "1.0.3", "zoomyboy/phone": "^1.0", - "zoomyboy/tex": "dev-main as 1.0" + "zoomyboy/tex": "dev-main as 1.0", + "zoomyboy/medialibrary-helper": "dev-master as 1.0" }, "require-dev": { "fakerphp/faker": "^1.9.1", diff --git a/config/media-library.php b/config/media-library.php new file mode 100644 index 00000000..6a47dc73 --- /dev/null +++ b/config/media-library.php @@ -0,0 +1,267 @@ + env('MEDIA_DISK', 'public'), + + /* + * The maximum file size of an item in bytes. + * Adding a larger file will result in an exception. + */ + 'max_file_size' => 1024 * 1024 * 10, // 10MB + + /* + * This queue connection will be used to generate derived and responsive images. + * Leave empty to use the default queue connection. + */ + 'queue_connection_name' => env('QUEUE_CONNECTION', 'sync'), + + /* + * This queue will be used to generate derived and responsive images. + * Leave empty to use the default queue. + */ + 'queue_name' => '', + + /* + * By default all conversions will be performed on a queue. + */ + 'queue_conversions_by_default' => env('QUEUE_CONVERSIONS_BY_DEFAULT', true), + + /* + * The fully qualified class name of the media model. + */ + 'media_model' => Spatie\MediaLibrary\MediaCollections\Models\Media::class, + + /* + * When enabled, media collections will be serialised using the default + * laravel model serialization behaviour. + * + * Keep this option disabled if using Media Library Pro components (https://medialibrary.pro) + */ + 'use_default_collection_serialization' => false, + + /* + * The fully qualified class name of the model used for temporary uploads. + * + * This model is only used in Media Library Pro (https://medialibrary.pro) + */ + 'temporary_upload_model' => Spatie\MediaLibraryPro\Models\TemporaryUpload::class, + + /* + * When enabled, Media Library Pro will only process temporary uploads that were uploaded + * in the same session. You can opt to disable this for stateless usage of + * the pro components. + */ + 'enable_temporary_uploads_session_affinity' => true, + + /* + * When enabled, Media Library pro will generate thumbnails for uploaded file. + */ + 'generate_thumbnails_for_temporary_uploads' => true, + + /* + * This is the class that is responsible for naming generated files. + */ + 'file_namer' => Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer::class, + + /* + * The class that contains the strategy for determining a media file's path. + */ + 'path_generator' => Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator::class, + + /* + * The class that contains the strategy for determining how to remove files. + */ + 'file_remover_class' => Spatie\MediaLibrary\Support\FileRemover\DefaultFileRemover::class, + + /* + * Here you can specify which path generator should be used for the given class. + */ + 'custom_path_generators' => [ + // Model::class => PathGenerator::class + // or + // 'model_morph_alias' => PathGenerator::class + ], + + /* + * When urls to files get generated, this class will be called. Use the default + * if your files are stored locally above the site root or on s3. + */ + 'url_generator' => Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator::class, + + /* + * Moves media on updating to keep path consistent. Enable it only with a custom + * PathGenerator that uses, for example, the media UUID. + */ + 'moves_media_on_update' => false, + + /* + * Whether to activate versioning when urls to files get generated. + * When activated, this attaches a ?v=xx query string to the URL. + */ + 'version_urls' => false, + + /* + * The media library will try to optimize all converted images by removing + * metadata and applying a little bit of compression. These are + * the optimizers that will be used by default. + */ + 'image_optimizers' => [ + Spatie\ImageOptimizer\Optimizers\Jpegoptim::class => [ + '-m85', // set maximum quality to 85% + '--force', // ensure that progressive generation is always done also if a little bigger + '--strip-all', // this strips out all text information such as comments and EXIF data + '--all-progressive', // this will make sure the resulting image is a progressive one + ], + Spatie\ImageOptimizer\Optimizers\Pngquant::class => [ + '--force', // required parameter for this package + ], + Spatie\ImageOptimizer\Optimizers\Optipng::class => [ + '-i0', // this will result in a non-interlaced, progressive scanned image + '-o2', // this set the optimization level to two (multiple IDAT compression trials) + '-quiet', // required parameter for this package + ], + Spatie\ImageOptimizer\Optimizers\Svgo::class => [ + '--disable=cleanupIDs', // disabling because it is known to cause troubles + ], + Spatie\ImageOptimizer\Optimizers\Gifsicle::class => [ + '-b', // required parameter for this package + '-O3', // this produces the slowest but best results + ], + Spatie\ImageOptimizer\Optimizers\Cwebp::class => [ + '-m 6', // for the slowest compression method in order to get the best compression. + '-pass 10', // for maximizing the amount of analysis pass. + '-mt', // multithreading for some speed improvements. + '-q 90', //quality factor that brings the least noticeable changes. + ], + Spatie\ImageOptimizer\Optimizers\Avifenc::class => [ + '-a cq-level=23', // constant quality level, lower values mean better quality and greater file size (0-63). + '-j all', // number of jobs (worker threads, "all" uses all available cores). + '--min 0', // min quantizer for color (0-63). + '--max 63', // max quantizer for color (0-63). + '--minalpha 0', // min quantizer for alpha (0-63). + '--maxalpha 63', // max quantizer for alpha (0-63). + '-a end-usage=q', // rate control mode set to Constant Quality mode. + '-a tune=ssim', // SSIM as tune the encoder for distortion metric. + ], + ], + + /* + * These generators will be used to create an image of media files. + */ + 'image_generators' => [ + Spatie\MediaLibrary\Conversions\ImageGenerators\Image::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Webp::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Avif::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Pdf::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Svg::class, + Spatie\MediaLibrary\Conversions\ImageGenerators\Video::class, + ], + + /* + * The path where to store temporary files while performing image conversions. + * If set to null, storage_path('media-library/temp') will be used. + */ + 'temporary_directory_path' => null, + + /* + * The engine that should perform the image conversions. + * Should be either `gd` or `imagick`. + */ + 'image_driver' => env('IMAGE_DRIVER', 'gd'), + + /* + * FFMPEG & FFProbe binaries paths, only used if you try to generate video + * thumbnails and have installed the php-ffmpeg/php-ffmpeg composer + * dependency. + */ + 'ffmpeg_path' => env('FFMPEG_PATH', '/usr/bin/ffmpeg'), + 'ffprobe_path' => env('FFPROBE_PATH', '/usr/bin/ffprobe'), + + /* + * Here you can override the class names of the jobs used by this package. Make sure + * your custom jobs extend the ones provided by the package. + */ + 'jobs' => [ + 'perform_conversions' => Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob::class, + 'generate_responsive_images' => Spatie\MediaLibrary\ResponsiveImages\Jobs\GenerateResponsiveImagesJob::class, + ], + + /* + * When using the addMediaFromUrl method you may want to replace the default downloader. + * This is particularly useful when the url of the image is behind a firewall and + * need to add additional flags, possibly using curl. + */ + 'media_downloader' => Spatie\MediaLibrary\Downloaders\DefaultDownloader::class, + + 'remote' => [ + /* + * Any extra headers that should be included when uploading media to + * a remote disk. Even though supported headers may vary between + * different drivers, a sensible default has been provided. + * + * Supported by S3: CacheControl, Expires, StorageClass, + * ServerSideEncryption, Metadata, ACL, ContentEncoding + */ + 'extra_headers' => [ + 'CacheControl' => 'max-age=604800', + ], + ], + + 'responsive_images' => [ + /* + * This class is responsible for calculating the target widths of the responsive + * images. By default we optimize for filesize and create variations that each are 30% + * smaller than the previous one. More info in the documentation. + * + * https://docs.spatie.be/laravel-medialibrary/v9/advanced-usage/generating-responsive-images + */ + 'width_calculator' => Spatie\MediaLibrary\ResponsiveImages\WidthCalculator\FileSizeOptimizedWidthCalculator::class, + + /* + * By default rendering media to a responsive image will add some javascript and a tiny placeholder. + * This ensures that the browser can already determine the correct layout. + */ + 'use_tiny_placeholders' => true, + + /* + * This class will generate the tiny placeholder used for progressive image loading. By default + * the media library will use a tiny blurred jpg image. + */ + 'tiny_placeholder_generator' => Spatie\MediaLibrary\ResponsiveImages\TinyPlaceholderGenerator\Blurred::class, + ], + + /* + * When enabling this option, a route will be registered that will enable + * the Media Library Pro Vue and React components to move uploaded files + * in a S3 bucket to their right place. + */ + 'enable_vapor_uploads' => env('ENABLE_MEDIA_LIBRARY_VAPOR_UPLOADS', false), + + /* + * When converting Media instances to response the media library will add + * a `loading` attribute to the `img` tag. Here you can set the default + * value of that attribute. + * + * Possible values: 'lazy', 'eager', 'auto' or null if you don't want to set any loading instruction. + * + * More info: https://css-tricks.com/native-lazy-loading/ + */ + 'default_loading_attribute_value' => null, + + /* + * You can specify a prefix for that is used for storing all media. + * If you set this to `/my-subdir`, all your media will be stored in a `/my-subdir` directory. + */ + 'prefix' => env('MEDIA_PREFIX', ''), + + /* + * The temp disk for deferred media data. + * Only used by the medialibrary-helper package. + */ + 'temp_disk' => 'temp', +]; diff --git a/database/migrations/2024_01_12_225811_create_media_table.php b/database/migrations/2024_01_12_225811_create_media_table.php new file mode 100644 index 00000000..32303893 --- /dev/null +++ b/database/migrations/2024_01_12_225811_create_media_table.php @@ -0,0 +1,32 @@ +id(); + + $table->morphs('model'); + $table->uuid('uuid')->nullable()->unique(); + $table->string('collection_name'); + $table->string('name'); + $table->string('file_name'); + $table->string('mime_type')->nullable(); + $table->string('disk'); + $table->string('conversions_disk')->nullable(); + $table->unsignedBigInteger('size'); + $table->json('manipulations'); + $table->json('custom_properties'); + $table->json('generated_conversions'); + $table->json('responsive_images'); + $table->unsignedInteger('order_column')->nullable()->index(); + + $table->nullableTimestamps(); + }); + } +}; diff --git a/packages/medialibrary-helper b/packages/medialibrary-helper new file mode 160000 index 00000000..c4eb67c0 --- /dev/null +++ b/packages/medialibrary-helper @@ -0,0 +1 @@ +Subproject commit c4eb67c09f8e310d20ebfdb330b70a265e45b1a4 diff --git a/tests/Feature/Form/FormRequest.php b/tests/Feature/Form/FormRequest.php index 25c52b31..4b99d92c 100644 --- a/tests/Feature/Form/FormRequest.php +++ b/tests/Feature/Form/FormRequest.php @@ -2,6 +2,8 @@ namespace Tests\Feature\Form; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Worksome\RequestFactories\RequestFactory; /** @@ -33,6 +35,7 @@ class FormRequest extends RequestFactory 'registration_until' => $this->faker->dateTime()->format('Y-m-d H:i:s'), 'mail_top' => $this->faker->text(), 'mail_bottom' => $this->faker->text(), + 'header_image' => $this->getHeaderImagePayload(str()->uuid() . '.jpg') ]; } @@ -51,4 +54,30 @@ class FormRequest extends RequestFactory { return $this->state([str($method)->snake()->toString() => $args[0]]); } + + public function headerImage(string $fileName): self + { + UploadedFile::fake()->image($fileName, 1000, 1000)->storeAs('media-library', $fileName, 'temp'); + + Storage::disk('temp')->assertExists('media-library/' . $fileName); + + return $this->state([ + 'header_image' => $this->getHeaderImagePayload($fileName) + ]); + } + + /** + * @return array + */ + private function getHeaderImagePayload(string $fileName): array + { + UploadedFile::fake()->image($fileName, 1000, 1000)->storeAs('media-library', $fileName, 'temp'); + + Storage::disk('temp')->assertExists('media-library/' . $fileName); + + return [ + 'file_name' => $fileName, + 'collection_name' => 'headerImage', + ]; + } } diff --git a/tests/Feature/Form/FormStoreActionTest.php b/tests/Feature/Form/FormStoreActionTest.php index cc8d81eb..ee30afbb 100644 --- a/tests/Feature/Form/FormStoreActionTest.php +++ b/tests/Feature/Form/FormStoreActionTest.php @@ -8,12 +8,20 @@ use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Support\Facades\Event; use Tests\TestCase; use Generator; +use Illuminate\Support\Facades\Storage; class FormStoreActionTest extends TestCase { use DatabaseTransactions; + public function setUp(): void + { + parent::setUp(); + + Storage::fake('temp'); + } + public function testItStoresForm(): void { Event::fake([Succeeded::class]); @@ -25,6 +33,7 @@ class FormStoreActionTest extends TestCase ->registrationFrom('2023-05-04 01:00:00')->registrationUntil('2023-07-07 01:00:00')->from('2023-07-07')->to('2023-07-08') ->mailTop('Guten Tag') ->mailBottom('Viele Grüße') + ->headerImage('htzz.jpg') ->sections([FormtemplateSectionRequest::new()->name('sname')->fields([FormtemplateFieldRequest::new()])]) ->fake(); @@ -41,6 +50,8 @@ class FormStoreActionTest extends TestCase $this->assertEquals('2023-07-07 01:00', $form->registration_until->format('Y-m-d H:i')); $this->assertEquals('2023-07-07', $form->from->format('Y-m-d')); $this->assertEquals('2023-07-08', $form->to->format('Y-m-d')); + $this->assertCount(1, $form->getMedia('headerImage')); + $this->assertEquals('formname.jpg', $form->getMedia('headerImage')->first()->file_name); Event::assertDispatched(Succeeded::class, fn (Succeeded $event) => $event->message === 'Veranstaltung gespeichert.'); } @@ -63,6 +74,7 @@ class FormStoreActionTest extends TestCase yield [FormRequest::new()->description(''), ['description' => 'Beschreibung ist erforderlich.']]; yield [FormRequest::new()->state(['from' => null]), ['from' => 'Start ist erforderlich']]; yield [FormRequest::new()->state(['to' => null]), ['to' => 'Ende ist erforderlich']]; + yield [FormRequest::new()->state(['header_image' => null]), ['header_image' => 'Bild ist erforderlich']]; } /**