├── docs ├── record.gif ├── screenshot_1.png ├── screenshot_2.png ├── screenshot_3.png ├── screenshot_4.png ├── screenshot_5.png └── screenshot_6.png ├── dist ├── mix-manifest.json └── css │ └── tool.css ├── resources ├── js │ ├── tool │ │ ├── loader │ │ │ ├── script.js │ │ │ └── index.vue │ │ ├── folders │ │ │ ├── index.vue │ │ │ └── script.js │ │ ├── index.vue │ │ ├── search │ │ │ ├── script.js │ │ │ └── index.vue │ │ ├── items │ │ │ ├── script.js │ │ │ └── index.vue │ │ ├── crop │ │ │ ├── script.js │ │ │ └── index.vue │ │ ├── action │ │ │ ├── index.vue │ │ │ └── script.js │ │ ├── popup │ │ │ ├── script.js │ │ │ └── index.vue │ │ └── script.js │ ├── field │ │ ├── module │ │ │ ├── Trix │ │ │ │ ├── index.vue │ │ │ │ └── script.js │ │ │ ├── Callback │ │ │ │ ├── index.vue │ │ │ │ └── script.js │ │ │ ├── File │ │ │ │ ├── script.js │ │ │ │ └── index.vue │ │ │ ├── Library.vue │ │ │ └── Array │ │ │ │ ├── script.js │ │ │ │ └── index.vue │ │ ├── Detail │ │ │ ├── script.js │ │ │ └── index.vue │ │ ├── Index │ │ │ ├── index.vue │ │ │ └── script.js │ │ └── Form │ │ │ ├── index.vue │ │ │ └── script.js │ ├── _mixin.js │ └── tool.js ├── views │ └── navigation.blade.php ├── lang │ ├── en.json │ └── de.json └── sass │ └── tool.sass ├── webpack.mix.js ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── routes └── api.php ├── src ├── Http │ ├── Requests │ │ ├── UploadFr.php │ │ ├── DeleteFr.php │ │ ├── FolderDelFr.php │ │ ├── UpdateFr.php │ │ ├── FolderNewFr.php │ │ ├── CropFr.php │ │ └── GetFr.php │ ├── Middleware │ │ └── Authorize.php │ └── Controllers │ │ └── Tool.php ├── ToolServiceProvider.php ├── NovaMediaLibrary.php ├── Core │ ├── Helper.php │ ├── Model.php │ ├── Crop.php │ └── Upload.php ├── API.php └── MediaLibrary.php ├── composer.json ├── LICENSE ├── database └── 2020_01_01_000000_create_nova_media_library_table.php ├── package.json ├── CHANGELOG.md ├── config └── nova-media-library.php └── README.md /docs/record.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classic-o/nova-media-library/HEAD/docs/record.gif -------------------------------------------------------------------------------- /docs/screenshot_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classic-o/nova-media-library/HEAD/docs/screenshot_1.png -------------------------------------------------------------------------------- /docs/screenshot_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classic-o/nova-media-library/HEAD/docs/screenshot_2.png -------------------------------------------------------------------------------- /docs/screenshot_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classic-o/nova-media-library/HEAD/docs/screenshot_3.png -------------------------------------------------------------------------------- /docs/screenshot_4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classic-o/nova-media-library/HEAD/docs/screenshot_4.png -------------------------------------------------------------------------------- /docs/screenshot_5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classic-o/nova-media-library/HEAD/docs/screenshot_5.png -------------------------------------------------------------------------------- /docs/screenshot_6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/classic-o/nova-media-library/HEAD/docs/screenshot_6.png -------------------------------------------------------------------------------- /dist/mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/js/tool.js": "/js/tool.js", 3 | "/css/tool.css": "/css/tool.css" 4 | } 5 | -------------------------------------------------------------------------------- /resources/js/tool/loader/script.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data() { 3 | return { 4 | 5 | } 6 | }, 7 | methods: { 8 | 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | let mix = require('laravel-mix'); 2 | 3 | mix 4 | .setPublicPath('dist') 5 | .js('resources/js/tool.js', 'js') 6 | .sass('resources/sass/tool.sass', 'css'); 7 | 8 | -------------------------------------------------------------------------------- /resources/js/tool/folders/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /resources/js/field/module/Trix/index.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /resources/js/field/Detail/script.js: -------------------------------------------------------------------------------- 1 | import nmlArray from '../module/Array/' 2 | import nmlCallback from '../module/Callback/' 3 | import nmlFile from '../module/File/' 4 | import nmlTrix from '../module/Trix/' 5 | 6 | export default { 7 | props: ['field'], 8 | components: { nmlArray, nmlFile, nmlCallback, nmlTrix }, 9 | data() { 10 | return { 11 | isHidden: this.field.nmlHidden === true 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /resources/js/field/module/Callback/index.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /resources/js/field/Index/index.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. See error 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /resources/js/field/Index/script.js: -------------------------------------------------------------------------------- 1 | import Mixin from '../../_mixin' 2 | 3 | export default { 4 | props: ['field'], 5 | mixins: [Mixin], 6 | data() { 7 | return { 8 | count: 0, 9 | item: null 10 | } 11 | }, 12 | created() { 13 | let field = this.field; 14 | if ( !field.value ) return; 15 | 16 | if ( Array.isArray(field.value) && field.value.length > 0 ) { 17 | this.count = field.value.length; 18 | this.item = field.value[0]; 19 | } else if ( 'object' === typeof field.value ) { 20 | this.item = field.value; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /resources/js/_mixin.js: -------------------------------------------------------------------------------- 1 | export default { 2 | methods: { 3 | bgSize(item) { 4 | let wh = item.options.wh; 5 | if (!wh) return ''; 6 | return ( wh[0] < 151 || wh[1] < 151 ) ? 'auto !important' : ''; 7 | }, 8 | bg(item) { 9 | return 'image' === item.options.mime ? { 10 | backgroundImage: `url(${item.preview || item.url})`, 11 | backgroundSize: this.bgSize(item) 12 | } : {}; 13 | }, 14 | mime(item) { 15 | return ['image', 'audio', 'video'].indexOf(item.options.mime) > -1 16 | ? item.options.mime : 'file'; 17 | }, 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /resources/js/tool/index.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /resources/views/navigation.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ __('Media Library') }} 6 | 7 | -------------------------------------------------------------------------------- /routes/api.php: -------------------------------------------------------------------------------- 1 | name('nml-private-file-admin'); 8 | Route::post('/upload', Tool::class . '@upload'); 9 | Route::post('/delete', Tool::class . '@delete'); 10 | Route::post('/update', Tool::class . '@update'); 11 | Route::post('/crop', Tool::class . '@crop'); 12 | Route::post('/folder/new', Tool::class . '@folderNew'); 13 | Route::post('/folder/del', Tool::class . '@folderDel'); 14 | Route::get('/folders', Tool::class . '@folders'); 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /resources/js/field/module/Callback/script.js: -------------------------------------------------------------------------------- 1 | import Library from '../Library' 2 | 3 | export default { 4 | props: ['field'], 5 | components: { Library }, 6 | data() { 7 | return { 8 | popup: false 9 | } 10 | }, 11 | methods: { 12 | select(array) { 13 | let cb = this.field.nmlJsCallback; 14 | if ( 'object' === typeof cb && cb[0] && window[cb[0]] ) 15 | eval(cb[0])(array, cb[1]); 16 | } 17 | }, 18 | created() { 19 | Nova.$on(`nmlSelectFiles[${this.field.attribute}]`, array => { 20 | this.popup = false; 21 | this.select(array); 22 | }); 23 | }, 24 | beforeDestroy() { 25 | Nova.$off(`nmlSelectFiles[${this.field.attribute}]`); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /resources/js/tool/search/script.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data() { 3 | let all = null; 4 | let types = this.$parent.field ? (this.$parent.$props.types || []) : []; 5 | if ( !types.length ) { 6 | types = this.$parent.config.types; 7 | } else { 8 | all = types; 9 | } 10 | return { 11 | all, 12 | types 13 | } 14 | }, 15 | methods: { 16 | updateDate(value, target) { 17 | this.$parent.filter[target] = value || null; 18 | this.$parent.doSearch(); 19 | }, 20 | display(val) { 21 | this.$parent.config.display = val; 22 | localStorage.setItem('nml-display', val); 23 | } 24 | }, 25 | created() { 26 | this.$parent.filter.type = this.all; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Http/Requests/UploadFr.php: -------------------------------------------------------------------------------- 1 | json([ 13 | 'message' => $validator->errors()->first() 14 | ], 422)); 15 | } 16 | 17 | public function rules() 18 | { 19 | return [ 20 | 'file' => 'required|file' 21 | ]; 22 | } 23 | 24 | public function messages() 25 | { 26 | return [ 27 | 'file.*' => __('Invalid file') 28 | ]; 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/Http/Requests/DeleteFr.php: -------------------------------------------------------------------------------- 1 | json([ 13 | 'message' => $validator->errors()->first() 14 | ], 422)); 15 | } 16 | 17 | public function rules() 18 | { 19 | return [ 20 | 'ids' => 'required|array' 21 | ]; 22 | } 23 | 24 | public function messages() 25 | { 26 | return [ 27 | 'ids.*' => __('Invalid field of items ids') 28 | ]; 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/Http/Requests/FolderDelFr.php: -------------------------------------------------------------------------------- 1 | json([ 13 | 'message' => $validator->errors()->first() 14 | ], 422)); 15 | } 16 | 17 | public function rules() 18 | { 19 | return [ 20 | 'folder' => 'required|string|regex:/^[a-zA-Z0-9_\-\/]+$/' 21 | ]; 22 | } 23 | 24 | public function messages() 25 | { 26 | return [ 27 | 'folder.*' => __('Invalid path') 28 | ]; 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "classic-o/nova-media-library", 3 | "description": "Tool and field that will let you managing files and add them to the posts", 4 | "keywords": ["laravel", "nova", "media", "gallery"], 5 | "license": "MIT", 6 | "require": { 7 | "php": ">=7.1.0", 8 | "intervention/image": "^2." 9 | }, 10 | "autoload": { 11 | "psr-4": { 12 | "ClassicO\\NovaMediaLibrary\\": "src/" 13 | } 14 | }, 15 | "extra": { 16 | "laravel": { 17 | "providers": [ 18 | "ClassicO\\NovaMediaLibrary\\ToolServiceProvider" 19 | ] 20 | } 21 | }, 22 | "config": { 23 | "sort-packages": true 24 | }, 25 | "minimum-stability": "dev", 26 | "prefer-stable": true 27 | } 28 | -------------------------------------------------------------------------------- /resources/js/field/module/File/script.js: -------------------------------------------------------------------------------- 1 | import Library from '../Library' 2 | import Mixin from '../../../_mixin' 3 | 4 | export default { 5 | props: ['field', 'handler'], 6 | mixins: [Mixin], 7 | components: { Library }, 8 | data() { 9 | return { 10 | popup: false, 11 | isForm: this.$parent.$parent.isFormField === true, 12 | item: this.field.value 13 | } 14 | }, 15 | methods: { 16 | changeFile(item) { 17 | this.item = item; 18 | if ( this.handler ) this.handler(item); 19 | } 20 | }, 21 | created() { 22 | Nova.$on(`nmlSelectFiles[${this.field.attribute}]`, array => { 23 | this.popup = false; 24 | this.changeFile(array[0]); 25 | }); 26 | }, 27 | beforeDestroy() { 28 | Nova.$off(`nmlSelectFiles[${this.field.attribute}]`); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /resources/js/tool/loader/index.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /resources/js/field/Detail/index.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /resources/js/field/Form/index.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Http/Requests/UpdateFr.php: -------------------------------------------------------------------------------- 1 | json([ 13 | 'message' => $validator->errors()->first() 14 | ], 422)); 15 | } 16 | 17 | public function rules() 18 | { 19 | return [ 20 | 'id' => 'required|numeric', 21 | 'title' => 'required|string|max:250', 22 | 'private' => 'boolean' 23 | ]; 24 | } 25 | 26 | public function messages() 27 | { 28 | return [ 29 | 'id.*' => __('Invalid id'), 30 | 'title.*' => __('Invalid title'), 31 | 'private.*' => __('Field private must be boolean') 32 | ]; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/Http/Requests/FolderNewFr.php: -------------------------------------------------------------------------------- 1 | json([ 13 | 'message' => $validator->errors()->first() 14 | ], 422)); 15 | } 16 | 17 | public function rules() 18 | { 19 | return [ 20 | 'base' => 'required|string|regex:/^[a-zA-Z0-9_\-\/]+$/', 21 | 'folder' => 'required|string|regex:/^[a-zA-Z0-9_\-]+$/' 22 | ]; 23 | } 24 | 25 | public function messages() 26 | { 27 | return [ 28 | 'base.*' => __('Invalid base path. Use only: a-z 0-9 - _'), 29 | 'folder.*' => __('Invalid new folder name. Use only: a-z 0-9 - _') 30 | ]; 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/Http/Middleware/Authorize.php: -------------------------------------------------------------------------------- 1 | first([$this, 'matchesTool']); 20 | 21 | return optional($tool)->authorize($request) ? $next($request) : abort(403); 22 | } 23 | 24 | /** 25 | * Determine whether this tool belongs to the package. 26 | * 27 | * @param \Laravel\Nova\Tool $tool 28 | * @return bool 29 | */ 30 | public function matchesTool($tool) 31 | { 32 | return $tool instanceof NovaMediaLibrary; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Http/Requests/CropFr.php: -------------------------------------------------------------------------------- 1 | json([ 13 | 'message' => $validator->errors()->first() 14 | ], 422)); 15 | } 16 | 17 | public function rules() 18 | { 19 | return [ 20 | 'id' => 'required|numeric', 21 | 'x' => 'required|numeric', 22 | 'y' => 'required|numeric', 23 | 'width' => 'required|numeric', 24 | 'height' => 'required|numeric', 25 | 'rotate' => 'required|numeric|min:0|max:360', 26 | 'over' => 'required|integer|min:0|max:1' 27 | ]; 28 | } 29 | 30 | public function messages() 31 | { 32 | return [ 33 | '*' => __('Invalid request data') 34 | ]; 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /resources/js/field/module/Library.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 35 | -------------------------------------------------------------------------------- /src/Http/Requests/GetFr.php: -------------------------------------------------------------------------------- 1 | json([ 13 | 'message' => $validator->errors()->first() 14 | ], 422)); 15 | } 16 | 17 | public function rules() 18 | { 19 | return [ 20 | 'title' => 'nullable|string', 21 | 'from' => 'nullable|date_format:Y-m-d', 22 | 'to' => 'nullable|date_format:Y-m-d', 23 | 'page' => 'required|integer|min:0' 24 | ]; 25 | } 26 | 27 | public function messages() 28 | { 29 | return [ 30 | 'title.*' => __('Invalid title'), 31 | 'from.*' => __('Invalid FROM date format'), 32 | 'to.*' => __('Invalid TO date format'), 33 | 'page.*' => __('Invalid page') 34 | ]; 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Pavel 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /database/2020_01_01_000000_create_nova_media_library_table.php: -------------------------------------------------------------------------------- 1 | increments('id'); 18 | $table->string('title')->index()->nullable(); 19 | $table->timestamp('created')->index()->useCurrent(); 20 | $table->string('type')->index(); 21 | $table->string('folder')->index(); 22 | $table->string('name'); 23 | $table->boolean('private')->default(0); 24 | $table->boolean('lp')->default(0); 25 | $table->text('options')->nullable(); 26 | }); 27 | } 28 | 29 | /** 30 | * Reverse the migrations. 31 | * 32 | * @return void 33 | */ 34 | public function down() 35 | { 36 | Schema::dropIfExists('nova_media_library'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /resources/js/tool/items/script.js: -------------------------------------------------------------------------------- 1 | import Mixin from '../../_mixin' 2 | import Folders from '../folders' 3 | 4 | export default { 5 | mixins: [Mixin], 6 | components: { Folders }, 7 | computed: { 8 | folders() { 9 | return this.$parent.config.folders; 10 | }, 11 | folder() { 12 | return this.$parent.filter.folder; 13 | }, 14 | getFolders() { 15 | let keys = Object.assign({}, this.folders); 16 | this.folder.slice(1, -1).split('/').forEach(item => { 17 | if ( '' !== item ) 18 | keys = Object.assign({}, keys[item] || {}); 19 | }); 20 | return Object.keys(keys); 21 | } 22 | }, 23 | methods: { 24 | clickItem(item) { 25 | if ( this.$parent.bulk.enable ) { 26 | if ( this.$parent.bulk.ids[item.id] ) { 27 | this.$delete(this.$parent.bulk.ids, item.id); 28 | } else { 29 | this.$set(this.$parent.bulk.ids, item.id, item); 30 | } 31 | } else { 32 | if ( this.$parent.field ) { 33 | Nova.$emit(`nmlSelectFiles[${this.$parent.field}]`, [item]); 34 | } else { 35 | this.$parent.item = item; 36 | this.$parent.popup = 'info'; 37 | } 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /resources/js/field/Form/script.js: -------------------------------------------------------------------------------- 1 | import { FormField, HandlesValidationErrors } from 'laravel-nova' 2 | 3 | import nmlArray from '../module/Array/' 4 | import nmlCallback from '../module/Callback/' 5 | import nmlFile from '../module/File/' 6 | import nmlTrix from '../module/Trix/' 7 | 8 | export default { 9 | mixins: [FormField, HandlesValidationErrors], 10 | components: { nmlArray, nmlFile, nmlCallback, nmlTrix }, 11 | props: ['field'], 12 | data() { 13 | return { 14 | isFormField: true, 15 | isHidden: this.field.nmlHidden === true 16 | } 17 | }, 18 | methods: { 19 | setInitialValue() { 20 | this.value = this.field.value || null 21 | }, 22 | fill(formData) { 23 | let data = null; 24 | 25 | if ( this.value ) { 26 | if ( this.field.nmlArray && Array.isArray(this.value) ) { 27 | data = this.value.map(item => item.id); 28 | } else if ( !this.field.nmlArray && 'object' === typeof this.value && this.value.id ) { 29 | data = this.value.id; 30 | } 31 | if ( Array.isArray(data) ) data = JSON.stringify(data); 32 | } 33 | 34 | formData.append(this.field.attribute, data); 35 | }, 36 | handleChange(value) { 37 | this.value = value 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /resources/js/tool.js: -------------------------------------------------------------------------------- 1 | Nova.booting((Vue, router, store) => { 2 | Vue.component('index-media-library-field', require('./field/Index/')); 3 | Vue.component('detail-media-library-field', require('./field/Detail/')); 4 | Vue.component('form-media-library-field', require('./field/Form/')); 5 | 6 | router.addRoutes([ 7 | { 8 | name: 'nova-media-library', 9 | path: '/media-library', 10 | component: require('./tool/'), 11 | }, 12 | ]); 13 | 14 | window.nmlToastHook = e => { 15 | if ( 422 === e.response.status && e.response.data.message ) 16 | Vue.prototype.$toasted.show(e.response.data.message, { type: 'error' }) 17 | }; 18 | }); 19 | 20 | 21 | if ('object' === typeof Nova.config.novaMediaLibrary) { 22 | if (Nova.config.novaMediaLibrary.store === 'folders') { 23 | Nova.request().get('/nova-vendor/nova-media-library/folders').then(r => { 24 | Object.assign(Nova.config.novaMediaLibrary, { folders: r.data }) 25 | }) 26 | } 27 | if ('object' === typeof Nova.config.novaMediaLibrary.lang) { 28 | Object.assign(Nova.config.translations, Nova.config.novaMediaLibrary.lang) 29 | } 30 | } 31 | 32 | //document.querySelector('meta[name="viewport"]').setAttribute('content', 'width=device-width, initial-scale=1.0, user-scalable=yes'); 33 | -------------------------------------------------------------------------------- /resources/js/field/module/Trix/script.js: -------------------------------------------------------------------------------- 1 | import Library from '../Library' 2 | 3 | export default { 4 | props: ['field'], 5 | components: { Library }, 6 | data() { 7 | return { 8 | popup: false 9 | } 10 | }, 11 | methods: { 12 | select(array) { 13 | let trix = document.querySelector(`[nml-trix="${this.field.nmlTrix}"]`); 14 | 15 | if ( trix && Array.isArray(array) ) { 16 | array.forEach(item => { 17 | trix.editor.insertHTML( 18 | 'image' === item.options.mime 19 | ? `` 20 | : `${item.url}` 21 | ); 22 | }); 23 | } 24 | 25 | this.clearAttach(); 26 | }, 27 | clearAttach() { 28 | let trix = document.querySelector(`[nml-trix="${this.field.nmlTrix}"]`); 29 | if ( trix ) trix.editor.composition.attachments = []; 30 | } 31 | }, 32 | created() { 33 | addEventListener('trix-attachment-add', this.clearAttach); 34 | Nova.$on(`nmlSelectFiles[${this.field.attribute}]`, array => { 35 | this.popup = false; 36 | this.select(array); 37 | }); 38 | }, 39 | beforeDestroy() { 40 | removeEventListener('trix-attachment-add', this.clearAttach); 41 | Nova.$off(`nmlSelectFiles[${this.field.attribute}]`); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /resources/js/field/module/Array/script.js: -------------------------------------------------------------------------------- 1 | import draggable from 'vuedraggable' 2 | import Library from '../Library' 3 | import Mixin from '../../../_mixin' 4 | 5 | export default { 6 | props: ['field', 'handler'], 7 | mixins: [Mixin], 8 | components: { draggable, Library }, 9 | data() { 10 | let type = this.field.nmlArray; 11 | if ( 'auto' === type ) type = 'list' === localStorage.getItem('nml-display') ? 'list' : 'gallery'; 12 | 13 | return { 14 | popup: false, 15 | isForm: this.$parent.$parent.isFormField === true, 16 | array: [], 17 | type 18 | } 19 | }, 20 | methods: { 21 | changeArray(array) { 22 | this.$set(this, 'array', array || []); 23 | if ( this.handler ) this.handler(array); 24 | }, 25 | remove(num) { 26 | this.changeArray(this.array.slice().filter((item, i) => i !== num)); 27 | } 28 | }, 29 | created() { 30 | Nova.$on(`nmlSelectFiles[${this.field.attribute}]`, array => { 31 | this.popup = false; 32 | this.array = this.array.concat(array); 33 | this.changeArray(this.array); 34 | }); 35 | 36 | try { 37 | if ( Array.isArray(this.field.value) ) this.array = this.field.value; 38 | } catch (e) {} 39 | }, 40 | beforeDestroy() { 41 | Nova.$off(`nmlSelectFiles[${this.field.attribute}]`); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 6 | "watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 7 | "watch-poll": "npm run watch -- --watch-poll", 8 | "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", 9 | "prod": "npm run production", 10 | "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" 11 | }, 12 | "devDependencies": { 13 | "cross-env": "^5.2.1", 14 | "laravel-mix": "^3.0.0", 15 | "laravel-nova": "^1.0.9", 16 | "resolve-url-loader": "^2.3.2", 17 | "sass": "^1.23.7", 18 | "sass-loader": "^7.3.1", 19 | "vue-template-compiler": "^2.6.10" 20 | }, 21 | "dependencies": { 22 | "cropperjs": "^1.5.6", 23 | "v-copy": "^0.1.0", 24 | "vue": "^2.6.10", 25 | "vuedraggable": "^2.23.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /resources/js/tool/items/index.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /resources/js/field/module/File/index.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /resources/js/tool/crop/script.js: -------------------------------------------------------------------------------- 1 | import Cropper from 'cropperjs'; 2 | 3 | export default { 4 | data() { 5 | return { 6 | img: null, 7 | crop: null, 8 | info: { rotate: 0 }, 9 | } 10 | }, 11 | methods: { 12 | rotate() { 13 | this.crop.rotateTo(parseInt(this.info.rotate || 0)); 14 | }, 15 | save(over) { 16 | this.info.over = over; 17 | this.info.id = this.$parent.item.id; 18 | this.$parent.loading = true; 19 | Nova.request().post('/nova-vendor/nova-media-library/crop', this.info).then(() => { 20 | this.$toasted.show(this.__('Image cropped successfully'), { type: 'success' }); 21 | this.$parent.clearData(); 22 | this.$parent.get(); 23 | this.$parent.item = null; 24 | }).catch(e => { 25 | this.$parent.loading = false; 26 | window.nmlToastHook(e); 27 | }); 28 | } 29 | }, 30 | mounted() { 31 | document.body.classList.add('overflow-hidden'); 32 | let el = this; 33 | el.img = document.getElementById('cropper-img'); 34 | el.crop = new Cropper(el.img, { 35 | autoCrop: false, 36 | checkCrossOrigin: false, 37 | guides: false, 38 | //toggleDragModeOnDblclick: false, 39 | viewMode: 1, 40 | //zoomable: false, 41 | crop(e) { 42 | el.info = { 43 | x: e.detail.x, 44 | y: e.detail.y, 45 | width: parseInt(e.detail.width), 46 | height: parseInt(e.detail.height), 47 | rotate: e.detail.rotate 48 | }; 49 | }, 50 | }); 51 | }, 52 | beforeDestroy() { 53 | document.body.classList.remove('overflow-hidden'); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/ToolServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadViewsFrom(__DIR__.'/../resources/views', 'nova-media-library'); 19 | $this->loadJsonTranslationsFrom(resource_path('lang/vendor/nova-media-library')); 20 | $this->loadMigrationsFrom(__DIR__.'/../database/'); 21 | 22 | $this->publishes([ 23 | __DIR__.'/../config/' => config_path(), 24 | __DIR__.'/../database/' => base_path('/database/migrations'), 25 | __DIR__.'/../resources/lang' => resource_path('lang/vendor/nova-media-library'), 26 | ], 'config-nml'); 27 | 28 | $this->app->booted(function () { 29 | $this->routes(); 30 | }); 31 | } 32 | 33 | /** 34 | * Register the tool's routes. 35 | * 36 | * @return void 37 | */ 38 | protected function routes() 39 | { 40 | if ($this->app->routesAreCached()) { 41 | return; 42 | } 43 | 44 | Route::middleware(['nova', Authorize::class]) 45 | ->prefix('nova-vendor/nova-media-library') 46 | ->group(__DIR__.'/../routes/api.php'); 47 | } 48 | 49 | /** 50 | * Register any application services. 51 | * 52 | * @return void 53 | */ 54 | public function register() 55 | { 56 | 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /resources/js/field/module/Array/index.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /resources/js/tool/action/index.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /resources/js/tool/crop/index.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /resources/js/tool/popup/script.js: -------------------------------------------------------------------------------- 1 | import { copy } from 'v-copy'; 2 | 3 | export default { 4 | directives: { copy }, 5 | data() { 6 | return { 7 | folder: null 8 | } 9 | }, 10 | computed: { 11 | folders() { 12 | return this.getFolders(this.$parent.config.folders, '/', ['/']); 13 | } 14 | }, 15 | methods: { 16 | getFolders(obj, path, array) { 17 | for (let i in obj) { 18 | array.push(path+i+'/'); 19 | if ( 'object' === typeof obj[i] ) 20 | array = this.getFolders(obj[i], path+i+'/', array); 21 | } 22 | return array; 23 | }, 24 | onPrivate(e) { 25 | this.$set(this.$parent.item, 'private', e.target.checked) 26 | }, 27 | update() { 28 | let cp = this.$parent.config.can_private; 29 | this.$parent.loading = true; 30 | let data = { id: this.$parent.item.id, title: this.$parent.item.title, folder: this.folder }; 31 | if ( cp ) data.private = Boolean(this.$parent.item.private); 32 | 33 | Nova.request().post('/nova-vendor/nova-media-library/update', data).then(r => { 34 | this.$toasted.show(this.__('Successfully updated'), { type: 'success' }); 35 | this.$parent.loading = false; 36 | this.$parent.item = null; 37 | if ( this.folder || cp ) { 38 | this.$parent.clearData(); 39 | this.$parent.get(); 40 | this.folder = null; 41 | } else { 42 | let index = this.$parent.items.array.findIndex(x => x.id === r.data.id); 43 | if ( index > -1 && r.data.id ) { 44 | r.data.url += '?'+Date.now(); 45 | this.$set(this.$parent.items.array, index, r.data); 46 | } 47 | } 48 | }).catch(e => { 49 | this.$parent.loading = false; 50 | window.nmlToastHook(e); 51 | }); 52 | }, 53 | onCopy() { 54 | this.$toasted.show(this.__('URL has been copied'), { type: 'success' }); 55 | } 56 | }, 57 | 58 | mounted() { 59 | document.body.classList.add('overflow-hidden'); 60 | }, 61 | beforeDestroy() { 62 | document.body.classList.remove('overflow-hidden'); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/NovaMediaLibrary.php: -------------------------------------------------------------------------------- 1 | $this->config() ]); 22 | } 23 | 24 | /** 25 | * Build the view that renders the navigation links for the tool. 26 | * 27 | * @return \Illuminate\View\View 28 | */ 29 | public function renderNavigation() 30 | { 31 | return view('nova-media-library::navigation'); 32 | } 33 | 34 | 35 | 36 | private function config() 37 | { 38 | $cfg = config('nova-media-library'); 39 | $types = data_get($cfg, 'types'); 40 | 41 | $config = [ 42 | 'can_private' => 's3' == data_get($cfg, 'disk'), 43 | 'disk' => data_get($cfg, 'disk', 'public'), 44 | 'front_crop' => data_get($cfg, 'resize.front_crop', false), 45 | 'lang' => $this->lang(), 46 | 'store' => data_get($cfg, 'store', 'together'), 47 | ]; 48 | 49 | if ( 'folders' == $config['store']) 50 | $config['folders'] = [];//Helper::directories(); 51 | 52 | if ( is_array($types) ) { 53 | $accept = []; 54 | 55 | foreach ($types as $key) 56 | $accept = array_merge($accept, $key); 57 | 58 | if ( in_array('*', $accept) ) 59 | $accept = []; 60 | 61 | $config['accept'] = preg_filter('/^/', '.', $accept); 62 | $config['types'] = array_keys($types); 63 | } 64 | 65 | return $config; 66 | } 67 | 68 | private function lang() 69 | { 70 | $file = resource_path('lang/vendor/nova-media-library/'.app()->getLocale().'.json'); 71 | if ( !is_readable($file)) return []; 72 | 73 | $json = json_decode(file_get_contents($file)); 74 | return is_object($json) ? $json : []; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /resources/js/tool/folders/script.js: -------------------------------------------------------------------------------- 1 | export default { 2 | props: { 3 | type: { type: String, default: 'folder' }, 4 | label: { type: String, default: '' } 5 | }, 6 | data() { 7 | return { 8 | title: { 9 | back: '', 10 | create: '', 11 | remove: '', 12 | folder: '' 13 | }, 14 | className: '' 15 | } 16 | }, 17 | methods: { 18 | action(type) { 19 | let parent = this.$parent.$parent; 20 | 21 | if ( 'folder' === type ) { 22 | this.$set(parent.filter, 'folder', parent.filter.folder + this.label + '/'); 23 | parent.clearData(); 24 | parent.get(); 25 | } else if ( 'back' === type ) { 26 | let array = parent.filter.folder.split('/'); 27 | array.pop(); 28 | array.pop(); 29 | this.$set(parent.filter, 'folder', array.join('/') + '/'); 30 | parent.clearData(); 31 | parent.get(); 32 | } else if ( 'remove' === type ) { 33 | if ( !confirm(this.__('Delete this folder?')) ) return; 34 | 35 | Nova.request().post('/nova-vendor/nova-media-library/folder/del', { folder: parent.filter.folder }).then(r => { 36 | if ( r.data.folders ) 37 | this.$set(this.$parent.$parent.config, 'folders', r.data.folders); 38 | if ( r.data.message ) 39 | this.$toasted.show(r.data.message, { type: 'success' }); 40 | this.action('back'); 41 | }).catch(e => { 42 | window.nmlToastHook(e); 43 | }); 44 | } else if ( 'create' === type ) { 45 | let folder = prompt(this.__('New folder name')); 46 | if ( !folder ) return; 47 | 48 | Nova.request().post('/nova-vendor/nova-media-library/folder/new', { 49 | base: parent.filter.folder, 50 | folder: folder 51 | }).then(r => { 52 | if ( r.data.folders ) 53 | this.$set(this.$parent.$parent.config, 'folders', r.data.folders); 54 | if ( r.data.message ) 55 | this.$toasted.show(r.data.message, { type: 'success' }); 56 | }).catch(e => { 57 | window.nmlToastHook(e); 58 | }); 59 | } 60 | } 61 | }, 62 | created() { 63 | this.title.folder = this.label; 64 | this.className = 'folder' === this.type ? '' : '-'+this.type; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Core/Helper.php: -------------------------------------------------------------------------------- 1 | allDirectories(config('nova-media-library.folder')) as $item) { 21 | if ( 'nml_temp' == $item ) continue; 22 | $path = str_replace('/', '.', substr($item, $len)); 23 | if ( $path ) data_set($array, $path, 0); 24 | } 25 | 26 | return $array; 27 | } 28 | 29 | static function replace($str) 30 | { 31 | return preg_replace('/(\/)\\1+/', '$1', str_replace('\\', '/', $str)); 32 | } 33 | 34 | static function folder($path = '') 35 | { 36 | return self::replace('/'. (string)config('nova-media-library.folder', '') .'/'. $path); 37 | } 38 | 39 | static function size($bytes) 40 | { 41 | if ( $bytes / 1073741824 >= 1 ) 42 | return round($bytes / 1073741824, 2) .' '. __('gb'); 43 | 44 | if ( $bytes / 1048576 >= 1 ) 45 | return round($bytes / 1048576, 2) .' '. __('mb'); 46 | 47 | if ( $bytes / 1024 >= 1 ) 48 | return round($bytes / 1024, 2) .' '. __('kb'); 49 | 50 | return $bytes .' '. __('b'); 51 | } 52 | 53 | static function isPrivate($folder) 54 | { 55 | $disk = config('nova-media-library.disk'); 56 | $private = false; 57 | 58 | if ( 's3' == $disk ) 59 | $private = config('nova-media-library.private') ?? false; 60 | else if ( 'local' == $disk ) 61 | $private = '/public/' != substr(self::folder($folder), 0, 8); 62 | 63 | return $private; 64 | } 65 | 66 | static function visibility($bool) 67 | { 68 | return $bool ? 'private' : 'public'; 69 | } 70 | 71 | static function preview($item, $size) 72 | { 73 | if ( !in_array($size, data_get($item, 'options.img_sizes', [])) ) return null; 74 | 75 | $url = data_get($item, 'url'); 76 | 77 | return data_get($item, 'private') ? $url . '&img_size='. $size : API::getImageSize($url, $size); 78 | } 79 | 80 | static function localPublic($folder, $private) 81 | { 82 | return ( 83 | 'local' == config('nova-media-library.disk') and 84 | !$private and 85 | '/public/' == substr(self::folder($folder), 0, 8) 86 | ); 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /resources/js/tool/action/script.js: -------------------------------------------------------------------------------- 1 | export default { 2 | data() { 3 | return { 4 | files: [], 5 | upload: {} 6 | } 7 | }, 8 | methods: { 9 | clearUpload(length = 0) { 10 | this.upload = { total: length, done: 0 } 11 | }, 12 | selectFiles(input) { 13 | if ( !input.target.files.length ) return; 14 | this.$parent.loading = true; 15 | this.clearUpload(input.target.files.length); 16 | 17 | this.files = Object.assign({}, input.target.files); 18 | this.uploadFile(0); 19 | 20 | document.getElementById('nml_upload').value = null; 21 | }, 22 | uploadFile(i) { 23 | let file = this.files[i]; 24 | if ( !file ) return this.uploadCheck(); 25 | 26 | let config = { headers: { 'Content-Type': 'multipart/form-data' } }; 27 | let data = new FormData(); 28 | data.append('file', file); 29 | data.append('folder', this.$parent.filter.folder); 30 | 31 | Nova.request().post('/nova-vendor/nova-media-library/upload', data, config).then(r => { 32 | this.upload.done++; 33 | this.$toasted.show(this.upload.done +' / '+ this.upload.total, { type: 'info', duration: 500 }); 34 | this.uploadFile(i+1); 35 | if ( r.data.message ) this.$toasted.show(r.data.message, { type: 'success' }); 36 | }).catch(e => { 37 | this.uploadFile(i+1); 38 | window.nmlToastHook(e); 39 | }); 40 | }, 41 | uploadCheck() { 42 | this.$parent.loading = false; 43 | this.$toasted.show(this.__('Uploaded') +': '+ this.upload.done +'/'+ this.upload.total, { type: 'success' }); 44 | this.$parent.clearData(); 45 | this.$parent.get(); 46 | }, 47 | 48 | changeBulk() { 49 | this.$set(this.$parent.bulk, 'ids', {}); 50 | this.$parent.bulk.enable = !this.$parent.bulk.enable; 51 | }, 52 | 53 | bulkAll() { 54 | if ( this.$parent.bulkLen() === this.$parent.items.array.length ) { 55 | this.$set(this.$parent.bulk, 'ids', {}); 56 | } else { 57 | this.$parent.items.array.forEach(item => { 58 | this.$set(this.$parent.bulk.ids, item.id, item); 59 | }); 60 | } 61 | }, 62 | 63 | pushFiles() { 64 | let data = this.$parent.bulk.ids, array = []; 65 | for (let key in data) 66 | array.push(data[key]); 67 | 68 | Nova.$emit(`nmlSelectFiles[${this.$parent.field}]`, array); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Core/Model.php: -------------------------------------------------------------------------------- 1 | 'datetime', 33 | 'options' => 'object' 34 | ]; 35 | 36 | function getUrlAttribute() { 37 | if ( $this->lp ) 38 | return config('nova-media-library.url', '') . substr($this->path, 7); 39 | 40 | if ( !$this->private ) 41 | return config('nova-media-library.url', '') . $this->path; 42 | 43 | if ( Route::has('nml-private-file') ) 44 | return route('nml-private-file', [ 'id' => $this->id, 'img_size' => request('img_size') ]); 45 | 46 | return null; 47 | } 48 | 49 | function getPathAttribute() { 50 | return Helper::folder($this->folder . $this->name); 51 | } 52 | 53 | function search() 54 | { 55 | $param = request()->all(); 56 | $title = trim(htmlspecialchars(request('title', ''))); 57 | $folder = trim(htmlspecialchars(request('folder', ''))); 58 | 59 | $step = config('nova-media-library.step'); 60 | if ( !is_int($step) or $step < 1 ) $step = 40; 61 | 62 | $data = $this 63 | ->where(function($query) use ($folder) { 64 | if ( $folder ) 65 | $query->where('folder', $folder); 66 | }) 67 | ->where(function($query) use ($param) { 68 | if ( is_array($param['type']) and $param['type'] ) 69 | $query->whereIn('type', $param['type']); 70 | }) 71 | ->where(function($query) use ($param) { 72 | if ( $param['from'] ) 73 | $query->where('created', '>=', $param['from'] . ' 00:00:00'); 74 | }) 75 | ->where(function($query) use ($param) { 76 | if ( $param['to'] ) 77 | $query->where('created', '<=', $param['to'] . ' 23:59:59'); 78 | }) 79 | ->where(function($query) use ($title) { 80 | if ( $title ) 81 | $query->where('title', 'LIKE', "%$title%"); 82 | }); 83 | 84 | return [ 85 | 'total' => (clone $data)->count(), 86 | 'array' => $data->skip($param['page'] * $step) 87 | ->take($step) 88 | ->orderBy('id', 'DESC') 89 | ->get() ?? [] 90 | ]; 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | ### [1.0.8] - 2021-10-23 4 | #### Fixed 5 | - stream file while uploading fixes 6 | 7 | ### [1.0.7] - 2021-05-31 8 | #### Fixed 9 | - translation 10 | 11 | ### [1.0.6] - 2021-01-17 12 | #### Fixed 13 | - bug with incorrect display folders 14 | 15 | ### [1.0.5] - 2020-12-01 16 | #### Fixed 17 | - file range while streaming private file 18 | 19 | ### [1.0.4] - 2020-09-24 20 | #### Fixed 21 | - slow loading folder structure 22 | 23 | ### [1.0.3] - 2020-04-03 24 | #### Fixed 25 | - Database migration bug 26 | 27 | ### [1.0.2] - 2020-03-18 28 | #### Fixed 29 | - Database migration code in tool 30 | 31 | ### [1.0.1] - 2020-02-09 32 | #### Fixed 33 | - Image icon in list mode 34 | 35 | ### [1.0.0] - 2020-01-19 36 | Rewritten backend. Added new features. 37 | 38 | ### Changed 39 | - Configuration file and database migration 40 | - Changed translation file from *.php to *.json format 41 | 42 | #### Added 43 | - Ability to control files visibility 44 | - Organize files in manageable folders 45 | - Display type `list` in the tool 46 | - Preview images in tool and fields 47 | 48 | #### Fixed 49 | - Some bugs 50 | 51 | ### [0.5.3] - 2019-11-02 52 | #### Fixed 53 | - Bug with checkbox in Nova 2.6.0 54 | 55 | ### [0.5.2] - 2019-10-19 56 | #### Fixed 57 | - Blank value when a single video file selected 58 | 59 | ### [0.5.0] - 2019-08-11 60 | #### Fixed 61 | - Bug with cropping image on frontend 62 | 63 | #### Added 64 | - Ability to create image size variations 65 | 66 | ### [0.4.2] - 2019-08-06 67 | #### Fixed 68 | - Image resizing bug 69 | 70 | ### [0.4.1] - 2019-08-04 71 | #### Added 72 | - Error details while uploads files 73 | 74 | ### [0.4.0] - 2019-06-29 75 | #### Added 76 | - Programmatically upload files by url or path 77 | 78 | ### [0.3.0] - 2019-06-23 79 | #### Added 80 | - Cropping image on the frontend 81 | 82 | ### [0.2.1] - 2019-06-20 83 | #### Added 84 | - Changelog file 85 | 86 | ### [0.2.0] - 2019-06-19 87 | Rewritten backend. Added new features. 88 | 89 | ### Changed 90 | - Configuration file and database migration 91 | 92 | #### Added 93 | - s3 filesystem support 94 | - Localization 95 | - Filtering by type and size 96 | - Image resizing 97 | - Integration media field with the Trix editor 98 | - Support custom callback for the media field 99 | 100 | ### [0.1.2] - 2019-04-18 101 | #### Added 102 | - Added frontend resources. 103 | 104 | ### [0.1.1] - 2019-03-27 105 | #### Fixed 106 | - Fixed bug with multiple uses "Media Field" in the same panel. 107 | 108 | ### [0.1.0] - 2019-03-24 109 | #### Added 110 | - Initial tool and field of media library. 111 | 112 | -------------------------------------------------------------------------------- /resources/lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "b": "b", 3 | "kb": "kb", 4 | "mb": "mb", 5 | "gb": "gb", 6 | 7 | "Cannot manage folders": "Cannot manage folders", 8 | "Crop module disabled": "Crop module disabled", 9 | "Field private must be boolean": "Field private must be boolean", 10 | "File size limit exceeded": "File size limit exceeded", 11 | "Forbidden file format": "Forbidden file format", 12 | "Invalid base path. Use only: a-z 0-9 - _": "Invalid base path. Use only: a-z 0-9 - _", 13 | "Invalid field of items ids": "Invalid field of items ids", 14 | "Invalid file": "Invalid file", 15 | "Invalid FROM date format": "Invalid FROM date format", 16 | "Invalid id": "Invalid id", 17 | "Invalid new folder name. Use only: a-z 0-9 - _": "Invalid new folder name. Use only: a-z 0-9 - _", 18 | "Invalid page": "Invalid page", 19 | "Invalid path": "Invalid path", 20 | "Invalid request data": "Invalid request data", 21 | "Invalid title": "Invalid title", 22 | "Invalid TO date format": "Invalid TO date format", 23 | "The file was not downloaded for unknown reasons": "The file was not downloaded for unknown reasons", 24 | "Unsupported image type for resizing, only the original is uploaded.": "Unsupported image type for resizing, only the original is uploaded", 25 | 26 | "Bulk Select": "Bulk Select", 27 | "Cancel": "Cancel", 28 | "Change Display Type": "Change Display Type", 29 | "Choose": "Choose", 30 | "Clear": "Clear", 31 | "Copy": "Copy", 32 | "Delete": "Delete", 33 | "Delete selected files?": "Delete selected files?", 34 | "Delete this folder?": "Delete this folder?", 35 | "Displayed": "Displayed", 36 | "Edit Image": "Edit Image", 37 | "ID": "ID", 38 | "Image cropped successfully": "Image cropped successfully", 39 | "Media Library": "Media Library", 40 | "More": "More", 41 | "Move to folder": "Move to folder", 42 | "New folder name": "New folder name", 43 | "No files found": "No files found", 44 | "Overwrite existing": "Overwrite existing", 45 | "Open": "Open", 46 | "Private": "Private", 47 | "Save as new": "Save as new", 48 | "Search by name": "Search by name", 49 | "Select All": "Select All", 50 | "Select File": "Select File", 51 | "Select Files": "Select Files", 52 | "Size": "Size", 53 | "Show": "Show", 54 | "Successfully updated": "Successfully updated", 55 | "Rotate": "Rotate", 56 | "Title": "Title", 57 | "This file could not be found": "This file could not be found", 58 | "Type": "Type", 59 | "Update": "Update", 60 | "Upload": "Upload", 61 | "Upload From": "Upload From", 62 | "Upload To": "Upload To", 63 | "Uploaded": "Uploaded", 64 | "Url": "Url", 65 | "URL has been copied": "URL has been copied", 66 | "Width x Height": "Width x Height", 67 | 68 | "All Types": "All Types", 69 | "Image": "Image", 70 | "Docs": "Docs", 71 | "Audio": "Audio", 72 | "Video": "Video" 73 | } 74 | -------------------------------------------------------------------------------- /resources/js/tool/search/index.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /resources/lang/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "b": "b", 3 | "kb": "kb", 4 | "mb": "mb", 5 | "gb": "gb", 6 | 7 | "Cannot manage folders": "Ordner können nicht bearbeiten werden", 8 | "Crop module disabled": "Beschneiden-Modul deaktiviert", 9 | "Field private must be boolean": "Feld Privat muss ein Boolean sein", 10 | "File size limit exceeded": "Dateigrößen-Grenze übeschritten", 11 | "Forbidden file format": "Unzulässiges Dateiformat", 12 | "Invalid base path. Use only: a-z 0-9 - _": "Unzulässiger Pfad. Verwenden Sie nur: a-z 0-9 - _", 13 | "Invalid field of items ids": "Unzulässiges Feld für IDs", 14 | "Invalid file": "Unzlässige Datei", 15 | "Invalid FROM date format": "Unzulässiges Datumsfomat für VON", 16 | "Invalid id": "Unzulässige ID", 17 | "Invalid new folder name. Use only: a-z 0-9 - _": "Unzulässiger Ordnername. Verwenden Sie nur: a-z 0-9 - _", 18 | "Invalid page": "Unzulässige Seite", 19 | "Invalid path": "Unzulässiger Pfad", 20 | "Invalid request data": "Unzulässige Request-Daten", 21 | "Invalid title": "Unzulässiger Titel", 22 | "Invalid TO date format": "Unzulässiges Datumsfomat für BIS", 23 | "The file was not downloaded for unknown reasons": "Die Datei wurde aus unbekannten Gründen nicht heruntergeladen", 24 | "Unsupported image type for resizing, only the original is uploaded.": "Nicht unterstützter Bildtyp für die Größenänderung, es wurde nur das Original hochgeladen.", 25 | 26 | "Bulk Select": "Alle auswählen", 27 | "Cancel": "Abbrechen", 28 | "Change Display Type": "Anzeige wechseln", 29 | "Choose": "Auswählen", 30 | "Clear": "Leeren", 31 | "Copy": "Kopieren", 32 | "Delete": "Löschen", 33 | "Delete selected files?": "Ausgewählte Dateien löschen?", 34 | "Delete this folder?": "Diesen Ordner löschen?", 35 | "Displayed": "Dargestellt", 36 | "Edit Image": "Bild bearbeiten", 37 | "ID": "ID", 38 | "Image cropped successfully": "Bild erfolgreich beschnitten", 39 | "Media Library": "Mediathek", 40 | "More": "Mehr", 41 | "Move to folder": "In Ordner verschieben", 42 | "New folder name": "Neuer Ordnername", 43 | "No files found": "Keine Dateien gefunden", 44 | "Overwrite existing": "Bestehende übeschreiben", 45 | "Open": "Öffnen", 46 | "Private": "Privat", 47 | "Save as new": "Als neu speichern", 48 | "Search by name": "Nach Namen suchen", 49 | "Select All": "Alle auswählen", 50 | "Select File": "Datei auswählen", 51 | "Select Files": "Dateien auswählen", 52 | "Size": "Größe", 53 | "Show": "Zeigen", 54 | "Successfully updated": "Erfolgreich aktualisiert", 55 | "Rotate": "Drehen", 56 | "Title": "Titel", 57 | "This file could not be found": "Diese Datei wurde nicht gefunden", 58 | "Type": "Typ", 59 | "Update": "Aktualisieren", 60 | "Upload": "Hochladen", 61 | "Upload From": "Hochgeladen seit", 62 | "Upload To": "Hochgeladen bis", 63 | "Uploaded": "Hochgeladen", 64 | "Url": "URL", 65 | "URL has been copied": "URL wurde kopiert", 66 | "Width x Height": "Breite x Höhe", 67 | 68 | "All Types": "Alle Typen", 69 | "Image": "Bild", 70 | "Docs": "Dokument", 71 | "Audio": "Audio", 72 | "Video": "Video" 73 | } 74 | -------------------------------------------------------------------------------- /config/nova-media-library.php: -------------------------------------------------------------------------------- 1 | env('FILESYSTEM_DRIVER', 'public'), 13 | 14 | /** 15 | * Will use to return base url of media file. 16 | * 17 | * @var string 18 | */ 19 | 20 | 'url' => 's3' == env('FILESYSTEM_DRIVER') ? env('AWS_URL', '') : env('APP_URL', '') . '/storage', 21 | 22 | /** 23 | * Store files `together` or in separate `folders` 24 | * 25 | * @var string 26 | */ 27 | 28 | 'store' => 'together', 29 | 30 | /** 31 | * Default file visibility (only for s3) 32 | * For disk `local` will be `true`, for `public` - `false` 33 | * 34 | * @var boolean 35 | */ 36 | 37 | 'private' => false, 38 | 39 | /** 40 | * Store all files in a separate folder of storage 41 | * 42 | * @var string 43 | */ 44 | 45 | 'folder' => '', 46 | 47 | /** 48 | * Organize uploads into date based folders 49 | * Available date characters: `Y`, `m`, `d` and symbols: `-`, `_`, `/` 50 | * Does not work when parameter `store` != `together` 51 | * 52 | * @example `Y-m`, `Y/m`, `Y-m/d` 53 | * @var string 54 | */ 55 | 56 | 'by_date' => '', 57 | 58 | /** 59 | * This option allow you to filter your files by types and extensions 60 | * Format: Label => ['array', 'of', 'extensions'] 61 | * 62 | * @example ['*'] - allow you to save any file extensions to the specified type 63 | * @var array - not empty! 64 | */ 65 | 66 | 'types' => [ 67 | 'Image' => ['jpg', 'jpeg', 'png', 'gif', 'svg'], 68 | 'Docs' => ['doc', 'xls', 'docx', 'xlsx', 'pdf'], 69 | 'Audio' => ['mp3'], 70 | 'Video' => ['mp4'], 71 | #'Other' => ['*'], 72 | ], 73 | 74 | /** 75 | * Maximum upload size for each type 76 | * Add `Label` => `max_size` in bytes for needed types to enable limitation 77 | * If you want to disable the limitation - leave empty array 78 | * 79 | * @var array 80 | */ 81 | 82 | 'max_size' => [ 83 | 'Image' => 2097152, 84 | 'Docs' => 5242880, 85 | ], 86 | 87 | /** 88 | * The number of files that will be returned with each step 89 | * 90 | * @var integer 91 | */ 92 | 93 | 'step' => 40, 94 | 95 | /** 96 | * Allow duplicate files in field (when use as array) 97 | * 98 | * @var bool 99 | */ 100 | 101 | 'duplicates' => true, 102 | 103 | /** 104 | * Allow you to resize original images by width\height. Using http://image.intervention.io library. 105 | * Width and height can be integer or null. If one of them is null - will resize image proportionally. 106 | * 107 | * @see supports image formats: http://image.intervention.io/getting_started/formats. 108 | * @var array 109 | */ 110 | 111 | 'resize' => [ 112 | 113 | # `gd` or `imagick` 114 | 'driver' => 'gd', 115 | 116 | # 0 - 100 117 | 'quality' => 80, 118 | 119 | # Cropping image on the frontend 120 | 'front_crop' => true, 121 | 122 | # Maximum width and height in pixels for the original image [ width, height, upSize, upWH ] 123 | # upSize {bool} - Crop image even if size will be larger. (If set to `false` - size image will be as original). 124 | # upWH {bool} - Crop even if width and height image less than limits. 125 | 'original' => [ 1200, null, false, false ], 126 | 127 | # Crop additional image variations [ width, height, upSize, upWH ] 128 | 'sizes' => [ 129 | 'thumb' => [ 200, 200, true, false ], 130 | 'medium' => [ 800, null, true, false ], 131 | ], 132 | 133 | # Set `size name` from `sizes` above for preview in admin area or leave `null` 134 | 'preview' => 'thumb' 135 | ] 136 | 137 | ]; 138 | -------------------------------------------------------------------------------- /resources/js/tool/script.js: -------------------------------------------------------------------------------- 1 | import Action from './action' 2 | import Search from './search' 3 | import Items from './items' 4 | import Loader from './loader' 5 | import Popup from './popup' 6 | import Crop from './crop' 7 | 8 | let timeout = null; 9 | let wheel = null; 10 | 11 | export default { 12 | props: { 13 | field: { type: String, default: null }, 14 | isArray: { default: false }, 15 | types: { type: Array, default: [] }, 16 | }, 17 | 18 | components: { 19 | Action, 20 | Search, 21 | Items, 22 | Loader, 23 | Crop, 24 | Popup, 25 | }, 26 | 27 | data() { 28 | let config = window.Nova.config.novaMediaLibrary; 29 | config.display = 'list' === localStorage.getItem('nml-display') ? 'list' : 'gallery'; 30 | return { 31 | config, 32 | 33 | bulk: { 34 | ids: {}, 35 | enable: false 36 | }, 37 | 38 | items: { 39 | array: [], 40 | total: null 41 | }, 42 | 43 | filter: { 44 | title: null, 45 | type: this.types, 46 | from: null, 47 | to: null, 48 | page: 0, 49 | folder: 'folders' === config.store ? '/' : null 50 | }, 51 | oldFilter: {}, 52 | 53 | loading: false, 54 | item: null, 55 | popup: null 56 | } 57 | }, 58 | 59 | methods: { 60 | bulkLen() { 61 | return Object.keys(this.bulk.ids).length 62 | }, 63 | clearData() { 64 | this.items = { array: [], total: null }; 65 | this.filter.page = 0; 66 | }, 67 | get() { 68 | this.loading = true; 69 | Nova.request().post('/nova-vendor/nova-media-library/get', this.filter).then(r => { 70 | this.loading = false; 71 | this.items = { 72 | array: this.items.array.concat(r.data.array), 73 | total: r.data.total 74 | }; 75 | }).catch(e => { 76 | this.loading = false; 77 | window.nmlToastHook(e); 78 | }); 79 | }, 80 | deleteFiles(ids) { 81 | if ( !ids.length || !confirm(this.__('Delete selected files?')) ) return; 82 | this.loading = true; 83 | Nova.request().post('/nova-vendor/nova-media-library/delete', { ids: ids }).then(r => { 84 | this.popup = null; 85 | this.$set(this.bulk, 'ids', {}); 86 | this.clearData(); 87 | this.get(); 88 | this.loading = false; 89 | }).catch(e => { 90 | this.loading = false; 91 | window.nmlToastHook(e); 92 | }); 93 | }, 94 | doSearch() { 95 | if ( JSON.stringify(this.filter) === JSON.stringify(this.oldFilter) ) return; 96 | this.oldFilter = {...this.filter}; 97 | clearTimeout(timeout); 98 | timeout = setTimeout(() => { 99 | this.clearData(); 100 | this.get(); 101 | }, 1000); 102 | }, 103 | loader() { 104 | this.filter.page++; 105 | this.oldFilter.page++; 106 | this.get(); 107 | }, 108 | scroller() { 109 | if ( this.loading || this.items.array.length === this.items.total ) return false; 110 | try { 111 | if ( (window.innerHeight + window.scrollY) >= document.body.offsetHeight ) this.loader(); 112 | } catch (e) {} 113 | }, 114 | }, 115 | created() { 116 | if ( 'onwheel' in document ) wheel = 'wheel'; 117 | if ( 'onmousewheel' in document ) wheel = 'mousewheel'; 118 | this.oldFilter = {...this.filter}; 119 | this.get(); 120 | 121 | if ( !this.field && wheel ) document.addEventListener(wheel, this.scroller); 122 | }, 123 | 124 | beforeDestroy() { 125 | if ( !this.field && wheel ) document.removeEventListener(wheel, this.scroller); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /resources/js/tool/popup/index.vue: -------------------------------------------------------------------------------- 1 | 93 | 94 | 95 | -------------------------------------------------------------------------------- /src/API.php: -------------------------------------------------------------------------------- 1 | writeStream('nml_temp/' . $base, $stream); 30 | fclose($stream); 31 | 32 | $file = new UploadedFile(storage_path('app/nml_temp/' . $base), $base); 33 | if ( !$file ) throw new \Exception(__('The file was not downloaded for unknown reasons'), 0); 34 | 35 | $upload = new Upload($file); 36 | 37 | if ( !$upload->setType() ) throw new \Exception(__('Forbidden file format'), 1); 38 | 39 | $upload->setWH(); 40 | 41 | $upload->setFolder($folder); 42 | 43 | $upload->setPrivate(); 44 | 45 | $upload->setFile(); 46 | 47 | if ( !$upload->checkSize() ) throw new \Exception(__('File size limit exceeded'), 2); 48 | 49 | $item = $upload->save(); 50 | 51 | if ( $item ) { 52 | Crop::createSizes($item); 53 | return $item; 54 | } 55 | 56 | throw new \Exception(__('The file was not downloaded for unknown reasons'), 0); 57 | } finally { 58 | Storage::disk('local')->delete('nml_temp/' . $base); 59 | } 60 | } 61 | 62 | /** 63 | * Returns files by ids 64 | * 65 | * @param int|array $ids - id or array of ids 66 | * @param string|null $imgSize - label from config `media-library.resize.sizes` 67 | * @param bool $object - returns full object of files data from DB (by default returns only urls) 68 | * @return mixed 69 | */ 70 | static function getFiles($ids, $imgSize = null, $object = false) 71 | { 72 | $items = Model::find(is_array($ids) ? $ids : [$ids]); 73 | 74 | if ( !$items ) 75 | return is_array($ids) ? [] : null; 76 | 77 | $array = $items->map(function ($item) use ($imgSize, $object) { 78 | $item = $item->toArray(); 79 | 80 | if ( !$item['url'] and !$object ) return false; 81 | 82 | if ( $imgSize and in_array($imgSize, data_get($item, 'options.img_sizes', [])) ) 83 | $item['url'] = self::getImageSize($item['url'], $imgSize); 84 | 85 | return $object ? (object)$item : $item['url']; 86 | })->reject(function ($value) { 87 | return !$value; 88 | }); 89 | 90 | return is_array($ids) ? $array : ($array[0] ?? 1); 91 | } 92 | 93 | /** 94 | * Generate image url for needed size 95 | * 96 | * @param string $url - image url 97 | * @param string $size - image size from `media-library.resize.sizes` 98 | * @return string 99 | */ 100 | static function getImageSize($url, $size) 101 | { 102 | $name = explode('.', $url); 103 | array_pop($name); 104 | return implode('.', $name) .'-'. $size .'.'. pathinfo($url, PATHINFO_EXTENSION); 105 | } 106 | 107 | /** 108 | * Return file content 109 | * Must be used after checking user access in the controller 110 | * 111 | * @param string $path - data from DB ($item->path) 112 | * @param string|null $size - image size from `media-library.resize.sizes` 113 | * @return mixed 114 | */ 115 | static function getPrivateFile($path, $size = null) 116 | { 117 | try { 118 | if ( $size ) $path = self::getImageSize($path, $size); 119 | $file = Helper::storage()->get($path); 120 | $name = explode('/', $path); 121 | $bytes = Helper::storage()->size($path); 122 | $length = $bytes; 123 | $end = $bytes - 1; 124 | $start = 0; 125 | if (isset($_SERVER['HTTP_RANGE'])) { 126 | $temp = explode('bytes=', $_SERVER['HTTP_RANGE'], 2); 127 | $start = (float)(explode('-', $temp[1], 1))[0]; 128 | $length = $bytes - $start; 129 | } 130 | 131 | return response($file) 132 | ->header('Content-Type', Helper::storage()->mimeType($path)) 133 | ->header('Accept-Ranges', 'bytes') 134 | ->header('Content-Length', $length) 135 | ->header('Content-Range', "bytes $start-$end/$bytes") 136 | ->header('Content-Disposition', 'filename="'. array_pop($name) .'"'); 137 | } catch (\Exception $e) { 138 | return response()->noContent(404); 139 | } 140 | } 141 | 142 | } 143 | -------------------------------------------------------------------------------- /src/Core/Crop.php: -------------------------------------------------------------------------------- 1 | config = config('nova-media-library.resize'); 20 | if ( !$this->config['front_crop'] or !class_exists('\Intervention\Image\ImageManager')) return; 21 | 22 | $this->form = $form; 23 | $this->image = Model::findOrFail($this->form['id'])->toArray(); 24 | } 25 | 26 | function make() 27 | { 28 | $manager = new ImageManager([ 'driver' => $this->config['driver'] ]); 29 | $image = $manager->make( 30 | Helper::storage()->readStream( 31 | Helper::folder($this->image['folder']) . $this->image['name'] 32 | ) 33 | ); 34 | 35 | $image->rotate(-1 * $this->form['rotate']); 36 | $image->crop((int)$this->form['width'], (int)$this->form['height'], (int)$this->form['x'], (int)$this->form['y']); 37 | 38 | $this->file = $image->stream(null, $this->config['quality'])->__toString(); 39 | $this->bytes = strlen($this->file); 40 | $this->image['options']->size = Helper::size($this->bytes); 41 | $this->image['options']->wh = [(int)$this->form['width'], (int)$this->form['height']]; 42 | } 43 | 44 | function save() 45 | { 46 | $this->image['created'] = now(); 47 | 48 | if ( 0 === $this->form['over'] ) { 49 | $ext = explode('.', $this->image['name']); 50 | $name = explode('-', $ext[0]); 51 | array_pop($name); 52 | 53 | unset($this->image['id']); 54 | $this->image['name'] = implode('-', $name) .'-'. time() . Str::random(5) .'.'. $ext[1]; 55 | } 56 | 57 | if ( 58 | Helper::storage()->put( 59 | Helper::folder($this->image['folder'] . $this->image['name']), 60 | $this->file, 61 | Helper::visibility($this->image['private']) 62 | ) 63 | ) { 64 | if ( 1 === $this->form['over'] ) { 65 | $item = Model::find($this->form['id']); 66 | $item->update($this->image); 67 | self::createSizes($item); 68 | return $item; 69 | } else { 70 | $item = Model::create($this->image); 71 | self::createSizes($item); 72 | return $item; 73 | } 74 | } 75 | return false; 76 | } 77 | 78 | ##### Crop additional image sizes ##### 79 | 80 | static function createSizes($item) 81 | { 82 | $config = config('nova-media-library.resize'); 83 | if ( 84 | 'image' != data_get($item, 'options.mime') 85 | or !is_array($config) 86 | or !class_exists('\Intervention\Image\ImageManager') 87 | ) return; 88 | 89 | $sizes = []; 90 | $name = explode('.', $item->name); 91 | $ext = '.'. array_pop($name); 92 | $name = implode('.', $name) .'-'; 93 | 94 | $folder = Helper::folder($item->folder . $item->name); 95 | $file = Helper::storage()->get($folder); 96 | if ( !$file ) return; 97 | 98 | $img_sizes = data_get($item->options, 'img_sizes'); 99 | if ( $img_sizes ) { 100 | data_set($item->options, 'img_sizes', []); 101 | foreach ($img_sizes as $key) 102 | Helper::storage()->delete(API::getImageSize($folder, $key)); 103 | } 104 | 105 | $manager = new \Intervention\Image\ImageManager([ 'driver' => $config['driver'] ]); 106 | 107 | foreach ($config['sizes'] as $size => $data) { 108 | if ( !is_int($data[0]) and !is_int($data[1]) or self::cantResize($item, $data) ) continue; 109 | 110 | try { 111 | $fn = ( $data[0] and $data[1] ) ? 'fit' : 'resize'; 112 | $img = $manager->make($file)->$fn($data[0], $data[1], function ($constraint) use ($data) { 113 | if ( !$data[0] or !$data[1] ) $constraint->aspectRatio(); 114 | if ( $data[2] !== true ) $constraint->upsize(); 115 | })->stream(null, $config['quality'])->__toString(); 116 | 117 | if ( 118 | Helper::storage()->put( 119 | Helper::folder($item->folder . $name . $size . $ext), 120 | $img, 121 | Helper::visibility($item->private) 122 | ) 123 | ) $sizes[] = $size; 124 | } catch (\Exception $e) {} 125 | } 126 | 127 | if ( $sizes ) { 128 | $item->options = data_set($item->options, 'img_sizes', $sizes); 129 | $item->save(); 130 | } 131 | } 132 | 133 | static private function cantResize($item, $data) 134 | { 135 | $width = data_get($item, 'options.wh.0'); 136 | $height = data_get($item, 'options.wh.1'); 137 | 138 | if ( 139 | !is_numeric($width) or !is_numeric($height) or 140 | !$data[3] and 141 | ( !$data[0] or $data[0] > $width) and 142 | ( !$data[1] or $data[1] > $height ) 143 | ) { 144 | return true; 145 | } 146 | return false; 147 | } 148 | 149 | 150 | } 151 | -------------------------------------------------------------------------------- /src/MediaLibrary.php: -------------------------------------------------------------------------------- 1 | private and !$item->url ) { 17 | $item = $item->toArray(); 18 | $item['url'] = route('nml-private-file-admin', [ 'id' => $item['id'] ]); 19 | } 20 | data_set($item, 'preview', Helper::preview($item, $this->preview)); 21 | 22 | return $item; 23 | } 24 | 25 | public function resolve($resource, $attribute = null) { 26 | parent::resolve( $resource, $attribute ); 27 | if ( !$this->value ) return $this->value = null; 28 | 29 | $value = $this->value; 30 | $this->value = null; 31 | $data = Core\Model::find($value); 32 | 33 | if ( is_array($value) ) { 34 | if ( !count($data) ) return $this->value = null; 35 | $data = $data->keyBy('id'); 36 | $this->value = []; 37 | foreach ($value as $i) 38 | if ( isset($data[$i]) ) $this->value[] = $data[$i]; 39 | } else { 40 | if ( !$data ) return $this->value = null; 41 | $this->value = $data; 42 | } 43 | 44 | $this->preview = array_key_exists('nmlPreview', $this->meta) 45 | ? $this->meta['nmlPreview'] : config('nova-media-library.resize.preview'); 46 | 47 | if ( is_array($value) ) { 48 | $this->value = collect($this->value)->map(function ($item) { 49 | return $this->privateUrl($item); 50 | }); 51 | } else if ( $value ) { 52 | $this->value = $this->privateUrl($this->value); 53 | } 54 | 55 | if ( !$this->value ) 56 | $this->value = null; 57 | } 58 | 59 | protected function fillAttributeFromRequest(NovaRequest $request, $requestAttribute, $model, $attribute) 60 | { 61 | if ( !isset($this->meta['nmlTrix']) and !isset($this->meta['nmlJsCallback']) and $request->exists($requestAttribute) ) { 62 | $value = $request[$requestAttribute]; 63 | if ( !$value or 'null' == $value ) $value = null; 64 | if ( isset($this->meta['nmlArray']) ) { 65 | $value = json_decode($request[$requestAttribute], true); 66 | if ( is_array($value) and true != config('nova-media-library.duplicates') ) 67 | $value = array_unique($value); 68 | } 69 | $model->{$attribute} = $value; 70 | } 71 | } 72 | 73 | /** 74 | * Hide image on detail page and show by click on button 75 | * 76 | * @return $this 77 | */ 78 | public function hidden() 79 | { 80 | return $this->withMeta([ 'nmlHidden' => true ]); 81 | } 82 | 83 | /** 84 | * Preview size of images (Label of cropped additional image variation) 85 | * 86 | * @param null|string $size - label from config: resize.sizes 87 | * @return $this 88 | */ 89 | public function preview($size) 90 | { 91 | return $this->withMeta([ 'nmlPreview' => $size ]); 92 | } 93 | 94 | /** 95 | * Contain array of files. Display as list 96 | * Table column must be `TEXT` nullable 97 | * Set casts as `array` in model 98 | * 99 | * @param string $display - display method (gallery or list) 100 | * @return $this 101 | */ 102 | public function array($display = 'auto') 103 | { 104 | if ( !in_array($display, ['gallery', 'list']) ) $display = 'auto'; 105 | return $this->withMeta([ 'nmlArray' => $display ]); 106 | } 107 | 108 | /** 109 | * Limit display by file extension 110 | * 111 | * @param array|string $types 112 | * @return $this 113 | */ 114 | public function types($types) 115 | { 116 | return $this->withMeta([ 117 | 'nmlTypes' => is_array($types) 118 | ? $types : [$types] 119 | ]); 120 | } 121 | 122 | /** 123 | * Snap media field to Trix editor 124 | * To connect media field to trix editor, set here unique name 125 | * and to Trix field add extra attribute `nml-trix` with this name 126 | * 127 | * @param string $name - unique name of Trix field 128 | * @return $this 129 | * 130 | * @example 131 | * MediaLibrary::make('Media Library')->trix('unique_trix_name') 132 | * Trix::make('Content')->withMeta([ 'extraAttributes' => [ 'nml-trix' => 'unique_trix_name' ] ]) 133 | */ 134 | public function trix($name = 'unique_trix_name') 135 | { 136 | return $this->onlyOnForms() 137 | ->withMeta([ 'nmlTrix' => $name ]); 138 | } 139 | 140 | /** 141 | * Use media field with custom callback. 142 | * 143 | * @param string $callback - Name of the callback function in JS 144 | * (First parameter will be array of files, second - options) 145 | * @param mixed $options - add custom options 146 | * @return $this 147 | * 148 | * @example 149 | * MediaLibrary::make('Media Library')->jsCallback('callback', [ 'example' => true ]) 150 | * // In any JS file create function: 151 | * window.callbackName = (array, options) => { ... } 152 | */ 153 | public function jsCallback($callback, $options = []) 154 | { 155 | return $this->onlyOnForms() 156 | ->withMeta([ 'nmlJsCallback' => [$callback, $options] ]); 157 | } 158 | 159 | } 160 | -------------------------------------------------------------------------------- /src/Http/Controllers/Tool.php: -------------------------------------------------------------------------------- 1 | search(); 29 | $data['array'] = collect($data['array'])->map(function ($item) use ($preview) { 30 | if ( !$item->url ) { 31 | $item = $item->toArray(); 32 | $item['url'] = route('nml-private-file-admin', [ 'id' => $item['id'] ]); 33 | } 34 | $item['preview'] = Helper::preview($item, $preview); 35 | 36 | return $item; 37 | }); 38 | 39 | return $data; 40 | } 41 | 42 | function private() 43 | { 44 | $item = Model::find(request('id')); 45 | $size = request('img_size'); 46 | 47 | if ( !$item or !$item->path ) 48 | return response()->noContent(404); 49 | 50 | if ( !in_array($size, data_get($item, 'options.img_sizes', [])) ) $size = null; 51 | 52 | return API::getPrivateFile($item->path, $size); 53 | } 54 | 55 | function upload(UploadFr $fr) 56 | { 57 | $file = request()->file('file'); 58 | $file_name = " ({$file->getClientOriginalName()})"; 59 | 60 | $upload = new Upload($file); 61 | 62 | if ( !$upload->setType() ) 63 | abort(422, __('Forbidden file format')); 64 | 65 | $upload->setWH(); 66 | 67 | $upload->setFolder(request('folder')); 68 | 69 | $upload->setPrivate(); 70 | 71 | $upload->setFile(); 72 | 73 | if ( !$upload->checkSize() ) 74 | abort(422, __('File size limit exceeded') . $file_name); 75 | 76 | $item = $upload->save(); 77 | if ( $item ) { 78 | Crop::createSizes($item); 79 | if ( $upload->noResize ) { 80 | abort(200, __('Unsupported image type for resizing, only the original is uploaded') . $file_name); 81 | } 82 | return; 83 | } 84 | 85 | abort(422, __('The file was not downloaded for unknown reasons') . $file_name); 86 | } 87 | 88 | function delete(DeleteFr $fr) 89 | { 90 | $get = Model::find(request('ids')); 91 | $delete = Model::whereIn('id', request('ids'))->delete(); 92 | 93 | if ( count($get) > 0 ) { 94 | $array = []; 95 | foreach ($get as $key) { 96 | $sizes = data_get($key, 'options.img_sizes', []); 97 | $array[] = Helper::folder($key->folder) . $key->name; 98 | 99 | if ( $sizes ) { 100 | foreach ($sizes as $size) { 101 | $name = explode('.', $key->name); 102 | $array[] = Helper::folder($key->folder . implode('-'. $size .'.', $name)); 103 | } 104 | } 105 | } 106 | 107 | Helper::storage()->delete($array); 108 | } 109 | 110 | return [ 'status' => !!$delete ]; 111 | } 112 | 113 | function update(UpdateFr $fr) 114 | { 115 | $item = Model::find(request('id')); 116 | if ( !$item ) abort(422, __('Invalid id')); 117 | 118 | $item->title = request('title'); 119 | $img_sizes = data_get($item->options, 'img_sizes', []); 120 | 121 | if ( request()->has('private') and 's3' === config('nova-media-library.disk') ) { 122 | $item->private = (boolean)request('private'); 123 | $visibility = Helper::visibility($item->private); 124 | 125 | Helper::storage()->setVisibility($item->path, $visibility); 126 | foreach ($img_sizes as $key) 127 | Helper::storage()->setVisibility(API::getImageSize($item->path, $key), $visibility); 128 | } 129 | 130 | $folder = request('folder'); 131 | if ( $folder and 'folders' === config('nova-media-library.store') and $folder !== $item->folder ) { 132 | $private = Helper::isPrivate($folder); 133 | $array = [ [$item->path, Helper::folder($folder . $item->name)] ]; 134 | 135 | foreach ($img_sizes as $key) { 136 | $name = API::getImageSize($item->name, $key); 137 | $array[] = [Helper::folder($item->folder . $name), Helper::folder($folder . $name)]; 138 | } 139 | 140 | foreach ($array as $key) { 141 | Helper::storage()->move($key[0], $key[1]); 142 | if ( $private != $item->private ) 143 | Helper::storage()->setVisibility($key[1], Helper::visibility($private)); 144 | } 145 | 146 | $item->private = $private; 147 | $item->folder = Helper::replace('/'. $folder .'/'); 148 | $item->lp = Helper::localPublic($item->folder, $private); 149 | } 150 | 151 | $item->save(); 152 | 153 | return $item; 154 | } 155 | 156 | function crop(CropFr $fr) 157 | { 158 | $crop = new Crop(request()->toArray()); 159 | if ( !$crop->form ) 160 | abort(422, __('Crop module disabled')); 161 | 162 | if ( !$crop->image ) 163 | abort(422, __('Invalid request data')); 164 | 165 | $crop->make(); 166 | 167 | if ( $crop->save() ) return; 168 | 169 | abort(422, __('The file was not downloaded for unknown reasons')); 170 | } 171 | 172 | function folderNew(FolderNewFr $fr) 173 | { 174 | if ( Helper::storage()->makeDirectory(Helper::folder(request('base') . request('folder') .'/')) ) { 175 | return [ 'folders' => Helper::directories() ]; 176 | } 177 | 178 | abort(422, __('Cannot manage folders')); 179 | } 180 | 181 | function folderDel(FolderDelFr $fr) 182 | { 183 | if ( Helper::storage()->deleteDirectory(Helper::folder(request('folder'))) ) { 184 | return [ 'folders' => Helper::directories() ]; 185 | } 186 | 187 | abort(422, __('Cannot manage folders')); 188 | } 189 | 190 | function folders() 191 | { 192 | return Helper::directories(); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/Core/Upload.php: -------------------------------------------------------------------------------- 1 | config = config('nova-media-library'); 29 | $this->file = $file; 30 | $this->extension = strtolower($file->getClientOriginalExtension()); 31 | 32 | $this->title = data_get(pathinfo($file->getClientOriginalName()), 'filename', Str::random()); 33 | $this->name = Str::slug($this->title) .'-'. time() . Str::random(5) .'.'. $this->extension; 34 | $this->options['mime'] = explode('/', $file->getMimeType())[0]; 35 | } 36 | 37 | function setType() 38 | { 39 | $types = config('nova-media-library.types'); 40 | if ( !is_array($types) ) return false; 41 | 42 | foreach ($types as $label => $array) { 43 | if ( in_array($this->extension, $array) or in_array('*', $array) ) { 44 | $this->type = $label; 45 | return $label; 46 | break; 47 | } 48 | } 49 | 50 | return false; 51 | } 52 | 53 | function setWH() 54 | { 55 | if ($this->options['mime'] == 'image') { 56 | [$width, $height] = getimagesize($this->file); 57 | if ($width and $height) { 58 | $this->options['wh'] = [$width, $height]; 59 | } 60 | } 61 | } 62 | 63 | function setFolder($folder = null) 64 | { 65 | if ( 'folders' != config('nova-media-library.store') ) 66 | $this->folder = $this->date(); 67 | elseif ( is_string($folder) ) 68 | $this->folder = Helper::replace('/'. $folder .'/'); 69 | else 70 | $this->folder = '/'; 71 | } 72 | 73 | function setPrivate() 74 | { 75 | $this->private = Helper::isPrivate($this->folder); 76 | $this->lp = Helper::localPublic($this->folder, $this->private); 77 | } 78 | 79 | function setFile() 80 | { 81 | $this->resize['width'] = data_get($this->config, 'resize.original.0'); 82 | $this->resize['height'] = data_get($this->config, 'resize.original.1'); 83 | $this->resize['upSize'] = data_get($this->config, 'resize.original.2'); 84 | $this->resize['upWH'] = data_get($this->config, 'resize.original.3'); 85 | if ( !is_int($this->resize['width']) ) $this->resize['width'] = null; 86 | if ( !is_int($this->resize['height']) ) $this->resize['height'] = null; 87 | 88 | 89 | if ( 90 | 'image' == $this->options['mime'] and 91 | ( $this->resize['width'] or $this->resize['height'] ) and 92 | class_exists('\Intervention\Image\ImageManager') 93 | ) { 94 | $this->byResize(); 95 | } else { 96 | $this->byDefault(); 97 | } 98 | } 99 | 100 | function checkSize() 101 | { 102 | $size = data_get($this->config, 'max_size.'.$this->type); 103 | if ( $size and $size < $this->bytes ) return false; 104 | 105 | $this->options['size'] = Helper::size($this->bytes); 106 | return true; 107 | } 108 | 109 | function save() 110 | { 111 | if ( 112 | Helper::storage()->put( 113 | Helper::folder($this->folder . $this->name), 114 | $this->stream, 115 | Helper::visibility($this->private) 116 | ) 117 | ) { 118 | return Model::create([ 119 | 'title' => $this->title, 120 | 'created' => now(), 121 | 'type' => $this->type, 122 | 'folder' => $this->folder, 123 | 'name' => $this->name, 124 | 'private' => $this->private, 125 | 'lp' => $this->lp, 126 | 'options' => $this->options 127 | ]); 128 | } 129 | return false; 130 | } 131 | 132 | ##### Set File ##### 133 | 134 | private function byDefault() 135 | { 136 | $this->bytes = $this->file->getSize(); 137 | $this->stream = Utils::streamFor(fopen($this->file, 'r+')); 138 | } 139 | 140 | private function byResize() 141 | { 142 | try { 143 | [$width, $height] = getimagesize($this->file); 144 | if ( 145 | !is_numeric($width) or !is_numeric($height) or 146 | !$this->resize['upWH'] and 147 | ( !$this->resize['width'] or $this->resize['width'] > $width) and 148 | ( !$this->resize['height'] or $this->resize['height'] > $height ) 149 | ) { 150 | return $this->noResize(false); 151 | } 152 | } catch (\Exception $e) { 153 | return $this->noResize(); 154 | } 155 | 156 | try { 157 | $manager = new \Intervention\Image\ImageManager([ 'driver' => data_get($this->config, 'resize.driver') ]); 158 | $image = $manager->make($this->file); 159 | 160 | $data = $image->resize($this->resize['width'], $this->resize['height'], function ($constraint) { 161 | if ( !$this->resize['width'] or !$this->resize['height'] ) $constraint->aspectRatio(); 162 | if ( true !== $this->resize['upSize'] ) $constraint->upsize(); 163 | }); 164 | 165 | $stream = $data->stream(null, data_get($this->config, 'resize.quality')); 166 | 167 | $this->bytes = $data->filesize(); 168 | $this->stream = $stream; 169 | } catch (\Exception $e) { 170 | $this->noResize(); 171 | } 172 | } 173 | 174 | private function noResize($bool = true) 175 | { 176 | $this->noResize = $bool; 177 | $this->byDefault(); 178 | return null; 179 | } 180 | 181 | private function date() 182 | { 183 | $folder = '/'; 184 | $by_date = config('nova-media-library.by_date'); 185 | 186 | if ( $by_date ) { 187 | $date = preg_replace('/[^Ymd_\-\/]/', '', $by_date); 188 | $folder .= date($date) .'/'; 189 | } 190 | 191 | return Helper::replace($folder); 192 | } 193 | 194 | } 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Nova Media Library 2 | 3 | Tool and Field for [Laravel Nova](https://nova.laravel.com) that will let you managing files and add them to the posts. 4 | 5 | ##### Table of Contents 6 | * [Features](#features) 7 | * [Migration from 0.x to 1.x](#migration-from-0x-to-1x) 8 | * [Requirements](#requirements) 9 | * [Install](#install) 10 | * [Configuration](#configuration) 11 | * [Usage](#usage) 12 | * [Customization](#customization) 13 | * [Upload by url or path](#upload-by-url-or-path) 14 | * [Get files by ids](#get-files-by-ids) 15 | * [Private files](#private-files) 16 | * [Localization](#localization) 17 | * [Screenshots](#screenshots) 18 | 19 | ### Features 20 | 21 | - [x] Store and manage your media files 22 | - [x] Use field for single file 23 | - [x] Use field for array of files 24 | - [x] Upload files by url/path 25 | - [x] Integrate Media Field with Trix editor 26 | - [x] Implement custom JS callback for field 27 | - [x] Automatic resize image on the backend by width\height 28 | - [x] Cropping image on the frontend 29 | - [x] Ability to create image size variations 30 | - [x] Organize files in single folder, separate by date or by manageable folders 31 | - [x] Ability to control files visibility 32 | 33 | ### Migration from 0.x to 1.x 34 | 35 | In version 1.x, the configuration file and database migration have been changed. 36 | After upgrading to version 1.x, you need to remove the old table from the database, then reinstall and reconfigure these files. 37 | 38 | ### Requirements 39 | 40 | - Laravel 5.8+ 41 | - Nova 2 42 | - [intervention/image](http://image.intervention.io) package for image resizing (optional) 43 | 44 | ### Install 45 | 46 | ``` 47 | composer require classic-o/nova-media-library 48 | 49 | php artisan vendor:publish --provider="ClassicO\NovaMediaLibrary\ToolServiceProvider" 50 | 51 | php artisan migrate 52 | 53 | php artisan storage:link 54 | ``` 55 | 56 | ### Configuration 57 | 58 | [See configuration file](https://github.com/classic-o/nova-media-library/blob/master/config/nova-media-library.php) 59 | 60 | ### Usage 61 | 62 | Add tool in app/Providers/NovaServiceProvider.php 63 | 64 | ``` 65 | public function tools() 66 | { 67 | return [ 68 | new \ClassicO\NovaMediaLibrary\NovaMediaLibrary() 69 | ]; 70 | } 71 | ``` 72 | 73 | Add Field to the resource. 74 | 75 | ``` 76 | use ClassicO\NovaMediaLibrary\MediaLibrary; 77 | 78 | class Post extends Resource 79 | { 80 | ... 81 | public function fields(Request $request) 82 | { 83 | return [ 84 | ... 85 | MediaLibrary::make('Image'), 86 | ... 87 | ]; 88 | } 89 | ... 90 | } 91 | ``` 92 | 93 | ### Customization 94 | 95 | By default, this field is used as single file. If you need to use as array of files, add option: 96 | 97 | ``` 98 | # Display 99 | MediaLibrary::make('Gallery') 100 | ->array(), 101 | ``` 102 | 103 | By default this files display automatically, `gallery` or `list` as in tool. 104 | You can set in first parameter needed display type: 105 | 106 | ``` 107 | MediaLibrary::make('Documents') 108 | ->array('list'), 109 | ``` 110 | 111 | _When you use array, set the casts as array to needed column in model and set type `nullable TEXT` in database_ 112 | _For single file - `nullable INT`_ 113 | 114 | If you want to hide files under the accordion, add the following option: 115 | ``` 116 | MediaLibrary::make('Gallery') 117 | ->array() 118 | ->hidden() 119 | ``` 120 | 121 | You can limit the selection of files by type (Labels of types from configuration file). 122 | 123 | ``` 124 | MediaLibrary::make('File') 125 | ->types(['Audio', 'Video']) 126 | ``` 127 | 128 | To set preview size of images in fields, add the following option (Label of cropped additional image variation) 129 | By default, the preview size is set in the configuration file. 130 | 131 | ``` 132 | MediaLibrary::make('File') 133 | ->preview('thumb') 134 | ``` 135 | 136 | You can also integrate the Media Field with the Trix editor. 137 | You need to set a unique name in the `trix` option and add an additional attribute with the same name in the Trix field: 138 | 139 | ``` 140 | MediaLibrary::make('For Trix') 141 | ->trix('unique_trix_name'), 142 | 143 | Trix::make('Content') 144 | ->withMeta([ 'extraAttributes' => [ 'nml-trix' => 'unique_trix_name' ] ]) 145 | ``` 146 | 147 | For set a custom callback for the Media Field, use the method `jsCallback`. 148 | - The first parameter set as the name of the JS function callback. 149 | - The second (optional) is an array of advanced options 150 | 151 | ``` 152 | MediaLibrary::make('JS Callback') 153 | ->jsCallback('callbackName', [ 'example' => 'Nova' ]), 154 | ``` 155 | 156 | Your JavaScript callback should have 2 parameters. The first will be an array of files, second - your options. 157 | 158 | ``` 159 | window.callbackName = function (array, options) { 160 | console.log(array, options); 161 | } 162 | ``` 163 | 164 | _When you use JS Callback or Trix option two or more times on one resource, set second parameter of make method to any unique name_ 165 | 166 | ``` 167 | MediaLibrary::make('JS Callback', 'js_cb_name_1') 168 | ->jsCallback('callbackName', [ 'example' => 'Nova' ]), 169 | MediaLibrary::make('Trix Field', 'trix_name_1') 170 | ->trix('unique_trix_name'), 171 | ``` 172 | 173 | ### Upload by url or path 174 | 175 | Also you can programmatically add files to the media library by url or path. 176 | 177 | ``` 178 | use \ClassicO\NovaMediaLibrary\API; 179 | 180 | $result = API::upload('https://pay.google.com/about/static/images/social/og_image.jpg'); 181 | ``` 182 | 183 | If upload done successfully, function return instance of model. 184 | If an error occurred while loading, function will throw exception. 185 | 186 | Exceptions (`code => text`): 187 | `0` - `The file was not downloaded for unknown reasons` 188 | `1` - `Forbidden file format` 189 | `2` - `File size limit exceeded` 190 | 191 | ### Get files by ids 192 | 193 | In your model stores only id of file(s). To get files, use the API class method: 194 | 195 | ``` 196 | $files = API::getFiles($ids, $imgSize = null, $object = false); 197 | 198 | # First parameter - id or array of ids 199 | # Second - if you want to get images by size variation, write label of size 200 | # Third - by default function return array of urls. If you want to get full data of files - set true (returns object / array of objects) 201 | ``` 202 | 203 | ### Private files 204 | 205 | When you use local storage or s3 (with private visibility), you can't get files by url. 206 | To get file, you need to create a GET Route with the name `nml-private-file` with parameter `id` and optional `img_size`. In controller add validation user access. 207 | If access is allowed, you can get file by API method: 208 | 209 | ``` 210 | ... Verifying access 211 | 212 | $file = API::getFiles($id, null, true); 213 | return API::getPrivateFile($file->path, request('img_size')) 214 | ``` 215 | 216 | ### Localization 217 | 218 | To translate this tool another language, you need to add the translation file `/resources/lang/vendor/nova-media-library/{lang}.json` by adding phrases from [en.json](https://github.com/classic-o/nova-media-library/tree/master/resources/lang/en.json) 219 | 220 | ### Screenshots 221 | 222 | ![Media Library](https://raw.githubusercontent.com/classic-o/nova-media-library/master/docs/screenshot_1.png) 223 | 224 | ![Media Library](https://raw.githubusercontent.com/classic-o/nova-media-library/master/docs/screenshot_2.png) 225 | 226 | ![Details](https://raw.githubusercontent.com/classic-o/nova-media-library/master/docs/screenshot_3.png) 227 | 228 | ![Crop Image](https://raw.githubusercontent.com/classic-o/nova-media-library/master/docs/screenshot_4.png) 229 | 230 | ![Index Field](https://raw.githubusercontent.com/classic-o/nova-media-library/master/docs/screenshot_5.png) 231 | 232 | ![Form Field](https://raw.githubusercontent.com/classic-o/nova-media-library/master/docs/screenshot_6.png) 233 | 234 | ![Record](https://raw.githubusercontent.com/classic-o/nova-media-library/master/docs/record.gif) 235 | -------------------------------------------------------------------------------- /resources/sass/tool.sass: -------------------------------------------------------------------------------- 1 | @import "~cropperjs/dist/cropper.min.css" 2 | 3 | 4 | #nml-tool 5 | .flatpickr-input 6 | width: 120px 7 | .loader > div > div 8 | height: 100vh 9 | .popup .bg-white 10 | position: relative 11 | width: 600px 12 | #nml_cropper 13 | .bg-white 14 | max-width: 1000px 15 | .cropper-point 16 | background: #fff 17 | .cropper-view-box 18 | outline: 1px dashed #fff 19 | .cropper-line 20 | display: none 21 | 22 | 23 | .nml-close 24 | font-size: 32px 25 | font-weight: 100 26 | height: 50px 27 | line-height: 1 28 | outline: none 29 | position: absolute 30 | right: 0 31 | top: 0 32 | width: 50px 33 | z-index: 2 34 | 35 | 36 | .nml-display 37 | width: 36px 38 | svg 39 | display: block 40 | height: 36px 41 | &-gallery 42 | margin-left: -4px 43 | margin-right: -4px 44 | .nml-item 45 | padding-left: 4px 46 | padding-right: 4px 47 | width: 158px 48 | &.checked .icon 49 | box-shadow: 0 0 0 3px var(--primary-70) 50 | .icon 51 | background-size: 36px 36px 52 | box-shadow: 0 4px 8px 0 rgba(#000, .12), 0 2px 4px 0 rgba(#000, .08) 53 | padding-bottom: 100% 54 | .title 55 | background: rgba(#000, .6) 56 | border-radius: 0 0 .5rem .5rem 57 | bottom: 0 58 | color: #fff 59 | font-size: 12px 60 | left: 4px 61 | padding: 2px 8px 62 | position: absolute 63 | text-align: center 64 | width: calc(100% - 8px) 65 | [class*="nml-icon-folder"] + .title 66 | background: transparent 67 | color: #434d5d 68 | 69 | &-list 70 | .nml-item 71 | background: #fff 72 | box-shadow: 0 4px 8px 0 rgba(#000, .06), 0 2px 4px 0 rgba(#000, .04) 73 | border-radius: .5rem 74 | flex-direction: row 75 | display: flex 76 | height: 40px 77 | width: 100% 78 | &.checked 79 | box-shadow: 0 0 0 3px var(--primary-70) 80 | .icon 81 | background-size: 24px 24px 82 | box-shadow: 1px 0 4px 0 rgba(#333, .05) 83 | height: 40px 84 | width: 50px 85 | .title 86 | color: var(--black) 87 | line-height: 40px 88 | margin-left: 10px 89 | max-width: calc(100% - 70px) 90 | .nml-folder-action 91 | margin-right: 10px 92 | width: auto 93 | .title 94 | display: none 95 | 96 | 97 | .nml-field-index .icon 98 | background-size: 24px 24px 99 | border-radius: 6px 100 | height: 40px 101 | width: 40px 102 | .count 103 | background: var(--primary-dark) 104 | border-radius: 4px 105 | color: var(--white) 106 | font-size: 12px 107 | height: 20px 108 | line-height: 20px 109 | margin: -3px -5px 110 | min-width: 20px 111 | padding: 0 4px 112 | position: absolute 113 | right: -5px 114 | text-align: center 115 | top: -5px 116 | 117 | .nml-field-form 118 | .popup 119 | .z-30 120 | max-width: 1280px 121 | position: relative 122 | width: 90% 123 | .delete 124 | background: var(--danger) 125 | border-radius: 8px 126 | cursor: pointer 127 | fill: var(--white) 128 | height: 30px 129 | padding: 5px 130 | position: absolute 131 | right: 9px 132 | top: 5px 133 | transition: .3s 134 | transform: scale(0) 135 | width: 30px 136 | .nml-item:hover .delete 137 | transform: scale(1) 138 | z-index: 2 139 | .nml-display-list .delete 140 | right: 5px 141 | 142 | 143 | [nml-trix] .attachment__metadata-container 144 | display: none 145 | 146 | 147 | .nml-item .icon 148 | background-color: #fff 149 | background-position: center 150 | background-repeat: no-repeat 151 | .nml-icon-image 152 | background-size: cover !important 153 | .nml-icon-file 154 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%233c4b5f' d='M67.508 468.467c-58.005-58.013-58.016-151.92 0-209.943l225.011-225.04c44.643-44.645 117.279-44.645 161.92 0 44.743 44.749 44.753 117.186 0 161.944l-189.465 189.49c-31.41 31.413-82.518 31.412-113.926.001-31.479-31.482-31.49-82.453 0-113.944L311.51 110.491c4.687-4.687 12.286-4.687 16.972 0l16.967 16.971c4.685 4.686 4.685 12.283 0 16.969L184.983 304.917c-12.724 12.724-12.73 33.328 0 46.058 12.696 12.697 33.356 12.699 46.054-.001l189.465-189.489c25.987-25.989 25.994-68.06.001-94.056-25.931-25.934-68.119-25.932-94.049 0l-225.01 225.039c-39.249 39.252-39.258 102.795-.001 142.057 39.285 39.29 102.885 39.287 142.162-.028A739446.174 739446.174 0 0 1 439.497 238.49c4.686-4.687 12.282-4.684 16.969.004l16.967 16.971c4.685 4.686 4.689 12.279.004 16.965a755654.128 755654.128 0 0 0-195.881 195.996c-58.034 58.092-152.004 58.093-210.048.041z'%3E%3C/path%3E%3C/svg%3E") 155 | .nml-icon-audio 156 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%233c4b5f' d='M470.38 1.51L150.41 96A32 32 0 0 0 128 126.51v261.41A139 139 0 0 0 96 384c-53 0-96 28.66-96 64s43 64 96 64 96-28.66 96-64V214.32l256-75v184.61a138.4 138.4 0 0 0-32-3.93c-53 0-96 28.66-96 64s43 64 96 64 96-28.65 96-64V32a32 32 0 0 0-41.62-30.49z'%3E%3C/path%3E%3C/svg%3E") 157 | .nml-icon-video 158 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 576 512'%3E%3Cpath fill='%233c4b5f' d='M336.2 64H47.8C21.4 64 0 85.4 0 111.8v288.4C0 426.6 21.4 448 47.8 448h288.4c26.4 0 47.8-21.4 47.8-47.8V111.8c0-26.4-21.4-47.8-47.8-47.8zm189.4 37.7L416 177.3v157.4l109.6 75.5c21.2 14.6 50.4-.3 50.4-25.8V127.5c0-25.4-29.1-40.4-50.4-25.8z'%3E%3C/path%3E%3C/svg%3E") 159 | .nml-icon-folder 160 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 576 512'%3E%3Cpath fill='%233c4b5f' d='M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z'%3E%3C/path%3E%3C/svg%3E") 161 | .nml-icon-folder-create 162 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%233c4b5f' d='M464 128H272l-64-64H48C21.49 64 0 85.49 0 112v288c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V176c0-26.51-21.49-48-48-48zm-96 168c0 8.84-7.16 16-16 16h-72v72c0 8.84-7.16 16-16 16h-16c-8.84 0-16-7.16-16-16v-72h-72c-8.84 0-16-7.16-16-16v-16c0-8.84 7.16-16 16-16h72v-72c0-8.84 7.16-16 16-16h16c8.84 0 16 7.16 16 16v72h72c8.84 0 16 7.16 16 16v16z'%3E%3C/path%3E%3C/svg%3E") 163 | .nml-icon-folder-back 164 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%233c4b5f' d='M328 256c0 39.8-32.2 72-72 72s-72-32.2-72-72 32.2-72 72-72 72 32.2 72 72zm104-72c-39.8 0-72 32.2-72 72s32.2 72 72 72 72-32.2 72-72-32.2-72-72-72zm-352 0c-39.8 0-72 32.2-72 72s32.2 72 72 72 72-32.2 72-72-32.2-72-72-72z'%3E%3C/path%3E%3C/svg%3E") 165 | .nml-icon-folder-remove 166 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%233c4b5f' d='M464 128H272l-64-64H48C21.49 64 0 85.49 0 112v288c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V176c0-26.51-21.49-48-48-48zM340.85 338.91c6.25 6.25 6.25 16.38 0 22.63l-11.31 11.31c-6.25 6.25-16.38 6.25-22.63 0L256 321.94l-50.91 50.91c-6.25 6.25-16.38 6.25-22.63 0l-11.31-11.31c-6.25-6.25-6.25-16.38 0-22.63L222.06 288l-50.91-50.91c-6.25-6.25-6.25-16.38 0-22.63l11.31-11.31c6.25-6.25 16.38-6.25 22.63 0L256 254.06l50.91-50.91c6.25-6.25 16.38-6.25 22.63 0l11.31 11.31c6.25 6.25 6.25 16.38 0 22.63L289.94 288l50.91 50.91z'%3E%3C/path%3E%3C/svg%3E") 167 | /*.nml-display-list, .nml-field-index 168 | .nml-icon-image 169 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%233c4b5f' d='M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z'%3E%3C/path%3E%3C/svg%3E") !important 170 | background-size: 24px 24px !important*/ 171 | 172 | body.overflow-hidden #nova 173 | &, #nml-tool .popup 174 | overflow-y: scroll 175 | 176 | @media (max-width: 1024px) 177 | #nml-tool 178 | .flatpickr-input 179 | margin-right: 1rem 180 | > .flex.select-none > * 181 | margin-bottom: 1rem 182 | 183 | @media (max-width: 767px) 184 | .nml-display-gallery .nml-item 185 | width: 25% 186 | .nml-field-form .nml-item a.pin 187 | display: none 188 | 189 | @media (max-width: 520px) 190 | .nml-display-gallery .nml-item 191 | width: 50% 192 | #nml-tool > .flex.select-none > .max-w-full 193 | margin: 0 0 1rem 194 | width: 100% 195 | -------------------------------------------------------------------------------- /dist/css/tool.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Cropper.js v1.5.7 3 | * https://fengyuanchen.github.io/cropperjs 4 | * 5 | * Copyright 2015-present Chen Fengyuan 6 | * Released under the MIT license 7 | * 8 | * Date: 2020-05-23T05:22:57.283Z 9 | */.cropper-container{direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}#nml-tool .flatpickr-input{width:120px}#nml-tool .loader>div>div{height:100vh}#nml-tool .popup .bg-white{position:relative;width:600px}#nml-tool #nml_cropper .bg-white{max-width:1000px}#nml-tool #nml_cropper .cropper-point{background:#fff}#nml-tool #nml_cropper .cropper-view-box{outline:1px dashed #fff}#nml-tool #nml_cropper .cropper-line{display:none}.nml-close{font-size:32px;font-weight:100;height:50px;line-height:1;outline:none;position:absolute;right:0;top:0;width:50px;z-index:2}.nml-display{width:36px}.nml-display svg{display:block;height:36px}.nml-display-gallery{margin-left:-4px;margin-right:-4px}.nml-display-gallery .nml-item{padding-left:4px;padding-right:4px;width:158px}.nml-display-gallery .nml-item.checked .icon{-webkit-box-shadow:0 0 0 3px var(--primary-70);box-shadow:0 0 0 3px var(--primary-70)}.nml-display-gallery .icon{background-size:36px 36px;-webkit-box-shadow:0 4px 8px 0 rgba(0,0,0,.12),0 2px 4px 0 rgba(0,0,0,.08);box-shadow:0 4px 8px 0 rgba(0,0,0,.12),0 2px 4px 0 rgba(0,0,0,.08);padding-bottom:100%}.nml-display-gallery .title{background:rgba(0,0,0,.6);border-radius:0 0 .5rem .5rem;bottom:0;color:#fff;font-size:12px;left:4px;padding:2px 8px;position:absolute;text-align:center;width:calc(100% - 8px)}.nml-display-gallery [class*=nml-icon-folder]+.title{background:transparent;color:#434d5d}.nml-display-list .nml-item{background:#fff;-webkit-box-shadow:0 4px 8px 0 rgba(0,0,0,.06),0 2px 4px 0 rgba(0,0,0,.04);box-shadow:0 4px 8px 0 rgba(0,0,0,.06),0 2px 4px 0 rgba(0,0,0,.04);border-radius:.5rem;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;display:-webkit-box;display:-ms-flexbox;display:flex;height:40px;width:100%}.nml-display-list .nml-item.checked{-webkit-box-shadow:0 0 0 3px var(--primary-70);box-shadow:0 0 0 3px var(--primary-70)}.nml-display-list .icon{background-size:24px 24px;-webkit-box-shadow:1px 0 4px 0 rgba(51,51,51,.05);box-shadow:1px 0 4px 0 rgba(51,51,51,.05);height:40px;width:50px}.nml-display-list .title{color:var(--black);line-height:40px;margin-left:10px;max-width:calc(100% - 70px)}.nml-display-list .nml-folder-action{margin-right:10px;width:auto}.nml-display-list .nml-folder-action .title{display:none}.nml-field-index .icon{background-size:24px 24px;border-radius:6px;height:40px;width:40px}.nml-field-index .icon .count{background:var(--primary-dark);border-radius:4px;color:var(--white);font-size:12px;height:20px;line-height:20px;margin:-3px -5px;min-width:20px;padding:0 4px;position:absolute;right:-5px;text-align:center;top:-5px}.nml-field-form .popup .z-30{max-width:1280px;position:relative;width:90%}.nml-field-form .delete{background:var(--danger);border-radius:8px;cursor:pointer;fill:var(--white);height:30px;padding:5px;position:absolute;right:9px;top:5px;-webkit-transition:.3s;transition:.3s;-webkit-transform:scale(0);transform:scale(0);width:30px}.nml-field-form .nml-item:hover .delete{-webkit-transform:scale(1);transform:scale(1);z-index:2}.nml-field-form .nml-display-list .delete{right:5px}[nml-trix] .attachment__metadata-container{display:none}.nml-item .icon{background-color:#fff;background-position:50%;background-repeat:no-repeat}.nml-icon-image{background-size:cover!important}.nml-icon-file{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%233c4b5f' d='M67.508 468.467c-58.005-58.013-58.016-151.92 0-209.943l225.011-225.04c44.643-44.645 117.279-44.645 161.92 0 44.743 44.749 44.753 117.186 0 161.944l-189.465 189.49c-31.41 31.413-82.518 31.412-113.926.001-31.479-31.482-31.49-82.453 0-113.944L311.51 110.491c4.687-4.687 12.286-4.687 16.972 0l16.967 16.971c4.685 4.686 4.685 12.283 0 16.969L184.983 304.917c-12.724 12.724-12.73 33.328 0 46.058 12.696 12.697 33.356 12.699 46.054-.001l189.465-189.489c25.987-25.989 25.994-68.06.001-94.056-25.931-25.934-68.119-25.932-94.049 0l-225.01 225.039c-39.249 39.252-39.258 102.795-.001 142.057 39.285 39.29 102.885 39.287 142.162-.028A739446.174 739446.174 0 0 1 439.497 238.49c4.686-4.687 12.282-4.684 16.969.004l16.967 16.971c4.685 4.686 4.689 12.279.004 16.965a755654.128 755654.128 0 0 0-195.881 195.996c-58.034 58.092-152.004 58.093-210.048.041z'/%3E%3C/svg%3E")}.nml-icon-audio{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%233c4b5f' d='M470.38 1.51L150.41 96A32 32 0 0 0 128 126.51v261.41A139 139 0 0 0 96 384c-53 0-96 28.66-96 64s43 64 96 64 96-28.66 96-64V214.32l256-75v184.61a138.4 138.4 0 0 0-32-3.93c-53 0-96 28.66-96 64s43 64 96 64 96-28.65 96-64V32a32 32 0 0 0-41.62-30.49z'/%3E%3C/svg%3E")}.nml-icon-video{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 576 512'%3E%3Cpath fill='%233c4b5f' d='M336.2 64H47.8C21.4 64 0 85.4 0 111.8v288.4C0 426.6 21.4 448 47.8 448h288.4c26.4 0 47.8-21.4 47.8-47.8V111.8c0-26.4-21.4-47.8-47.8-47.8zm189.4 37.7L416 177.3v157.4l109.6 75.5c21.2 14.6 50.4-.3 50.4-25.8V127.5c0-25.4-29.1-40.4-50.4-25.8z'/%3E%3C/svg%3E")}.nml-icon-folder{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 576 512'%3E%3Cpath fill='%233c4b5f' d='M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z'/%3E%3C/svg%3E")}.nml-icon-folder-create{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%233c4b5f' d='M464 128H272l-64-64H48C21.49 64 0 85.49 0 112v288c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V176c0-26.51-21.49-48-48-48zm-96 168c0 8.84-7.16 16-16 16h-72v72c0 8.84-7.16 16-16 16h-16c-8.84 0-16-7.16-16-16v-72h-72c-8.84 0-16-7.16-16-16v-16c0-8.84 7.16-16 16-16h72v-72c0-8.84 7.16-16 16-16h16c8.84 0 16 7.16 16 16v72h72c8.84 0 16 7.16 16 16v16z'/%3E%3C/svg%3E")}.nml-icon-folder-back{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%233c4b5f' d='M328 256c0 39.8-32.2 72-72 72s-72-32.2-72-72 32.2-72 72-72 72 32.2 72 72zm104-72c-39.8 0-72 32.2-72 72s32.2 72 72 72 72-32.2 72-72-32.2-72-72-72zm-352 0c-39.8 0-72 32.2-72 72s32.2 72 72 72 72-32.2 72-72-32.2-72-72-72z'/%3E%3C/svg%3E")}.nml-icon-folder-remove{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath fill='%233c4b5f' d='M464 128H272l-64-64H48C21.49 64 0 85.49 0 112v288c0 26.51 21.49 48 48 48h416c26.51 0 48-21.49 48-48V176c0-26.51-21.49-48-48-48zM340.85 338.91c6.25 6.25 6.25 16.38 0 22.63l-11.31 11.31c-6.25 6.25-16.38 6.25-22.63 0L256 321.94l-50.91 50.91c-6.25 6.25-16.38 6.25-22.63 0l-11.31-11.31c-6.25-6.25-6.25-16.38 0-22.63L222.06 288l-50.91-50.91c-6.25-6.25-6.25-16.38 0-22.63l11.31-11.31c6.25-6.25 16.38-6.25 22.63 0L256 254.06l50.91-50.91c6.25-6.25 16.38-6.25 22.63 0l11.31 11.31c6.25 6.25 6.25 16.38 0 22.63L289.94 288l50.91 50.91z'/%3E%3C/svg%3E")}body.overflow-hidden #nova,body.overflow-hidden #nova #nml-tool .popup{overflow-y:scroll}@media (max-width:1024px){#nml-tool .flatpickr-input{margin-right:1rem}#nml-tool>.flex.select-none>*{margin-bottom:1rem}}@media (max-width:767px){.nml-display-gallery .nml-item{width:25%}.nml-field-form .nml-item a.pin{display:none}}@media (max-width:520px){.nml-display-gallery .nml-item{width:50%}#nml-tool>.flex.select-none>.max-w-full{margin:0 0 1rem;width:100%}} --------------------------------------------------------------------------------