November 12, 2019

Создание галереи изображений с Laravel и React

Сегодня мы создаем галерею изображений с Laravel и React. Мы собираемся использовать react-dropzone to построить загрузчик изображений. response-dropzone - это реализация React популярной библиотеки перетаскивания для загрузки файлов. В конце мы будем использовать API хранилища Laravel для хранения изображений.

Начинаем

Давайте начнем с создания нового проекта Laravel и изменим стандартные леса на React. Запустите эти команды.

composer create-project laravel/ laravel laravel-gallery -- prefer-dist
cd laravel-gallery
php artisan preset react # change preset to react
npm install & & npm run watch # install dependencies
php artisan make:auth # generate authentication scaffolding

База данных и миграции

Заполните файл .env учетными данными своей базы данных и выполните эту команду, чтобы создать модель и перенести таблицу фотографий.

distphp artisan make:model Photos --migration

Добавьте этот код в вашу миграцию.

Schema::create('photos', function (Blueprint $table) {
    $table->increments('id');
    $table->integer('user_id')->unsigned();
    $table->string('uri');
    $table->boolean('public');
    $table->integer('height');
    $table->integer('width');
    $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
    $table->timestamps();
});

Мы храним user_id загрузчика вместе с URI изображения, height и width загруженных изображений в таблице photos. Позже, при создании фронтальной галереи, мы передадим высоту и ширину изображений в react-photo-gallery, которая автоматически вычисляет соотношение сторон для создания адаптивного макета изображения. После сохранения загруженного файла мы сохраним его локальный путь в столбце URI.

Кроме того, обновите модель Photos и добавьте эти столбцы в массив $fillable, чтобы избежать исключения массового назначения.

class Photos extends Model
{
    protected $table = 'photos'; 
    protected $fillable = ['uri', 'public', 'height', 'width'];
}

Установка зависимостей

Прежде чем перейти к части интерфейса, установите эти зависимости.

"dependencies": {
    "axios": "^0.18",
    "bootstrap": "^4.0.0",
    "jquery": "^3.2",
    "lodash": "^4.17.4",
    "popper.js": "^1.12",
    "react": "^16.2.0",
    "react-dom": "^16.2.0",
    "react-dropzone": "^4.2.11",
    "react-images": "^0.5.17",
    "react-photo-gallery": "^6.0.28",
    "react-router-dom": "^4.3.1",
    "toastr": "^2.1.4"
}

Мы используем response-router v4 с React для маршрутизации.

Bootstrapping React App

Добавьте файл app.blade.php в папку views и добавьте в него этот HTML-каркас.

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Laravel React Gallery</title>
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <link rel="stylesheet" href="{{mix('/css/app.css')}}">
</head>
<body>
<div id="root"></div>
<script src="{{mix('/js/app.js')}}"></script>
</body>
</html>

Теперь добавьте маршрут всех запросов, чтобы отобразить это представление для всех входящих запросов.

Auth::routes();
Route::group(['middleware' => ['auth']], function(){
    Route::get('{all?}', 'GalleryController@index')->where('all', '([A-z\d-\/_.]+)?');
});

Мы добавили все запросы на маршрутизацию в группу аутентификации, чтобы обеспечить аутентифицированный доступ к нашему одностраничному приложению. Создайте GalleryController и добавьте в него метод индекса.

php artisan make:controller GalleryController


class GalleryController extends Controller
{
    public function index(){
        return view('app');
    }
}

React App

Теперь, когда мы настроили маршруты наших приложений для обслуживания нашего SPA, мы можем приступить к написанию нашего приложения React. Давайте начнем с обертывания корневого компонента нашего приложения внутри BrowserRouter. resources/assets /js/app.js - это точка входа в наши приложения.

import React from 'react';
import './bootstrap';
import Root from './components/Root';
import ReactDOM from 'react-dom';
import {BrowserRouter} from 'react-router-dom';
if (document.getElementById('root')) {
    ReactDOM.render(
        <BrowserRouter>
            <Root/>
        </BrowserRouter>,
    document.getElementById('root'));
}

Теперь создайте файл Root.js в каталоге resources /assets/js/components и добавьте этот код.

import {Route} from 'react-router-dom';
import React, {Component, Fragment} from 'react';
import Navbar from "./Navbar";
import Gallery from "./Gallery";
import Uploader from "./Uploader";
import ManageGallery from "./ManageGallery";

export default class Root extends Component {
    render() {
        return (
            <Fragment>
                <Route path="/" render={(props) => (
                    <Navbar {...props}/>
                )}/>

                <div className="container">
                    <Route exact path="/" component={Gallery}/>
                    <Route exact path="/manage" component={ManageGallery}/>
                    <Route exact path="/upload" component={Uploader}/>
                </div>
            </Fragment>
        );
    }
}

Мы отображаем четыре подкомпонента под нашим root компонентом: Navbar, Gallery, ManageGallery и Uploader.

Навигация

Чтобы позволить пользователям перемещаться между компонентами, мы отображаем наш компонент Navbar сверху. Компонент Navbar виден на всех маршрутах.

export default class Navbar extends Component {
    constructor(props){        
super(props);
        console.log(props);
    }
    render() {
        return (
            <div className="container nav-bar">
                <ul className="nav justify-content-center">
                    <li className="nav-item">
                        <Link
                            to={'/'}
                            className={`nav-link ${this.props.location.pathname === '/' ? 'active' : ''}`}
                        >
                            GALLERY
                        </Link>
                    </li>
                    <li className="nav-item">
                        <Link
                            to={'/upload'}
                            className={`nav-link ${this.props.location.pathname === '/upload' ? 'active' : ''}`}
                        >
                            UPLOADER
                        </Link>
                    </li>
                    <li className="nav-item">
                        <Link
                            to={'/manage'}
                            className={`nav-link ${this.props.location.pathname === '/manage' ? 'active' : ''}`}
                        >
                            MANAGE
                        </Link>
                    </li>
                    <li className="nav-item">
                        <a className="nav-link" href="/logout">LOGOUT</a>
                    </li>
                </ul>
            </div>
        );
    }
}

Мы используем render проп вместо component на route, чтобы позволить ему передавать свои реквизиты компоненту Navbar. Мы используем имя пути из реквизита, чтобы назначить активный класс нашим навигационным ссылкам. Вот как это выглядит.

Загрузка изображений

Теперь создайте файл Uploader.js в каталоге компонентов и добавьте этот код.

import React, {Component, Fragment} from 'react';
import Dropzone from 'react-dropzone';
import toastr from 'toastr';
import {post} from 'axios';

export default class Uploader extends Component {
    constructor(props){
        super(props);
        this.state = {
            images : [],
            progress : 0,
            uploading : true,
            supported_mime : [
                'image/jpeg',
                'image/png',
            ]
        }
    }
    render() {
        return (
            <div className="uploader">
                <div className="text-center">
                    <Dropzone
                        onDropAccepted={this.onDrop.bind(this)}
                        onDropRejected={this.onDropRejected.bind(this)}
                        className="btn btn-dark"
                        accept={this.state.supported_mime}
                   >
                        Select Images
                    </Dropzone>
                    {this.state.images.length > 0 &&
                        <button
                            className="btn btn-dark uploadBtn"
                            onClick={this.uploadFiles.bind(this)}
                        >
                            Upload
                        </button>
                    }
                </div>
                {this.state.images.length ?
                    <Fragment>
                        {this.state.uploading &&
                            <div className="progress">
                                <div
                                    className="progress-bar"
                                    role="progressbar"                                    style={{width : this.state.progress}}
                                    aria-valuenow={this.state.progress}
                                    aria-valuemin="0"
                                    aria-valuemax="100"/>
                            </div>
                        }
                        <div className="images">
                           {
                                this.state.images.map((file) =>
                                    <div key={file.preview} className="image">
                                        <span
                                            className="close"
                                            onClick={this.removeDroppedFile.bind(this, file.preview)}
                                        >X</span>
                                        <img src={file.preview} alt=""/>
                                    </div>
                                )
                            }
                        </div>
                    </Fragment>
                    :
                    <div className="no-images">
                        <h5 className="text-center">
                            Selected images will appear here
                        </h5>
                    </div>
                }
            </div>
        );
    }
}

В нашем компоненте Uploader мы визуализируем кнопку dropzone, кнопку upload, ход начальной загрузки, div для отображения принятых изображений и сообщение, если изображения не выбраны. Мы добавили две функции обратного вызова в качестве реквизита в dropzone, которые будут вызываться после того, как выбранные изображения будут приняты или отклонены. Давайте определим эти методы для обработки этих обратных вызовов.

onDrop(images){
    this.setState({
        images : this.state.images.concat([...images])
    });
}
onDropRejected(images){
    if(images.length){
        toastr.error('Please upload valid image files. Supported extension JPEG and PNG', 'Invalid MIME type')
   }
}

dropzone автоматически проверяет файлы по типу mime. Мы передаем массив приемлемых строк типа MIME в качестве приемной опоры для компонента Dropzone. При успешном выборе мы добавим выбранные файлы в состояние изображений. При отклонении мы покажем ошибку тостера с ошибкой загрузки неверного файла. Мы покажем кнопку загрузки, если изображения существуют в состоянии изображений.

Теперь давайте реализуем кнопку загрузки на обработчике клика.

uploadFiles(){
        let images = this.state.images,
            config = { headers: { 'Content-Type': 'multipart/form-data' } },
            total_files = this.state.images.length,
            uploaded = 0;
        this.setState({
            uploading : true
        });
        images.map((image) => {
            let formData = new FormData();
            formData.append("file", image);
             post("/photos", formData, config).then(response => {
                const done = response.data;
                if(done){
                    this.removeDroppedFile(image.preview);
                    this.calculateProgress(total_files, ++uploaded);
                }
            });
        });
    }

Мы отдельно загружаем каждый принятый файл. После успешной загрузки мы удалим файл из состояния и обновим индикатор выполнения. Добавьте эти маршруты в ваше приложение для обработки файловых запросов.

 Route::group(['middleware' => ['auth']], function(){
 Route::get('/photos', 'GalleryController@getPhotos');
 Route::post('/photos', 'GalleryController@uploadPhotos');
 Route::delete('/photos', 'GalleryController@deletePhoto');
 Route::get('/logout', 'Auth\LoginController@logout');
 Route::get('{all?}', 'GalleryController@index')->where('all', '([A-z\d-\/_.]+)?');
});

Добавьте метод uploadPhotos в ваш GalleryController для загрузки файлов.

public function uploadPhotos(Request $request)
{
 $file = $request->file('file');
 $ext = $file->extension();
 $name = str_random(20).'.'.$ext ;
 list($width, $height) = getimagesize($file);
 $path = Storage::disk('public')->putFileAs(
 'uploads', $file, $name
 );
 if($path){
 $create = Auth::user()->photos()->create([
 'uri' => $path,
 'public' => false,
 'height' => $height,
 'width' => $width
 ]);
 
 if($create){
 return response()->json([
 'uploaded' => true
 ]);
 }
 }
}

В этом методе мы извлекаем загруженный файл и его расширение и генерируем уникальное имя, объединяя расширение файла со случайной строкой. Затем мы вычисляем высоту и ширину изображения методом getimagesize (). После сохранения файла на общедоступном диске с помощью фасада хранилища Laravel мы создаем новую запись в таблице публикаций с URI загруженного изображения. После успешной загрузки мы удалим файл из состояния компонента Uploader. Наш загрузчик файлов выглядит следующим образом.

Чтобы удалить любые выбранные изображения из загрузчика, нам также нужно определить метод removeDroppedFile.

removeDroppedFile(preview, e = null){

this.setState({

images : this.state.images.filter((image) => {

return image.preview !== preview

})

})

}

Мы отображаем кнопку удаления без наших изображений. При нажатии мы просто удалим выбранное изображение из состояния компонента Uploader.

Галерея

Мы будем отображать загруженные изображения в нашей Gallery. Добавьте файл Gallery.js в каталог компонентов.

export default class Gallery extends Component {

constructor(props){

super(props);

this.state = {

images : [],

currentImage: 0,

lightboxIsOpen: false

};

}

componentDidMount(){

get('/photos')

.then(response => {

const images = response.data;

this.setState({

images : images

})

})

}

openLightbox(event, obj) {

this.setState({

currentImage: obj.index,

lightboxIsOpen: true,

});

}

closeLightbox() {

this.setState({

currentImage: 0,

lightboxIsOpen: false,

});

}

gotoPrevious() {

this.setState({

currentImage: this.state.currentImage - 1,

});

}

gotoNext() {

this.setState({

currentImage: this.state.currentImage + 1,

});

}

render() {

let photos = this.state.images.map(image => {

return {

src : '/storage/' + image.uri,

width : image.width,

height : image.height,

id : image.id

}

});

return (

<div className="gallery">

{this.state.images.length ?

<ReactGallery

photos={photos}

onClick={this.openLightbox.bind(this)}

/>

:

<div className="no-images">

<h5 className="text-center">

You currently have no images in your photos gallery

</h5>

</div>

}

<Lightbox images={photos}

onClose={this.closeLightbox.bind(this)}

onClickPrev={this.gotoPrevious.bind(this)}

onClickNext={this.gotoNext.bind(this)}

currentImage={this.state.currentImage}

isOpen={this.state.lightboxIsOpen}/>

</div>

);

}

}

Добавьте метод getPhotos () в наш GalleryController для обработки нашего GET-запроса /posts.

public function getPhotos()

{

return response()->json(Auth::user()->photos->toArray());

}

Мы возвращаем ответ JSON со всеми изображениями, загруженными пользователем. Получив ответ, мы обновляем состояние изображений в компоненте Галерея. Мы используем react-photo-gallery для рендеринга изображений и react-images для отображения их в оверлее. Мы определяем несколько обработчиков событий для Lightbox для обработки событий закрытия, следующих и предыдущих событий.

При нажатии отобразится оверлей Lightbox.

Управление загрузками

У нас есть отдельный компонент для управления загруженными изображениями. ManageGallery имеет тот же макет, что и Компонент галереи. Единственное отличие заключается в том, что он не отображает наложение Lightbox на события щелчка. Вместо этого он позволяет выбирать изображения по щелчку и удалять их. Добавить Компонент ManageGallery.

export defaultclass ManageGallery extends Component {

constructor(props){

super(props);

this.state = {

images: [],

selectAll: false,

selected : false,

selected_count : true

};

}

componentDidMount(){

get('/photos')

.then(response => {

let images = response.data.map(image => {

return {

src : '/storage/' + image.uri,

width : image.width,

height : image.height,

id : image.id

}

});

this.setState({

images : images

})

})

}

render() {

return (

<div className="gallery">

{this.state.selected > 0 &&

<button

className="btn btn-danger deleteBtn"

onClick={this.deleteImages.bind(this)}

>

Delete {this.state.selected_count} Selected Photos

</button>

}

{this.state.images.length ?

<ReactGallery

photos={this.state.images}

onClick={this.selectImage.bind(this)}

ImageComponent={SelectedImage} />

:

<div className="no-images">

<h5 className="text-center">

You currently have no images in your photos gallery

</h5>

</div>

}

</div>

);

}

}

В нашем компоненте ManageGallery мы пометим изображение как выбранное при клике. Если есть отмеченные изображения, мы отобразим кнопку удаления. Добавьте этот метод для обработки событий onClick.

selectImage(event, obj) {

let images = this.state.images;

images[obj.index].selected = !images[obj.index].selected;

this.setState({

images: images,

} ,() => {

this.verifyMarked();

});

}

После обновления состояния мы вызываем verifyMarked, в котором мы рассчитаем общее количество отмеченных изображений, а также выбранное обновление и состояние нашего компонента.

verifyMarked(){

let marked = false,

mark_count = 0;

this.state.images.map(image => {

if(image.selected){

marked = true;

mark_count += 1;

}

});

this.setState({

selected : marked,

selected_count : mark_count

})

}

Чтобы обработать кнопку удаления на событии клика, добавьте этот метод.

deleteImages(e) {

e.preventDefault();

let marked = this.state.images.filter(image => {

return image.selected;

});

marked.map(image => {

axios.delete('/photos', {

params : {

id : image.id

}

}).then(response => {

if(response.data.deleted){

this.setState({

images : this.state.images.filter(img => {

return img.id !== image.id

})

});

toastr.success('Images deleted from gallery');

}

})

})

}

Мы отфильтровываем отмеченные изображения, а затем запрашиваем маршрут УДАЛИТЬ/posts, чтобы удалить изображение из базы данных и хранилища. Добавьте этот метод в ваш GalleryController для обработки этого запроса.

public function deletePhoto(Request $request)

{

$photo = Photos::find($request->id);

if(Storage::disk('public')->delete($photo->uri) && $photo->delete()){

return response()->json([

'deleted' => true

]);

}

}

После удаления файла из общего хранилища мы удалим запись из базы данных и вернем успешный ответ. На веб-интерфейсе мы удалим удаленные изображения из локального состояния компонентов.