Merge branch 'fileupload' into 'master'
Video upload through webpage Closes #59 See merge request lukas/openmediacenter!55
This commit is contained in:
commit
b685b7d7be
@ -45,6 +45,8 @@ module.exports = {
|
|||||||
|
|
||||||
// Map from global var to bool specifying if it can be redefined
|
// Map from global var to bool specifying if it can be redefined
|
||||||
globals: {
|
globals: {
|
||||||
|
File: true,
|
||||||
|
FileList: true,
|
||||||
jest: true,
|
jest: true,
|
||||||
__DEV__: true,
|
__DEV__: true,
|
||||||
__dirname: false,
|
__dirname: false,
|
||||||
|
@ -6,4 +6,5 @@ func AddHandlers() {
|
|||||||
addTagHandlers()
|
addTagHandlers()
|
||||||
addActorsHandlers()
|
addActorsHandlers()
|
||||||
addTvshowHandlers()
|
addTvshowHandlers()
|
||||||
|
addUploadHandler()
|
||||||
}
|
}
|
||||||
|
70
apiGo/api/FileUpload.go
Normal file
70
apiGo/api/FileUpload.go
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"openmediacenter/apiGo/api/api"
|
||||||
|
"openmediacenter/apiGo/database"
|
||||||
|
"openmediacenter/apiGo/videoparser"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
func addUploadHandler() {
|
||||||
|
api.AddHandler("fileupload", api.VideoNode, api.PermUser, func(ctx api.Context) {
|
||||||
|
// get path where to store videos to
|
||||||
|
mSettings, PathPrefix, _ := database.GetSettings()
|
||||||
|
|
||||||
|
req := ctx.GetRequest()
|
||||||
|
|
||||||
|
mr, err := req.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
ctx.Errorf("incorrect request!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
videoparser.InitDeps(&mSettings)
|
||||||
|
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo allow more video formats than mp4
|
||||||
|
// only allow valid extensions
|
||||||
|
if filepath.Ext(part.FileName()) != ".mp4" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vidpath := PathPrefix + mSettings.VideoPath + part.FileName()
|
||||||
|
dst, err := os.OpenFile(vidpath, os.O_WRONLY|os.O_CREATE, 0644)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error("error opening file")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Uploading file %s\n", part.FileName())
|
||||||
|
|
||||||
|
// so now loop through every appended file and upload
|
||||||
|
buffer := make([]byte, 100000)
|
||||||
|
for {
|
||||||
|
cBytes, err := part.Read(buffer)
|
||||||
|
if cBytes > 0 {
|
||||||
|
dst.Write(buffer[0:cBytes])
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == io.EOF {
|
||||||
|
fmt.Printf("Finished uploading file %s\n", part.FileName())
|
||||||
|
go videoparser.ProcessVideo(part.FileName())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = dst.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Json(struct {
|
||||||
|
Message string
|
||||||
|
}{Message: "finished all files"})
|
||||||
|
})
|
||||||
|
}
|
@ -15,7 +15,7 @@ const (
|
|||||||
LoginNode = "login"
|
LoginNode = "login"
|
||||||
)
|
)
|
||||||
|
|
||||||
func AddHandler(action string, apiNode string, perm uint8, handler func(ctx Context)) {
|
func AddHandler(action string, apiNode string, perm Perm, handler func(ctx Context)) {
|
||||||
http.Handle(fmt.Sprintf("/api/%s/%s", apiNode, action), http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
http.Handle(fmt.Sprintf("/api/%s/%s", apiNode, action), http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
srvPwd := settings.GetPassword()
|
srvPwd := settings.GetPassword()
|
||||||
if srvPwd == nil {
|
if srvPwd == nil {
|
||||||
|
@ -13,12 +13,18 @@ import (
|
|||||||
|
|
||||||
var srv *server.Server
|
var srv *server.Server
|
||||||
|
|
||||||
|
type Perm uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
PermAdmin uint8 = iota
|
PermAdmin Perm = iota
|
||||||
PermUser uint8 = iota
|
PermUser
|
||||||
PermUnauthorized uint8 = iota
|
PermUnauthorized
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (p Perm) String() string {
|
||||||
|
return [...]string{"PermAdmin", "PermUser", "PermUnauthorized"}[p]
|
||||||
|
}
|
||||||
|
|
||||||
const SignKey = "89013f1753a6890c6090b09e3c23ff43"
|
const SignKey = "89013f1753a6890c6090b09e3c23ff43"
|
||||||
const TokenExpireHours = 24
|
const TokenExpireHours = 24
|
||||||
|
|
||||||
@ -27,7 +33,7 @@ type Token struct {
|
|||||||
ExpiresAt int64
|
ExpiresAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func TokenValid(token string) (int, uint8) {
|
func TokenValid(token string) (int, Perm) {
|
||||||
t, err := jwt.ParseWithClaims(token, &jwt.StandardClaims{}, func(token *jwt.Token) (interface{}, error) {
|
t, err := jwt.ParseWithClaims(token, &jwt.StandardClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||||
return []byte(SignKey), nil
|
return []byte(SignKey), nil
|
||||||
})
|
})
|
||||||
@ -42,7 +48,7 @@ func TokenValid(token string) (int, uint8) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return -1, PermUnauthorized
|
return -1, PermUnauthorized
|
||||||
}
|
}
|
||||||
return id, uint8(permid)
|
return id, Perm(permid)
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitOAuth() {
|
func InitOAuth() {
|
||||||
|
@ -13,6 +13,7 @@ type Context interface {
|
|||||||
GetRequest() *http.Request
|
GetRequest() *http.Request
|
||||||
GetWriter() http.ResponseWriter
|
GetWriter() http.ResponseWriter
|
||||||
UserID() int
|
UserID() int
|
||||||
|
PermID() Perm
|
||||||
}
|
}
|
||||||
|
|
||||||
type apicontext struct {
|
type apicontext struct {
|
||||||
@ -20,7 +21,7 @@ type apicontext struct {
|
|||||||
request *http.Request
|
request *http.Request
|
||||||
responseWritten bool
|
responseWritten bool
|
||||||
userid int
|
userid int
|
||||||
permid uint8
|
permid Perm
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *apicontext) GetRequest() *http.Request {
|
func (r *apicontext) GetRequest() *http.Request {
|
||||||
@ -58,3 +59,7 @@ func (r *apicontext) Error(msg string) {
|
|||||||
func (r *apicontext) Errorf(msg string, args ...interface{}) {
|
func (r *apicontext) Errorf(msg string, args ...interface{}) {
|
||||||
r.Error(fmt.Sprintf(msg, &args))
|
r.Error(fmt.Sprintf(msg, &args))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *apicontext) PermID() Perm {
|
||||||
|
return r.permid
|
||||||
|
}
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"openmediacenter/apiGo/api/types"
|
"openmediacenter/apiGo/api/types"
|
||||||
|
"openmediacenter/apiGo/config"
|
||||||
"openmediacenter/apiGo/database"
|
"openmediacenter/apiGo/database"
|
||||||
"openmediacenter/apiGo/videoparser/tmdb"
|
"openmediacenter/apiGo/videoparser/tmdb"
|
||||||
"regexp"
|
"regexp"
|
||||||
@ -11,7 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
var mSettings types.SettingsType
|
var mSettings *types.SettingsType
|
||||||
var mExtDepsAvailable *ExtDependencySupport
|
var mExtDepsAvailable *ExtDependencySupport
|
||||||
|
|
||||||
// default Tag ids
|
// default Tag ids
|
||||||
@ -32,20 +33,22 @@ type VideoAttributes struct {
|
|||||||
Width uint
|
Width uint
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReIndexVideos(path []string, sett types.SettingsType) {
|
func InitDeps(sett *types.SettingsType) {
|
||||||
mSettings = sett
|
mSettings = sett
|
||||||
// check if the extern dependencies are available
|
// check if the extern dependencies are available
|
||||||
mExtDepsAvailable = checkExtDependencySupport()
|
mExtDepsAvailable = checkExtDependencySupport()
|
||||||
fmt.Printf("FFMPEG support: %t\n", mExtDepsAvailable.FFMpeg)
|
fmt.Printf("FFMPEG support: %t\n", mExtDepsAvailable.FFMpeg)
|
||||||
fmt.Printf("MediaInfo support: %t\n", mExtDepsAvailable.MediaInfo)
|
fmt.Printf("MediaInfo support: %t\n", mExtDepsAvailable.MediaInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReIndexVideos(path []string) {
|
||||||
// filter out those urls which are already existing in db
|
// filter out those urls which are already existing in db
|
||||||
nonExisting := filterExisting(path)
|
nonExisting := filterExisting(path)
|
||||||
|
|
||||||
fmt.Printf("There are %d videos not existing in db.\n", len(*nonExisting))
|
fmt.Printf("There are %d videos not existing in db.\n", len(*nonExisting))
|
||||||
|
|
||||||
for _, s := range *nonExisting {
|
for _, s := range *nonExisting {
|
||||||
processVideo(s)
|
ProcessVideo(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
AppendMessage("reindex finished successfully!")
|
AppendMessage("reindex finished successfully!")
|
||||||
@ -92,8 +95,8 @@ func filterExisting(paths []string) *[]string {
|
|||||||
return &resultarr
|
return &resultarr
|
||||||
}
|
}
|
||||||
|
|
||||||
func processVideo(fileNameOrig string) {
|
func ProcessVideo(fileNameOrig string) {
|
||||||
fmt.Printf("Processing %s video-", fileNameOrig)
|
fmt.Printf("Processing %s video\n", fileNameOrig)
|
||||||
|
|
||||||
// match the file extension
|
// match the file extension
|
||||||
r := regexp.MustCompile(`\.[a-zA-Z0-9]+$`)
|
r := regexp.MustCompile(`\.[a-zA-Z0-9]+$`)
|
||||||
@ -120,8 +123,10 @@ func addVideo(videoName string, fileName string, year int) {
|
|||||||
Width: 0,
|
Width: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vidFolder := config.GetConfig().General.ReindexPrefix + mSettings.VideoPath
|
||||||
|
|
||||||
if mExtDepsAvailable.FFMpeg {
|
if mExtDepsAvailable.FFMpeg {
|
||||||
ppic, err = parseFFmpegPic(mSettings.VideoPath + fileName)
|
ppic, err = parseFFmpegPic(vidFolder + fileName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("FFmpeg error occured: %s\n", err.Error())
|
fmt.Printf("FFmpeg error occured: %s\n", err.Error())
|
||||||
} else {
|
} else {
|
||||||
@ -130,7 +135,7 @@ func addVideo(videoName string, fileName string, year int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if mExtDepsAvailable.MediaInfo {
|
if mExtDepsAvailable.MediaInfo {
|
||||||
atr := getVideoAttributes(mSettings.VideoPath + fileName)
|
atr := getVideoAttributes(vidFolder + fileName)
|
||||||
if atr != nil {
|
if atr != nil {
|
||||||
vidAtr = atr
|
vidAtr = atr
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ package videoparser
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
|
"openmediacenter/apiGo/config"
|
||||||
"openmediacenter/apiGo/database"
|
"openmediacenter/apiGo/database"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -19,20 +20,21 @@ func StartReindex() bool {
|
|||||||
SendEvent("start")
|
SendEvent("start")
|
||||||
AppendMessage("starting reindex..")
|
AppendMessage("starting reindex..")
|
||||||
|
|
||||||
mSettings, PathPrefix, _ := database.GetSettings()
|
mSettings, _, _ := database.GetSettings()
|
||||||
|
|
||||||
// add the path prefix to videopath
|
// add the path prefix to videopath
|
||||||
mSettings.VideoPath = PathPrefix + mSettings.VideoPath
|
vidFolder := config.GetConfig().General.ReindexPrefix + mSettings.VideoPath
|
||||||
|
|
||||||
// check if path even exists
|
// check if path even exists
|
||||||
if _, err := os.Stat(mSettings.VideoPath); os.IsNotExist(err) {
|
if _, err := os.Stat(vidFolder); os.IsNotExist(err) {
|
||||||
fmt.Println("Reindex path doesn't exist!")
|
fmt.Println("Reindex path doesn't exist!")
|
||||||
AppendMessage(fmt.Sprintf("Reindex path doesn't exist! :%s", mSettings.VideoPath))
|
AppendMessage(fmt.Sprintf("Reindex path doesn't exist! :%s", vidFolder))
|
||||||
SendEvent("stop")
|
SendEvent("stop")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
var files []string
|
var files []string
|
||||||
err := filepath.Walk(mSettings.VideoPath, func(path string, info os.FileInfo, err error) error {
|
err := filepath.Walk(vidFolder, func(path string, info os.FileInfo, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err.Error())
|
fmt.Println(err.Error())
|
||||||
return err
|
return err
|
||||||
@ -49,7 +51,8 @@ func StartReindex() bool {
|
|||||||
}
|
}
|
||||||
// start reindex process
|
// start reindex process
|
||||||
AppendMessage("Starting Reindexing!")
|
AppendMessage("Starting Reindexing!")
|
||||||
go ReIndexVideos(files, mSettings)
|
InitDeps(&mSettings)
|
||||||
|
go ReIndexVideos(files)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,7 +13,8 @@ server {
|
|||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location ~* ^/(api/|token) {
|
location ~* ^/(api) {
|
||||||
|
client_max_body_size 10G;
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
}
|
}
|
||||||
location /subscribe {
|
location /subscribe {
|
||||||
|
30
src/elements/DropZone/DropZone.module.css
Normal file
30
src/elements/DropZone/DropZone.module.css
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
.dropArea {
|
||||||
|
border: 2px dashed #ccc;
|
||||||
|
border-radius: 20px;
|
||||||
|
width: 480px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropArea:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropArea.highlight {
|
||||||
|
border-color: purple;
|
||||||
|
}
|
||||||
|
|
||||||
|
.myForm {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progresswrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 5px;
|
||||||
|
margin-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finished {
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
108
src/elements/DropZone/DropZone.tsx
Normal file
108
src/elements/DropZone/DropZone.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import style from './DropZone.module.css';
|
||||||
|
import React, {useState} from 'react';
|
||||||
|
import {cookie} from '../../utils/context/Cookie';
|
||||||
|
import GlobalInfos from '../../utils/GlobalInfos';
|
||||||
|
|
||||||
|
export const DropZone = (): JSX.Element => {
|
||||||
|
const [ondrag, setDrag] = useState(0);
|
||||||
|
const [percent, setpercent] = useState(0.0);
|
||||||
|
const [finished, setfinished] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const theme = GlobalInfos.getThemeStyle();
|
||||||
|
|
||||||
|
const uploadFile = (f: FileList): void => {
|
||||||
|
const xhr = new XMLHttpRequest(); // create XMLHttpRequest
|
||||||
|
const data = new FormData(); // create formData object
|
||||||
|
|
||||||
|
for (let i = 0; i < f.length; i++) {
|
||||||
|
const file = f.item(i);
|
||||||
|
if (file) {
|
||||||
|
data.append('file' + i, file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.onload = function (): void {
|
||||||
|
console.log(this.responseText); // whatever the server returns
|
||||||
|
|
||||||
|
const resp = JSON.parse(this.responseText);
|
||||||
|
if (resp.Message === 'finished all files') {
|
||||||
|
setfinished('');
|
||||||
|
} else {
|
||||||
|
setfinished(resp.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setpercent(0);
|
||||||
|
setfinished(null);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.upload.onprogress = function (e): void {
|
||||||
|
console.log(e.loaded / e.total);
|
||||||
|
setpercent((e.loaded * 100.0) / e.total);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.open('post', '/api/video/fileupload'); // open connection
|
||||||
|
xhr.setRequestHeader('Accept', 'multipart/form-data');
|
||||||
|
|
||||||
|
const tkn = cookie.Load();
|
||||||
|
if (tkn) {
|
||||||
|
xhr.setRequestHeader('Token', tkn.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.send(data); // send data
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={style.dropArea + (ondrag > 0 ? ' ' + style.highlight : '') + ' ' + theme.secbackground}
|
||||||
|
onDragEnter={(e): void => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDrag(ondrag + 1);
|
||||||
|
}}
|
||||||
|
onDragLeave={(e): void => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDrag(ondrag - 1);
|
||||||
|
}}
|
||||||
|
onDragOver={(e): void => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onDrop={(e): void => {
|
||||||
|
setDrag(0);
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
uploadFile(e.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
onClick={(): void => {
|
||||||
|
let input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.multiple = true;
|
||||||
|
input.onchange = function (): void {
|
||||||
|
if (input.files) {
|
||||||
|
uploadFile(input.files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
}}>
|
||||||
|
<div className={style.myForm}>
|
||||||
|
<p>To upload new Videos darg and drop them here or click to select some...</p>
|
||||||
|
<div className={style.progresswrapper}>
|
||||||
|
<div style={{width: percent + '%', backgroundColor: 'green', height: 5}} />
|
||||||
|
</div>
|
||||||
|
{finished !== null ? (
|
||||||
|
finished === '' ? (
|
||||||
|
<div className={style.finished}>Finished uploading</div>
|
||||||
|
) : (
|
||||||
|
<div className={style.finished}>Upload failed: {finished}</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -11,3 +11,9 @@
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
width: 40%;
|
width: 40%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.uploadtext {
|
||||||
|
font-size: x-large;
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
import style from './MovieSettings.module.css';
|
import style from './MovieSettings.module.css';
|
||||||
import {APINode, callAPI} from '../../utils/Api';
|
import {APINode, callAPI} from '../../utils/Api';
|
||||||
import {GeneralSuccess} from '../../types/GeneralTypes';
|
import {GeneralSuccess} from '../../types/GeneralTypes';
|
||||||
|
import {DropZone} from '../../elements/DropZone/DropZone';
|
||||||
|
import GlobalInfos from '../../utils/GlobalInfos';
|
||||||
|
|
||||||
interface state {
|
interface state {
|
||||||
text: string[];
|
text: string[];
|
||||||
@ -99,6 +101,8 @@ class MovieSettings extends React.Component<Props, state> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(): JSX.Element {
|
render(): JSX.Element {
|
||||||
|
const theme = GlobalInfos.getThemeStyle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@ -123,6 +127,10 @@ class MovieSettings extends React.Component<Props, state> {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className={theme.textcolor}>
|
||||||
|
<div className={style.uploadtext}>Video Upload:</div>
|
||||||
|
<DropZone />
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user