diff --git a/demo/app/Sharp/Posts/PostForm.php b/demo/app/Sharp/Posts/PostForm.php index bd0c6538f..ee2c40812 100644 --- a/demo/app/Sharp/Posts/PostForm.php +++ b/demo/app/Sharp/Posts/PostForm.php @@ -67,7 +67,7 @@ public function buildFormFields(FieldsContainer $formFields): void SharpFormEditorUpload::make() ->setStorageDisk('local') ->setStorageBasePath('data/posts/{id}/embed') - ->setMaxFileSize(1) + ->setMaxFileSize(2) ->setHasLegend() ) ->setMaxLength(2000) @@ -126,8 +126,8 @@ public function buildFormFields(FieldsContainer $formFields): void ) ->addItemField( SharpFormUploadField::make('document') - ->setMaxFileSize(1) - ->setAllowedExtensions(['pdf', 'zip']) + ->setMaxFileSize(2) + ->setAllowedExtensions(['pdf', 'zip', 'mp4', 'mp3']) ->setStorageDisk('local') ->setStorageBasePath('data/posts/{id}') ->addConditionalDisplay('!is_link'), @@ -203,7 +203,7 @@ public function find($id): array return $this ->setCustomTransformer('author_id', fn ($value, Post $instance) => $instance->author) ->setCustomTransformer('cover', new SharpUploadModelFormAttributeTransformer()) - ->setCustomTransformer('attachments[document]', new SharpUploadModelFormAttributeTransformer()) + ->setCustomTransformer('attachments[document]', new SharpUploadModelFormAttributeTransformer(withPlayablePreview: true)) ->transform(Post::with('cover', 'attachments', 'categories')->findOrFail($id)); } diff --git a/demo/app/Sharp/Posts/PostShow.php b/demo/app/Sharp/Posts/PostShow.php index 25a4fa9fa..a2bcbc759 100644 --- a/demo/app/Sharp/Posts/PostShow.php +++ b/demo/app/Sharp/Posts/PostShow.php @@ -154,7 +154,7 @@ public function find(mixed $id): array ->setCustomTransformer('cover', new SharpUploadModelThumbnailUrlTransformer(500)) ->setCustomTransformer( 'attachments[document]', - new SharpUploadModelFormAttributeTransformer(withThumbnails: true) + new SharpUploadModelFormAttributeTransformer(withPlayablePreview: true) ) ->setCustomTransformer('attachments[link_url]', fn ($value, $instance) => $instance->is_link ? sprintf('%s', $value, str($value)->limit(30)) diff --git a/demo/config/filesystems.php b/demo/config/filesystems.php index cf5abce76..a63ac647c 100644 --- a/demo/config/filesystems.php +++ b/demo/config/filesystems.php @@ -33,6 +33,7 @@ 'local' => [ 'driver' => 'local', 'root' => storage_path('app'), + 'serve' => true, ], 'public' => [ diff --git a/docs/guide/sharp-uploads.md b/docs/guide/sharp-uploads.md index 942c62bc2..81d4b60b8 100644 --- a/docs/guide/sharp-uploads.md +++ b/docs/guide/sharp-uploads.md @@ -336,3 +336,26 @@ $this->addField( ``` In this code, the `legend` designates a custom attribute. + +## Preview audio or video upload + +If the field allows to upload an audio or video file, you can display a preview of it by specifying the `withPlayablePreview` option: + +```php +class MyForm extends SharpForm +{ + // ... + function find($id): array + { + return $this + ->setCustomTransformer( + 'video', + new SharpUploadModelFormAttributeTransformer(withPlayablePreview: true) + ) + ->transform(Book::with('video')->findOrFail($id)); + } +``` + +::: warning +This feature is using Laravel's file [Temporary URL](https://laravel.com/docs/12.x/filesystem#temporary-urls) feature which only supports S3 & local driver. +::: diff --git a/resources/js/form/components/fields/upload/Upload.vue b/resources/js/form/components/fields/upload/Upload.vue index 4840066ab..54be7fdc4 100644 --- a/resources/js/form/components/fields/upload/Upload.vue +++ b/resources/js/form/components/fields/upload/Upload.vue @@ -68,6 +68,7 @@ }>(); const form = useParentForm(); const transformedImg = ref(); + const playablePreviewUrl = ref(); const uppyFile = ref>(); const isEditable = computed(() => { return props.value && canTransform(props.value.name, props.value.mime_type) && !props.hasError @@ -135,6 +136,8 @@ const blob = await response.blob(); transformedImg.value = URL.createObjectURL(blob); } + } else if(file.type?.startsWith('video/') || file.type?.startsWith('audio/')) { + playablePreviewUrl.value = URL.createObjectURL(file.data); } }) .on('restriction-failed', (file, error) => { @@ -350,6 +353,7 @@ uppy.removeFile(uppyFile.value.id); uppyFile.value = null; transformedImg.value = null; + playablePreviewUrl.value = null; editModalImageUrl.value = null; } } @@ -382,6 +386,9 @@ if(!props.persistThumbnailUrl && transformedImg.value) { URL.revokeObjectURL(transformedImg.value); } + if(playablePreviewUrl.value) { + URL.revokeObjectURL(playablePreviewUrl.value); + } emit('uploading', false); }); @@ -392,69 +399,83 @@ - - - + + + - - - {{ __('sharp::form.upload.edit_button') }} - + :src="transformedImg ?? value?.thumbnail ?? uppyFile.preview" + :alt="value?.name ?? uppyFile?.name" + > + + + {{ __('sharp::form.upload.edit_button') }} + + + + + + + + + - - - - - - - - - - - - + + + + + + + + + + + + + + + - {{ value?.name }} - - + :download="value?.name" + > + {{ value?.name }} + + - - {{ __('sharp::form.upload.download_tooltip') }} - - - + + {{ __('sharp::form.upload.download_tooltip') }} + + + + + + {{ value?.name ?? uppyFile?.name }} + + + + + {{ filesizeLabel(value?.size ?? uppyFile.size) }} + - - {{ value?.name ?? uppyFile?.name }} + + {{ legend }} - - - - {{ filesizeLabel(value?.size ?? uppyFile.size) }} - - - - {{ legend }} - - - - + + + + - - + + diff --git a/resources/js/show/components/fields/File.vue b/resources/js/show/components/fields/File.vue index 2f251882c..ec1b76830 100644 --- a/resources/js/show/components/fields/File.vue +++ b/resources/js/show/components/fields/File.vue @@ -25,52 +25,66 @@ - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - {{ value.name }} - - + :download="value.name ?? ''" + > + {{ value.name }} + + - - {{ __('sharp::form.upload.download_tooltip') }} - - - - - - - {{ legend }} + + {{ __('sharp::form.upload.download_tooltip') }} + + + - - - - - {{ filesizeLabel(value.size) }} + + + {{ legend }} + + + + {{ filesizeLabel(value.size) }} + + + diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 4baf7643b..2031319a1 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -617,6 +617,7 @@ export type FormUploadFieldValueData = { mime_type: string; size: number; thumbnail: string | null; + playable_preview_url: string | null; uploaded: boolean | null; transformed: boolean | null; not_found: boolean | null; @@ -835,6 +836,7 @@ export type ShowFileFieldData = { name: string; path: string; thumbnail: string; + playable_preview_url: string; size: number; mime_type: string; }; diff --git a/src/Data/Form/Fields/FormUploadFieldValueData.php b/src/Data/Form/Fields/FormUploadFieldValueData.php index 061733d7b..50000bba9 100644 --- a/src/Data/Form/Fields/FormUploadFieldValueData.php +++ b/src/Data/Form/Fields/FormUploadFieldValueData.php @@ -19,6 +19,7 @@ public function __construct( public string $mime_type, public int $size, public ?string $thumbnail, + public ?string $playable_preview_url, public ?bool $uploaded, public ?bool $transformed, public ?bool $not_found, diff --git a/src/Data/Show/Fields/ShowFileFieldData.php b/src/Data/Show/Fields/ShowFileFieldData.php index a7dac04bf..217fa47a4 100644 --- a/src/Data/Show/Fields/ShowFileFieldData.php +++ b/src/Data/Show/Fields/ShowFileFieldData.php @@ -19,6 +19,7 @@ final class ShowFileFieldData extends Data 'name' => 'string', 'path' => 'string', 'thumbnail' => 'string', + 'playable_preview_url' => 'string', 'size' => 'int', 'mime_type' => 'string', ])] diff --git a/src/Form/Eloquent/Uploads/SharpUploadModel.php b/src/Form/Eloquent/Uploads/SharpUploadModel.php index d12621360..46c274f99 100644 --- a/src/Form/Eloquent/Uploads/SharpUploadModel.php +++ b/src/Form/Eloquent/Uploads/SharpUploadModel.php @@ -5,6 +5,7 @@ use Code16\Sharp\Form\Eloquent\Uploads\Thumbnails\Thumbnail; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Support\Facades\Storage; class SharpUploadModel extends Model { @@ -86,4 +87,9 @@ public function thumbnail(?int $width = null, ?int $height = null, array $modifi ->setAppendTimestamp() ->make($width, $height); } + + public function playablePreviewUrl(): string + { + return Storage::disk($this->disk)->temporaryUrl($this->file_name, now()->addMinutes(30)); + } } diff --git a/src/Form/Eloquent/Uploads/Transformers/SharpUploadModelFormAttributeTransformer.php b/src/Form/Eloquent/Uploads/Transformers/SharpUploadModelFormAttributeTransformer.php index 29fb2fda3..582fd779f 100644 --- a/src/Form/Eloquent/Uploads/Transformers/SharpUploadModelFormAttributeTransformer.php +++ b/src/Form/Eloquent/Uploads/Transformers/SharpUploadModelFormAttributeTransformer.php @@ -20,7 +20,8 @@ class SharpUploadModelFormAttributeTransformer implements SharpAttributeTransfor public function __construct( protected bool $withThumbnails = true, protected int $thumbnailWidth = 200, - protected int $thumbnailHeight = 200 + protected int $thumbnailHeight = 200, + protected bool $withPlayablePreview = false, ) {} public function dynamicInstance(): self @@ -70,7 +71,16 @@ public function apply($value, $instance = null, $attribute = null) return $instance->$attribute ->map(function ($upload) { $array = $this->transformUpload($upload); - $fileAttrs = ['name', 'path', 'disk', 'thumbnail', 'size', 'filters', 'mime_type']; + $fileAttrs = [ + 'name', + 'path', + 'disk', + 'thumbnail', + 'playable_preview_url', + 'size', + 'filters', + 'mime_type', + ]; return array_merge( ['file' => Arr::only($array, $fileAttrs) ?: null], @@ -93,6 +103,7 @@ protected function transformUpload(SharpUploadModel $upload): array 'disk' => $upload->disk, 'mime_type' => $upload->mime_type, 'thumbnail' => $this->getThumbnailUrl($upload), + 'playable_preview_url' => $this->getPlayableMediaUrl($upload), 'size' => $upload->size, ] : [], @@ -113,14 +124,34 @@ private function getThumbnailUrl(SharpUploadModel $upload): ?string } try { - $url = $upload->thumbnail($this->thumbnailWidth, $this->thumbnailHeight); - - // Return relative URL if possible, to avoid CORS issues in multidomain case. - return Str::startsWith($url, config('app.url')) - ? Str::after($url, config('app.url')) - : $url; + return $this->getRelativeUrlIfPossible( + $upload->thumbnail($this->thumbnailWidth, $this->thumbnailHeight) + ); } catch (DecoderException) { return null; } } + + private function getPlayableMediaUrl(SharpUploadModel $upload): ?string + { + if (! $this->withPlayablePreview) { + return null; + } + + if ($upload->mime_type && ! str($upload->mime_type)->startsWith(['video/', 'audio/'])) { + return null; + } + + return $this->getRelativeUrlIfPossible( + $upload->playablePreviewUrl() + ); + } + + private function getRelativeUrlIfPossible(string $url): ?string + { + // Return relative URL if possible, to avoid CORS issues in multidomain case. + return Str::startsWith($url, config('app.url')) + ? Str::after($url, config('app.url')) + : $url; + } } diff --git a/tests/Unit/Form/Eloquent/Uploads/Transformers/Fakes/FakePicturable.php b/tests/Unit/Form/Eloquent/Uploads/Transformers/Fakes/FakePicturable.php index 4f19d695f..d63678a02 100644 --- a/tests/Unit/Form/Eloquent/Uploads/Transformers/Fakes/FakePicturable.php +++ b/tests/Unit/Form/Eloquent/Uploads/Transformers/Fakes/FakePicturable.php @@ -16,8 +16,18 @@ public function picture(): MorphOne return $this->morphOne(SharpUploadModel::class, 'model'); } + public function video(): MorphOne + { + return $this->morphOne(SharpUploadModel::class, 'model'); + } + public function pictures(): MorphMany { return $this->morphMany(SharpUploadModel::class, 'model'); } + + public function songs(): MorphMany + { + return $this->morphMany(SharpUploadModel::class, 'model'); + } } diff --git a/tests/Unit/Form/Eloquent/Uploads/Transformers/SharpUploadModelFormAttributeTransformerTest.php b/tests/Unit/Form/Eloquent/Uploads/Transformers/SharpUploadModelFormAttributeTransformerTest.php index a74cb47e9..19cd210b1 100644 --- a/tests/Unit/Form/Eloquent/Uploads/Transformers/SharpUploadModelFormAttributeTransformerTest.php +++ b/tests/Unit/Form/Eloquent/Uploads/Transformers/SharpUploadModelFormAttributeTransformerTest.php @@ -3,6 +3,8 @@ use Code16\Sharp\Form\Eloquent\Uploads\SharpUploadModel; use Code16\Sharp\Form\Eloquent\Uploads\Transformers\SharpUploadModelFormAttributeTransformer; use Code16\Sharp\Tests\Unit\Form\Eloquent\Uploads\Transformers\Fakes\FakePicturable; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Storage; beforeEach(function () { @@ -35,6 +37,7 @@ 'disk' => 'local', 'size' => $upload->size, 'thumbnail' => $upload->thumbnail(200, 200), + 'playable_preview_url' => null, 'mime_type' => 'image/png', ], $transformer->apply('', $picturable, 'picture'), @@ -73,6 +76,7 @@ 'size' => $upload->size, 'mime_type' => 'image/png', 'thumbnail' => $upload->thumbnail(200, 200), + 'playable_preview_url' => null, 'filters' => [ 'crop' => [ 'height' => .5, @@ -116,6 +120,7 @@ 'disk' => 'local', 'size' => $upload1->size, 'thumbnail' => $upload1->thumbnail(200, 200), + 'playable_preview_url' => null, 'mime_type' => 'image/png', ], 'id' => $upload1->id, @@ -126,6 +131,7 @@ 'disk' => 'local', 'size' => $upload2->size, 'thumbnail' => $upload2->thumbnail(200, 200), + 'playable_preview_url' => null, 'mime_type' => 'image/png', ], 'id' => $upload2->id, @@ -175,6 +181,7 @@ 'disk' => 'local', 'size' => $upload1->size, 'thumbnail' => $upload1->thumbnail(200, 200), + 'playable_preview_url' => null, 'filters' => $filters, 'mime_type' => 'image/png', ], @@ -186,6 +193,7 @@ 'disk' => 'local', 'size' => $upload2->size, 'thumbnail' => $upload2->thumbnail(200, 200), + 'playable_preview_url' => null, 'mime_type' => 'image/png', ], 'id' => $upload2->id, @@ -193,6 +201,63 @@ ]); }); +it('transforms an upload with playable preview', function () { + $this->freezeTime(function (Carbon $time) { + $upload = new SharpUploadModel([ + 'file_name' => UploadedFile::fake()->create('video.mp4', 120, 'video/mp4'), + 'size' => 120, + 'mime_type' => 'video/mp4', + 'disk' => 'local', + ]); + $picturable = new FakePicturable(['id' => 1]); + $picturable->setRelation('video', $upload); + + $transformer = new SharpUploadModelFormAttributeTransformer(withPlayablePreview: true); + + expect($transformer->apply('', $picturable, 'video')) + ->toEqual([ + 'id' => $upload->id, + 'name' => basename($upload->file_name), + 'path' => $upload->file_name, + 'disk' => 'local', + 'size' => $upload->size, + 'thumbnail' => null, + 'playable_preview_url' => $upload->file_name.'?expiration='.$time->addMinutes(30)->timestamp, + 'mime_type' => 'video/mp4', + ]); + }); +}); + +it('transforms a list of upload with playable preview', function () { + $this->freezeTime(function (Carbon $time) { + $upload1 = new SharpUploadModel([ + 'file_name' => UploadedFile::fake()->create('audio.mp3', 120, 'audio/mp3'), + 'size' => 120, + 'mime_type' => 'audio/mp3', + 'disk' => 'local', + ]); + $picturable = new FakePicturable(['id' => 1]); + $picturable->setRelation('songs', collect([$upload1])); + + $transformer = new SharpUploadModelFormAttributeTransformer(withPlayablePreview: true); + + expect($transformer->apply('', $picturable, 'songs'))->toEqual([ + [ + 'file' => [ + 'name' => basename($upload1->file_name), + 'path' => $upload1->file_name, + 'disk' => 'local', + 'size' => $upload1->size, + 'thumbnail' => null, + 'playable_preview_url' => $upload1->file_name.'?expiration='.$time->addMinutes(30)->timestamp, + 'mime_type' => 'audio/mp3', + ], + 'id' => $upload1->id, + ], + ]); + }); +}); + describe('dynamicInstance', function () { it('allows to fake a sharpUpload and transform a single upload', function () { $file = createImage(); @@ -215,6 +280,7 @@ 'disk' => 'local', 'size' => 120, 'thumbnail' => (new SharpUploadModel($uploadData))->thumbnail(200, 200), + 'playable_preview_url' => null, 'filters' => [], 'mime_type' => 'image/png', ], diff --git a/tests/Unit/Form/Fields/Formatters/EditorFormatterTest.php b/tests/Unit/Form/Fields/Formatters/EditorFormatterTest.php index 135e4e120..4566d6d0e 100644 --- a/tests/Unit/Form/Fields/Formatters/EditorFormatterTest.php +++ b/tests/Unit/Form/Fields/Formatters/EditorFormatterTest.php @@ -155,6 +155,7 @@ '/storage/thumbnails/data/Posts/1/200-200_q-90/image.jpg?%s', Storage::disk('public')->lastModified('/thumbnails/data/Posts/1/200-200_q-90/image.jpg') ), + 'playable_preview_url' => null, 'size' => 120, 'mime_type' => 'image/jpeg', 'filters' => null, @@ -168,6 +169,7 @@ 'path' => 'data/Posts/1/doc.pdf', 'disk' => 'local', 'thumbnail' => null, + 'playable_preview_url' => null, 'size' => 120, 'mime_type' => 'application/pdf', 'filters' => null, @@ -287,6 +289,7 @@ 'path' => 'data/Posts/1/image.jpg', 'disk' => 'local', 'thumbnail' => $thumbnail, + 'playable_preview_url' => null, 'size' => 120, 'mime_type' => 'image/jpeg', 'filters' => null, diff --git a/tests/Unit/Show/Fields/Formatters/TextFormatterTest.php b/tests/Unit/Show/Fields/Formatters/TextFormatterTest.php index c6346a38f..4959e3a29 100644 --- a/tests/Unit/Show/Fields/Formatters/TextFormatterTest.php +++ b/tests/Unit/Show/Fields/Formatters/TextFormatterTest.php @@ -61,6 +61,7 @@ '/storage/thumbnails/data/Posts/1/200-200_q-90/image.jpg?%s', Storage::disk('public')->lastModified('/thumbnails/data/Posts/1/200-200_q-90/image.jpg') ), + 'playable_preview_url' => null, 'size' => 120, 'mime_type' => 'image/jpeg', 'filters' => null, @@ -74,6 +75,7 @@ 'path' => 'data/Posts/1/doc.pdf', 'disk' => 'local', 'thumbnail' => null, + 'playable_preview_url' => null, 'size' => 120, 'mime_type' => 'application/pdf', 'filters' => null,