Laravel-Backpack / addons

A place for the Backpack community to talk about possible Backpack add-ons.
5 stars 2 forks source link

[Add-on] Dropzone field type #8

Closed skys215 closed 1 year ago

skys215 commented 7 years ago

Hi, I see image field and multiple file upload field. But it seems that there's no multiple image upload field. It should work like the combination of image field and multiple file upload filed, a multiple file upload field with the image preview. Hope to see this field type added in the project. Thanks.

tabacitu commented 7 years ago

Hi @skys215 ,

You're right :-) There are a few big technical problems to solve for such a field, but I think in the end we'll be able to add something that would scratch that itch. My thoughts were (and you can do that for your project now):

Here's a work-in-progress dropzone field (only with reorder right now). Maybe it will be of some use to you or someone, until we have something for this.

Cheers!


In your setup() method:

        $this->crud->addField([
            'name'          => 'images',
            'type'          => 'dropzone',
            'upload_route'  => 'upload_images',
            'reorder_route' => 'reorder_images',
            'delete_route'  => 'delete_image',
            'disk'          => 'app_public', // local disk where images will be uploaded
            'mimes'         => 'image/*', //allowed mime types separated by comma. eg. image/*, application/*, etc
            'filesize'      => 5, // maximum file size in MB
        ], 'update');

In your dropzone.blade.php:

<div class="form-group col-md-12">
    <strong>{{ $field['label'] }}</strong> <br>
    <div class="dropzone sortable dz-clickable sortable">
        <div class="dz-message">
            Drop files here or click to upload.
        </div>

        @if ($entry->{$field['name']})
            @foreach($entry->{$field['name']} as $key => $image)
                <div class="dz-preview" data-id="{{ $key }}" data-path="{{ $image }}">
                    <img class="dropzone-thumbnail" src={{ asset($image) }}>
                    <a class="dz-remove" href="javascript:void(0);" data-remove="{{ $key }}" data-path="{{ $image }}">Remove file</a>
                </div>
            @endforeach
        @endif
    </div>
</div>

@if ($crud->checkIfFieldIsFirstOfItsType($field, $fields))
  {{-- FIELD EXTRA CSS  --}}
  {{-- push things in the after_styles section --}}

  @push('crud_fields_styles')
      <style>
        .sortable { list-style-type: none; margin: 0; padding: 0; width: 100%; overflow: auto;}
            /*border: 1px SOLID #000;*/
        .sortable { margin: 3px 3px 3px 0; padding: 1px; float: left; /*width: 120px; height: 120px;*/ vertical-align:bottom; text-align: center; }
        .dropzone-thumbnail { width: 115px; cursor: move!important; }
        .dz-preview { cursor: move !important; }
      </style>
  @endpush

  {{-- FIELD EXTRA JS --}}
  {{-- push things in the after_scripts section --}}

      @push('crud_fields_scripts')

        <script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
        <script src="https://rawgit.com/enyo/dropzone/master/dist/dropzone.js"></script>
        <link rel="stylesheet" href="https://rawgit.com/enyo/dropzone/master/dist/dropzone.css">

        <script>
            Dropzone.autoDiscover = false;
            var uploaded = false;

            var dropzone = new Dropzone(".dropzone", {
                url: "{{ url($crud->route.'/'.$entry->id.'/'.$field['upload_route']) }}",
                paramName: '{{ $field['name'] }}',
                uploadMultiple: true,
                acceptedFiles: "{{ $field['mimes'] }}",
                addRemoveLinks: true,
                // autoProcessQueue: false,
                maxFilesize: {{ $field['filesize'] }},
                parallelUploads: 10,
                // previewTemplate:
                sending: function(file, xhr, formData) {
                    formData.append("_token", $('[name=_token').val());
                    formData.append("id", {{ $entry->id }});
                },
                error: function(file, response) {
                    console.log('error');
                    console.log(file)
                    console.log(response)

                    $(file.previewElement).find('.dz-error-message').remove();
                    $(file.previewElement).remove();

                    $(function(){
                      new PNotify({
                        title: file.name+" was not uploaded!",
                        text: response,
                        type: "error",
                        icon: false
                      });
                    });

                },
                success : function(file, status) {
                    console.log('success');

                    // clear the images in the dropzone
                    $('.dropzone').empty();

                    // repopulate the dropzone with all images (new and old)
                    $.each(status.images, function(key, image_path) {
                        $('.dropzone').append('<div class="dz-preview" data-id="'+key+'" data-path="'+image_path+'"><img class="dropzone-thumbnail" src="{{ url('') }}/'+image_path+'" /><a class="dz-remove" href="javascript:void(0);" data-remove="'+key+'" data-path="'+image_path+'">Remove file</a></div>');
                    });

                    var notification_type;

                    if (status.success) {
                        notification_type = 'success';
                    } else {
                        notification_type = 'error';
                    }

                    new PNotify({
                        text: status.message,
                        type: notification_type,
                        icon: false
                    });
                }
            });

            // Reorder images
            $(".dropzone").sortable({
                items: '.dz-preview',
                cursor: 'move',
                opacity: 0.5,
                containment: '.dropzone',
                distance: 20,
                scroll: true,
                tolerance: 'pointer',
                stop: function (event, ui) {
                    // console.log('sortable stop');
                    var image_order = [];

                    $('.dz-preview').each(function() {
                        var image_id = $(this).data('id');
                        var image_path = $(this).data('path');
                        image_order.push({ id: image_id, path: image_path});
                    });

                    // console.log(image_order);

                    $.ajax({
                        url: '{{ url($crud->route.'/'.$entry->id.'/'.$field['reorder_route']) }}',
                        type: 'POST',
                        data: {
                            order: image_order,
                            entry_id: {{ $entry->id }}
                        },
                    })
                    .done(function(status) {
                        var notification_type;

                        if (status.success) {
                            notification_type = 'success';
                        } else {
                            notification_type = 'error';
                        }

                        new PNotify({
                            text: status.message,
                            type: notification_type,
                            icon: false
                        });
                    });
                }
            });

            // Delete image
            $(document).on('click', '.dz-remove', function () {
                var image_id = $(this).data('remove');
                var image_path = $(this).data('path');

                $.ajax({
                    url: '{{ url($crud->route.'/'.$entry->id.'/'.$field['delete_route']) }}',
                    type: 'POST',
                    data: {
                        entry_id: {{ $entry->id }},
                        image_id: image_id,
                        image_path: image_path
                    },
                })
                .done(function(status) {
                    var notification_type;

                    if (status.success) {
                        notification_type = 'success';
                        $('div.dz-preview[data-id="'+image_id+'"]').remove();
                    } else {
                        notification_type = 'error';
                    }

                    new PNotify({
                        text: status.message,
                        type: notification_type,
                        icon: false
                    });
                });

            });

        </script>

      @endpush
@endif

In your model:

    public function updateImageOrder($order) {
        $new_images_attribute = [];

        foreach ($order as $key => $image) {
            $new_images_attribute[$image['id']] = $image['path'];
        }
        $new_images_attribute = json_encode($new_images_attribute);

        $this->attributes['images'] = $new_images_attribute;
        $this->save();
    }

    public function removeImage($image_id, $image_path, $disk)
    {
        // delete the image from the db
        $images = json_encode(array_except($this->images, [$image_id]));
        $this->attributes['images'] = $images;
        $this->save();

        // delete the image from the folder
        if (Storage::disk($disk)->has($image_path)) {
            Storage::disk($disk)->delete($image_path);
        }
    }

    public function setImagesAttribute($value)
    {
        $attribute_name = "images";
        $disk = "app_public";
        $destination_path = "assets/hotels";

        $this->uploadMultipleFilesToDisk($value, $attribute_name, $disk, $destination_path);
    }

AjaxUploadImagesTrait.php (you need to use this on your EntityCrudController:

<?php namespace App\Http\Controllers\Admin\Traits;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;

trait AjaxUploadImagesTrait {

    /**
     * Upload an image with AJAX to the disk
     * and store its path in the database.
     *
     * @param  Request $request [description]
     * @return [type]           [description]
     */
    public function ajaxUploadImages(Request $request)
    {
        $entry = $this->crud->getEntry($request->input('id'));
        $attribute_name = $entry->upload_multiple['attribute'];
        $files = $request->file($attribute_name);
        $file_count = count($files);

        $entry->{$attribute_name} = $files;
        $entry->save();

        return response()->json([
            'success' => true,
            'message' => ($file_count>1)?'Uploaded '.$file_count.' images.':'Image uploaded',
            'images' => $entry->{$attribute_name}
        ]);
    }

    /**
     * Save new images order from sortable object.
     *
     * @param  Request $request [description]
     * @return [type]           [description]
     */
    public function ajaxReorderImages(Request $request)
    {
        $entry = $this->crud->getEntry($request->input('entry_id'));
        $entry->updateImageOrder($request->input('order'));

        return response()->json([
            'success' => true,
            'message' => 'New image order saved.'
        ]);
    }

    /**
     * Delete an image from the database and disk.
     *
     * @param  Request $request [description]
     * @return [type]           [description]
     */
    public function ajaxDeleteImage(Request $request)
    {
        $image_id = $request->input('image_id');
        $image_path = $request->input('image_path');
        $entry = $this->crud->getEntry($request->input('entry_id'));
        $disk = $this->crud->getFields('update', $entry->id)['images']['disk'];

        // delete the image from the db
        $entry->removeImage($image_id, $image_path, $disk);

        return response()->json([
            'success' => true,
            'message' => 'Image deleted.'
        ]);
    }
}
skys215 commented 7 years ago

I was trying to create a custom field from image field and upload_multi field. But it didn't go well. Thanks for the reply, I will try the code later.

b8ne commented 7 years ago

I've built an extension of the original image field. Currently its limited to 4 images but the addition of another field and a slight alteration to the logic could change that.

image_multiple.blade.php

<?php
    // Set counter
    $counter = 1;
    // Get existing images
    if (isset($id)) {
        $model = get_class($crud->model)::find($id);
        $modelField = $model->{$field['name']};
        $counter = count($modelField);
    }
?>

<div id="image-multiple">
    @for ($i = 0; $i < $counter; $i++)
    <div id="image_{{ $i + 1 }}" class="form-group col-md-6 image image-multiple clearfix" data-preview="#{{ $field['name'] }}" data-name="{{ $field['name'] }}" data-aspectRatio="{{ isset($field['aspect_ratio']) ? $field['aspect_ratio'] : 0 }}" data-crop="{{ isset($field['crop']) ? $field['crop'] : false }}" @include('crud::inc.field_wrapper_attributes')>
        <div>
            <label class="label">{!! $field['label'] !!} 1</label>
        </div>
        <!-- Wrap the image or canvas element with a block element (container) -->
        <div class="row">
            <div class="col-sm-6" style="margin-bottom: 20px;">
                <img id="mainImage" src="{{ isset($id) ? $modelField[$i] : '' }}">
            </div>
        </div>
        <div class="btn-group">
            <label class="btn btn-primary btn-file">
                {{ trans('backpack::crud.choose_file') }} <input type="file" accept="image/*" id="uploadImage"  @include('crud::inc.field_attributes', ['default_class' => 'hide'])>
                <input type="hidden" id="hiddenImage" name="{{ $field['name'] }}[{{ $i }}]">
            </label>
            @if(isset($field['crop']) && $field['crop'])
            <button class="btn btn-default" id="rotateLeft" type="button" style="display: none;"><i class="fa fa-rotate-left"></i></button>
            <button class="btn btn-default" id="rotateRight" type="button" style="display: none;"><i class="fa fa-rotate-right"></i></button>
            <button class="btn btn-default" id="zoomIn" type="button" style="display: none;"><i class="fa fa-search-plus"></i></button>
            <button class="btn btn-default" id="zoomOut" type="button" style="display: none;"><i class="fa fa-search-minus"></i></button>
            <button class="btn btn-warning" id="reset" type="button" style="display: none;"><i class="fa fa-times"></i></button>
            @endif
            <button class="btn btn-danger" id="remove" type="button"><i class="fa fa-trash"></i></button>
        </div>

        {{-- HINT --}}
        @if (isset($field['hint']))
        <p class="help-block">{!! $field['hint'] !!}</p>
        @endif
    </div>
    @endfor
</div>

<div id="add-button-container" class="form-group col-md-12 add-button" {{ isset($id) ? '' : "style='display: none;'" }}>
    <button class="btn btn-success" id="add" type="button"><i class="fa fa-plus"></i> Add Another Image</button>
</div>

{{-- ########################################## --}}
{{-- Extra CSS and JS for this particular field --}}
{{-- If a field type is shown multiple times on a form, the CSS and JS will only be loaded once --}}
@if ($crud->checkIfFieldIsFirstOfItsType($field, $fields))

{{-- FIELD CSS - will be loaded in the after_styles section --}}
@push('crud_fields_styles')
{{-- YOUR CSS HERE --}}
<link href="{{ asset('vendor/backpack/cropper/dist/cropper.min.css') }}" rel="stylesheet" type="text/css" />
<style>
    .hide {
        display: none;
    }
    .image .btn-group {
        margin-top: 10px;
    }
    img {
        max-width: 100%; /* This rule is very important, please do not ignore this! */
    }
    .img-container, .img-preview {
        width: 100%;
        text-align: center;
    }
    .img-preview {
        float: left;
        margin-right: 10px;
        margin-bottom: 10px;
        overflow: hidden;
    }
    .preview-lg {
        width: 263px;
        height: 148px;
    }

    .btn-file {
        position: relative;
        overflow: hidden;
    }
    .btn-file input[type=file] {
        position: absolute;
        top: 0;
        right: 0;
        min-width: 100%;
        min-height: 100%;
        font-size: 100px;
        text-align: right;
        filter: alpha(opacity=0);
        opacity: 0;
        outline: none;
        background: white;
        cursor: inherit;
        display: block;
    }
    .clearfix:after {
        content: " "; /* Older browser do not support empty content */
        visibility: hidden;
        display: block;
        height: 0;
        clear: both;
    }
</style>
@endpush

{{-- FIELD JS - will be loaded in the after_scripts section --}}
@push('crud_fields_scripts')
{{-- YOUR JS HERE --}}
<script src="{{ asset('vendor/backpack/cropper/dist/cropper.min.js') }}"></script>
<script>
    jQuery(document).ready(function($) {
        // Set counter
        let counter = $('.form-group.image-multiple').length;
        // Start ID counter
        let id = 1;
        // Get element refs
        const $addContainer = $('#image-multiple');
        const $addButtonContainer = $('#add-button-container');
        const $addButton = $('#add');
        // Get initial blank template as clone
        const $template = $('#image_1').clone();
        // Get Field name
        const $name = $template.data('name');
        // On add another
        $addButton.click(function(e) {
            e.preventDefault();
            id++;
            if (counter < 4) {
                // Create clone
                let $clone = $template.clone().prop('id', 'image_' + id);
                // Update input name
                $clone.find('#hiddenImage').prop('name', $name + '[' + id + ']');
                // Bind crop handlers
                uploader($clone);
                $addContainer.append($clone);
                // Attach remove button
                $clone.find("#remove").show();
            }
            // Increase counter
            counter++;
            if (counter < 4) {
                $addButtonContainer.show();
            } else {
                $addButtonContainer.hide();
            }
        });

        const uploader = function($this) {
            // Find DOM elements under this form-group element
            var $mainImage = $this.find('#mainImage');
            var $uploadImage = $this.find("#uploadImage");
            var $hiddenImage = $this.find("#hiddenImage");
            var $rotateLeft = $this.find("#rotateLeft")
            var $rotateRight = $this.find("#rotateRight")
            var $zoomIn = $this.find("#zoomIn")
            var $zoomOut = $this.find("#zoomOut")
            var $reset = $this.find("#reset")
            var $remove = $this.find("#remove")
            // Options either global for all image type fields, or use 'data-*' elements for options passed in via the CRUD controller
            var options = {
                viewMode: 2,
                checkOrientation: false,
                autoCropArea: 1,
                responsive: true,
                preview : $this.attr('data-preview'),
                aspectRatio : $this.attr('data-aspectRatio')
            };
            var crop = $this.attr('data-crop');

            // Hide 'Remove' button if there is no image saved
            if (!$mainImage.attr('src')){
                $remove.hide();
            }
            // Initialise hidden form input in case we submit with no change
            $hiddenImage.val($mainImage.attr('src'));

            // Only initialize cropper plugin if crop is set to true
            if(crop){

                $remove.click(function() {
                    $mainImage.cropper("destroy");
                    $mainImage.attr('src','');
                    $hiddenImage.val('');
                    $rotateLeft.hide();
                    $rotateRight.hide();
                    $zoomIn.hide();
                    $zoomOut.hide();
                    $reset.hide();
                    $remove.hide();
                    // Remove if this isnt the 1st
                    if ($(this).parents().eq(1).prop('id') != 'image_1') {
                        $(this).parents().eq(1).remove();
                        counter--;
                    }
                    console.log(counter);
                    if (counter < 5) {
                        $addButtonContainer.show();
                    }
                });
            } else {

                $this.find("#remove").click(function() {
                    $mainImage.attr('src','');
                    $hiddenImage.val('');
                    $remove.hide();
                });
            }

            $uploadImage.change(function() {
                var fileReader = new FileReader(),
                    files = this.files,
                    file;

                if (!files.length) {
                    return;
                }
                file = files[0];

                if (/^image\/\w+$/.test(file.type)) {
                    fileReader.readAsDataURL(file);
                    fileReader.onload = function () {
                        $uploadImage.val("");
                        if(crop){
                            $mainImage.cropper(options).cropper("reset", true).cropper("replace", this.result);
                            // Override form submit to copy canvas to hidden input before submitting
                            $('form').submit(function() {
                                var imageURL = $mainImage.cropper('getCroppedCanvas').toDataURL();
                                $hiddenImage.val(imageURL);
                                return true; // return false to cancel form action
                            });
                            $rotateLeft.click(function() {
                                $mainImage.cropper("rotate", 90);
                            });
                            $rotateRight.click(function() {
                                $mainImage.cropper("rotate", -90);
                            });
                            $zoomIn.click(function() {
                                $mainImage.cropper("zoom", 0.1);
                            });
                            $zoomOut.click(function() {
                                $mainImage.cropper("zoom", -0.1);
                            });
                            $reset.click(function() {
                                $mainImage.cropper("reset");
                            });
                            $rotateLeft.show();
                            $rotateRight.show();
                            $zoomIn.show();
                            $zoomOut.show();
                            $reset.show();
                            $remove.show();
                            // Show add more button
                            if (counter < 4) {
                                $addButtonContainer.show();
                            }
                        } else {
                            $mainImage.attr('src',this.result);
                            $hiddenImage.val(this.result);
                            $remove.show();
                        }
                    };
                } else {
                    alert("Please choose an image file.");
                }
            });
        };

        // Loop through all instances of the image field
        $('.form-group.image-multiple').each(function(index){
            uploader($(this));
        });
    });
</script>

@endpush
@endif
{{-- End of Extra CSS and JS --}}
{{-- ########################################## --}}

CrudController.php - setup

$this->crud->addFields([
[ // image
                'label' => "Images",
                'name' => "images",
                'type' => 'image_multiple',
                'upload' => true,
                'crop' => true, // set to true to allow cropping, false to disable
                'aspect_ratio' => 1.8, // ommit or set to 0 to allow any aspect ratio
            ]
]);

CrudController.php - store & update

public function store(StoreRequest $request)
    {
        // Setup storage
        $attribute_name = "images";
        $disk = "uploads";
        $destination_path = "/uploads/projects";
        // Then get images from request
        $input = $request->all();
        $images = $input[$attribute_name];
        $imageArray = [];
        // Now iterate images
        foreach ($images as $value) {
            // Store on disk and add to array
            if (starts_with($value, 'data:image'))
            {
                // 0. Make the image
                $image = \Image::make($value);
                // 1. Generate a filename.
                $filename = md5($value.time()).'.jpg';
                // 2. Store the image on disk.
                \Storage::disk($disk)->put($destination_path.'/'.$filename, $image->stream());
                // 3. Save the path to the database
                array_push($imageArray, $destination_path.'/'.$filename);
            }
        }
        // Update $request with new array
        $request->request->set($attribute_name, $imageArray);

        // Save $request
        $redirect_location = parent::storeCrud($request);
        // your additional operations after save here
        // use $this->data['entry'] or $this->crud->entry
        return $redirect_location;
    }

    public function update(UpdateRequest $request)
    {
        // Setup storage
        $attribute_name = "images";
        $disk = "uploads";
        $destination_path = "/uploads/projects";
        // Then get images from request
        $input = $request->all();
        $images = $input[$attribute_name];
        $imageArray = [];
        // Now iterate images
        foreach ($images as $value) {
            // Store on disk and add to array
            if (starts_with($value, 'data:image'))
            {
                // 0. Make the image
                $image = \Image::make($value);
                // 1. Generate a filename.
                $filename = md5($value.time()).'.jpg';
                // 2. Store the image on disk.
                \Storage::disk($disk)->put($destination_path.'/'.$filename, $image->stream());
                // 3. Save the path to the database
                array_push($imageArray, $destination_path.'/'.$filename);
            } else {
                array_push($imageArray, $value);
            }
        }
        // Update $request with new array
        $request->request->set($attribute_name, $imageArray);
        // your additional operations before save here
        $redirect_location = parent::updateCrud($request);
        // your additional operations after save here
        // use $this->data['entry'] or $this->crud->entry
        return $redirect_location;
    }

And just store them as an array in the DB

lukechanning commented 7 years ago

In using the first solution (which has been truly awesome for my project, thank you @tabacitu ) I'm getting a 404 error returned from AJAX on all the routes (upload_images, reorder_images, delete_images). I'm likely just an idiot, but is there something we need to do additionally to tie the new field into the CRUD routes?

I've plopped code in as displayed above, but haven't modified my routes in anyway. Standard CRUD::resource configuration. I figured I'd ask in case it helps others until an official release.

EDIT: Idiot status achieved! If someone else comes along and needs the final push, just remember that you need to point to the parts of the Trait. Example from my project:

Route::post('/project/{id}/reorder_images ','Admin\ProjectCrudController@ajaxReorderImages');

I also needed to remember to add use Storage; to each model that needed the new field.

Probably obvious for most, but if you're stuck, hope it helps!

eleven59 commented 7 years ago

I built this again from scratch using DropboxJS. I also included Sortable to be able to reorder the images. The DB column should be TEXT or LONGTEXT (if you dare) type as I'm storing them using a JSON array (but files are stored on disk, so no base64 limits in the database). I guess it's ready to include in the Backpack CRUD base code with a few minor adjustments (not having to manually correct some things and maybe not using CDN but including JS/CSS in the vendor folders).

I post this here in a nonsensical matter because I have yet to develop a genuine interest in submitting composer-ready packages. I hope it may still be of some use to someone.

Prerequisites:

Pros:

Cons:

Guide, file by file:

app/Http/Controllers/Admin/CrudController.php

Add requirement:

use App\Http\Requests\DropzoneRequest;

In setup() add this for the field type:

$this->crud->addField([
    'name' => 'photos', // db column name
    'label' => 'Photos', // field caption
    'type' => 'dropzone', // voodoo magic
    'prefix' => '/uploads/', // upload folder (should match the driver specified in the upload handler defined below)
    'upload-url' => '/' . config('backpack.base.route_prefix') . '/media-dropzone', // POST route to handle the individual file uploads
]);

Prepend this to the update(UpdateRequest $request) function (make sure to use the correct column name in the array):

if (empty ($request->get('photos'))) {
    $this->crud->update(\Request::get($this->crud->model->getKeyName()), ['photos' => '[]']);
}

Add function:

public function handleDropzoneUpload(DropzoneRequest $request)
{
    $disk = "uploads"; // 
    $destination_path = "media";
    $file = $request->file('file');

    try
    {
        $image = \Image::make($file);
        $filename = md5($file->getClientOriginalName().time()).'.jpg';
        \Storage::disk($disk)->put($destination_path.'/'.$filename, $image->stream());
        return response()->json(['success' => true, 'filename' => $destination_path . '/' . $filename]);
    }
    catch (\Exception $e)
    {
        if (empty ($image)) {
            return response('Not a valid image type', 412);
        } else {
            return response('Unknown error', 412);
        }
    }
}

app/Http/Requests/DropzoneRequest.php

New file with the following content:

<?php

namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;

class DropzoneRequest extends FormRequest
{
    public function authorize()
    {
        return \Auth::check();
    }

    public function rules()
    {
        return [
            'file' => 'required|image',
        ];
    }

    public function messages()
    {
        return [
            'file.required' => 'No file specified.',
            'file.image' => 'Not a valid image.',
        ];
    }
}

app/Models/.php

Add (again, make sure you use the correct column name)

protected $casts = ['photos' => 'array'];

resources/lang/vendor/backpack/en/dropzone.php

New file with the following contents:

<?php

return [
    'drop_to_upload' => 'Drop files here to upload',
    'not_supported' => 'Your browser does not support drag\'n\'drop file uploads. Please use the fallback form below to upload your files like in the olden days.',
    'invalid_file_type' => 'Invalid file type.',
    'file_too_big' => 'Filesize too big. You uploaded a file as big as {{filesize}} whereas {{maxFilesize}} is the maximum size.',
    'response_error' => 'The server responsed with an error code: {{statusCode}}.',
    'max_files_exceeded' => 'You have reached the maximum number of files supported.',
    'cancel_upload' => 'Cancel upload',
    'cancel_upload_confirmation' => 'Upload cancelled',
    'remove_file' => 'Remove file',
];

resources/views/vendor/backpack/crud/fields/dropzone.php

New file with the following contents:

@push('crud_fields_styles')
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/dropzone/4.3.0/min/dropzone.min.css" integrity="sha256-e47xOkXs1JXFbjjpoRr1/LhVcqSzRmGmPqsrUQeVs+g=" crossorigin="anonymous" />
    <style>
        .dropzone-target {
            background: #f3f3f3;
            border-bottom: 2px dashed #ddd;
            border-left: 2px dashed #ddd;
            border-right: 2px dashed #ddd;
            border-top: 0;
            border-bottom-left-radius: 10px;
            border-bottom-right-radius: 10px;
            color: Laravel-Backpack/CRUD#999;
            font-size: 1.2em;
            padding: 2em 2em 0;
        }

        .dropzone-previews {
            background: #f3f3f3;
            border-top-left-radius: 10px;
            border-top-right-radius: 10px;
            border-bottom: 0;
            border-left: 2px solid Laravel-Backpack/CRUD#999;
            border-right: 2px solid Laravel-Backpack/CRUD#999;
            border-top: 2px solid Laravel-Backpack/CRUD#999;
            padding: 2em;
        }

        .dropzone.dz-drag-hover {
            background: #ececec;
            border-bottom: 2px dashed Laravel-Backpack/CRUD#999;
            border-left: 2px dashed Laravel-Backpack/CRUD#999;
            border-right: 2px dashed Laravel-Backpack/CRUD#999;
            color: Laravel-Backpack/CRUD#333;
        }

        .dz-message {
            text-align: center;
        }

        .dropzone .dz-preview .dz-image-no-hover {
            border-radius: 20px;
            cursor: move;
            display: block;
            height: 120px;
            overflow: hidden;
            position: relative;
            width: 120px;
            z-index: 10;
        }
    </style>
@endpush

<div @include('crud::inc.field_wrapper_attributes') >
    <div id="{{ $field['name'] }}-existing" class="dropzone dropzone-previews">
        @if (isset($field['value']) && count($field['value']))
            @foreach($field['value'] as $key => $file_path)
                <div class="dz-preview dz-image-preview dz-complete">
                    <input type="hidden" name="{{ $field['name'] }}[]" value="{{ $file_path }}" />
                    <div class="dz-image-no-hover"><img src="/thumbs/dropzone/{{ basename ($file_path) }}" /></div>
                    <a class="dz-remove dz-remove-existing" href="javascript:undefined;">{{ trans('backpack::dropzone.remove_file') }}</a>
                </div>
            @endforeach
        @endif
    </div>
    <div id="{{ $field['name'] }}-dropzone" class="dropzone dropzone-target"></div>
    <div id="{{ $field['name'] }}-hidden-input" class="hidden"></div>
</div>

@push('crud_fields_scripts')
    <script src="https://cdnjs.cloudflare.com/ajax/libs/dropzone/4.3.0/min/dropzone.min.js" integrity="sha256-p2l8VeL3iL1J0NxcXbEVtoyYSC+VbEbre5KHbzq1fq8=" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.5.1/Sortable.min.js" integrity="sha256-OQFsXEK3UpvAlOjkWPTPt+jOHF04+PgHoZES3LTjWos=" crossorigin="anonymous"></script>
    <script>
        $("div#{{ $field['name'] }}-dropzone").dropzone({
            url: "{{ $field['upload-url'] }}",
            headers: {
                'X-CSRF-Token': '{{ csrf_token() }}'
            },
            dictDefaultMessage: "{{ trans('backpack::dropzone.drop_to_upload') }}",
            dictFallbackMessage: "{{ trans('backpack::dropzone.not_supported') }}",
            dictFallbackText: null,
            dictInvalidFileType: "{{ trans('backpack::dropzone.invalid_file_type') }}",
            dictFileTooBig: "{{ trans('backpack::dropzone.file_too_big') }}",
            dictResponseError: "{{ trans('backpack::dropzone.response_error') }}",
            dictMaxFilesExceeded: "{{ trans('backpack::dropzone.max_files_exceeded') }}",
            dictCancelUpload: "{{ trans('backpack::dropzone.cancel_upload') }}",
            dictCancelUploadConfirmation: "{{ trans('backpack::dropzone.cancel_upload_confirmation') }}",
            dictRemoveFile: "{{ trans('backpack::dropzone.remove_file') }}",
            success: function (file, response, request) {
                if (response.success) {
                    $(file.previewElement).find('.dropzone-filename-field').val(response.filename);
                }
            },
            addRemoveLinks: true,
            previewsContainer: "div#{{ $field['name'] }}-existing",
            hiddenInputContainer: "div#{{ $field['name'] }}-hidden-input",
            previewTemplate: '<div class="dz-preview dz-file-preview"><input type="hidden" name="{{ $field['name'] }}[]" class="dropzone-filename-field" /><div class="dz-image-no-hover"><img data-dz-thumbnail /></div><div class="dz-progress"><span class="dz-upload" data-dz-uploadprogress></span></div><div class="dz-error-message"><span data-dz-errormessage></span></div><div class="dz-success-mark"><svg width="54px" height="54px" viewBox="0 0 54 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns"><title>Check</title><defs></defs><g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage"><path d="M23.5,31.8431458 L17.5852419,25.9283877 C16.0248253,24.3679711 13.4910294,24.366835 11.9289322,25.9289322 C10.3700136,27.4878508 10.3665912,30.0234455 11.9283877,31.5852419 L20.4147581,40.0716123 C20.5133999,40.1702541 20.6159315,40.2626649 20.7218615,40.3488435 C22.2835669,41.8725651 24.794234,41.8626202 26.3461564,40.3106978 L43.3106978,23.3461564 C44.8771021,21.7797521 44.8758057,19.2483887 43.3137085,17.6862915 C41.7547899,16.1273729 39.2176035,16.1255422 37.6538436,17.6893022 L23.5,31.8431458 Z M27,53 C41.3594035,53 53,41.3594035 53,27 C53,12.6405965 41.3594035,1 27,1 C12.6405965,1 1,12.6405965 1,27 C1,41.3594035 12.6405965,53 27,53 Z" id="Oval-2" stroke-opacity="0.198794158" stroke="#747474" fill-opacity="0.816519475" fill="#FFFFFF" sketch:type="MSShapeGroup"></path></g></svg></div><div class="dz-error-mark"><svg width="54px" height="54px" viewBox="0 0 54 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns"><title>Error</title><defs></defs><g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage"><g id="Check-+-Oval-2" sketch:type="MSLayerGroup" stroke="#747474" stroke-opacity="0.198794158" fill="#FFFFFF" fill-opacity="0.816519475"><path d="M32.6568542,29 L38.3106978,23.3461564 C39.8771021,21.7797521 39.8758057,19.2483887 38.3137085,17.6862915 C36.7547899,16.1273729 34.2176035,16.1255422 32.6538436,17.6893022 L27,23.3431458 L21.3461564,17.6893022 C19.7823965,16.1255422 17.2452101,16.1273729 15.6862915,17.6862915 C14.1241943,19.2483887 14.1228979,21.7797521 15.6893022,23.3461564 L21.3431458,29 L15.6893022,34.6538436 C14.1228979,36.2202479 14.1241943,38.7516113 15.6862915,40.3137085 C17.2452101,41.8726271 19.7823965,41.8744578 21.3461564,40.3106978 L27,34.6568542 L32.6538436,40.3106978 C34.2176035,41.8744578 36.7547899,41.8726271 38.3137085,40.3137085 C39.8758057,38.7516113 39.8771021,36.2202479 38.3106978,34.6538436 L32.6568542,29 Z M27,53 C41.3594035,53 53,41.3594035 53,27 C53,12.6405965 41.3594035,1 27,1 C12.6405965,1 1,12.6405965 1,27 C1,41.3594035 12.6405965,53 27,53 Z" id="Oval-2" sketch:type="MSShapeGroup"></path></g></g></svg></div></div>'
        });

        var el = document.getElementById('{{ $field['name'] }}-existing');
        var sortable = new Sortable(el, {
            group: "{{ $field['name'] }}-sortable",
            handle: ".dz-preview",
            draggable: ".dz-preview",
            scroll: false,
        });

        $('.dz-remove-existing').click(function (e) {
            e.preventDefault();
            e.stopPropagation();
            $(this).closest('.dz-preview').remove();
        });
    </script>
@endpush

routes/web.php

Add to the CRUD route group (the one that has namespace' => 'Admin'):

Route::post('media-dropzone', ['uses' => '<YourModel>CrudController@handleDropzoneUpload']);
NBZ4live commented 7 years ago

Imho it would make sense to have something like a "managed files" model and store all uploaded managed files in this table. Than just save relations to this files through a table with additional settings. This would allow:

  1. Delete unused files without active relations by cron (if the form was not submitted or the related main entity was deleted)
  2. Reuse the same file for different relations to other models

EDIT: Thinking about adding a field for https://github.com/jasekz/laradrop file manager. Than one can just upload in the file manager and pick the file/s in the regular form

Jimmylet commented 7 years ago

@eleven59 I tested your method and it works well! It helped me a lot. Only as you say, the images can't be deleted from the folder. On the other hand, I didn't use imagecache, I just stored the thumbnail in the right folder.

$destination_thumb = "thumbs/dropzone";
\Storage::disk($disk)->put($destination_thumb.'/'.$filename, $image->fit(120,120)->stream());

Let me know if you have added the delete feature.

ihsanberahim commented 7 years ago

Show existing photos dropzone way

Thx @eleven59, I suggest use dropzone api to show existing uploaded photos using this

https://github.com/enyo/dropzone/wiki/FAQ#how-to-show-files-already-stored-on-server

...
init: function()
          {
           var existingFiles = [];
           var mockFile;

           @foreach($entry->photos as $photo)
            existingFiles.push({
             name: '{{$photo->name}}',
             size: {{$photo->size}},
             url: '{{$photo->getUrl()}}'
            });
           @endforeach

           for(var key in existingFiles)
           {
            mockFile = existingFiles[key];

            this.emit("addedfile", mockFile); //add exiting photo
            this.emit("thumbnail", mockFile, mockFile.url); //set the thumbnail
            this.emit("complete", mockFile); //remove proress bar
           }
          }
...
ihsanberahim commented 7 years ago

Delete photo

@Jimmylet i use spatie/laravel-medialibrary to handle store/destroy photo

...
 public function deletePhotosDropzone()
 {
  //find entry use entry_id params

  $entry->deleteMedia(request()->input('media_id'));

  return response('media deleted', 200);
 }
 public function handlePhotosDropzone(DropzoneRequest $request)
 {
  //store photo use spatie/laravel-medialibrary
  //then get last stored photo id
   ...
   return response()->json([
    'success' => true,
    'filename' => $destination_path . '/' . $filename,
    'media_id' => $entry->photos->last()->id,
   ]);
 }
...
//backpack/crud/fields/dropzone.php
...
@push('crud_fields_scripts')
    <!--START: DROPZONE SCRIPTS-->
    <script>

        var UPLOAD_PHOTOS_URL = "{{url($field['upload-url'].'?entry_id='.$entry->id)}}";
        var DELETE_PHOTOS_URL = "{{url($field['delete-url'].'?entry_id='.$entry->id)}}";

        jQuery(document).ready(function($) {
         Dropzone.autoDiscover = false;

         var {{ $field['name'] }}Dropzone = new Dropzone("div#{{ $field['name'] }}-dropzone", {
          url: UPLOAD_PHOTOS_URL,
          //all dict here
          addRemoveLinks: true,
          removedfile: function(file)
          {
           var vm = this;

           $.ajax({
               url: DELETE_PHOTOS_URL,
               data: {
                media_id: file.media_id,
                _token: '{{ csrf_token() }}'
               },
               type: 'DELETE',
               success: function(result) {
                file.previewElement.remove();
               }
           });

           return true;
          },
          init: function()
          {
           ...

           this.on('complete', function(file)
           {
            var response = JSON.parse(file.xhr.response);

            file.media_id = response.media_id; //set media_id to use it in removedFile method
           });
          }
         });
        });
    </script>
    <!--END: DROPZONE SCRIPTS-->
@endpush
...
galvaodev commented 7 years ago

Help error ReflectionException in RouteDependencyResolverTrait.php line 79: Class Backpack\CRUD\app\Http\Controllers\DropzoneRequest does not exist

:'(

ymihaylov commented 7 years ago

@tabacitu Very spacial thanks for your answer!

lloy0076 commented 7 years ago

This seems to be something that a number of people in gitter are talking about recently...

Jimmylet commented 6 years ago

It is true that it would be nice to have this kind of possibility for Backpack. It's a bit of an obligation to have a field like this. It really misses the panel and makes me lose time every time.

Is there a chance that one day it will come with Backpack / CRUD?

I also vote for the fact of being able to add a description and / or an "alt" field for each image.

NBZ4live commented 6 years ago

I managed to combine CRUD with Spatie's media librarys (spatie/laravel-medialibrary) in one of our services. This package provides a nice media management. And it works great as a nested ressource, too. (https://backpackforlaravel.com/articles/tutorials/nested-resources-in-backpack-crud)

I'm working from time to time on implementing what I described in #issuecomment-300555986 using the Media. I'm just not sure if I should make a PR to CRUD or make it as a package for CRUD.

Jimmylet commented 6 years ago

@NBZ4live Hmm, This approach looks very interesting. I am curious to see where it will take us once in action. Do not hesitate to keep me informed, I am interested in testing your proposal.

lloy0076 commented 6 years ago

@NBZ4live If you're adding something that large, I'd be inclined to make your own library and if you do I think @tabacitu has a backpack market place out in the wilds somewhere.

Gaspertrix commented 6 years ago

I am in need of this with custom properties, so If there is not a working pr o library, I could make it.

I am going to use spatie/laravel-medialibrary, creating a new field type named "media". What do you think? Should we go directly with dropzone?

Ant design idea?

lloy0076 commented 6 years ago

@Gaspertrix We'd welcome any PRs or ideas - spatie/laravel-medialibrary should be quite good for what we need.

I'd suggest if you are still interested in doing so to open a new issue but reference this one.

lachogenchev commented 6 years ago

Hello, I'm trying the solution of @tabacitu but without success. The problem is that there is not dropzone filed appear into the GUI, either there is a trace of any html into the source code and no errors. I was using it in previous projects before and it was working perfect. Using Laravel 5.6 MySQL 5.6 PHP 7.1.6 Backpack 3.4 (installed 2 ago)

Any suggestions?

Gaspertrix commented 5 years ago

I just totally forgot about this.

Just released what I have developed some time ago. Check this out and let me know: https://github.com/Gaspertrix/laravel-backpack-dropzone-field

You can upload, delete and reorder media using spatie/laravel-medialibrary and Dropzone.

egerb commented 5 years ago

I just totally forgot about this.

Just released what I have developed some time ago. Check this out and let me know: https://github.com/Gaspertrix/laravel-backpack-dropzone-field

You can upload, delete and reorder media using spatie/laravel-medialibrary and Dropzone.

@Gaspertrix Got this error Method Illuminate\Database\Query\Builder::getMedia does not exist, I guess in model missed this method. Yep, that because I've missed install Spatie Laravel Medialibrary and implement it in model.

tabacitu commented 4 years ago

Note to self: just noticed @Gaspertrix added 4.0 support to his package https://github.com/Gaspertrix/laravel-backpack-dropzone-field/tree/2.0.2 so it might be a better idea to help polish that one instead.

It looks like the heavy lifting has already been done. I think it only needs to turn the AJAX calls into one Backpack 4.0 operation - then it would be perfect. That way, people would not have to add routes, they'd just use the operation trait.

Gaspertrix commented 4 years ago

Note to self: just noticed @Gaspertrix added 4.0 support to his package https://github.com/Gaspertrix/laravel-backpack-dropzone-field/tree/2.0.2 so it might be a better idea to help polish that one instead.

It looks like the heavy lifting has already been done. I think it only needs to turn the AJAX calls into one Backpack 4.0 operation - then it would be perfect. That way, people would not have to add routes, they'd just use the operation trait.

Nice, I will implement this new architecture.

ducho commented 4 years ago

@Gaspertrix @tabacitu the problem with your solution is that not working with CREATE action because the ajax post url seems like: {{ url($crud->route . '/' . $entry->id . '/media') }}

But we need to use for CREATE without model $entry;

tabacitu commented 3 years ago

Update: @eduardoarandah worked on something that not only provides a dropzone field, but an integration with spatie/media-library. Check it out here - https://github.com/eduardoarandah/medialibrary-dropzone-for-laravel-backpack/issues/6

It's a first draft, but testing & feedback would be greatly appreciated, before it's tagged and released.


@ducho indeed that's the problem. For the Create operation you don't already have an $entry so you have nothing to tie the uploads to. One quick&dirty solution would be for the field to just include the upload field on Create, and then have the dropzone on Update. But... it feels a bit hacky.

prodixx commented 3 years ago

If you don't want to use spatie's media-library package, and you work (just like me) with a dedicated images column on models, you can try my package - https://github.com/prodixx/dropzone-field-for-backpack

eduardoarandah commented 3 years ago

If you don't want to use spatie's media-library package, and you work (just like me) with a dedicated images column on models, you can try my package - https://github.com/prodixx/dropzone-field-for-backpack

@prodixx it's a pretty cool package! So, you save images in a json column, right?

prodixx commented 3 years ago

@eduardoarandah yes, i forgot to mention that into description.

thank you.

ashek1412 commented 2 years ago

@prodixx will it work on backpack 5

ashek1412 commented 2 years ago

Hello guys... I am desperately looking for a dropzone solution for backpack 5. can anyone help ???

eduardoarandah commented 2 years ago

Hello guys... I am desperately looking for a dropzone solution for backpack 5. can anyone help ???

@ashek1412 as you can see in my repo, integration with dropzone isn't that hard. https://github.com/eduardoarandah/medialibrary-dropzone-for-laravel-backpack

Tricky part is integrating with your media library solution, you know.. creating a thumbnail, sorting, deleting. My solution relies on "media library" by spatie and is oriented to pictures.

I'm sure it can be improved to a more general solution, not only pictures.

Also, on the frontend I use vue, axios, sortablejs via CDN. This can be improved to a more vanilla solution

prodixx commented 2 years ago

@ashek1412 haven't test it yet maybe i'll find some time in weekend to do it

tabacitu commented 1 year ago

I have GREAT news on this. We have a Dropzone field in Backpack v6, baked into PRO 🎉 Backpack v6 is in public beta right now, so you can start using it right away.

Thanks everyone for contributing to this over the years, your code and examples and ideas helped a lot. It hasn't been easy to create the Dropzone field - in fact @jorgetwgroup and @pxpm spent over 3 months on this 🤯 because an official field like this has to support all Backpack features (repeatable, CrudField JS library, different events etc) but... it's here, and it's freaking awesome. Check out the docs here - https://backpackforlaravel.com/docs/6.x/crud-fields#dropzone-pro