├── .editorconfig ├── .eslintrc ├── .github └── workflows │ └── run-tests.yml ├── .gitignore ├── .php_cs ├── .prettierrc ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json ├── dist ├── css │ ├── field.css │ └── field.css.map ├── js │ ├── field.js │ ├── field.js.LICENSE.txt │ └── field.js.map └── mix-manifest.json ├── docs ├── actions.png ├── create.png ├── crop-dialog.png ├── details.png ├── existing-media-dialog.png ├── index.png ├── media-details-dialog.png └── update.png ├── package-lock.json ├── package.json ├── phpunit.xml ├── postcss.config.js ├── resources ├── js │ ├── components │ │ ├── Buttons │ │ │ └── LoadingButton.vue │ │ ├── Common │ │ │ └── Loader.vue │ │ ├── GeneratedConversionsDetailField.vue │ │ ├── Icons │ │ │ ├── Crop.vue │ │ │ ├── Cropper │ │ │ │ ├── Lock.vue │ │ │ │ ├── Rotate.vue │ │ │ │ ├── Unlock.vue │ │ │ │ ├── ZoomIn.vue │ │ │ │ └── ZoomOut.vue │ │ │ └── Link.vue │ │ ├── Medialibrary │ │ │ ├── ChooseExistingMediaList.vue │ │ │ ├── ChooseExistingMediaListItem.vue │ │ │ ├── Context.js │ │ │ ├── Media.js │ │ │ ├── MediaList.vue │ │ │ ├── MediaListItem.vue │ │ │ ├── MediaListItemActions.vue │ │ │ ├── MediaListItemModals.vue │ │ │ ├── MediaListItemPreview.vue │ │ │ ├── MediaPreview.vue │ │ │ ├── MediaUploading.vue │ │ │ ├── MediaUploadingList.vue │ │ │ ├── MediaUploadingListItem.vue │ │ │ ├── Modals │ │ │ │ ├── ChooseExistingMedia.vue │ │ │ │ ├── Cropper.vue │ │ │ │ ├── Detail.vue │ │ │ │ └── Edit.vue │ │ │ ├── PaginationButton.vue │ │ │ ├── UploadingMedia.js │ │ │ └── Utils.js │ │ ├── MedialibraryDetailField.vue │ │ ├── MedialibraryField.vue │ │ ├── MedialibraryFormField.vue │ │ └── MedialibraryIndexField.vue │ └── field.js ├── lang │ ├── de.json │ ├── en.json │ ├── fr.json │ ├── lt.json │ ├── pt-BR.json │ ├── ru.json │ ├── tr.json │ ├── uk.json │ └── zh-CN.json └── sass │ └── field.scss ├── routes └── api.php ├── src ├── Actions │ ├── MediaAttachAction.php │ ├── MediaAttachmentListAction.php │ ├── MediaCropAction.php │ ├── MediaListAction.php │ ├── MediaRegenerateAction.php │ └── MediaSortAction.php ├── Data │ ├── MediaAttachData.php │ ├── MediaAttachmentListData.php │ ├── MediaCropData.php │ ├── MediaListData.php │ └── MediaSortData.php ├── FieldServiceProvider.php ├── Fields │ ├── GeneratedConversions.php │ ├── Medialibrary.php │ └── Support │ │ ├── AttachCallback.php │ │ ├── AttachableMediaPresenter.php │ │ ├── MediaCollectionRules.php │ │ ├── MediaFields.php │ │ ├── MediaPresenter.php │ │ └── ResolveMediaCallback.php ├── Http │ ├── Controllers │ │ ├── MediaAttachController.php │ │ ├── MediaAttachmentListController.php │ │ ├── MediaCropController.php │ │ ├── MediaListController.php │ │ ├── MediaRegenerateController.php │ │ └── MediaSortController.php │ └── Requests │ │ ├── MediaAttachRequest.php │ │ ├── MediaAttachmentListRequest.php │ │ ├── MediaCropRequest.php │ │ ├── MediaListRequest.php │ │ ├── MediaRequest.php │ │ └── MediaSortRequest.php ├── Integrations │ ├── NovaDependencyContainer │ │ └── ResolveFromDependencyContainerFields.php │ └── NovaFlexibleContent │ │ ├── HasMedialibraryField.php │ │ ├── MedialibraryFieldLayout.php │ │ └── ResolveFromFlexibleLayoutFields.php ├── MedialibraryFieldResolver.php ├── Models │ └── TransientModel.php ├── Resources │ └── Media.php └── helpers.php ├── tests ├── Fixtures │ ├── Nova │ │ ├── ContainerField.php │ │ └── TestPost.php │ └── TestPost.php ├── Integration │ ├── AttachControllerTest.php │ ├── AttachableControllerTest.php │ ├── CreationFieldControllerTest.php │ ├── CropControllerTest.php │ ├── IndexControllerTest.php │ ├── RegenerateControllerTest.php │ ├── ShowControllerTest.php │ ├── SortControllerTest.php │ └── UpdateControllerTest.php ├── Support │ └── files │ │ ├── ignored.txt │ │ ├── test.jpg │ │ └── test.txt ├── TestCase.php └── Unit │ ├── HelpersTest.php │ ├── MediaTest.php │ └── MedialibraryRequestTest.php └── webpack.mix.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml,js,vue,scss,blade.php}] 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:vue/recommended", 5 | "prettier" 6 | ], 7 | "plugins": [ 8 | "vue" 9 | ], 10 | "parserOptions": { 11 | "parser": "babel-eslint" 12 | }, 13 | "env": { 14 | "browser": true, 15 | "node": true 16 | }, 17 | "globals": { 18 | "Nova": "readonly", 19 | "_": "readonly" 20 | }, 21 | "rules": { 22 | "indent": ["error", 2], 23 | "semi": ["error", "never"], 24 | "quotes": ["error", "single"], 25 | "comma-dangle": ["error", "always-multiline"], 26 | "space-before-function-paren": ["error", "never"], 27 | "object-curly-spacing": ["error", "always"], 28 | "arrow-parens": ["error", "as-needed"], 29 | "vue/html-indent": ["error", 2], 30 | "vue/max-attributes-per-line": ["error", {"singleline": 5, "multiline": {"max": 1, "allowFirstLine": true}}], 31 | "vue/component-name-in-template-casing": ["error", "PascalCase", {"registeredComponentsOnly": true}], 32 | "vue/object-curly-spacing": ["error", "always"], 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | php: [7.4, 8.0, 8.1] 12 | 13 | name: PHP${{ matrix.php }} 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v1 18 | 19 | - name: Setup PHP 20 | uses: shivammathur/setup-php@v2 21 | with: 22 | php-version: ${{ matrix.php }} 23 | extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick 24 | coverage: none 25 | 26 | - name: Add HTTP basic auth credentials 27 | run: echo '${{ secrets.COMPOSER_AUTH }}' > $GITHUB_WORKSPACE/auth.json 28 | 29 | - name: Install Dependencies 30 | env: 31 | COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }} 32 | run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist 33 | 34 | - name: Execute tests 35 | run: vendor/bin/phpunit tests 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | /vendor 3 | /node_modules 4 | yarn.lock 5 | composer.phar 6 | composer.lock 7 | .phpunit.result.cache 8 | .php_cs.cache 9 | .php-cs-fixer.cache 10 | .vim 11 | .DS_Store 12 | Thumbs.db 13 | tests/Support/tmp 14 | -------------------------------------------------------------------------------- /.php_cs: -------------------------------------------------------------------------------- 1 | notPath('vendor') 5 | ->notPath('bootstrap') 6 | ->notPath('storage') 7 | ->in(__DIR__) 8 | ->name('*.php') 9 | ->notName('*.blade.php'); 10 | 11 | return PhpCsFixer\Config::create() 12 | ->setRules([ 13 | '@PSR2' => true, 14 | 15 | 'array_syntax' => ['syntax' => 'short'], 16 | 'array_indentation' => true, 17 | 'trailing_comma_in_multiline_array' => true, 18 | 19 | 'ordered_imports' => ['sortAlgorithm' => 'alpha'], 20 | 'no_unused_imports' => true, 21 | 22 | 'unary_operator_spaces' => true, 23 | 'binary_operator_spaces' => true, 24 | 'cast_spaces' => true, 25 | 'not_operator_with_successor_space' => true, 26 | 27 | 'phpdoc_trim' => true, 28 | 'phpdoc_scalar' => true, 29 | 'phpdoc_single_line_var_spacing' => true, 30 | 'phpdoc_var_without_name' => true, 31 | 32 | 'no_empty_phpdoc' => true, 33 | 'no_superfluous_phpdoc_tags' => true, 34 | 35 | 'class_attributes_separation' => [ 36 | 'elements' => [ 37 | 'method', 38 | ], 39 | ], 40 | 41 | 'visibility_required' => ['property', 'method'], 42 | 43 | 'void_return' => true, 44 | 'protected_to_private' => true, 45 | 46 | 'explicit_string_variable' => true, 47 | 'single_quote' => true, 48 | 49 | 'declare_strict_types' => true, 50 | ]) 51 | ->setFinder($finder); 52 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "semi": false 6 | } 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.1.0 (2018-12-30)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.0.1..1.1.0) 4 | 5 | ### Fixed 6 | - Fix wrong prop type ([#5](https://github.com/dmitrybubyakin/nova-medialibrary-field/pull/5)) 7 | - Authorieze to any action if gates are not defined ([fe74200](https://github.com/dmitrybubyakin/nova-medialibrary-field/commit/fe74200)) 8 | 9 | ## [1.1.1 (2019-01-02)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.1.0...1.1.1) 10 | 11 | ### Fixed 12 | 13 | - Global Search : Class name must be a valid object or a string ([#7](https://github.com/dmitrybubyakin/nova-medialibrary-field/issues/7)) 14 | 15 | ## [1.1.2 (2019-01-02)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.1.1...1.1.2) 16 | 17 | ### Fixed 18 | 19 | - Fixed problem with file storing when Medialibrary is in Panel or Panels are before the field ([#8](https://github.com/dmitrybubyakin/nova-medialibrary-field/pull/8)) 20 | 21 | ## [1.1.3 (2019-01-08)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.1.2...1.1.3) 22 | 23 | ### Added 24 | 25 | - Pass an uploaded file to callbacks ([df988bc](https://github.com/dmitrybubyakin/nova-medialibrary-field/commit/df988bc)) 26 | 27 | ## [1.1.4 (2019-01-16)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.1.3...1.1.4) 28 | 29 | ### Fixed 30 | 31 | - Undefined `registerMediaCollections` method is called on related resources ([#10](https://github.com/dmitrybubyakin/nova-medialibrary-field/issues/10)) 32 | 33 | ## [1.1.5 (2019-01-28)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.1.4...1.1.5) 34 | 35 | ### Fixed 36 | 37 | - Replace Panel with MergeValue ([b6002ba](https://github.com/dmitrybubyakin/nova-medialibrary-field/commit/b6002ba)) 38 | 39 | ## [1.1.6 (2019-01-28)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.1.5...1.1.6) 40 | 41 | ### Added 42 | 43 | - Possibility to customize the thumbnail title and show a short thumbnail description text 44 | - Possibility to customize the thumbnail size 45 | 46 | ## [1.1.7 (2019-02-01)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.1.6...1.1.7) 47 | 48 | ### Added 49 | 50 | - Possibility to display labels 51 | 52 | ## [1.1.8 (2019-02-09)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.1.7...1.1.8) 53 | 54 | ### Fixed 55 | 56 | - Ignore MissingValue ([f20bf3d](https://github.com/dmitrybubyakin/nova-medialibrary-field/commit/f20bf3d)) 57 | 58 | ## [1.1.9 (2019-02-26)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.1.8...1.1.9) 59 | 60 | ### Added 61 | 62 | - Possibility to crop images 63 | 64 | ## [1.1.10 (2019-07-01)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.1.9...1.1.10) 65 | 66 | ### Added 67 | 68 | - Possibility to customize URL of the download button 69 | 70 | ## [1.2.0 (2019-10-15)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.1.10...1.2.0) 71 | 72 | ### Added 73 | 74 | - Add Nova 2.4 support 75 | 76 | ## [2.0 (2020-01-19)](https://github.com/dmitrybubyakin/nova-medialibrary-field/compare/1.1.10...2.0) 77 | 78 | ### Upgrade guide 79 | 80 | Remove `\DmitryBubyakin\NovaMedialibraryField\Resources\Media::class` from your NovaServiceProvider. [Media](src/Resources/Media.php) is used by default. 81 | 82 | TODO 83 | 84 | ### Added 85 | - support create/edit views 86 | - use existing media 87 | - [GeneratedConversions](src/Fields/GeneratedConversions.php) field (can be used only with media) 88 | 89 | TODO 90 | 91 | ### Changed 92 | - thumbnailTitle -> title 93 | - storeUsing, replaceUsing -> attachUsing 94 | - rules is now applied to a media collection, attachRules is applied to media 95 | 96 | TODO 97 | 98 | ### Removed 99 | - labels are not supported, let me know if they are needed 100 | - imageMimes 101 | - thumbnailSize 102 | - bigThumbnails 103 | - thumbnailDescription (try new `tooltip` method) 104 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Dmitry Bubyakin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Medialibrary Field for Laravel Nova 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/dmitrybubyakin/nova-medialibrary-field.svg?style=flat-square)](https://github.com/dmitrybubyakin/nova-medialibrary-field/releases) 4 | [![Total Downloads](https://img.shields.io/packagist/dt/dmitrybubyakin/nova-medialibrary-field.svg?style=flat-square)](https://packagist.org/packages/dmitrybubyakin/nova-medialibrary-field) 5 | 6 | Laravel Nova field for managing the Spatie media library. 7 | 8 | This is the documentation for v2 and v3. For v1 follow this [link](https://github.com/dmitrybubyakin/nova-medialibrary-field/tree/1.2.2) 9 | 10 | Features: 11 | - add media on update/create views 12 | - add existing media 13 | - crop media 14 | - sort media 15 | - display on the index view 16 | 17 | ## Table of Contents 18 | 19 | - [Screenshots](#screenshots) 20 | - [Installation](#installation) 21 | - [Usage](#usage) 22 | - [Methods](#methods) 23 | - [Attribute](#attribute) 24 | - [Fields](#fields) 25 | - [AttachUsing](#attachusing) 26 | - [ResolveMediaUsing](#resolvemediausing) 27 | - [AttachExisting](#attachexisting) 28 | - [MediaOnIndex](#mediaonindex) 29 | - [DownloadUsing](#downloadusing) 30 | - [PreviewUsing](#previewusing) 31 | - [Tooltip](#tooltip) 32 | - [Title](#title) 33 | - [CopyAs](#copyAs) 34 | - [Croppable](#croppable) 35 | - [Single](#single) 36 | - [Accept](#accept) 37 | - [MaxSizeInBytes](#maxsizeinbytes) 38 | - [AttachOnDetails](#attachondetails) 39 | - [AttachRules](#attachrules) 40 | - [Autouploading](#autouploading) 41 | - [Preview Customization](#preview-customization) 42 | - [Validation](#validation) 43 | - [Sorting](#sorting) 44 | - [Authorization Gates 'view', 'update' and 'delete'](#authorization-gates-view-update-and-delete) 45 | - [Translations](#translations) 46 | - [Changelog](#changelog) 47 | - [Alternatives](#alternatives) 48 | - [License](#license) 49 | 50 | ## Screenshots 51 | 52 | ![index view](https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/master/docs/index.png) 53 | ![create view](https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/master/docs/create.png) 54 | ![details view](https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/master/docs/details.png) 55 | ![update view](https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/master/docs/update.png) 56 | ![media actions](https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/master/docs/actions.png) 57 | ![media crop dialog](https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/master/docs/crop-dialog.png) 58 | ![media details dialog](https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/master/docs/media-details-dialog.png) 59 | ![existing media dialog](https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/master/docs/existing-media-dialog.png) 60 | 61 | ## Installation 62 | 63 | This package can be installed via command: 64 | 65 | ```bash 66 | composer require dmitrybubyakin/nova-medialibrary-field 67 | ``` 68 | 69 | ## Usage 70 | 71 | ```php 72 | Medialibrary::make($name, $collectionName = '', $diskName = '', $attribute = null), 73 | ``` 74 | 75 | ### Methods 76 | 77 | #### Attribute 78 | 79 | Sometimes you may need to use the same field label (duplicated sections, etc). The attribute must be unique. In this case you can change the default behaviour using the `attribute()` method. 80 | 81 | ```php 82 | Medialibrary::make('name', 'collection name', 'disk name', 'custom_attribute'); 83 | // or 84 | Medialibrary::make('name', 'collection name', 'disk name')->attribute('custom_attribute'); 85 | ``` 86 | 87 | #### Fields 88 | 89 | Define custom fields for media. [MediaFields](src/Fields/Support/MediaFields.php) is used by default. 90 | 91 | ```php 92 | Medialibrary::make('Media')->fields(function () { 93 | return [ 94 | Text::make('File Name', 'file_name') 95 | ->rules('required', 'min:2'), 96 | 97 | Text::make('Tooltip', 'custom_properties->tooltip') 98 | ->rules('required', 'min:2'), 99 | 100 | GeneratedConversions::make('Conversions') 101 | ->withTooltips(), 102 | ]; 103 | }); 104 | ``` 105 | #### ResolveMediaUsing 106 | 107 | ```php 108 | Medialibrary::make('Media')->resolveMediaUsing(function (HasMedia $model, string $collectionName) { 109 | return $model->getMedia($collectionName); 110 | }); 111 | ``` 112 | 113 | #### AttachUsing 114 | 115 | Called inside [AttachController](src/Http/Controllers/AttachController.php#L32). [AttachCallback](src/Fields/Support/AttachCallback.php) is used by default. 116 | It accepts `$fieldUuid` which is used when a resource is not created. 117 | If you want to attach media on the create view, you should keep [these lines](src/Fields/Support/AttachCallback.php#L20-L22) in your callback. 118 | 119 | ```php 120 | Medialibrary::make('Media') 121 | ->attachUsing(function (HasMedia $model, UploadedFile $file, string $collectionName, string $diskName, string $fieldUuid) { 122 | if ($model instanceof TransientModel) { 123 | $collectionName = $fieldUuid; 124 | } 125 | 126 | $fileAdder = $model->addMedia($file); 127 | 128 | // do something 129 | 130 | $fileAdder->toMediaCollection($collectionName, $diskName); 131 | }); 132 | ``` 133 | 134 | #### AttachExisting 135 | 136 | Allow attaching existing media. 137 | 138 | ```php 139 | Medialibrary::make('Media')->attachExisting(); // display all media 140 | Medialibrary::make('Media')->attachExisting('collectionName'); // display media from a specific collection 141 | Medialibrary::make('Media')->attachExisting(function (Builder $query, Request $request, HasMedia $model) { 142 | $query->where(...); 143 | }); 144 | ``` 145 | 146 | #### MediaOnIndex 147 | 148 | Display media on index 149 | 150 | ```php 151 | Medialibrary::make('Media')->mediaOnIndex(1); 152 | Medialibrary::make('Media')->mediaOnIndex(function (HasMedia $resource, string $collectionName) { 153 | return $resource->media()->where('collection_name', $collectionName)->limit(5)->get(); 154 | }); 155 | ``` 156 | 157 | #### DownloadUsing 158 | 159 | ```php 160 | Medialibrary::make('Media')->downloadUsing('conversionName'); 161 | Medialibrary::make('Media')->downloadUsing(function (Media $media) { 162 | return $media->getFullUrl(); 163 | }); 164 | ``` 165 | 166 | #### PreviewUsing 167 | 168 | ```php 169 | Medialibrary::make('Media')->previewUsing('conversionName'); 170 | Medialibrary::make('Media')->previewUsing(function (Media $media) { 171 | return $media->getFullUrl('preview'); 172 | }); 173 | ``` 174 | 175 | #### Tooltip 176 | 177 | ```php 178 | Medialibrary::make('Media')->tooltip('file_name'); 179 | Medialibrary::make('Media')->tooltip(function (Media $media) { 180 | return $media->getCustomProperty('tooltip'); 181 | }); 182 | ``` 183 | 184 | #### Title 185 | 186 | ```php 187 | Medialibrary::make('Media')->title('name'); 188 | Medialibrary::make('Media')->title(function (Media $media) { 189 | return $media->name; 190 | }); 191 | ``` 192 | 193 | #### CopyAs 194 | 195 | ```php 196 | Medialibrary::make('Media')->copyAs('Url', function (Media $media) { 197 | return $media->getFullUrl(); 198 | }); 199 | 200 | Medialibrary::make('Media')->copyAs('Html', function (Media $media) { 201 | return $media->img(); 202 | }, 'custom-icon'); 203 | 204 | // You can hide default "Copy Url" 205 | Medialibrary::make('Media')->hideCopyUrlAction(); 206 | ``` 207 | 208 | #### Croppable 209 | 210 | https://github.com/fengyuanchen/cropperjs#options 211 | 212 | ```php 213 | Medialibrary::make('Media')->croppable('conversionName'); 214 | Medialibrary::make('Media')->croppable('conversionName', ['viewMode' => 3]); 215 | Medialibrary::make('Media')->croppable('conversionName', [ 216 | 'rotatable' => false, 217 | 'zoomable' => false, 218 | 'cropBoxResizable' => false, 219 | ]); 220 | Medialibrary::make('Media')->croppable('conversionName', function (Media $media) { 221 | return $media->getCustomProperty('croppable') ? ['viewMode' => 3] : null; 222 | }); 223 | ``` 224 | https://docs.spatie.be/laravel-medialibrary/v8/converting-images/defining-conversions/#performing-conversions-on-specific-collections 225 | > {note} If your media in different collection, make sure pass your collectionName to `performOnCollections` 226 | 227 | ```php 228 | $this->addMediaConversion('conversionName')->performOnCollections('collectionName') 229 | ``` 230 | 231 | #### Single 232 | 233 | https://docs.spatie.be/laravel-medialibrary/v7/working-with-media-collections/defining-media-collections/#single-file-collections 234 | 235 | ```php 236 | Medialibrary::make('Media')->single(); 237 | ``` 238 | 239 | #### Accept 240 | 241 | ```php 242 | Medialibrary::make('Media')->accept('image/*'); 243 | ``` 244 | 245 | #### MaxSizeInBytes 246 | 247 | ```php 248 | Medialibrary::make('Media')->maxSizeInBytes(1024 * 1024); 249 | ``` 250 | 251 | #### AttachOnDetails 252 | 253 | Allows attaching files on the details view. 254 | 255 | ```php 256 | Medialibrary::make('Media')->attachOnDetails(); 257 | ``` 258 | 259 | #### AttachRules 260 | 261 | ```php 262 | Medialibrary::make('Media')->attachRules('image', 'dimensions:min_width=500,min_height=500'); 263 | ``` 264 | 265 | #### Autouploading 266 | 267 | ```php 268 | Medialibrary::make('Media')->autouploading(); 269 | ``` 270 | 271 | #### Preview Customization 272 | 273 | ```php 274 | Medialibrary::make('Media')->withMeta([ 275 | 'indexPreviewClassList' => 'rounded w-8 h-8 ml-2', 276 | 'detailsPreviewClassList' => 'w-32 h-24 rounded-b', 277 | ]); 278 | ``` 279 | 280 | ### Validation 281 | 282 | ```php 283 | Medialibrary::make('Media') 284 | ->rules('array', 'required') // applied to the media collection 285 | ->creationRules('min:2') // applied to the media collection 286 | ->updateRules('max:4') // applied to the media collection 287 | ->attachRules('image', 'dimensions:min_width=500,min_height=500'); // applied to media 288 | ``` 289 | 290 | ### Sorting 291 | 292 | ```php 293 | Medialibrary::make('Media')->sortable(); 294 | ``` 295 | 296 | ### Authorization Gates 'view', 'update' and 'delete' 297 | 298 | To view, update and delete uploaded media, you need to setup some gates. 299 | You can use the store and replace callbacks to store additional information to the custom_properties. 300 | The additional information can be used inside the gates for authorization. 301 | 302 | ```php 303 | Gate::define('view', function ($user, $media) { 304 | return true; // view granted 305 | }); 306 | 307 | Gate::define('update', function ($user, $media) { 308 | return true; // update granted 309 | }); 310 | 311 | Gate::define('delete', function ($user, $media) { 312 | return true; // deletion granted 313 | }); 314 | ``` 315 | 316 | You can also use the policy. 317 | 318 | ```php 319 | class MediaPolicy 320 | { 321 | public function view(User $user, Media $media): bool 322 | { 323 | return true; 324 | } 325 | 326 | public function update(User $user, Media $media): bool 327 | { 328 | return true; 329 | } 330 | 331 | public function delete(User $user, Media $media): bool 332 | { 333 | return true; 334 | } 335 | } 336 | 337 | class AuthServiceProvider extends ServiceProvider 338 | { 339 | protected $policies = [ 340 | Media::class => MediaPolicy::class, 341 | ]; 342 | 343 | //... 344 | } 345 | ``` 346 | 347 | ## Translations 348 | 349 | - [de](resources/lang/de.json) 350 | - [en](resources/lang/en.json) 351 | - [fr](resources/lang/fr.json) 352 | - [pt-BR](resources/lang/pt-BR.json) 353 | - [ru](resources/lang/ru.json) 354 | - [tr](resources/lang/tr.json) 355 | - [uk](resources/lang/uk.json) 356 | - [zh-CN](resources/lang/zh-CN.json) 357 | 358 | ## Changelog 359 | 360 | Please see the [CHANGELOG](CHANGELOG.md) for more information about the most recent changed. 361 | 362 | ## Alternatives 363 | 364 | - https://github.com/ebess/advanced-nova-media-library 365 | 366 | ## License 367 | 368 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 369 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dmitrybubyakin/nova-medialibrary-field", 3 | "description": "Laravel Nova field for managing the Spatie media library.", 4 | "keywords": [ 5 | "laravel", 6 | "nova", 7 | "medialibrary" 8 | ], 9 | "homepage": "https://github.com/dmitrybubyakin/nova-medialibrary-field", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Dmitry Bubyakin", 14 | "email": "dimabubyakin97@gmail.com" 15 | } 16 | ], 17 | "repositories": [ 18 | { 19 | "type": "composer", 20 | "url": "https://nova.laravel.com" 21 | } 22 | ], 23 | "require": { 24 | "php": "^8.2", 25 | "spatie/laravel-medialibrary": "^11.0", 26 | "spatie/laravel-data": "^3.0|^4.0" 27 | }, 28 | "require-dev": { 29 | "laravel/nova": "^5.0", 30 | "laravel/nova-devtool": "^1.7", 31 | "nunomaduro/collision": "^4.0|^5.0|^6.0", 32 | "orchestra/testbench": "^6.0|^7.0|^8.0", 33 | "phpunit/phpunit": "^9.0" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "DmitryBubyakin\\NovaMedialibraryField\\": "src/" 38 | }, 39 | "files": [ 40 | "src/helpers.php" 41 | ] 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "DmitryBubyakin\\NovaMedialibraryField\\Tests\\": "tests/" 46 | } 47 | }, 48 | "extra": { 49 | "laravel": { 50 | "providers": [ 51 | "DmitryBubyakin\\NovaMedialibraryField\\FieldServiceProvider" 52 | ] 53 | } 54 | }, 55 | "config": { 56 | "sort-packages": true 57 | }, 58 | "minimum-stability": "dev", 59 | "prefer-stable": true 60 | } 61 | -------------------------------------------------------------------------------- /dist/css/field.css: -------------------------------------------------------------------------------- 1 | .h-16{height:4rem}.w-16{width:4rem}.w-32{width:8rem}.h-32{height:8rem}.w-24{width:6rem}.h-24{height:6rem}.btn-block button{display:block}.w-choose-existing-media{width:1024px}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.gap-2{gap:.5rem}.place-self-end{place-self:end}.bg-overlay{background:rgba(0,0,0,.3)}.object-cover{-o-object-fit:cover;object-fit:cover}.group:hover .group-hover\:block{display:block}.group:hover .group-hover\:opacity-75{opacity:.75}.group:hover .group-hover\:hidden{display:none}.shadow-danger{box-shadow:0 0 0 2px var(--danger)}.shadow-media-chosen{box-shadow:0 0 0 2px var(--info-dark)}.bg-info-dark-half{background-color:rgba(49,130,206,.5)}.dragging .dragging\:hidden{display:none!important}.dragging .dragging\:border-none,.dragging.dragging\:border-none{border:none!important}.medialibrary-tooltips-hidden .medialibrary-tooltip{opacity:0}.icon-sm{height:18px!important;width:18px!important}.icon-md{height:20px!important;width:20px!important}.icon-lg{height:24px!important;width:24px!important}.icon-xl{height:32px!important;width:32px!important}.icon-2xl{height:40px!important;width:40px!important} 2 | -------------------------------------------------------------------------------- /dist/css/field.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"css/field.css","mappings":"AAAA,MACE,WACF,CAEA,MACE,UACF,CAEA,MACE,UACF,CACA,MACE,WAEF,CACA,MACE,UAEF,CACA,MACE,WAEF,CACA,kBACE,aAEF,CACA,yBACE,YAEF,CACA,aACE,6CAEF,CACA,OACE,SAEF,CACA,gBACE,cAEF,CACA,YACE,yBAEF,CACA,cACE,oCAEF,CAGI,iCACE,aAAN,CAEI,sCACE,WAAN,CAEI,kCACE,YAAN,CAKA,eACE,kCAFF,CAKA,qBACE,qCAFF,CAKA,mBACE,oCAFF,CAME,4BACE,sBAHJ,CAME,iEAEE,qBALJ,CAUE,oDACE,SAPJ,C","sources":["webpack://dmitrybubyakin/nova-medialibrary-field/./resources/sass/field.scss"],"sourcesContent":[".h-16 {\n height: 4rem;\n}\n\n.w-16 {\n width: 4rem;\n}\n\n.w-32 {\n width: 8rem;\n}\n.h-32 {\n height: 8rem;\n}\n\n.w-24 {\n width: 6rem;\n}\n\n.h-24 {\n height: 6rem;\n}\n\n.btn-block button {\n display: block;\n}\n\n.w-choose-existing-media {\n width: 1024px;\n}\n\n.grid-cols-5 {\n grid-template-columns: repeat(5, minmax(0, 1fr));\n}\n\n.gap-2 {\n gap: 0.5rem;\n}\n\n.place-self-end {\n place-self: end;\n}\n\n.bg-overlay {\n background: hsla(0, 0%, 0%, 0.3);\n}\n\n.object-cover {\n object-fit: cover;\n}\n\n.group {\n &:hover {\n .group-hover\\:block {\n display: block;\n }\n .group-hover\\:opacity-75 {\n opacity: 0.75;\n }\n .group-hover\\:hidden {\n display: none;\n }\n }\n}\n\n.shadow-danger {\n box-shadow: 0 0 0 2px var(--danger);\n}\n\n.shadow-media-chosen {\n box-shadow: 0 0 0 2px var(--info-dark);\n}\n\n.bg-info-dark-half {\n background-color: rgba(49, 130, 206, 0.5);\n}\n\n.dragging {\n .dragging\\:hidden {\n display: none !important;\n }\n\n .dragging\\:border-none,\n &.dragging\\:border-none {\n border: none !important;\n }\n}\n\n.medialibrary-tooltips-hidden {\n .medialibrary-tooltip {\n opacity: 0;\n }\n}\n"],"names":[],"sourceRoot":""} -------------------------------------------------------------------------------- /dist/js/field.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | * Cropper.js v1.6.2 3 | * https://fengyuanchen.github.io/cropperjs 4 | * 5 | * Copyright 2015-present Chen Fengyuan 6 | * Released under the MIT license 7 | * 8 | * Date: 2024-04-21T07:43:05.335Z 9 | */ 10 | 11 | /*! 12 | * clipboard.js v2.0.11 13 | * https://clipboardjs.com/ 14 | * 15 | * Licensed MIT © Zeno Rocha 16 | */ 17 | 18 | /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ 19 | 20 | /**! 21 | * Sortable 1.14.0 22 | * @author RubaXa 23 | * @author owenm 24 | * @license MIT 25 | */ 26 | -------------------------------------------------------------------------------- /dist/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/field.js": "/js/field.js?id=59c0f1792ef137c6674c940f14a1df91", 3 | "/css/field.css": "/css/field.css?id=0e2d88854f6d4328faaa7862448bd9fc" 4 | } 5 | -------------------------------------------------------------------------------- /docs/actions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/fae6e53b8833e00fa1867b3c6d7ad8cf53704854/docs/actions.png -------------------------------------------------------------------------------- /docs/create.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/fae6e53b8833e00fa1867b3c6d7ad8cf53704854/docs/create.png -------------------------------------------------------------------------------- /docs/crop-dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/fae6e53b8833e00fa1867b3c6d7ad8cf53704854/docs/crop-dialog.png -------------------------------------------------------------------------------- /docs/details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/fae6e53b8833e00fa1867b3c6d7ad8cf53704854/docs/details.png -------------------------------------------------------------------------------- /docs/existing-media-dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/fae6e53b8833e00fa1867b3c6d7ad8cf53704854/docs/existing-media-dialog.png -------------------------------------------------------------------------------- /docs/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/fae6e53b8833e00fa1867b3c6d7ad8cf53704854/docs/index.png -------------------------------------------------------------------------------- /docs/media-details-dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/fae6e53b8833e00fa1867b3c6d7ad8cf53704854/docs/media-details-dialog.png -------------------------------------------------------------------------------- /docs/update.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/fae6e53b8833e00fa1867b3c6d7ad8cf53704854/docs/update.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "mix", 6 | "watch": "mix watch", 7 | "watch-poll": "mix watch -- --watch-options-poll=1000", 8 | "prod": "npm run production", 9 | "production": "mix --production", 10 | "nova:install": "npm --prefix='vendor/laravel/nova' ci" 11 | }, 12 | "devDependencies": { 13 | "clipboard": "^2.0.11", 14 | "laravel-nova-devtool": "file:vendor/laravel/nova-devtool", 15 | "lodash": "^4.17.21", 16 | "resolve-url-loader": "^5.0.0", 17 | "sass": "^1.85.0", 18 | "sass-loader": "^12.6.0", 19 | "vue-cropperjs": "^5.0.0", 20 | "vuedraggable": "^4.1.0" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | ./app 11 | 12 | 13 | 14 | 15 | ./tests/ 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /resources/js/components/Buttons/LoadingButton.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 54 | -------------------------------------------------------------------------------- /resources/js/components/Common/Loader.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 95 | -------------------------------------------------------------------------------- /resources/js/components/GeneratedConversionsDetailField.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 65 | -------------------------------------------------------------------------------- /resources/js/components/Icons/Crop.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /resources/js/components/Icons/Cropper/Lock.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /resources/js/components/Icons/Cropper/Rotate.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /resources/js/components/Icons/Cropper/Unlock.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /resources/js/components/Icons/Cropper/ZoomIn.vue: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /resources/js/components/Icons/Cropper/ZoomOut.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /resources/js/components/Icons/Link.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/ChooseExistingMediaList.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 42 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/ChooseExistingMediaListItem.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 66 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/Context.js: -------------------------------------------------------------------------------- 1 | import Media from './Media' 2 | 3 | export const context = Symbol() 4 | 5 | export const Provider = { 6 | props: { 7 | resourceName: { 8 | type: String, 9 | required: true, 10 | }, 11 | resourceId: { 12 | required: true, 13 | }, 14 | field: { 15 | type: Object, 16 | required: true, 17 | }, 18 | }, 19 | 20 | provide() { 21 | return { 22 | [context]: this.context, 23 | } 24 | }, 25 | 26 | data() { 27 | return { 28 | context: { 29 | media: [], 30 | loading: false, 31 | field: this.field, 32 | resourceId: this.resourceId, 33 | resourceName: this.resourceName, 34 | refresh: this.refresh, 35 | setMedia: this.setMedia, 36 | }, 37 | } 38 | }, 39 | 40 | created() { 41 | this.refresh() 42 | 43 | Nova.$on(`nova-medialibrary-field:refresh:${this.field.attribute}`, this.refresh) 44 | }, 45 | 46 | beforeDestroy() { 47 | Nova.$off(`nova-medialibrary-field:refresh:${this.field.attribute}`, this.refresh) 48 | }, 49 | 50 | methods: { 51 | withLoading(promise) { 52 | this.context.loading = true 53 | 54 | return promise.finally(() => { 55 | this.context.loading = false 56 | }) 57 | }, 58 | 59 | fetch() { 60 | const { attribute } = this.field 61 | const { resourceName, resourceId } = this 62 | const params = { 63 | fieldUuid: this.field.value, 64 | } 65 | 66 | return this.withLoading( 67 | Nova.request() 68 | .get(`/nova-vendor/dmitrybubyakin/nova-medialibrary-field/${resourceName}/${resourceId}/media/${attribute}`, { 69 | params, 70 | }) 71 | .then((response) => response.data.map(this.wrap)) 72 | ) 73 | }, 74 | 75 | wrap(media) { 76 | return new Media(media, this.field.attribute, { 77 | viaField: this.field.attribute, 78 | viaResource: this.resourceName, 79 | }) 80 | }, 81 | 82 | async refresh(callback = null) { 83 | this.context.media.forEach((media) => media.setLoading(true)) 84 | 85 | this.setMedia(await this.fetch()) 86 | 87 | if (typeof callback === 'function') { 88 | callback() 89 | } 90 | }, 91 | 92 | setMedia(media) { 93 | this.context.media = media 94 | }, 95 | }, 96 | 97 | render() { 98 | return this.$slots.default() 99 | }, 100 | } 101 | 102 | export const Consumer = { 103 | inject: { 104 | context, 105 | }, 106 | 107 | render() { 108 | return this.$slots.default(this.context) 109 | }, 110 | } 111 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/Media.js: -------------------------------------------------------------------------------- 1 | import { Errors, Localization } from 'laravel-nova' 2 | import Clipboard from 'clipboard' 3 | 4 | export default class Media { 5 | constructor(media, attribute, requestParams) { 6 | for (let key in media) { 7 | this[key] = media[key] 8 | } 9 | 10 | this.__loading = false 11 | this.__updating = false 12 | this.__attribute = attribute 13 | this.__requestParams = requestParams 14 | 15 | this.__detailModalOpen = false 16 | this.__deleteModalOpen = false 17 | this.__updateModalOpen = false 18 | this.__cropperModalOpen = false 19 | 20 | this.__errors = null 21 | this.__fields = null 22 | this.__resource = null 23 | } 24 | 25 | get loading() { 26 | return this.__loading 27 | } 28 | 29 | get updating() { 30 | return this.__updating 31 | } 32 | 33 | get resource() { 34 | return this.__resource 35 | } 36 | 37 | get resourceId() { 38 | return this.id 39 | } 40 | 41 | get resourceName() { 42 | return 'dmitrybubyakin-nova-medialibrary-media' 43 | } 44 | 45 | get singularLabel() { 46 | return Nova.config('resources') 47 | .find((resource) => resource.uriKey == this.resourceName) 48 | .singularLabel; 49 | } 50 | 51 | get fields() { 52 | return this.__fields 53 | } 54 | 55 | get errors() { 56 | return this.__errors 57 | } 58 | 59 | get detailModalOpen() { 60 | return this.__detailModalOpen 61 | } 62 | 63 | get updateModalOpen() { 64 | return this.__updateModalOpen 65 | } 66 | 67 | get deleteModalOpen() { 68 | return this.__deleteModalOpen 69 | } 70 | 71 | get cropperModalOpen() { 72 | return this.__cropperModalOpen 73 | } 74 | 75 | setLoading(loading) { 76 | this.__loading = loading 77 | } 78 | 79 | withLoading(promise) { 80 | this.setLoading(true) 81 | 82 | return promise.finally(() => this.setLoading(false)) 83 | } 84 | 85 | withUpdating(promise) { 86 | this.__updating = true 87 | 88 | return promise.finally(() => (this.__updating = false)) 89 | } 90 | 91 | fetch(uri) { 92 | return this.withLoading( 93 | Nova.request() 94 | .get(`/nova-api/dmitrybubyakin-nova-medialibrary-media/${uri}`, { params: this.__requestParams }) 95 | .then((response) => response.data) 96 | ) 97 | } 98 | 99 | openDeleteModal() { 100 | this.__deleteModalOpen = true 101 | } 102 | 103 | closeDeleteModal() { 104 | this.__deleteModalOpen = false 105 | } 106 | 107 | openDetailModal() { 108 | this.__detailModalOpen = true 109 | } 110 | 111 | closeDetailModal() { 112 | this.__detailModalOpen = false 113 | } 114 | 115 | openUpdateModal() { 116 | this.__updateModalOpen = true 117 | } 118 | 119 | closeUpdateModal() { 120 | this.__updateModalOpen = false 121 | } 122 | 123 | openCropperModal() { 124 | this.__cropperModalOpen = true 125 | } 126 | 127 | closeCropperModal() { 128 | this.__cropperModalOpen = false 129 | } 130 | 131 | closeAllModals() { 132 | this.closeDetailModal() 133 | this.closeUpdateModal() 134 | this.closeDeleteModal() 135 | this.closeCropperModal() 136 | } 137 | 138 | async view() { 139 | this.__resource = await this.fetch(this.id).then((response) => response.resource) 140 | 141 | this.openDetailModal() 142 | } 143 | 144 | async edit() { 145 | this.__errors = new Errors() 146 | this.__fields = await this.fetch(`${this.id}/update-fields`).then((response) => Object.values(response.fields)) 147 | 148 | this.openUpdateModal() 149 | } 150 | 151 | async update(formData) { 152 | formData.append('_method', 'PUT') 153 | 154 | for (let key in this.__requestParams) { 155 | formData.append(key, this.__requestParams[key]) 156 | } 157 | 158 | try { 159 | await this.withUpdating(Nova.request().post(`/nova-api/${this.resourceName}/${this.resourceId}`, formData)) 160 | 161 | this.closeAllModals() 162 | this.refresh() 163 | 164 | Nova.success(Localization.methods.__('Media was updated!', { resource: this.singularLabel.toLowerCase() })) 165 | } catch (error) { 166 | if (!error.response) { 167 | throw error 168 | } else if (error.response.status === 422) { 169 | this.__errors = new Errors(error.response.data.errors) 170 | } 171 | 172 | Nova.error(Localization.methods.__('There was a problem submitting the form.')) 173 | } 174 | } 175 | 176 | async confirmDelete() { 177 | await Nova.request().delete(`/nova-api/${this.resourceName}`, { 178 | params: { 179 | ...this.__requestParams, 180 | resources: [this.id], 181 | }, 182 | }) 183 | 184 | this.closeAllModals() 185 | this.refresh() 186 | 187 | Nova.success(Localization.methods.__('Media was deleted!', { resource: this.singularLabel.toLowerCase() })) 188 | } 189 | 190 | async regenerate() { 191 | await Nova.request().post(`/nova-vendor/dmitrybubyakin/nova-medialibrary-field/${this.id}/regenerate`) 192 | 193 | Nova.success(Localization.methods.__('Media was regenerated!', { resource: this.singularLabel.toLowerCase() })) 194 | 195 | this.refresh() 196 | } 197 | 198 | async copy(as, container) { 199 | let value = null 200 | 201 | for (const copyAs of this.copyAs) { 202 | if (copyAs.as === as) { 203 | value = copyAs.value 204 | } 205 | } 206 | 207 | if (value === null) { 208 | value = this[as] 209 | } 210 | 211 | Clipboard.copy(value, { 212 | container: typeof container === 'object' ? container : document.body, 213 | }) 214 | 215 | Nova.success(Localization.methods.__('Copied!')) 216 | } 217 | 218 | async crop(data) { 219 | data = { ...data, conversion: this.cropperConversion } 220 | 221 | await this.withUpdating( 222 | Nova.request().post(`/nova-vendor/dmitrybubyakin/nova-medialibrary-field/${this.id}/crop`, data) 223 | ) 224 | 225 | this.closeAllModals() 226 | this.refresh() 227 | 228 | Nova.success(Localization.methods.__('Media was cropped!', { resource: this.singularLabel.toLowerCase() })) 229 | } 230 | 231 | refresh() { 232 | Nova.$emit(`nova-medialibrary-field:refresh:${this.__attribute}`) 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/MediaList.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 72 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/MediaListItem.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 58 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/MediaListItemActions.vue: -------------------------------------------------------------------------------- 1 | 76 | 77 | 128 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/MediaListItemModals.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 69 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/MediaListItemPreview.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 35 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/MediaPreview.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 45 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/MediaUploading.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 249 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/MediaUploadingList.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 29 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/MediaUploadingListItem.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 147 | 148 | 155 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/Modals/ChooseExistingMedia.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 176 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/Modals/Cropper.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 168 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/Modals/Detail.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 97 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/Modals/Edit.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 144 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/PaginationButton.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 24 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/UploadingMedia.js: -------------------------------------------------------------------------------- 1 | import { Errors } from 'laravel-nova' 2 | 3 | export class UploadingMedia { 4 | constructor(props) { 5 | for (let key of ['id', 'size', 'fileName', 'mimeType', 'extension']) { 6 | if (props[key] === undefined) { 7 | throw new Error(`[nova-medialibrary-field]: property ${key} is required`) 8 | } 9 | 10 | this[key] = props[key] 11 | } 12 | 13 | this.uploading = false 14 | this.uploadingFailed = false 15 | this.uploadingProgress = 0 16 | this.validationErrors = new Errors() 17 | } 18 | 19 | get exists() { 20 | return false 21 | } 22 | 23 | get isImage() { 24 | return /^image/.test(this.mimeType) 25 | } 26 | 27 | onRemove(callback) { 28 | this.removeHandler = callback 29 | } 30 | 31 | remove() { 32 | if (typeof this.removeHandler !== 'function') { 33 | throw new Error('[nova-medialibrary-field]: onRemove is not called') 34 | } 35 | 36 | this.removeHandler(this) 37 | } 38 | 39 | fillFormData() { 40 | throw new Error('Not implemented') 41 | } 42 | 43 | handleUploadProgress({ loaded, total }) { 44 | this.uploadingProgress = Math.round((loaded / total) * 100) 45 | } 46 | 47 | handleUploadFailed(error) { 48 | this.uploading = false 49 | this.uploadingFailed = true 50 | this.uploadingProgress = 0 51 | 52 | if (error.response && error.response.status === 422) { 53 | this.validationErrors = new Errors(error.response.data.errors) 54 | } 55 | } 56 | 57 | hasValidSize(field) { 58 | return field.maxSize !== undefined ? field.maxSize >= this.size : true 59 | } 60 | } 61 | 62 | export class UploadingFile extends UploadingMedia { 63 | constructor(props) { 64 | super(props) 65 | 66 | this.file = props.file 67 | } 68 | 69 | static create(file) { 70 | const id = Math.random().toString(36).substr(-8) 71 | 72 | return new UploadingFile({ 73 | file, 74 | id, 75 | size: file.size, 76 | fileName: file.name, 77 | mimeType: file.type, 78 | extension: file.name.split('.').pop(), 79 | }) 80 | } 81 | 82 | fillFormData(formData) { 83 | formData.append('file', this.file) 84 | } 85 | } 86 | 87 | export class UploadingExistingMedia extends UploadingMedia { 88 | constructor(props) { 89 | super(props) 90 | 91 | this.previewUrl = props.previewUrl 92 | } 93 | 94 | static create(media) { 95 | return new UploadingExistingMedia(media) 96 | } 97 | 98 | get exists() { 99 | return true 100 | } 101 | 102 | fillFormData(formData) { 103 | formData.append('media', this.id) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /resources/js/components/Medialibrary/Utils.js: -------------------------------------------------------------------------------- 1 | const defaultTooltipOptions = { 2 | classes: 'bg-white p-2 rounded border border-50 shadow text-sm leading-normal', 3 | offset: 10, 4 | placement: 'bottom', 5 | } 6 | 7 | export function tooltip(content, options = null) { 8 | if (!content) { 9 | return null 10 | } 11 | 12 | options = options || {} 13 | 14 | const tooltip = { ...defaultTooltipOptions, ...options, content } 15 | 16 | // see resources/js/components/Medialibrary/MediaList.vue handleDragStart and handleDragEnd 17 | tooltip.classes = `medialibrary-tooltip ${tooltip.classes}` 18 | 19 | return tooltip 20 | } 21 | -------------------------------------------------------------------------------- /resources/js/components/MedialibraryDetailField.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 31 | -------------------------------------------------------------------------------- /resources/js/components/MedialibraryField.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 41 | -------------------------------------------------------------------------------- /resources/js/components/MedialibraryFormField.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 33 | -------------------------------------------------------------------------------- /resources/js/components/MedialibraryIndexField.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 48 | -------------------------------------------------------------------------------- /resources/js/field.js: -------------------------------------------------------------------------------- 1 | import IndexField from './components/MedialibraryIndexField' 2 | import DetailField from './components/MedialibraryDetailField' 3 | import FormField from './components/MedialibraryFormField' 4 | import GeneratedConversionsDetailField from './components/GeneratedConversionsDetailField' 5 | 6 | import IconCrop from './components/Icons/Crop' 7 | import IconLink from './components/Icons/Link' 8 | 9 | import IconCropperRotate from './components/Icons/Cropper/Rotate' 10 | import IconCropperLock from './components/Icons/Cropper/Lock' 11 | import IconCropperUnlock from './components/Icons/Cropper/Unlock' 12 | import IconCropperZoomIn from './components/Icons/Cropper/ZoomIn' 13 | import IconCropperZoomOut from './components/Icons/Cropper/ZoomOut' 14 | 15 | import LoadingButton from './components/Buttons/LoadingButton'; 16 | import Loader from './components/Common/Loader'; 17 | 18 | Nova.booting((app, store) => { 19 | // Icons 20 | app.component('icon-crop', IconCrop) 21 | app.component('icon-link', IconLink) 22 | 23 | // Cropper icons 24 | app.component('icon-cropper-rotate', IconCropperRotate) 25 | app.component('icon-cropper-lock', IconCropperLock) 26 | app.component('icon-cropper-unlock', IconCropperUnlock) 27 | app.component('icon-cropper-zoom-in', IconCropperZoomIn) 28 | app.component('icon-cropper-zoom-out', IconCropperZoomOut) 29 | 30 | app.component('loader', Loader); 31 | 32 | // Buttons 33 | app.component('loading-button', LoadingButton); 34 | 35 | app.component('index-nova-medialibrary-field', IndexField) 36 | app.component('detail-nova-medialibrary-field', DetailField) 37 | app.component('form-nova-medialibrary-field', FormField) 38 | 39 | app.component('detail-nova-generated-conversions-field', GeneratedConversionsDetailField) 40 | }) -------------------------------------------------------------------------------- /resources/lang/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "Copied!": "Kopiert!", 3 | "Media was regenerated!": "Datei wurde erneuert!", 4 | "Media was cropped!": "Datei wurde zugeschnitten!", 5 | "Media was deleted!": "Datei wurde gelöscht!", 6 | "Media was updated!": "Datei wurde aktualisiert!", 7 | "Media sorted": "Dateien sortiert", 8 | "Copy Url": "Url kopieren", 9 | "Crop": "Zuschneiden", 10 | "Regenerate": "Erneuern", 11 | "Validation failed. Hover media to see details.": "Validierung fehlgeschlagen. Hover über die Datei um Details zu sehen.", 12 | "File :filename must be less than :size kilobytes": "Datei :filename muss weniger als :size Kilobytes haben", 13 | "Use existing": "Verwende bestehendes", 14 | "Upload": "Hochladen", 15 | "Replace File": "Datei ersetzen", 16 | "Choose File": "Datei wählen", 17 | "Choose Files": "Dateien wählen", 18 | "Choose existing media": "Verwende bestehende Datei", 19 | "Crop Media": "Datei zurechtschneiden", 20 | "Edit Media": "Datei bearbeiten", 21 | "Update Media": "Datei aktualisieren", 22 | "Media Details": "Datei Details", 23 | "Media Filename": "Dateiname", 24 | "Media Description": "Beschreibung", 25 | "Media Disk": "Speicherort", 26 | "Media Download Url": "Download Url", 27 | "Media Size": "Größe", 28 | "Media Updated At": "Aktualisiert am", 29 | "Media Conversions": "Konvertierungen" 30 | } 31 | -------------------------------------------------------------------------------- /resources/lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "Copied!": "Copied!", 3 | "Media was regenerated!": "Media was regenerated!", 4 | "Media was cropped!": "Media was cropped!", 5 | "Media was deleted!": "Media was deleted!", 6 | "Media was updated!": "Media was updated!", 7 | "Media sorted": "Media sorted", 8 | "Copy Url": "Copy Url", 9 | "Crop": "Crop", 10 | "Regenerate": "Regenerate", 11 | "Validation failed. Hover media to see details.": "Validation failed. Hover media to see details.", 12 | "File :filename must be less than :size kilobytes": "File :filename must be less than :size kilobytes", 13 | "Use existing": "Use existing", 14 | "Upload": "Upload", 15 | "Replace File": "Replace file", 16 | "Choose File": "Choose file", 17 | "Choose Files": "Choose files", 18 | "Choose existing media": "Choose existing media", 19 | "Crop Media": "Crop Media", 20 | "Edit Media": "Edit Media", 21 | "Update Media": "Update Media", 22 | "Media Details": "Media Details", 23 | "Media Filename": "Filename", 24 | "Media Description": "Description", 25 | "Media Disk": "Disk", 26 | "Media Download Url": "Download Url", 27 | "Media Size": "Size", 28 | "Media Updated At": "Updated At", 29 | "Media Conversions": "Conversions" 30 | } 31 | -------------------------------------------------------------------------------- /resources/lang/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "Copied!": "Copié !", 3 | "Media was regenerated!": "Le média a été régénéré !", 4 | "Media was cropped!": "Le média a été recadré !", 5 | "Media was deleted!": "Le média a été supprimé !", 6 | "Media was updated!": "Le média a été mis à jour !", 7 | "Media sorted": "Médias triés", 8 | "Copy Url": "Copier le lien", 9 | "Crop": "Recadrer", 10 | "Regenerate": "Régénérer les conversions", 11 | "Validation failed. Hover media to see details.": "La validation a échoué. Survolez le média pour voir les détails.", 12 | "File :filename must be less than :size kilobytes": "Le fichier :filename doit être plus petit que :size kilobytes", 13 | "Use existing": "Utiliser l'existant", 14 | "Upload": "Envoyer", 15 | "Replace File": "Remplacer le fichier", 16 | "Choose File": "Choisissez le fichier", 17 | "Choose Files": "Choisissez les fichiers", 18 | "Choose existing media": "Choisissez des médias existants", 19 | "Crop Media": "Recadrer le média", 20 | "Edit Media": "Modifier le média", 21 | "Update Media": "Enregistrer le média", 22 | "Media Details": "Détails du média", 23 | "Media Filename": "Nom du fichier", 24 | "Media Description": "Description", 25 | "Media Disk": "Disque", 26 | "Media Download Url": "Lien de téléchargement", 27 | "Media Size": "Taille", 28 | "Media Updated At": "Modifié à", 29 | "Media Conversions": "Conversions" 30 | } 31 | -------------------------------------------------------------------------------- /resources/lang/lt.json: -------------------------------------------------------------------------------- 1 | { 2 | "Copied!": "Nukopijuota!", 3 | "Media was regenerated!": "Medija pergeneruota!", 4 | "Media was cropped!": "Medija sėkmingai apkarpyta!", 5 | "Media was deleted!": "Medija sėkmingai ištrinta!", 6 | "Media was updated!": "Medija sėkmingai atnaujinta!", 7 | "Media sorted": "Medija perrikiuota", 8 | "Copy Url": "Kopijuoti Url", 9 | "Crop": "Apkarpyti", 10 | "Regenerate": "Pergeneruoti", 11 | "Validation failed. Hover media to see details.": "Validacija nepavyko. Užveskite pelę norėdami peržiūrėti klaidas.", 12 | "File :filename must be less than :size kilobytes": "Failo :filename dydis turi būti mažesnis negu :size kilobaitai", 13 | "Use existing": "Pasirinkti egzistuojantį", 14 | "Upload": "Įkelti", 15 | "Replace File": "Pakeisti failą", 16 | "Choose File": "Pasirinkti failą", 17 | "Choose Files": "Pasirinkti failus", 18 | "Choose existing media": "Pasirinkti egzistuojančią mediją", 19 | "Crop Media": "Apkarpyti mediją", 20 | "Edit Media": "Redaguoti mediją", 21 | "Update Media": "Atnaujinti mediją", 22 | "Media Details": "Medijos informacija", 23 | "Media Filename": "Pavadinimas", 24 | "Media Description": "Aprašymas", 25 | "Media Disk": "Talpykla", 26 | "Media Download Url": "Atsisiuntimo Url", 27 | "Media Size": "Dydis", 28 | "Media Updated At": "Atnaujinta", 29 | "Media Conversions": "Konversijos" 30 | } 31 | -------------------------------------------------------------------------------- /resources/lang/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "Copied!": "Copiado!", 3 | "Media was regenerated!": "Mídia regenerada!", 4 | "Media was cropped!": "Mídia cortada!", 5 | "Media was deleted!": "Mídia excluída!", 6 | "Media was updated!": "Mídia atualizada!", 7 | "Media sorted": "Mídia ordenada", 8 | "Copy Url": "Copiar Url", 9 | "Crop": "Cortar", 10 | "Regenerate": "Regenerar", 11 | "Validation failed. Hover media to see details.": "Falha na validação. Posicione o cursor na mídia para ver detalhes.", 12 | "File :filename must be less than :size kilobytes": "Arquivo :filename deve ser menor que :size kilobytes", 13 | "Use existing": "Usar existente", 14 | "Upload": "Upload", 15 | "Replace File": "Substituir Arquivo", 16 | "Choose File": "Escolher Arquivo", 17 | "Choose Files": "Escolher Arquivos", 18 | "Choose existing media": "Escolher mídia existente", 19 | "Crop Media": "Cortar Mídia", 20 | "Edit Media": "Editar Mídia", 21 | "Update Media": "Atualizar Mídia", 22 | "Media Details": "Detalhes da Mídia", 23 | "Media Filename": "Nome do Arquivo", 24 | "Media Description": "Descrição", 25 | "Media Disk": "Disco", 26 | "Media Download Url": "Url para download", 27 | "Media Size": "Tamanho", 28 | "Media Updated At": "Atualizado em", 29 | "Media Conversions": "Conversões" 30 | } 31 | -------------------------------------------------------------------------------- /resources/lang/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "Copied!": "Скопировано!", 3 | "Media was regenerated!": "Медиа файл регенерирован!", 4 | "Media was cropped!": "Медиа файл обрезан!", 5 | "Media was deleted!": "Медиа файл удалён!", 6 | "Media was updated!": "Медиа файл загружен!", 7 | "Media sorted": "Медиа файлы отсортированы", 8 | "Copy Url": "Скопировать адрес", 9 | "Crop": "Обрезать", 10 | "Regenerate": "Регенерировать", 11 | "Validation failed. Hover media to see details.": "Проверка не удалась. Наведите курсор на медиа файл, чтобы увидеть подробности.", 12 | "File :filename must be less than :size kilobytes": "Файл :filename должен быть меньше :size килобайт.", 13 | "Use existing": "Использовать существующий", 14 | "Upload": "Загрузить", 15 | "Replace File": "Заменить файл", 16 | "Choose File": "Выбрать файл", 17 | "Choose Files": "Выбрать файлы", 18 | "Choose existing media": "Выбрать существующий медиа файл", 19 | "Crop Media": "Обрезать медиа файл", 20 | "Edit Media": "Редактировать медиа файл", 21 | "Update Media": "Обновить медиа файл", 22 | "Media Details": "Детали медиа файла", 23 | "Media Filename": "Имя файла", 24 | "Media Description": "Описание", 25 | "Media Disk": "Диск", 26 | "Media Download Url": "Адрес для скачивания", 27 | "Media Size": "Размер", 28 | "Media Updated At": "Дата обновления", 29 | "Media Conversions": "Конверсии" 30 | } 31 | -------------------------------------------------------------------------------- /resources/lang/tr.json: -------------------------------------------------------------------------------- 1 | { 2 | "Copied!": "Kopyalandı!", 3 | "Media was regenerated!": "Medya yeniden oluşturuldu!", 4 | "Media was cropped!": "Medya kopyalandı!", 5 | "Media was deleted!": "Medya silindi!", 6 | "Media was updated!": "Medya güncellendi!", 7 | "Media sorted": "Medya sıralandı", 8 | "Copy Url": "URL'i kopyala", 9 | "Crop": "Kırp", 10 | "Regenerate": "Yeniden oluştur", 11 | "Validation failed. Hover media to see details.": "Doğrulama başarısız. Ayrıntıları görmek için imleci medya üzerine getirin.", 12 | "File :filename must be less than :size kilobytes": "Dosya :filename :size kilobayttan küçük olmalıdır", 13 | "Use existing": "Var olanı kullan", 14 | "Upload": "Yükle", 15 | "Replace File": "Dosyayı değiştir", 16 | "Choose File": "Dosya seç", 17 | "Choose Files": "Dosyaları seç", 18 | "Choose existing media": "Var olan medyayı seç", 19 | "Crop Media": "Medyayı Kırp", 20 | "Edit Media": "Medyayı Düzenle", 21 | "Update Media": "Medyayı Güncelle", 22 | "Media Details": "Medya Detayları", 23 | "Media Filename": "Dosya Adı", 24 | "Media Description": "Açıklama", 25 | "Media Disk": "Disk", 26 | "Media Download Url": "İndirme URL'i", 27 | "Media Size": "Boyut", 28 | "Media Updated At": "Güncellenme Tarihi", 29 | "Media Conversions": "Dönüşümler" 30 | } 31 | -------------------------------------------------------------------------------- /resources/lang/uk.json: -------------------------------------------------------------------------------- 1 | { 2 | "Copied!": "Скопійовано!", 3 | "Media was regenerated!": "Медіа файл регенерировано!", 4 | "Media was cropped!": "Медіа файл обрізано!", 5 | "Media was deleted!": "Медіа файл видалено!", 6 | "Media was updated!": "Медіа файл завантажено!", 7 | "Media sorted": "Медіа файли відсортовані", 8 | "Copy Url": "Скопіювати адресу", 9 | "Crop": "Обрізати", 10 | "Regenerate": "Регенерувати", 11 | "Validation failed. Hover media to see details.": "Перевірка не вдалася. Наведіть курсор на медіа файл, щоб побачити подробиці.", 12 | "File :filename must be less than :size kilobytes": "Файл: filename повинен бути менше :size кілобайт.", 13 | "Use existing": "Використовувати існуючий", 14 | "Upload": "Завантажити", 15 | "Replace File": "Замінити файл", 16 | "Choose File": "Вибрати файл", 17 | "Choose Files": "Вибрати файли", 18 | "Choose existing media": "Вибрати існуючий медіа файл", 19 | "Crop Media": "Обрізати медіа файл", 20 | "Edit Media": "Редагувати медіа файл", 21 | "Update Media": "Оновити медіа файл", 22 | "Media Details": "Деталі медіа файлу", 23 | "Media Filename": "Ім'я файлу", 24 | "Media Description": "Опис", 25 | "Media Disk": "Диск", 26 | "Media Download Url": "Адреса для скачування", 27 | "Media Size": "Розмір", 28 | "Media Updated At": "Дата оновлення", 29 | "Media Conversions": "Конверсії" 30 | } 31 | -------------------------------------------------------------------------------- /resources/lang/zh-CN.json: -------------------------------------------------------------------------------- 1 | { 2 | "Copied!": "复制成功!", 3 | "Media was regenerated!": "已重新生成!", 4 | "Media was cropped!": "已剪裁!", 5 | "Media was deleted!": "已删除!", 6 | "Media was updated!": "已更新!", 7 | "Media sorted": "已重新排序", 8 | "Copy Url": "复制链接", 9 | "Crop": "剪裁", 10 | "Regenerate": "重新生成", 11 | "Validation failed. Hover media to see details.": "验证失败,鼠标悬停到文件以查看详情", 12 | "File :filename must be less than :size kilobytes": "文件 :filename 必须小于 :size KB", 13 | "Use existing": "使用已有文件", 14 | "Upload": "上传", 15 | "Replace File": "替换文件", 16 | "Choose File": "选择文件", 17 | "Choose Files": "选择文件", 18 | "Choose existing media": "从已有文件中选择", 19 | "Crop Media": "剪裁", 20 | "Edit Media": "编辑", 21 | "Update Media": "更新", 22 | "Media Details": "详情", 23 | "Media Filename": "文件名", 24 | "Media Description": "描述", 25 | "Media Disk": "磁盘", 26 | "Media Download Url": "下载链接", 27 | "Media Size": "大小", 28 | "Media Updated At": "更新时间", 29 | "Media Conversions": "转换列表" 30 | } 31 | -------------------------------------------------------------------------------- /resources/sass/field.scss: -------------------------------------------------------------------------------- 1 | .h-16 { 2 | height: 4rem; 3 | } 4 | 5 | .w-16 { 6 | width: 4rem; 7 | } 8 | 9 | .w-32 { 10 | width: 8rem; 11 | } 12 | .h-32 { 13 | height: 8rem; 14 | } 15 | 16 | .w-24 { 17 | width: 6rem; 18 | } 19 | 20 | .h-24 { 21 | height: 6rem; 22 | } 23 | 24 | .btn-block button { 25 | display: block; 26 | } 27 | 28 | .w-choose-existing-media { 29 | width: 1024px; 30 | } 31 | 32 | .grid-cols-5 { 33 | grid-template-columns: repeat(5, minmax(0, 1fr)); 34 | } 35 | 36 | .gap-2 { 37 | gap: 0.5rem; 38 | } 39 | 40 | .place-self-end { 41 | place-self: end; 42 | } 43 | 44 | .bg-overlay { 45 | background: hsla(0, 0%, 0%, 0.3); 46 | } 47 | 48 | .object-cover { 49 | object-fit: cover; 50 | } 51 | 52 | .group { 53 | &:hover { 54 | .group-hover\:block { 55 | display: block; 56 | } 57 | .group-hover\:opacity-75 { 58 | opacity: 0.75; 59 | } 60 | .group-hover\:hidden { 61 | display: none; 62 | } 63 | } 64 | } 65 | 66 | .shadow-danger { 67 | box-shadow: 0 0 0 2px var(--danger); 68 | } 69 | 70 | .shadow-media-chosen { 71 | box-shadow: 0 0 0 2px var(--info-dark); 72 | } 73 | 74 | .bg-info-dark-half { 75 | background-color: rgba(49, 130, 206, 0.5); 76 | } 77 | 78 | .dragging { 79 | .dragging\:hidden { 80 | display: none !important; 81 | } 82 | 83 | .dragging\:border-none, 84 | &.dragging\:border-none { 85 | border: none !important; 86 | } 87 | } 88 | 89 | .medialibrary-tooltips-hidden { 90 | .medialibrary-tooltip { 91 | opacity: 0; 92 | } 93 | } 94 | 95 | .icon-sm { 96 | width: 18px !important; 97 | height: 18px !important; 98 | } 99 | 100 | .icon-md { 101 | width: 20px !important; 102 | height: 20px !important; 103 | } 104 | 105 | .icon-lg { 106 | width: 24px !important; 107 | height: 24px !important; 108 | } 109 | 110 | .icon-xl { 111 | width: 32px !important; 112 | height: 32px !important; 113 | } 114 | 115 | .icon-2xl { 116 | width: 40px !important; 117 | height: 40px !important; 118 | } 119 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | getResourceModel($data); 15 | $uuid = $this->getUuid($data); 16 | 17 | $this->rememberTargetModel($data, $resourceModel); 18 | 19 | call_user_func_array($data->field->attachCallback, [ 20 | $resourceModel, 21 | $data->file, 22 | $data->field->collectionName, 23 | $data->field->diskName, 24 | $uuid, 25 | ]); 26 | } 27 | 28 | private function getResourceModel(MediaAttachData $data): ?HasMedia 29 | { 30 | return $data->resourceExists ? $data->resourceModel : TransientModel::make(); 31 | } 32 | 33 | private function getUuid(MediaAttachData $data): ?string 34 | { 35 | return $data->resourceExists ? '' : $data->fieldUuid; 36 | } 37 | 38 | private function rememberTargetModel(MediaAttachData $data, HasMedia $resourceModel): void 39 | { 40 | if (! $resourceModel instanceof TransientModel) { 41 | return; 42 | } 43 | 44 | /** @var Media $media */ 45 | $media = config('media-library.media_model'); 46 | 47 | $callback = function (Media $media) use ($data, $resourceModel): void { 48 | if ($media->collection_name !== $data->fieldUuid) { 49 | return; 50 | } 51 | 52 | TransientModel::setCustomPropertyValue( 53 | $media, 54 | $data->resource::$model, 55 | $data->field->collectionName, 56 | ); 57 | 58 | $resourceModel->registerAllMediaConversions($media); 59 | }; 60 | 61 | $media::creating($callback); 62 | } 63 | } -------------------------------------------------------------------------------- /src/Actions/MediaAttachmentListAction.php: -------------------------------------------------------------------------------- 1 | getResourceModel($data); 19 | 20 | $queryBuilder = $this->getBaseQueryBuilder(); 21 | 22 | $queryBuilder = $this->applyNameFilter($queryBuilder, $data->name); 23 | $queryBuilder = $this->applyMaxSizeFilter($queryBuilder, $data->maxSize); 24 | $queryBuilder = $this->applyMimeTypeFilter($queryBuilder, $data->mimeType); 25 | 26 | $queryBuilder = call_or_default( 27 | $data->field->attachExistingCallback, 28 | [$queryBuilder, $data->request, $resourceModel], 29 | ) ?: $queryBuilder; 30 | 31 | return $queryBuilder 32 | ->paginate($data->perPage) 33 | ->through(fn (Media $media): AttachableMediaPresenter => new AttachableMediaPresenter($media)); 34 | } 35 | 36 | private function getResourceModel(MediaAttachmentListData $data): HasMedia 37 | { 38 | return $data->resourceExists 39 | ? $data->resourceModel 40 | : TransientModel::make(); 41 | } 42 | 43 | private function getBaseQueryBuilder(): Builder 44 | { 45 | /** @var Media $media */ 46 | $media = config('media-library.media_model'); 47 | 48 | return $media::query(); 49 | } 50 | 51 | private function applyNameFilter(Builder $builder, ?string $name): Builder 52 | { 53 | $callback = function (Builder $builder) use ($name): Builder { 54 | return $builder->where(function (Builder $builder) use ($name): Builder { 55 | return $builder 56 | ->where('name', 'like', '%' . $name . '%') 57 | ->orWhere('file_name', 'like', '%' . $name . '%'); 58 | }); 59 | }; 60 | 61 | return $builder->when(!empty($name), $callback); 62 | } 63 | 64 | private function applyMaxSizeFilter(Builder $builder, ?int $maxSize): Builder 65 | { 66 | $callback = function (Builder $builder) use ($maxSize): Builder { 67 | return $builder->where('size', '<=', $maxSize); 68 | }; 69 | 70 | return $builder->when(!empty($maxSize), $callback); 71 | } 72 | 73 | private function applyMimeTypeFilter(Builder $builder, ?string $mimeType): Builder 74 | { 75 | $callback = function (Builder $builder) use ($mimeType): Builder { 76 | if (str_contains($mimeType, ',')) { 77 | $mimeTypes = explode(',', $mimeType); 78 | 79 | return $builder->whereIn('mime_type', $mimeTypes); 80 | } 81 | 82 | $mimeType = str_replace('*', '%', $mimeType); 83 | 84 | return $builder->where('mime_type', 'like', $mimeType); 85 | }; 86 | 87 | return $builder->when(!empty($mimeType), $callback); 88 | } 89 | } -------------------------------------------------------------------------------- /src/Actions/MediaCropAction.php: -------------------------------------------------------------------------------- 1 | findMedia($data->mediaId); 14 | 15 | $this->setManipulations($media, $data); 16 | } 17 | 18 | private function findMedia(int $mediaId): Media 19 | { 20 | /** @var Media $media */ 21 | $media = config('media-library.media_model'); 22 | 23 | /** @var Media $media */ 24 | $media = $media::query()->findOrFail($mediaId); 25 | 26 | return $media; 27 | } 28 | 29 | private function setManipulations(Media $media, MediaCropData $data): void 30 | { 31 | $media->manipulations = [ 32 | $data->conversion => [ 33 | 'manualCrop' => $this->getManualCrop($data), 34 | //'orientation' => $this->getOrientation($data), 35 | ], 36 | ]; 37 | 38 | $media->save(); 39 | } 40 | 41 | private function getManualCrop(MediaCropData $data): array 42 | { 43 | return [ 44 | $data->cropWidth, 45 | $data->cropHeight, 46 | $data->cropX, 47 | $data->cropY, 48 | ]; 49 | } 50 | 51 | private function getOrientation(MediaCropData $data): Orientation 52 | { 53 | return match ($data->rotate) { 54 | 90 => Orientation::Rotate90, 55 | 180 => Orientation::Rotate180, 56 | 270 => Orientation::Rotate270, 57 | default => Orientation::Rotate0, 58 | }; 59 | } 60 | } -------------------------------------------------------------------------------- /src/Actions/MediaListAction.php: -------------------------------------------------------------------------------- 1 | getResourceModel($data); 18 | $collectionName = $this->getCollectionName($data); 19 | 20 | $media = $this->getMedia($data->field, $resourceModel, $collectionName); 21 | 22 | return $this->transformMedia($media, $data->field); 23 | } 24 | 25 | private function getResourceModel(MediaListData $data): HasMedia 26 | { 27 | return $data->resourceExists ? $data->resourceModel : TransientModel::make(); 28 | } 29 | 30 | private function getCollectionName(MediaListData $data): ?string 31 | { 32 | return $data->resourceExists ? $data->field->collectionName : $data->fieldUuid; 33 | } 34 | 35 | private function getMedia( 36 | Medialibrary $field, 37 | HasMedia $resourceModel, 38 | string $collectionName, 39 | ): MediaCollection 40 | { 41 | return call_user_func( 42 | $field->resolveMediaUsingCallback, 43 | $resourceModel, 44 | $collectionName, 45 | ); 46 | } 47 | 48 | private function transformMedia(MediaCollection $media, Medialibrary $field): array 49 | { 50 | return $media 51 | ->map(fn (Media $media): MediaPresenter => new MediaPresenter($media, $field)) 52 | ->toArray(); 53 | } 54 | } -------------------------------------------------------------------------------- /src/Actions/MediaRegenerateAction.php: -------------------------------------------------------------------------------- 1 | fileManipulator = $fileManipulator; 15 | } 16 | 17 | public function handle(int $mediaId): void 18 | { 19 | $media = $this->findMedia($mediaId); 20 | 21 | $this->fileManipulator->createDerivedFiles($media); 22 | } 23 | 24 | private function findMedia(int $mediaId): Media 25 | { 26 | /** @var Media $media */ 27 | $media = config('media-library.media_model'); 28 | 29 | /** @var Media $media */ 30 | $media = $media::query()->findOrFail($mediaId); 31 | 32 | return $media; 33 | } 34 | } -------------------------------------------------------------------------------- /src/Actions/MediaSortAction.php: -------------------------------------------------------------------------------- 1 | changeOrder($data); 13 | } 14 | 15 | private function changeOrder(MediaSortData $data): void 16 | { 17 | /** @var Media $model */ 18 | $model = config('media-library.media_model'); 19 | 20 | $model::setNewOrder($data->ids); 21 | } 22 | } -------------------------------------------------------------------------------- /src/Data/MediaAttachData.php: -------------------------------------------------------------------------------- 1 | publishLang(); 18 | $this->publishNovaResources(); 19 | 20 | $this->app->booted(function (): void { 21 | $this->routes(); 22 | $this->translations(); 23 | }); 24 | } 25 | 26 | private function publishLang(): void 27 | { 28 | $this->publishes([ 29 | '/../resources/lang/' => resource_path('lang/vendor/nova-medialibrary-field'), 30 | ]); 31 | 32 | $this->loadJSONTranslationsFrom(resource_path('lang/vendor/nova-medialibrary-field')); 33 | } 34 | 35 | private function publishNovaResources(): void 36 | { 37 | Nova::serving(function (ServingNova $event): void { 38 | Nova::mix('nova-medialibrary-field', __DIR__.'/../dist/mix-manifest.json'); 39 | 40 | Media::$model = config('media-library.media_model'); 41 | 42 | Nova::resources([Media::class]); 43 | }); 44 | } 45 | 46 | public function routes(): void 47 | { 48 | if ($this->app->routesAreCached()) { 49 | return; 50 | } 51 | 52 | Route::middleware(['nova']) 53 | ->prefix('nova-vendor/dmitrybubyakin/nova-medialibrary-field') 54 | ->group(__DIR__ . '/../routes/api.php'); 55 | } 56 | 57 | public function translations(): void 58 | { 59 | $locale = $this->app->getLocale(); 60 | 61 | Nova::translations(__DIR__ . '/../resources/lang/' . $locale . '.json'); 62 | Nova::translations(resource_path('lang/vendor/nova-medialibrary-field/' . $locale . '.json')); 63 | } 64 | 65 | public function register(): void 66 | { 67 | // 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Fields/GeneratedConversions.php: -------------------------------------------------------------------------------- 1 | $media->getFullUrl($conversionName), 23 | ]; 24 | }; 25 | 26 | return $media 27 | ->getGeneratedConversions() 28 | ->filter() 29 | ->keys() 30 | ->mapWithKeys($mapCallback); 31 | }; 32 | 33 | parent::__construct($name, $callback); 34 | } 35 | 36 | public function withTooltips(bool $withTooltips = true): self 37 | { 38 | return $this->withMeta(['withTooltips' => $withTooltips]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Fields/Support/AttachCallback.php: -------------------------------------------------------------------------------- 1 | addMedia($file) 26 | ->toMediaCollection($collectionName, $diskName); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Fields/Support/AttachableMediaPresenter.php: -------------------------------------------------------------------------------- 1 | media = $media; 15 | } 16 | 17 | public function toArray(): array 18 | { 19 | return array_merge([ 20 | 'id' => $this->media->id, 21 | 'size' => $this->media->size, 22 | 'mimeType' => $this->media->mime_type, 23 | 'fileName' => $this->media->file_name, 24 | 'collectionName' => $this->media->collection_name, 25 | 'extension' => $this->media->extension, 26 | 'previewUrl' => $this->media->getFullUrl(), 27 | ]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Fields/Support/MediaCollectionRules.php: -------------------------------------------------------------------------------- 1 | collectionName, 26 | $field->resolveMediaUsingCallback, 27 | ); 28 | 29 | $temporaryAttribute = ':value'; 30 | 31 | $validator = Validator::make( 32 | [$temporaryAttribute => $media], 33 | [$temporaryAttribute => $rules], 34 | ); 35 | 36 | if ($validator->fails()) { 37 | $fail(Str::replaceFirst( 38 | $temporaryAttribute, 39 | $attribute, 40 | $validator->errors()->first($temporaryAttribute), 41 | )); 42 | } 43 | }; 44 | 45 | return [ 46 | new class($callback) extends ClosureValidationRule implements ImplicitRule { 47 | // 48 | }, 49 | ]; 50 | } 51 | 52 | private static function getMedia(NovaRequest $request, string $uuid, string $collectionName, callable $resolver): array 53 | { 54 | [$model, $collectionName] = is_null($request->route('resourceId')) 55 | ? [TransientModel::make(), $uuid] 56 | : [$request->findModelOrFail(), $collectionName]; 57 | 58 | return collect(call_user_func($resolver, $model, $collectionName))->toArray(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Fields/Support/MediaFields.php: -------------------------------------------------------------------------------- 1 | model(); 24 | 25 | $mediaUrlCallback = function () use ($resource): ?string { 26 | return $resource->exists ? $resource->getFullUrl() : null; 27 | }; 28 | 29 | $mediaSizeDisplayCallback = function () use ($resource): ?string { 30 | return $resource->humanReadableSize; 31 | }; 32 | 33 | $updatedAtDisplayCallback = function () use ($resource): ?string { 34 | return $resource->updated_at->diffForHumans(); 35 | }; 36 | 37 | return [ 38 | ID::make(), 39 | 40 | Text::make(__('Media Filename'), 'file_name') 41 | ->rules('required', 'min:2'), 42 | 43 | Textarea::make(__('Media Description'), 'custom_properties->description') 44 | ->alwaysShow(), 45 | 46 | Text::make(__('Media Disk')) 47 | ->exceptOnForms(), 48 | 49 | Text::make(__('Media Download Url'), $mediaUrlCallback), 50 | 51 | Text::make(__('Media Size')) 52 | ->displayUsing($mediaSizeDisplayCallback) 53 | ->exceptOnForms(), 54 | 55 | Text::make(__('Media Updated At')) 56 | ->displayUsing($updatedAtDisplayCallback) 57 | ->exceptOnForms(), 58 | 59 | GeneratedConversions::make(__('Media Conversions')), 60 | ]; 61 | }; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Fields/Support/MediaPresenter.php: -------------------------------------------------------------------------------- 1 | media = $media; 22 | $this->field = $field; 23 | } 24 | 25 | public function downloadUrl(): ?string 26 | { 27 | $defaultCallback = function (): ?string { 28 | return $this->media->getFullUrl(); 29 | }; 30 | 31 | return call_or_default( 32 | $this->field->downloadCallback, 33 | [$this->media], 34 | $defaultCallback, 35 | ); 36 | } 37 | 38 | public function previewUrl(): ?string 39 | { 40 | $mediaUrlCallback = function (): ?string { 41 | return Str::is('image/*', $this->media->mime_type) 42 | ? $this->media->getFullUrl() 43 | : null; 44 | }; 45 | 46 | $mediaUrl = call_or_default( 47 | $this->field->previewCallback, 48 | [$this->media], 49 | $mediaUrlCallback, 50 | ); 51 | 52 | $appendTimestampCallback = function (string $url): string { 53 | if ($this->field->appendTimestampToPreview) { 54 | return $url . '?timestamp=' . $this->media->updated_at->getTimestamp(); 55 | } 56 | 57 | return $url; 58 | }; 59 | 60 | return transform($mediaUrl, $appendTimestampCallback); 61 | } 62 | 63 | public function tooltip(): ?string 64 | { 65 | return call_or_default($this->field->tooltipCallback, [$this->media]); 66 | } 67 | 68 | public function title(): ?string 69 | { 70 | return call_or_default($this->field->titleCallback, [$this->media]); 71 | } 72 | 73 | public function copyAs(): array 74 | { 75 | return collect($this->field->copyAs) 76 | ->mapSpread(function (string $as, string $icon, callable $value): array { 77 | $value = (string) $value($this->media); 78 | 79 | return compact('as', 'icon', 'value'); 80 | })->toArray(); 81 | } 82 | 83 | public function attached(): bool 84 | { 85 | return $this->media->model_type !== TransientModel::class; 86 | } 87 | 88 | public function authorizedTo(string $ability): bool 89 | { 90 | return !Gate::getPolicyFor($this->media) or Gate::check($ability, $this->media); 91 | } 92 | 93 | public function toArray(): array 94 | { 95 | return array_merge([ 96 | 'id' => $this->media->id, 97 | 'order' => $this->media->order_column, 98 | 'fileName' => $this->media->file_name, 99 | 'extension' => $this->media->extension, 100 | 'downloadUrl' => $this->downloadUrl(), 101 | 'previewUrl' => $this->previewUrl(), 102 | 'tooltip' => $this->tooltip(), 103 | 'title' => $this->title(), 104 | 'copyAs' => $this->copyAs(), 105 | 'attached' => $this->attached(), 106 | 'authorizedToView' => $this->authorizedTo('view'), 107 | 'authorizedToUpdate' => $this->authorizedTo('update'), 108 | 'authorizedToDelete' => $this->authorizedTo('delete'), 109 | ], $this->cropperOptions()); 110 | } 111 | 112 | public function cropperOptions(): array 113 | { 114 | $options = call_or_default($this->field->cropperOptionsCallback, [$this->media]); 115 | 116 | $enabled = ! is_null($options); 117 | 118 | return [ 119 | 'cropperEnabled' => $enabled, 120 | 'cropperOptions' => $options, 121 | 'cropperMediaUrl' => $this->media->getFullUrl(), 122 | 'cropperConversion' => $this->field->cropperConversion, 123 | 'cropperData' => $enabled ? ($this->cropperData() ?: null) : null, 124 | ]; 125 | } 126 | 127 | public function cropperData(): array 128 | { 129 | $manipulations = $this->getMediaManipulations(); 130 | $manualCrop = $this->resolveMediaCropperData(); 131 | 132 | return array_map('intval', array_filter([ 133 | 'rotate' => $manipulations['orientation'] ?? null, 134 | 'width' => $manualCrop[0] ?? null, 135 | 'height' => $manualCrop[1] ?? null, 136 | 'x' => $manualCrop[2] ?? null, 137 | 'y' => $manualCrop[3] ?? null, 138 | ], 'is_numeric')); 139 | } 140 | 141 | private function getMediaManipulations(): array 142 | { 143 | return $this->media->manipulations[$this->field->cropperConversion] ?? []; 144 | } 145 | 146 | private function resolveMediaCropperData(): array 147 | { 148 | $manipulations = $this->getMediaManipulations(); 149 | 150 | if (isset($manipulations['manualCrop']) and ! empty($manipulations['manualCrop'])) { 151 | if (is_string($manipulations['manualCrop'])) { 152 | $manualCrop = explode(',', $manipulations['manualCrop']); 153 | } else { 154 | $manualCrop = $manipulations['manualCrop']; 155 | } 156 | } else { 157 | $manualCrop = []; 158 | } 159 | 160 | return $manualCrop; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/Fields/Support/ResolveMediaCallback.php: -------------------------------------------------------------------------------- 1 | getMedia($collectionName); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Http/Controllers/MediaAttachController.php: -------------------------------------------------------------------------------- 1 | handle($request->getData()); 17 | 18 | return response()->json(status: 201); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Http/Controllers/MediaAttachmentListController.php: -------------------------------------------------------------------------------- 1 | handle($request->getData()); 19 | 20 | return response()->json($media); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Http/Controllers/MediaCropController.php: -------------------------------------------------------------------------------- 1 | handle($request->getData()); 19 | 20 | return response()->json(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Http/Controllers/MediaListController.php: -------------------------------------------------------------------------------- 1 | handle($request->getData()); 17 | 18 | return response()->json($media); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Http/Controllers/MediaRegenerateController.php: -------------------------------------------------------------------------------- 1 | handle( 18 | (int) $request->route('media'), 19 | ); 20 | 21 | return response()->json(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Http/Controllers/MediaSortController.php: -------------------------------------------------------------------------------- 1 | handle($request->getData()); 17 | 18 | return response()->json(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Http/Requests/MediaAttachRequest.php: -------------------------------------------------------------------------------- 1 | medialibraryField(); 17 | 18 | return [ 19 | 'file' => $field->getAttachRules($this), 20 | ]; 21 | } 22 | 23 | public function authorize(): bool 24 | { 25 | return true; 26 | } 27 | 28 | protected function prepareForValidation(): void 29 | { 30 | if (! $this->has('media')) { 31 | return; 32 | } 33 | 34 | /** @var Model $model */ 35 | $model = config('media-library.media_model'); 36 | 37 | /** @var Media $media */ 38 | $media = $model::query()->findOrFail($this->get('media')); 39 | 40 | $temporaryFileBasePath = TemporaryDirectory::create()->path('/'); 41 | $temporaryFilePath = implode('', [ 42 | $temporaryFileBasePath, 43 | DIRECTORY_SEPARATOR, 44 | $media->file_name, 45 | ]); 46 | 47 | app(Filesystem::class)->copyFromMediaLibrary($media, $temporaryFilePath); 48 | 49 | $uploadedFile = new UploadedFile( 50 | $temporaryFilePath, 51 | $media->file_name, 52 | $media->mime_type, 53 | null, 54 | true 55 | ); 56 | 57 | $this->merge([ 58 | 'file' => $uploadedFile, 59 | ]); 60 | } 61 | 62 | public function getData(): MediaAttachData 63 | { 64 | $resourceExists = $this->resourceExists(); 65 | 66 | return MediaAttachData::from([ 67 | 'field' => $this->medialibraryField(), 68 | 'fieldUuid' => $this->fieldUuid(), 69 | 'resourceModel' => $resourceExists ? $this->findModelOrFail() : null, 70 | 'resourceExists' => $resourceExists, 71 | 'resource' => $this->resource(), 72 | 'file' => $this->file('file', $this->input('file')), 73 | ]); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Http/Requests/MediaAttachmentListRequest.php: -------------------------------------------------------------------------------- 1 | [ 13 | 'sometimes', 14 | 'integer', 15 | 'min:1', 16 | ], 17 | 18 | 'name' => [ 19 | 'sometimes', 20 | 'nullable', 21 | 'string', 22 | ], 23 | 24 | 'maxSize' => [ 25 | 'sometimes', 26 | 'nullable', 27 | 'integer', 28 | 'min:0', 29 | ], 30 | 31 | 'mimeType' => [ 32 | 'sometimes', 33 | 'nullable', 34 | 'string', 35 | ], 36 | ]; 37 | } 38 | 39 | public function authorize(): bool 40 | { 41 | return true; 42 | } 43 | 44 | public function getData(): MediaAttachmentListData 45 | { 46 | $resourceExists = $this->resourceExists(); 47 | 48 | return MediaAttachmentListData::from([ 49 | 'request' => $this, 50 | 'field' => $this->medialibraryField(), 51 | 'resourceModel' => $resourceExists ? $this->findModelOrFail() : null, 52 | 'resourceExists' => $resourceExists, 53 | 'perPage' => $this->get('perPage', 25), 54 | 'name' => $this->get('name'), 55 | 'maxSize' => $this->get('maxSize'), 56 | 'mimeType' => $this->get('mimeType'), 57 | ]); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Http/Requests/MediaCropRequest.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'required', 15 | 'string', 16 | ], 17 | 18 | 'width' => [ 19 | 'required', 20 | 'integer', 21 | ], 22 | 23 | 'height' => [ 24 | 'required', 25 | 'integer', 26 | ], 27 | 28 | 'x' => [ 29 | 'required', 30 | 'integer', 31 | ], 32 | 33 | 'y' => [ 34 | 'required', 35 | 'integer', 36 | ], 37 | 38 | 'rotate' => [ 39 | 'required', 40 | 'integer', 41 | ], 42 | ]; 43 | } 44 | 45 | public function authorize(): bool 46 | { 47 | return true; 48 | } 49 | 50 | public function getData(): MediaCropData 51 | { 52 | return MediaCropData::from([ 53 | 'mediaId' => $this->route('media'), 54 | 'conversion' => $this->get('conversion'), 55 | 'cropWidth' => $this->get('width'), 56 | 'cropHeight' => $this->get('height'), 57 | 'cropX' => $this->get('x'), 58 | 'cropY' => $this->get('y'), 59 | 'rotate' => $this->get('rotate'), 60 | ]); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Http/Requests/MediaListRequest.php: -------------------------------------------------------------------------------- 1 | resourceExists(); 17 | 18 | return MediaListData::from([ 19 | 'field' => $this->medialibraryField(), 20 | 'fieldUuid' => $this->fieldUuid(), 21 | 'resourceModel' => $resourceExists ? $this->findModelOrFail() : null, 22 | 'resourceExists' => $resourceExists, 23 | ]); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Http/Requests/MediaRequest.php: -------------------------------------------------------------------------------- 1 | route('resourceId')) and $this->route('resourceId') !== 'undefined'; 21 | } 22 | 23 | public function fieldUuid(): ?string 24 | { 25 | return $this->input('fieldUuid'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Http/Requests/MediaSortRequest.php: -------------------------------------------------------------------------------- 1 | [ 14 | 'sometimes', 15 | 'nullable', 16 | 'array', 17 | ], 18 | 19 | 'media.*' => [ 20 | 'required', 21 | 'integer', 22 | 'exists:' . config('media-library.media_model') . ',id', 23 | ], 24 | ]; 25 | } 26 | 27 | public function authorize(): bool 28 | { 29 | return true; 30 | } 31 | 32 | public function getData(): MediaSortData 33 | { 34 | return MediaSortData::from([ 35 | 'ids' => $this->input('media', []), 36 | ]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Integrations/NovaDependencyContainer/ResolveFromDependencyContainerFields.php: -------------------------------------------------------------------------------- 1 | meta['fields']; 18 | }; 19 | 20 | /** @var FieldCollection $fields */ 21 | $fields = $fields 22 | ->map($callback) 23 | ->flatten() 24 | ->whereInstanceOf(Medialibrary::class); 25 | 26 | return $fields->findFieldByAttribute($attribute); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Integrations/NovaFlexibleContent/HasMedialibraryField.php: -------------------------------------------------------------------------------- 1 | _key) || is_null($this->key)) { 18 | return; 19 | } 20 | 21 | $medialibraryFields = $this->fields->whereInstanceOf(Medialibrary::class); 22 | 23 | $callback = function (NovaRequest $request, mixed $model): void { 24 | if ($model instanceof Layout) { 25 | /** @var Media $media */ 26 | $media = config('media-library.media_model'); 27 | 28 | $media::query() 29 | ->where('custom_properties->flexibleKey', $model->_key) 30 | ->update([ 31 | 'custom_properties->flexibleKey' => $model->key, 32 | ]); 33 | } 34 | }; 35 | 36 | /** @var Medialibrary $field */ 37 | foreach ($medialibraryFields as $field) { 38 | $field->fillUsing($callback); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Integrations/NovaFlexibleContent/MedialibraryFieldLayout.php: -------------------------------------------------------------------------------- 1 | replaceTemporaryKeysWhenFilling(); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/Integrations/NovaFlexibleContent/ResolveFromFlexibleLayoutFields.php: -------------------------------------------------------------------------------- 1 | meta['layouts'] 26 | ->map(function (\Whitecube\NovaFlexibleContent\Layouts\Layout $layout) use ($key) { 27 | return $this->setupLayoutFields($layout, $key); 28 | }) 29 | ->collapse(); 30 | }; 31 | 32 | /** @var FieldCollection $fields */ 33 | $fields = $fields 34 | ->map($callback) 35 | ->flatten() 36 | ->whereInstanceOf(Medialibrary::class); 37 | 38 | return $fields->findFieldByAttribute($attribute); 39 | } 40 | 41 | public function setupLayoutFields(\Whitecube\NovaFlexibleContent\Layouts\Layout $layout, string $key): Collection 42 | { 43 | $callback = function (Medialibrary $field) use ($key): void { 44 | $this->resolveUsing($field, $key); 45 | $this->attachUsing($field, $key); 46 | }; 47 | 48 | return collect($layout->fields()) 49 | ->whereInstanceOf(Medialibrary::class) 50 | ->each($callback); 51 | } 52 | 53 | public function resolveUsing(Medialibrary $field, string $key): void 54 | { 55 | $callback = function (HasMedia $model, string $collectionName) use ($key) { 56 | return $model 57 | ->getMedia($collectionName, ['flexibleKey' => $key]) 58 | ->values(); 59 | }; 60 | 61 | $field->resolveMediaUsing($callback); 62 | } 63 | 64 | public function attachUsing(Medialibrary $field, string $key): void 65 | { 66 | $callback = function ($model, $file, $collectionName, $diskName, $fieldUuid) use ($key) { 67 | if ($model instanceof TransientModel) { 68 | $collectionName = $fieldUuid; 69 | } 70 | 71 | return $model 72 | ->addMedia($file) 73 | ->withCustomProperties(['flexibleKey' => $key]) 74 | ->toMediaCollection($collectionName, $diskName); 75 | }; 76 | 77 | $field->attachUsing($callback); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/MedialibraryFieldResolver.php: -------------------------------------------------------------------------------- 1 | request = $request; 33 | $this->resource = $resource ?: $request->newResource(); 34 | $this->attribute = $attribute ?: $request->route('field'); 35 | } 36 | 37 | public function __invoke(): Medialibrary 38 | { 39 | $fields = $this 40 | ->resource 41 | ->availableFields($this->request); 42 | 43 | foreach (static::$resolvers as $className) { 44 | $resolver = new $className; 45 | 46 | if ($field = $resolver($fields, $this->attribute)) { 47 | return $field; 48 | } 49 | } 50 | 51 | /** @var FieldCollection $fields */ 52 | $fields = $fields->whereInstanceOf(Medialibrary::class); 53 | 54 | $callback = function (): void { 55 | throw new Exception("Field with attribute `$this->attribute` is not found."); 56 | }; 57 | 58 | return $fields->findFieldByAttribute($this->attribute, $callback); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Models/TransientModel.php: -------------------------------------------------------------------------------- 1 | '1']; 36 | } 37 | 38 | public function newBaseQueryBuilder(): Builder 39 | { 40 | return new class($this->getConnection()) extends Builder { 41 | public function get($columns = ['*']): Collection 42 | { 43 | return collect([(object) TransientModel::instanceAttributes()]); 44 | } 45 | }; 46 | } 47 | 48 | public function staleMedia(): MorphMany 49 | { 50 | return $this 51 | ->media() 52 | ->where('created_at', '<=', now()->subDay()); 53 | } 54 | 55 | public static function setCustomPropertyName(string $name): void 56 | { 57 | static::$customPropertyName = $name; 58 | } 59 | 60 | public static function getCustomPropertyName(): string 61 | { 62 | return static::$customPropertyName; 63 | } 64 | 65 | public static function setCustomPropertyValue(Media $media, string $target, string $collectionName): void 66 | { 67 | $media->setCustomProperty(static::getCustomPropertyName(), [ 68 | 'target' => $target, 69 | 'collectionName' => $collectionName, 70 | ]); 71 | } 72 | 73 | public function registerAllMediaConversions(?Media $media = null): void 74 | { 75 | [$modelClassName, $collectionName] = $this->getCustomPropertyValue($media); 76 | 77 | if (is_null($modelClassName)) { 78 | return; 79 | } 80 | 81 | $model = new $modelClassName; 82 | 83 | $model->registerAllMediaConversions($media); 84 | 85 | $this->mediaConversions = $model->mediaConversions; 86 | $this->mediaCollections = $model->mediaCollections; 87 | 88 | foreach ($this->mediaCollections as $collection) { 89 | if ($collection->name === $collectionName) { 90 | $collection->name = $media->collection_name; 91 | } 92 | } 93 | 94 | foreach ($this->mediaConversions as $conversion) { 95 | if (in_array($collectionName, $conversion->getPerformOnCollections())) { 96 | $conversion->performOnCollections($media->collection_name); 97 | } 98 | 99 | $conversion->nonQueued(); 100 | } 101 | } 102 | 103 | public static function getCustomPropertyValue(?Media $media): array 104 | { 105 | $value = $media?->getCustomProperty(static::getCustomPropertyName()); 106 | 107 | if (is_null($media) || is_null($value)) { 108 | return [null, null]; 109 | } 110 | 111 | return [ 112 | $value['target'], 113 | $value['collectionName'], 114 | ]; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Resources/Media.php: -------------------------------------------------------------------------------- 1 | input('viaResource'); 32 | $resource = Nova::resourceInstanceForKey($viaResource); 33 | $viaField = $request->input('viaField'); 34 | 35 | $resolver = new MedialibraryFieldResolver($request, $resource, $viaField); 36 | 37 | /** @var Medialibrary $field */ 38 | $field = call_user_func($resolver); 39 | 40 | if (is_null($field)) { 41 | return []; 42 | } 43 | 44 | return call_user_func( 45 | $field->fieldsCallback->bindTo($this), 46 | $request, 47 | ); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | withMeta(['fields' => $fields]); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/Fixtures/Nova/TestPost.php: -------------------------------------------------------------------------------- 1 | fields(function ($request) { 23 | return [ 24 | Text::make('File Name') 25 | ->onlyOnForms(), 26 | 27 | Text::make('Disk') 28 | ->onlyOnDetail(), 29 | ]; 30 | }) 31 | ->attachExisting(function (Builder $query, Request $request, HasMedia $model): void { 32 | if ($request->name) { 33 | $query->where('name', $request->name); 34 | } 35 | }) 36 | ->resolveMediaUsing(function (HasMedia $media, string $collectionName) { 37 | return $media->getMedia($collectionName)->where('file_name', '!=', 'ignored.txt'); 38 | }) 39 | ->copyAs('Url', function (Media $media) { 40 | return $media->getFullUrl(); 41 | }) 42 | ->copyAs('Html', function (Media $media) { 43 | return $media->img(); 44 | }, 'custom-icon'), 45 | 46 | Medialibrary::make('Media testing', 'testing') 47 | ->rules('required', 'array') 48 | ->creationRules('min:1') 49 | ->updateRules('min:2') 50 | ->attribute('media_testing_custom_attribute'), 51 | 52 | Medialibrary::make('Media testing single', 'testing_single') 53 | ->single(), 54 | 55 | Medialibrary::make('Media testing validation', 'testing_validation') 56 | ->attachRules('required', 'image'), 57 | 58 | new Panel('Panel', [ 59 | Medialibrary::make('Media testing panel', 'testing_panel'), 60 | ]), 61 | 62 | ContainerField::make('Container', [ 63 | Medialibrary::make('Media testing container', 'testing_container'), 64 | ]), 65 | ]; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/Fixtures/TestPost.php: -------------------------------------------------------------------------------- 1 | addMediaCollection('testing_single') 21 | ->singleFile(); 22 | } 23 | 24 | public function registerMediaConversions(?Media $media = null): void 25 | { 26 | if (! static::$withConversions) { 27 | return; 28 | } 29 | 30 | $this->addMediaConversion('preview') 31 | ->width(368) 32 | ->height(232); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Integration/AttachControllerTest.php: -------------------------------------------------------------------------------- 1 | authenticate(); 23 | 24 | TestResponse::macro('assertJsonValidationErrorMessage', function (string $key, string $message) { 25 | $this->assertJsonValidationErrors($key); 26 | 27 | $messages = $this->json()['errors'][$key] ?? []; 28 | 29 | PHPUnit::assertContains($message, $messages); 30 | 31 | return $this; 32 | }); 33 | } 34 | 35 | /** @test */ 36 | public function media_can_be_validated_before_being_attached_to_an_existing_resource(): void 37 | { 38 | $post = $this->createPost(); 39 | 40 | $uri = "nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/{$post->id}/media/media_testing_validation"; 41 | 42 | $this 43 | ->postJson($uri) 44 | ->assertJsonValidationErrorMessage('file', 'The file field is required.'); 45 | 46 | $file = $this->makeUploadedFile($this->getJpgFile()); 47 | 48 | $this 49 | ->postJson($uri, ['file' => $file]) 50 | ->assertCreated(); 51 | } 52 | 53 | /** @test */ 54 | public function media_can_be_validated_before_being_attached_to_a_non_existing_resource(): void 55 | { 56 | $uuid = (string) Str::uuid(); 57 | 58 | $uri = 'nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/undefined/media/media_testing_validation'; 59 | 60 | $this 61 | ->postJson($uri, ['fieldUuid' => $uuid]) 62 | ->assertJsonValidationErrorMessage('file', 'The file field is required.'); 63 | 64 | $file = $this->makeUploadedFile($this->getJpgFile()); 65 | 66 | $this 67 | ->postJson($uri, ['fieldUuid' => $uuid, 'file' => $file]) 68 | ->assertCreated(); 69 | } 70 | 71 | /** @test */ 72 | public function media_collection_can_be_validated_before_resource_creation(): void 73 | { 74 | $uuid = (string) Str::uuid(); 75 | 76 | $this 77 | ->postJson('/nova-api/test-posts', ['media_testing_custom_attribute' => $uuid]) 78 | ->assertJsonValidationErrorMessage('media_testing_custom_attribute', 'The media_testing_custom_attribute field is required.'); 79 | 80 | $this->addMediaTo(TransientModel::make(), $this->getJpgFile(), $uuid); 81 | 82 | $this 83 | ->postJson('/nova-api/test-posts', ['media_testing_custom_attribute' => $uuid]) 84 | ->assertCreated(); 85 | } 86 | 87 | /** @test */ 88 | public function media_collection_can_be_validated_before_resource_update(): void 89 | { 90 | $uuid = (string) Str::uuid(); 91 | 92 | $post = $this->createPostWithMedia(1, 'testing'); 93 | 94 | $this 95 | ->putJson("/nova-api/test-posts/{$post->id}", ['media_testing_custom_attribute' => $uuid]) 96 | ->assertJsonValidationErrorMessage('media_testing_custom_attribute', 'The media_testing_custom_attribute field must have at least 2 items.'); 97 | 98 | $post = $this->createPostWithMedia(2, 'testing'); 99 | 100 | $this 101 | ->putJson("/nova-api/test-posts/{$post->id}", ['media_testing_custom_attribute' => $uuid]) 102 | ->assertOk(); 103 | } 104 | 105 | /** @test */ 106 | public function media_can_be_attached_to_an_existing_resource(): void 107 | { 108 | $post = $this->createPost(); 109 | 110 | $file = $this->makeUploadedFile($this->getJpgFile()); 111 | 112 | $this 113 | ->postJson("nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/{$post->id}/media/media_testing_custom_attribute", ['file' => $file]) 114 | ->assertCreated(); 115 | 116 | $this->assertCount(1, $post->media); 117 | } 118 | 119 | /** @test */ 120 | public function media_can_be_attached_to_a_non_existing_resource(): void 121 | { 122 | $uuid = (string) Str::uuid(); 123 | 124 | $file = $this->makeUploadedFile($this->getJpgFile()); 125 | 126 | TestPost::$withConversions = true; 127 | 128 | $this 129 | ->postJson('nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/undefined/media/media_testing_custom_attribute', [ 130 | 'file' => $file, 131 | 'fieldUuid' => $uuid, 132 | ]) 133 | ->assertCreated(); 134 | 135 | TestPost::$withConversions = false; 136 | 137 | $media = Media::first(); 138 | $media->setCustomProperty('a', 'b')->save(); 139 | $media->update(['manipulations' => ['*' => ['width' => [100]]]]); 140 | 141 | $this->assertSame($uuid, $media->collection_name); 142 | $this->assertTrue($media->model->is(TransientModel::make())); 143 | $this->assertCount(1, TransientModel::make()->media); 144 | $this->assertSame(['preview' => true], $media->getGeneratedConversions()->all()); 145 | $this->assertSame([ 146 | 'target' => TestPost::class, 147 | 'collectionName' => 'testing', 148 | ], $media->getCustomProperty(TransientModel::getCustomPropertyName())); 149 | 150 | TestPost::$withConversions = true; 151 | 152 | $this 153 | ->postJson('/nova-api/test-posts', ['media_testing_custom_attribute' => $uuid]) 154 | ->assertCreated(); 155 | 156 | TestPost::$withConversions = false; 157 | 158 | $post = TestPost::first(); 159 | 160 | $this->assertNull($media->fresh()); 161 | $this->assertCount(0, TransientModel::make()->media); 162 | $this->assertCount(1, $post->media); 163 | $this->assertSame('b', $post->media->first()->getCustomProperty('a')); 164 | $this->assertSame(null, $post->media->first()->getCustomProperty(TransientModel::getCustomPropertyName())); 165 | $this->assertSame(['*' => ['width' => [100]]], $post->media->first()->manipulations); 166 | } 167 | 168 | /** @test */ 169 | public function existing_media_can_be_attached_to_an_existing_resource(): void 170 | { 171 | $this->createPostWithMedia(); 172 | 173 | $post = $this->createPost(); 174 | 175 | $this 176 | ->postJson("nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/{$post->id}/media/media_testing_custom_attribute", ['media' => 1]) 177 | ->assertCreated(); 178 | 179 | $this->assertCount(1, $post->media); 180 | } 181 | 182 | /** @test */ 183 | public function existing_media_can_be_attached_to_a_non_existing_resource_with_single_file_collection(): void 184 | { 185 | $uuid = (string) Str::uuid(); 186 | 187 | $this->createPostWithMedia(); 188 | 189 | $this 190 | ->postJson('nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/undefined/media/media_testing_single', [ 191 | 'media' => 1, 192 | 'fieldUuid' => $uuid, 193 | ]) 194 | ->assertCreated(); 195 | 196 | $this 197 | ->postJson('nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/undefined/media/media_testing_single', [ 198 | 'media' => 1, 199 | 'fieldUuid' => $uuid, 200 | ]) 201 | ->assertCreated(); 202 | 203 | $this->assertCount(1, TransientModel::make()->media); 204 | } 205 | 206 | private function makeUploadedFile(string $path): UploadedFile 207 | { 208 | return new UploadedFile( 209 | $path, 210 | basename($path), 211 | null, 212 | null, 213 | true 214 | ); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /tests/Integration/AttachableControllerTest.php: -------------------------------------------------------------------------------- 1 | createPostWithMedia(10); 15 | 16 | $this 17 | ->getJson("nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/{$post->id}/media/media/attachable") 18 | ->assertSuccessful() 19 | ->assertJsonCount(10, 'data'); 20 | } 21 | 22 | /** @test */ 23 | public function test_can_retrieve_attachable_media_with_pagination(): void 24 | { 25 | $post = $this->createPostWithMedia(10); 26 | 27 | $this 28 | ->getJson("nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/{$post->id}/media/media/attachable?perPage=5") 29 | ->assertSuccessful() 30 | ->assertJsonCount(5, 'data'); 31 | 32 | $this 33 | ->getJson("nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/{$post->id}/media/media/attachable?perPage=3&page=4") 34 | ->assertSuccessful() 35 | ->assertJsonCount(1, 'data'); 36 | } 37 | 38 | /** @test */ 39 | public function test_can_retrieve_attachable_media_with_filtering(): void 40 | { 41 | $post = $this->createPostWithMedia(1, 'default', $this->getJpgFile()); 42 | $post = $this->createPostWithMedia(9, 'default', $this->getTextFile()); 43 | 44 | $post->media()->take(2)->get()->each->update(['name' => 'xxx']); 45 | 46 | $this 47 | ->getJson("nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/{$post->id}/media/media/attachable?name=xxx") 48 | ->assertSuccessful() 49 | ->assertJsonCount(2, 'data'); 50 | 51 | $this 52 | ->getJson("nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/{$post->id}/media/media/attachable?maxSize=0") 53 | ->assertSuccessful() 54 | ->assertJsonCount(10, 'data'); 55 | 56 | $this 57 | ->getJson("nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/{$post->id}/media/media/attachable?mimeType=text/*") 58 | ->assertSuccessful() 59 | ->assertJsonCount(9, 'data'); 60 | 61 | $this 62 | ->getJson("nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/{$post->id}/media/media/attachable?mimeType=image/*") 63 | ->assertSuccessful() 64 | ->assertJsonCount(1, 'data'); 65 | 66 | $this 67 | ->getJson("nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/{$post->id}/media/media/attachable?mimeType=image/jpg,image/jpeg,image/png,image/gif") 68 | ->assertSuccessful() 69 | ->assertJsonCount(1, 'data'); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Integration/CreationFieldControllerTest.php: -------------------------------------------------------------------------------- 1 | getJson('nova-api/test-posts/creation-fields?editing=true&editMode=create') 16 | ->assertSuccessful(); 17 | 18 | foreach ($response->decodeResponseJson()['fields'] as $field) { 19 | if ($field['component'] === 'nova-medialibrary-field') { 20 | $this->assertNotNull($field['value']); 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/Integration/CropControllerTest.php: -------------------------------------------------------------------------------- 1 | createPostWithMedia([ 31 | ['preview', $this->getJpgFile()], 32 | ]); 33 | 34 | $data = [ 35 | 'conversion' => 'preview', 36 | 'x' => 100, 37 | 'y' => 100, 38 | 'width' => 100, 39 | 'height' => 100, 40 | 'rotate' => 180, 41 | ]; 42 | 43 | $this 44 | ->postJson('nova-vendor/dmitrybubyakin/nova-medialibrary-field/1/crop', $data) 45 | ->assertOk(); 46 | 47 | $this->assertSame([ 48 | 'preview' => [ 49 | 'manualCrop' => [ 50 | 100, 51 | 100, 52 | 100, 53 | 100, 54 | ], 55 | ], 56 | ], Media::query()->first()->manipulations); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/Integration/IndexControllerTest.php: -------------------------------------------------------------------------------- 1 | createPostWithMedia($media); 20 | 21 | $this 22 | ->getJson("nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/{$post->id}/media/{$field}") 23 | ->assertSuccessful() 24 | ->assertJsonCount($count); 25 | } 26 | 27 | public function dataProvider(): array 28 | { 29 | return [ 30 | ['media', 2, [ 31 | ['', $this->getJpgFile()], 32 | ['', $this->getJpgFile()], 33 | ['testing', $this->getIgnoredTextFile()], 34 | ]], 35 | ['media_testing_custom_attribute', 3, [ 36 | ['testing', $this->getTextFile()], 37 | ['testing', $this->getTextFile()], 38 | ['testing', $this->getTextFile()], 39 | ]], 40 | ['media_testing_single', 1, [ 41 | ['testing_single', $this->getTextFile()], 42 | ['testing_single', $this->getTextFile()], 43 | ]], 44 | ]; 45 | } 46 | 47 | /** @test */ 48 | public function test_can_retrieve_unattached_media(): void 49 | { 50 | $uuid = (string) Str::uuid(); 51 | 52 | foreach (range(1, 5) as $_) { 53 | $this->addMediaTo(TransientModel::make(), $this->getTextFile(), $uuid); 54 | } 55 | 56 | $this 57 | ->getJson("nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/undefined/media/media?fieldUuid={$uuid}") 58 | ->assertSuccessful() 59 | ->assertJsonCount(5); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Integration/RegenerateControllerTest.php: -------------------------------------------------------------------------------- 1 | createPostWithMedia(); 24 | 25 | File::deleteDirectory(storage_path('app/public/1/conversions')); 26 | 27 | TestPost::$withConversions = true; 28 | 29 | $this 30 | ->postJson('nova-vendor/dmitrybubyakin/nova-medialibrary-field/1/regenerate') 31 | ->assertOk(); 32 | 33 | $this->assertTrue(File::exists(storage_path('app/public/1/conversions/test-preview.jpg'))); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/Integration/ShowControllerTest.php: -------------------------------------------------------------------------------- 1 | createPostWithMedia(); 15 | 16 | $media = $post->media->first(); 17 | 18 | $response = $this 19 | ->getJson("nova-api/dmitrybubyakin-nova-medialibrary-media/{$media->id}?viaResource=test-posts&viaField=media") 20 | ->assertSuccessful(); 21 | 22 | $this->assertCount(1, $response->decodeResponseJson()['resource']['fields']); 23 | $this->assertSame('disk', $response->decodeResponseJson()['resource']['fields'][0]['attribute']); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Integration/SortControllerTest.php: -------------------------------------------------------------------------------- 1 | createPostWithMedia(3); 14 | 15 | $mediaIds = Media::query() 16 | ->pluck('order_column', 'id') 17 | ->all(); 18 | 19 | $this->assertEquals([ 20 | 1 => 1, 21 | 2 => 2, 22 | 3 => 3, 23 | ], $mediaIds); 24 | 25 | $endpoint = 'nova-vendor/dmitrybubyakin/nova-medialibrary-field/sort'; 26 | 27 | $data = [ 28 | 'media' => [ 29 | 3, 2, 1, 30 | ], 31 | ]; 32 | 33 | $this 34 | ->postJson($endpoint, $data) 35 | ->assertOk(); 36 | 37 | $mediaIds = Media::query() 38 | ->pluck('order_column', 'id') 39 | ->all(); 40 | 41 | $this->assertEquals([ 42 | 1 => 3, 43 | 2 => 2, 44 | 3 => 1, 45 | ], $mediaIds); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/Integration/UpdateControllerTest.php: -------------------------------------------------------------------------------- 1 | authenticate(); 14 | } 15 | 16 | /** @test */ 17 | public function test_can_retrieve_media_update_fields(): void 18 | { 19 | $post = $this->createPostWithMedia(); 20 | 21 | $media = $post->media->first(); 22 | 23 | $response = $this 24 | ->getJson("nova-api/dmitrybubyakin-nova-medialibrary-media/{$media->id}/update-fields?viaResource=test-posts&viaField=media") 25 | ->assertSuccessful(); 26 | 27 | $this->assertCount(1, $response->decodeResponseJson()['fields']); 28 | $this->assertSame('file_name', $response->decodeResponseJson()['fields'][0]['attribute']); 29 | } 30 | 31 | /** @test */ 32 | public function test_can_update_media(): void 33 | { 34 | $post = $this->createPostWithMedia(); 35 | 36 | $media = $post->media->first(); 37 | 38 | $this 39 | ->putJson("nova-api/dmitrybubyakin-nova-medialibrary-media/{$media->id}?viaResource=test-posts&viaField=media", ['file_name' => 'Testing']) 40 | ->assertOk(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /tests/Support/files/ignored.txt: -------------------------------------------------------------------------------- 1 | text 2 | -------------------------------------------------------------------------------- /tests/Support/files/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dmitrybubyakin/nova-medialibrary-field/fae6e53b8833e00fa1867b3c6d7ad8cf53704854/tests/Support/files/test.jpg -------------------------------------------------------------------------------- /tests/Support/files/test.txt: -------------------------------------------------------------------------------- 1 | text 2 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | setUpTestFiles($this->app); 33 | $this->setUpDatabase($this->app); 34 | $this->setUpNova($this->app); 35 | } 36 | 37 | protected function getPackageProviders($app): array 38 | { 39 | return [ 40 | NovaServiceProvider::class, 41 | NovaCoreServiceProvider::class, 42 | FieldServiceProvider::class, 43 | MediaLibraryServiceProvider::class, 44 | LaravelDataServiceProvider::class, 45 | ]; 46 | } 47 | 48 | protected function getEnvironmentSetUp($app): void 49 | { 50 | $app['config']->set('database.default', 'sqlite'); 51 | $app['config']->set('database.connections.sqlite', [ 52 | 'driver' => 'sqlite', 53 | 'database' => ':memory:', 54 | 'prefix' => '', 55 | ]); 56 | } 57 | 58 | protected function setUpDatabase($app): void 59 | { 60 | $app['db']->connection()->getSchemaBuilder()->create('test_posts', function (Blueprint $table): void { 61 | $table->bigIncrements('id'); 62 | $table->timestamps(); 63 | }); 64 | 65 | include_once __DIR__.'/../vendor/spatie/laravel-medialibrary/database/migrations/create_media_table.php.stub'; 66 | include_once __DIR__.'/../vendor/laravel/nova/database/migrations/2018_01_01_000000_create_action_events_table.php'; 67 | include_once __DIR__.'/../vendor/laravel/nova/database/migrations/2019_05_10_000000_add_fields_to_action_events_table.php'; 68 | 69 | if (class_exists(\CreateMediaTable::class)) { 70 | // PHP 7 only 71 | (new \CreateMediaTable())->up(); 72 | } else { 73 | // PHP 8+ version of medialibrary uses anonymous class, so we can require and call it 74 | $mediaTableMigration = require __DIR__.'/../vendor/spatie/laravel-medialibrary/database/migrations/create_media_table.php.stub'; 75 | $mediaTableMigration->up(); 76 | } 77 | 78 | (new \CreateActionEventsTable())->up(); 79 | (new \AddFieldsToActionEventsTable())->up(); 80 | } 81 | 82 | protected function setUpNova($app): void 83 | { 84 | $this->app['config']->set('nova.actions.resource', ActionResource::class); 85 | 86 | // Middleware required because of impersonation check 87 | Route::middlewareGroup('nova', [ 88 | \Illuminate\Session\Middleware\StartSession::class, 89 | ]); 90 | 91 | Route::middlewareGroup('nova:api', ['nova']); 92 | 93 | Nova::resources([ 94 | \DmitryBubyakin\NovaMedialibraryField\Tests\Fixtures\Nova\TestPost::class, 95 | ]); 96 | 97 | Nova::$resources = array_filter(Nova::$resources); 98 | 99 | event(new ServingNova($app, Request::create('/'))); 100 | } 101 | 102 | protected function setUpTestFiles($app): void 103 | { 104 | $tmpDirectory = $this->getSupportPath('tmp'); 105 | 106 | if ($app['files']->isDirectory($tmpDirectory)) { 107 | $app['files']->deleteDirectory($tmpDirectory); 108 | } 109 | 110 | $app['files']->makeDirectory($tmpDirectory); 111 | $app['files']->copyDirectory($this->getSupportPath('files'), $tmpDirectory); 112 | } 113 | 114 | public function getSupportPath(string $path = ''): string 115 | { 116 | return __DIR__.'/Support'.($path ? '/'.$path : ''); 117 | } 118 | 119 | public function getTextFile(): string 120 | { 121 | return $this->getSupportPath('tmp/test.txt'); 122 | } 123 | 124 | public function getIgnoredTextFile(): string 125 | { 126 | return $this->getSupportPath('tmp/ignored.txt'); 127 | } 128 | 129 | public function getJpgFile(): string 130 | { 131 | return $this->getSupportPath('tmp/test.jpg'); 132 | } 133 | 134 | public function createPost(): TestPost 135 | { 136 | return TestPost::create(); 137 | } 138 | 139 | public function createPostWithMedia($media = 1, string $collectionName = 'default', ?string $file = null): TestPost 140 | { 141 | $post = $this->createPost(); 142 | 143 | $media = is_array($media) 144 | ? $media 145 | : array_pad([], $media, [$collectionName, $file ?: $this->getJpgFile()]); 146 | 147 | foreach ($media as [$collectionName, $file]) { 148 | $this->addMediaTo($post, $file, $collectionName); 149 | } 150 | 151 | return $post; 152 | } 153 | 154 | public function addMediaTo(HasMedia $model, string $file, string $collectionName): Media 155 | { 156 | return $model 157 | ->addMedia($file) 158 | ->preservingOriginal() 159 | ->toMediaCollection($collectionName); 160 | } 161 | 162 | public function authenticate(): self 163 | { 164 | $this->actingAs($this->authenticatedAs = Mockery::mock(Authenticatable::class)); 165 | 166 | $this->authenticatedAs->shouldReceive('getAuthIdentifier')->andReturn(1); 167 | 168 | return $this; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /tests/Unit/HelpersTest.php: -------------------------------------------------------------------------------- 1 | assertSame(1, call_or_default(null, [], 1)); 18 | 19 | $this->assertSame(2, call_or_default(function () { 20 | return 2; 21 | }, [], 1)); 22 | 23 | $this->assertSame(2, call_or_default(function () { 24 | return 2; 25 | })); 26 | } 27 | 28 | /** @test */ 29 | public function test_callable_or_default(): void 30 | { 31 | $this->assertTrue(is_callable(callable_or_default(null, function (): void { 32 | // 33 | }))); 34 | 35 | $this->assertTrue(is_callable(callable_or_default('string', function (): void { 36 | // 37 | }))); 38 | } 39 | } -------------------------------------------------------------------------------- /tests/Unit/MediaTest.php: -------------------------------------------------------------------------------- 1 | set('media-library.media_model', TestMedia::class); 18 | } 19 | 20 | /** @test */ 21 | public function it_uses_media_model_from_the_config(): void 22 | { 23 | $this->assertSame(config('media-library.media_model'), TestMedia::class); 24 | $this->assertSame(TestMedia::class, MediaResource::$model); 25 | } 26 | } 27 | 28 | class TestMedia extends Media 29 | { 30 | public $table = 'media'; 31 | } 32 | -------------------------------------------------------------------------------- /tests/Unit/MedialibraryRequestTest.php: -------------------------------------------------------------------------------- 1 | createRequest('nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/1/media/media_testing_custom_attribute'); 30 | $this->assertSame('media_testing_custom_attribute', $request->medialibraryField()->attribute); 31 | 32 | $request = $this->createRequest('nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/1/media/media_testing_single'); 33 | $this->assertSame('media_testing_single', $request->medialibraryField()->attribute); 34 | 35 | $request = $this->createRequest('nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/1/media/media_testing_panel'); 36 | $this->assertSame('media_testing_panel', $request->medialibraryField()->attribute); 37 | 38 | $request = $this->createRequest('nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/1/media/media_testing_container'); 39 | $this->assertSame('media_testing_container', $request->medialibraryField()->attribute); 40 | } 41 | 42 | /** @test */ 43 | public function test_medialibrary_field_error(): void 44 | { 45 | $this->expectExceptionMessage('Field with attribute `invalid-field` is not found.'); 46 | 47 | $request = $this->createRequest('nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/1/media/invalid-field'); 48 | 49 | $request->medialibraryField(); 50 | } 51 | 52 | /** @test */ 53 | public function test_resource_exists(): void 54 | { 55 | $request = $this->createRequest('nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/1/media/invalid-field'); 56 | $this->assertTrue($request->resourceExists()); 57 | 58 | $request = $this->createRequest('nova-vendor/dmitrybubyakin/nova-medialibrary-field/test-posts/undefined/media/invalid-field'); 59 | $this->assertFalse($request->resourceExists()); 60 | } 61 | 62 | /** @test */ 63 | public function test_field_uuid(): void 64 | { 65 | $request = MediaRequest::create('', 'POST', ['fieldUuid' => 'uuid']); 66 | 67 | $this->assertSame('uuid', $request->fieldUuid()); 68 | } 69 | 70 | private function createRequest(...$args): MediaRequest 71 | { 72 | $request = MediaRequest::create(...$args); 73 | 74 | $request->setRouteResolver(function () use ($request) { 75 | return $this->app['router']->getRoutes()->match($request); 76 | }); 77 | 78 | return $request; 79 | } 80 | } 81 | 82 | class ResolveFromContainerFields 83 | { 84 | public function __invoke(FieldCollection $fields, string $attribute): ?Medialibrary 85 | { 86 | return $fields->map(function ($field) { 87 | if ($field instanceof ContainerField) { 88 | return $field->meta['fields']; 89 | } 90 | 91 | return $field; 92 | }) 93 | ->flatten(1) 94 | ->whereInstanceOf(Medialibrary::class) 95 | ->findFieldByAttribute($attribute); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | let mix = require('laravel-mix') 2 | let NovaExtension = require('laravel-nova-devtool') 3 | 4 | mix.extend('nova', new NovaExtension()) 5 | 6 | mix 7 | .setPublicPath('dist') 8 | .js('resources/js/field.js', 'js') 9 | .vue({ version: 3 }) 10 | .sass('resources/sass/field.scss', 'css') 11 | .nova('dmitrybubyakin/nova-medialibrary-field') 12 | .version() 13 | // .sourceMaps() 14 | --------------------------------------------------------------------------------