Browse Source

before dnd

ilya_shyian 2 năm trước cách đây
mục cha
commit
174e9c0025

+ 59 - 0
package-lock.json

@@ -17,6 +17,7 @@
         "node-sass": "^7.0.1",
         "react": "^18.1.0",
         "react-dom": "^18.1.0",
+        "react-dropzone": "^14.2.1",
         "react-icons": "^4.3.1",
         "react-redux": "^8.0.1",
         "react-responsive-carousel": "^3.2.23",
@@ -4670,6 +4671,14 @@
         "node": ">= 4.5.0"
       }
     },
+    "node_modules/attr-accept": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
+      "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==",
+      "engines": {
+        "node": ">=4"
+      }
+    },
     "node_modules/autoprefixer": {
       "version": "10.4.7",
       "integrity": "sha512-ypHju4Y2Oav95SipEcCcI5J7CGPuvz8oat7sUtYj3ClK44bldfvtvcxK6IEK++7rqB7YchDGzweZIBG+SD0ZAA==",
@@ -7546,6 +7555,17 @@
         "webpack": "^4.0.0 || ^5.0.0"
       }
     },
+    "node_modules/file-selector": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
+      "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
+      "dependencies": {
+        "tslib": "^2.4.0"
+      },
+      "engines": {
+        "node": ">= 12"
+      }
+    },
     "node_modules/filelist": {
       "version": "1.0.3",
       "integrity": "sha512-LwjCsruLWQULGYKy7TX0OPtrL9kLpojOFKc5VCTxdFTV7w5zbsgqVKfnkKG7Qgjtq50gKfO56hJv88OfcGb70Q==",
@@ -13800,6 +13820,22 @@
         "react": "^18.1.0"
       }
     },
+    "node_modules/react-dropzone": {
+      "version": "14.2.1",
+      "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.1.tgz",
+      "integrity": "sha512-jzX6wDtAjlfwZ+Fbg+G17EszWUkQVxhMTWMfAC9qSUq7II2pKglHA8aarbFKl0mLpRPDaNUcy+HD/Sf4gkf76Q==",
+      "dependencies": {
+        "attr-accept": "^2.2.2",
+        "file-selector": "^0.6.0",
+        "prop-types": "^15.8.1"
+      },
+      "engines": {
+        "node": ">= 10.13"
+      },
+      "peerDependencies": {
+        "react": ">= 16.8 || 18.0.0"
+      }
+    },
     "node_modules/react-easy-swipe": {
       "version": "0.0.21",
       "resolved": "https://registry.npmjs.org/react-easy-swipe/-/react-easy-swipe-0.0.21.tgz",
@@ -20153,6 +20189,11 @@
       "version": "2.1.2",
       "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg=="
     },
+    "attr-accept": {
+      "version": "2.2.2",
+      "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz",
+      "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg=="
+    },
     "autoprefixer": {
       "version": "10.4.7",
       "integrity": "sha512-ypHju4Y2Oav95SipEcCcI5J7CGPuvz8oat7sUtYj3ClK44bldfvtvcxK6IEK++7rqB7YchDGzweZIBG+SD0ZAA==",
@@ -22200,6 +22241,14 @@
         "schema-utils": "^3.0.0"
       }
     },
+    "file-selector": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
+      "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
+      "requires": {
+        "tslib": "^2.4.0"
+      }
+    },
     "filelist": {
       "version": "1.0.3",
       "integrity": "sha512-LwjCsruLWQULGYKy7TX0OPtrL9kLpojOFKc5VCTxdFTV7w5zbsgqVKfnkKG7Qgjtq50gKfO56hJv88OfcGb70Q==",
@@ -26460,6 +26509,16 @@
         "scheduler": "^0.22.0"
       }
     },
+    "react-dropzone": {
+      "version": "14.2.1",
+      "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.1.tgz",
+      "integrity": "sha512-jzX6wDtAjlfwZ+Fbg+G17EszWUkQVxhMTWMfAC9qSUq7II2pKglHA8aarbFKl0mLpRPDaNUcy+HD/Sf4gkf76Q==",
+      "requires": {
+        "attr-accept": "^2.2.2",
+        "file-selector": "^0.6.0",
+        "prop-types": "^15.8.1"
+      }
+    },
     "react-easy-swipe": {
       "version": "0.0.21",
       "resolved": "https://registry.npmjs.org/react-easy-swipe/-/react-easy-swipe-0.0.21.tgz",

+ 1 - 0
package.json

@@ -13,6 +13,7 @@
     "node-sass": "^7.0.1",
     "react": "^18.1.0",
     "react-dom": "^18.1.0",
+    "react-dropzone": "^14.2.1",
     "react-icons": "^4.3.1",
     "react-redux": "^8.0.1",
     "react-responsive-carousel": "^3.2.23",

+ 40 - 38
src/actions/actionGoodById.js

@@ -2,42 +2,44 @@ import { mock, query } from '../helpers';
 
 import { actionPromise } from '../reducers';
 
-export const actionGoodById = (_id) => async (dispatch, getState) => {
-    dispatch(
-        actionPromise(
-            'goodById',
-            new Promise((resolve) => {
-                setTimeout(
-                    Math.random() > 0.01
-                        ? resolve({
-                              data: {
-                                  _id: 6,
-                                  name: 'Good 6',
-                                  description: 'adaadasda asasd asd asd asd asd ',
-                                  price: '999',
-                                  images: [
-                                      {
-                                          url: 'https://content2.rozetka.com.ua/goods/images/big/183546719.jpg',
-                                      },
-                                      {
-                                          url: 'https://content2.rozetka.com.ua/goods/images/big/183546719.jpg',
-                                      },
-                                  ],
-                              },
-                          })
-                        : resolve({
-                              errors: [{ message: 'Error adsasdadas' }],
-                          }),
-                    400
-                );
-            })
-                // .then((res) => res.json())
-                .then((data) => {
-                    console.log(data);
-                    if (data.errors) {
-                        throw new Error(JSON.stringify(data.errors));
-                    } else return data.data;
+export const actionGoodById =
+    ({ _id, promiseName = 'goodById' } = {}) =>
+    async (dispatch, getState) => {
+        dispatch(
+            actionPromise(
+                promiseName,
+                new Promise((resolve) => {
+                    setTimeout(
+                        Math.random() > 0.01
+                            ? resolve({
+                                  data: {
+                                      _id: 6,
+                                      name: 'Good 6',
+                                      description: 'adaadasda asasd asd asd asd asd ',
+                                      price: '999',
+                                      images: [
+                                          {
+                                              url: 'https://content2.rozetka.com.ua/goods/images/big/183546719.jpg',
+                                          },
+                                          {
+                                              url: 'https://content2.rozetka.com.ua/goods/images/big/183546719.jpg',
+                                          },
+                                      ],
+                                  },
+                              })
+                            : resolve({
+                                  errors: [{ message: 'Error adsasdadas' }],
+                              }),
+                        400
+                    );
                 })
-        )
-    );
-};
+                    // .then((res) => res.json())
+                    .then((data) => {
+                        console.log(data);
+                        if (data.errors) {
+                            throw new Error(JSON.stringify(data.errors));
+                        } else return data.data;
+                    })
+            )
+        );
+    };

+ 7 - 0
src/actions/actionGoodUpdate.js

@@ -0,0 +1,7 @@
+import { actionGoodsAll } from './actionGoodsAll';
+import { actionGoodUpsert } from './actionGoodUpsert';
+
+export const actionGoodUpdate = (good) => async (dispatch, getState) => {
+    await dispatch(actionGoodUpsert(good));
+    await dispatch(actionGoodsAll());
+};

+ 31 - 0
src/actions/actionGoodUpsert.js

@@ -0,0 +1,31 @@
+import { actionPromise } from '../reducers';
+
+export const actionGoodUpsert = (good) => async (dispatch) => {
+    dispatch(
+        actionPromise(
+            'goodUpsert',
+            new Promise((resolve) => {
+                setTimeout(
+                    Math.random() > 0.01
+                        ? resolve({
+                              data: {
+                                  _id: 1,
+                                  name: 'Good 1',
+                                  description: 'adaadasda asasd asd asd asd asd ',
+                                  price: '999',
+                                  amount: 9999,
+                                  images: [
+                                      { url: 'https://content2.rozetka.com.ua/goods/images/big/183546719.jpg' },
+                                      { url: 'https://content2.rozetka.com.ua/goods/images/big/183546719.jpg' },
+                                  ],
+                              },
+                          })
+                        : resolve({
+                              errors: [{ message: 'Error adsasdadas' }],
+                          }),
+                    400
+                );
+            })
+        )
+    );
+};

+ 14 - 0
src/actions/actionUploadFile.js

@@ -0,0 +1,14 @@
+import { actionPromise } from '../reducers';
+
+export const actionUploadFile = (file) => {
+    const fd = new FormData();
+    fd.append('photo', file);
+    return actionPromise(
+        'uploadFile',
+        fetch('/upload', {
+            method: 'POST',
+            headers: localStorage.authToken ? { Authorization: 'Bearer ' + localStorage.authToken } : {},
+            body: fd,
+        }).then((res) => res.json())
+    );
+};

+ 8 - 0
src/actions/actionUploadFiles.js

@@ -0,0 +1,8 @@
+import { actionUploadFile } from './actionUploadFile';
+import { actionPromise } from '../reducers';
+
+export const actionUploadFiles =
+    (files = []) =>
+    async (dispatch, getState) => {
+        actionPromise('uploadFiles', await Promise.all(files?.map((file) => dispatch(actionUploadFile(file)))));
+    };

+ 165 - 0
src/components/admin/AdminGoodPage/GoodForm.js

@@ -0,0 +1,165 @@
+import { connect } from 'react-redux';
+import React, { useState, useEffect } from 'react';
+import { actionPromise, actionPromiseClear } from '../../../reducers';
+
+import { actionGoodUpdate } from '../../../actions/actionGoodUpdate';
+import { EntityEditor } from '../../common/EntityEditor';
+import { actionUploadFiles } from '../../../actions/actionUploadFiles';
+import { Box, Button, InputLabel, Stack, TextareaAutosize, TextField, Typography } from '@mui/material';
+import { useFormik } from 'formik';
+import * as Yup from 'yup';
+import { Error } from '../../common/Error';
+
+const goodSchema = Yup.object().shape({
+    name: Yup.string().min(3, 'Too Short!').max(15, 'Too Long!').required('Required'),
+    description: Yup.string().min(3, 'Too Short!').max(15, 'Too Long!').required('Required'),
+    price: Yup.number().min(0, 'Должно быть больше нуля'),
+});
+
+const CGoodEditor = connect(
+    (state) => ({
+        entity: state.promise?.adminGoodById?.payload || {},
+        uploadFiles: state.promise?.uploadFiles,
+    }),
+    {
+        onFileDrop: (files) => actionUploadFiles(files),
+    }
+)(EntityEditor);
+
+export const GoodForm = ({
+    serverErrors,
+    onSaveClick,
+    onSave,
+    onClose,
+    promiseStatus,
+    catList = [],
+    good = {},
+} = {}) => {
+    const [inputCategories, setInputCategories] = useState([]);
+    const [inputImages, setInputImages] = useState([]);
+
+    const formik = useFormik({
+        initialValues: {
+            name: '',
+            description: '',
+            price: 0,
+        },
+        validationSchema: goodSchema,
+        validateOnChange: true,
+        onSubmit: () => {
+            let goodToSave = {};
+            good?._id && (goodToSave._id = good._id);
+            goodToSave.name = formik.values.name;
+            goodToSave.description = formik.values.description;
+            goodToSave.price = +formik.values.price;
+            goodToSave.categories = inputCategories;
+            goodToSave.images = inputImages?.map(({ _id }) => ({ _id })) || [];
+
+            onSaveClick && onSaveClick();
+            onSave(goodToSave);
+        },
+    });
+
+    useEffect(() => {
+        setInputCategories(good?.categories || []);
+        setInputImages(good?.images || []);
+        formik.setFieldValue('name', good.name || '');
+        formik.setFieldValue('description', good.description || '');
+        formik.setFieldValue('price', good.price || 0);
+    }, [good]);
+
+    useEffect(() => {
+        return () => {
+            onClose && onClose();
+        };
+    }, []);
+    return (
+        <Box className="GoodForm" component="form" onSubmit={formik.handleSubmit}>
+            {(serverErrors || []).map((error) => (
+                <Error>{error?.message}</Error>
+            ))}
+
+            <TextField
+                id="name"
+                name="name"
+                variant="standard"
+                label="Название"
+                error={formik.touched.name && Boolean(formik.errors.name)}
+                value={formik.values.name}
+                onBlur={formik.handleBlur}
+                onChange={formik.handleChange}
+                helperText={formik.touched.name && formik.errors.name}
+                multiline
+                fullWidth
+                sx={{ mt: 2 }}
+            />
+
+            <Box sx={{ mt: 3 }}>
+                <InputLabel>Картинки</InputLabel>
+                <CGoodEditor onImagesSave={(images) => setInputImages(images)} />
+            </Box>
+
+            <TextField
+                variant="standard"
+                id="description"
+                name="description"
+                label="Описание"
+                error={formik.touched.description && Boolean(formik.errors.description)}
+                value={formik.values.description}
+                onBlur={formik.handleBlur}
+                onChange={formik.handleChange}
+                helperText={formik.touched.description && formik.errors.description}
+                multiline
+                fullWidth
+                sx={{ mt: 2 }}
+            />
+
+            <Box sx={{ mt: 3 }}>
+                <InputLabel>Цена</InputLabel>
+                <TextField
+                    variant="standard"
+                    id="price"
+                    name="price"
+                    label="Цена"
+                    error={formik.touched.price && Boolean(formik.errors.price)}
+                    value={formik.values.price}
+                    onBlur={formik.handleBlur}
+                    onChange={formik.handleChange}
+                    helperText={formik.touched.price && formik.errors.price}
+                    multiline
+                    fullWidth
+                    sx={{ mt: 2 }}
+                />
+            </Box>
+
+            <Box sx={{ mt: 3 }}>
+                <InputLabel>Категории</InputLabel>
+                {/* <Select
+                    value={inputCategories.map(({ _id, name }) => ({ value: _id, label: name }))}
+                    closeMenuOnSelect={false}
+                    onChange={(e) => setInputCategories(e.map(({ label, value }) => ({ _id: value, name: label })))}
+                    options={catList?.map(({ _id, name }) => ({ value: _id, label: name }))}
+                    isMulti={true}
+                /> */}
+            </Box>
+
+            <Box direction="row" sx={{ mt: 3 }} justifyContent="flex-end">
+                <Button disabled={!formik.isValid} type="submit">
+                    Сохранить
+                </Button>
+            </Box>
+        </Box>
+    );
+};
+
+export const CGoodForm = connect(
+    (state) => ({
+        catList: state.promise.catAll?.payload || [],
+        promiseStatus: state.promise.goodUpsert?.status || null,
+        good: state.promise?.adminGoodById?.payload || {},
+    }),
+    {
+        onSave: (good) => actionGoodUpdate(good),
+        onClose: () => actionPromiseClear('goodUpsert'),
+    }
+)(GoodForm);

+ 12 - 0
src/components/admin/AdminGoodPage/index.js

@@ -0,0 +1,12 @@
+import { Box } from '@mui/material';
+import { CGoodForm } from './GoodForm';
+import { connect } from 'react-redux';
+
+export const AdminGoodPage = ({ good }) => (
+    <Box className="AdminGoodPage">
+        <CGoodForm good={good} />
+    </Box>
+);
+export const CAdminGoodPage = connect((state) => ({ good: state.promise?.adminGoodById?.payload || {} }))(
+    AdminGoodPage
+);

+ 18 - 1
src/components/admin/AdminLayoutPage/index.js

@@ -1,12 +1,27 @@
 import { Box, Container } from '@mui/material';
 import { useEffect } from 'react';
 import { connect, useDispatch, useSelector } from 'react-redux';
-import { Route, Routes } from 'react-router-dom';
+import { Route, Routes, useParams } from 'react-router-dom';
+import { actionGoodById } from '../../../actions/actionGoodById';
 import { actionPromiseClear, store } from '../../../reducers';
 import { actionFeedAdd, actionFeedClear, actionFeedGoods } from '../../../reducers/feedReducer';
 import { CProtectedRoute } from '../../common/ProtectedRoute';
+import { CAdminGoodPage } from '../AdminGoodPage';
 import { AdminGoodsPage } from '../AdminGoodsPage';
 
+const AdminGoodPageContainer = () => {
+    const params = useParams();
+    const dispatch = useDispatch();
+    useEffect(() => {
+        if (params._id) {
+            dispatch(actionGoodById({ _id: params._id, promiseName: 'adminGoodById' }));
+        } else {
+            dispatch(actionGoodById('adminGoodById'));
+        }
+    }, [params._id]);
+    return <CAdminGoodPage />;
+};
+
 const AdminGoodsPageContainer = ({ goods }) => {
     const dispatch = useDispatch();
     useEffect(() => {
@@ -46,6 +61,8 @@ const AdminLayoutPage = () => {
         <Box className="AdminLayoutPage">
             <Routes>
                 <Route path="/goods/" element={<CAdminGoodsPageContainer />} />
+                <Route path="/good/" element={<AdminGoodPageContainer />} />
+                <Route path="/good/:_id" element={<AdminGoodPageContainer />} />
             </Routes>
         </Box>
     );

+ 20 - 0
src/components/common/DropZone.js

@@ -0,0 +1,20 @@
+import { useDropzone } from "react-dropzone";
+import { useEffect } from "react";
+import { connect } from "react-redux";
+import { Box } from "@mui/material";
+export const DropZone = ({ onFileDrop, children }) => {
+    const { acceptedFiles, getRootProps, getInputProps } = useDropzone();
+
+    useEffect(() => {
+        if (acceptedFiles.length) {
+            onFileDrop(acceptedFiles);
+        }
+    }, [acceptedFiles]);
+
+    return (
+        <Box {...getRootProps({ className: "Dropzone" })}>
+            {/* <input {...getInputProps()} /> */}
+            {children}
+        </Box>
+    );
+};

+ 0 - 0
src/components/common/DropZone/index.js


+ 77 - 0
src/components/common/EntityEditor.js

@@ -0,0 +1,77 @@
+import { useEffect, useState } from 'react';
+import { arrayMoveImmutable } from 'array-move';
+import { DropZone } from './DropZone';
+import { SortableList } from './SortableList';
+import { SortableItem } from './SortableItem';
+import { backendURL } from '../helpers';
+import { Box, Button, IconButton, ImageList, ImageListItem, ImageListItemBar, Typography } from '@mui/material';
+import { MdClose } from 'react-icons/md';
+
+export const EntityEditor = ({ entity = { images: [] }, onSave, onFileDrop, uploadFiles, onImagesSave }) => {
+    const [state, setState] = useState(entity);
+
+    useEffect(() => {
+        if (uploadFiles?.status === 'FULFILLED') {
+            setState({ ...state, images: [...(state.images || []), ...uploadFiles?.payload] });
+        }
+    }, [uploadFiles]);
+
+    useEffect(() => {
+        onImagesSave && onImagesSave(state.images?.filter((img) => img?._id && img?.url));
+    }, [state.images]);
+
+    const onSortEnd = ({ oldIndex, newIndex }) => {
+        setState({ ...state, images: arrayMoveImmutable(state.images, oldIndex, newIndex) });
+    };
+    const onItemRemove = (toRemoveId) => {
+        setState({ ...state, images: [...state.images.filter((el) => el?._id !== toRemoveId)] });
+    };
+
+    return (
+        <Box className="EntityEditor">
+            <DropZone onFileDrop={(files) => onFileDrop(files)}>
+                <Typography>Drop images here!</Typography>
+            </DropZone>
+            <SortableList pressDelay={200} onSortEnd={onSortEnd} axis="xy" className="SortableContainer">
+                <ImageList sx={{ maxHeight: 800 }} cols={3} rowHeight={164} fullwidth>
+                    {state.images?.map(
+                        (image, index) =>
+                            !!image?._id &&
+                            !!image?.url && (
+                                <SortableItem key={`item-${image._id}`} index={index}>
+                                    <ImageListItem key={image._id}>
+                                        <ImageListItemBar
+                                            sx={{
+                                                background: 'rgba(0,0,0,0.1)',
+                                            }}
+                                            actionIcon={
+                                                <IconButton onClick={() => onItemRemove(image._id)}>
+                                                    <MdClose />
+                                                </IconButton>
+                                            }
+                                        />
+                                        <Box
+                                            component="img"
+                                            className="DropZoneImage"
+                                            src={`/${image.url}`}
+                                            loading="lazy"
+                                            sx={{ width: 165, height: 165 }}
+                                        />
+                                    </ImageListItem>
+                                </SortableItem>
+                            )
+                    )}
+                </ImageList>
+            </SortableList>
+            {!!onSave && (
+                <Button
+                    onClick={() => {
+                        onSave(entity._id, state.images);
+                    }}
+                >
+                    Save
+                </Button>
+            )}
+        </Box>
+    );
+};

+ 2 - 0
src/components/common/EntityEditor/SortableItem.js

@@ -0,0 +1,2 @@
+import { SortableElement } from "react-sortable-hoc";
+export const SortableItem = SortableElement(({ children }) => <div className="SortableItem">{children}</div>);

+ 5 - 0
src/components/common/EntityEditor/SortableList.js

@@ -0,0 +1,5 @@
+import { SortableContainer } from 'react-sortable-hoc';
+
+export const SortableList = SortableContainer(({ children }) => {
+    return <div className="SortableContainer">{children}</div>;
+});

+ 76 - 0
src/components/common/EntityEditor/index.js

@@ -0,0 +1,76 @@
+import { useEffect, useState } from 'react';
+import { arrayMoveImmutable } from 'array-move';
+import { DropZone } from './DropZone';
+import { SortableList } from './SortableList';
+import { SortableItem } from './SortableItem';
+import { Box, Button, IconButton, ImageList, ImageListItem, ImageListItemBar, Typography } from '@mui/material';
+import { MdClose } from 'react-icons/md';
+
+export const EntityEditor = ({ entity = { images: [] }, onSave, onFileDrop, uploadFiles, onImagesSave }) => {
+    const [state, setState] = useState(entity);
+
+    useEffect(() => {
+        if (uploadFiles?.status === 'FULFILLED') {
+            setState({ ...state, images: [...(state.images || []), ...uploadFiles?.payload] });
+        }
+    }, [uploadFiles]);
+
+    useEffect(() => {
+        onImagesSave && onImagesSave(state.images?.filter((img) => img?._id && img?.url));
+    }, [state.images]);
+
+    const onSortEnd = ({ oldIndex, newIndex }) => {
+        setState({ ...state, images: arrayMoveImmutable(state.images, oldIndex, newIndex) });
+    };
+    const onItemRemove = (toRemoveId) => {
+        setState({ ...state, images: [...state.images.filter((el) => el?._id !== toRemoveId)] });
+    };
+
+    return (
+        <Box className="EntityEditor">
+            <DropZone onFileDrop={(files) => onFileDrop(files)}>
+                <Typography>Drop images here!</Typography>
+            </DropZone>
+            <SortableList pressDelay={200} onSortEnd={onSortEnd} axis="xy" className="SortableContainer">
+                <ImageList sx={{ maxHeight: 800 }} cols={3} rowHeight={164} fullwidth>
+                    {state.images?.map(
+                        (image, index) =>
+                            !!image?._id &&
+                            !!image?.url && (
+                                <SortableItem key={`item-${image._id}`} index={index}>
+                                    <ImageListItem key={image._id}>
+                                        <ImageListItemBar
+                                            sx={{
+                                                background: 'rgba(0,0,0,0.1)',
+                                            }}
+                                            actionIcon={
+                                                <IconButton onClick={() => onItemRemove(image._id)}>
+                                                    <MdClose />
+                                                </IconButton>
+                                            }
+                                        />
+                                        <Box
+                                            component="img"
+                                            className="DropZoneImage"
+                                            src={`/${image.url}`}
+                                            loading="lazy"
+                                            sx={{ width: 165, height: 165 }}
+                                        />
+                                    </ImageListItem>
+                                </SortableItem>
+                            )
+                    )}
+                </ImageList>
+            </SortableList>
+            {!!onSave && (
+                <Button
+                    onClick={() => {
+                        onSave(entity._id, state.images);
+                    }}
+                >
+                    Save
+                </Button>
+            )}
+        </Box>
+    );
+};

+ 12 - 1
src/index.scss

@@ -168,7 +168,18 @@
       padding: 10px;
       padding-bottom: 400px;
 
-
+      & .AdminGoodPage{
+        & .EntityEditor{
+          & .Dropzone{
+            background: #F1F2F4;
+            width: 400px;
+            padding: 70px 0;
+            border: 1px dashed #E9EAEC;
+            border-radius: 5px;
+            text-align: center;
+          }
+        }
+      }
       & .AdminGoodList{
         width:90%;
         display: flex;