From fb932f399feaf8242220a232b039672f30fe4cec Mon Sep 17 00:00:00 2001 From: Luca Tessaro Date: Sun, 25 Oct 2020 15:55:54 +0100 Subject: [PATCH] Drawing upload and visualization. Small improvements also to the dropzone (#2) --- .flaskenv | 3 +- frontend/package.json | 1 + frontend/src/App.js | 8 +- frontend/src/index.js | 4 + frontend/src/project_defaults.js | 7 ++ frontend/src/structure/Content.js | 2 +- frontend/src/structure/Toasts.js | 1 + frontend/src/structure/tabs/Drawings.js | 19 ----- frontend/src/structure/tabs/Home.js | 41 ++++++++-- .../structure/tabs/drawings/DrawingCard.js | 52 +++++++++++++ .../structure/tabs/drawings/DrawingCard.scss | 4 + .../tabs/drawings/DrawingDataDownloader.js | 21 ++++++ .../src/structure/tabs/drawings/Drawings.js | 61 +++++++++++++++ .../structure/tabs/drawings/UploadDrawing.js | 65 ++++++++++++++++ .../tabs/drawings/UploadDrawing.scss | 31 ++++++++ frontend/yarn.lock | 26 +++++++ requirements.txt | 3 +- server/__init__.py | 3 + server/api/drawings.py | 74 +++++++++++++++++++ server/views/drawings_management.py | 6 -- 20 files changed, 388 insertions(+), 44 deletions(-) create mode 100644 frontend/src/project_defaults.js delete mode 100644 frontend/src/structure/tabs/Drawings.js create mode 100644 frontend/src/structure/tabs/drawings/DrawingCard.js create mode 100644 frontend/src/structure/tabs/drawings/DrawingCard.scss create mode 100644 frontend/src/structure/tabs/drawings/DrawingDataDownloader.js create mode 100644 frontend/src/structure/tabs/drawings/Drawings.js create mode 100644 frontend/src/structure/tabs/drawings/UploadDrawing.js create mode 100644 frontend/src/structure/tabs/drawings/UploadDrawing.scss create mode 100644 server/api/drawings.py diff --git a/.flaskenv b/.flaskenv index 67175a39..21109c8d 100644 --- a/.flaskenv +++ b/.flaskenv @@ -4,5 +4,4 @@ FLASK_APP=server FLASK_ENV=development # can change this to 1 to make flask autoreload on files change (can set it from the launch.json file in vscode, for production must be 0 until a production server is setup) -# with react frontend it is not necessary to put this to 1 -FLASK_DEBUG=0 +FLASK_DEBUG=1 diff --git a/frontend/package.json b/frontend/package.json index 5f9c66f4..a6285cb9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "react": "^17.0.1", "react-bootstrap": "^1.4.0", "react-dom": "^17.0.1", + "react-dropzone": "^11.2.1", "react-multi-carousel": "^2.5.5", "react-scripts": "4.0.0", "react-scroll-horizontal": "^1.6.6", diff --git a/frontend/src/App.js b/frontend/src/App.js index fe3f9c95..2b59abdf 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -5,17 +5,11 @@ import Footer from './structure/Footer.js'; import Content from './structure/Content.js'; import Toasts from './structure/Toasts'; -import {check_software_updates} from "./utils/SWUpdates"; - -import {useState, useEffect} from 'react'; +import {useState} from 'react'; function App() { const [tab, setTab] = useState("home"); - useEffect(()=>{ - check_software_updates(); // check for updates when the app is ready - }) - function handleTab(tab){ setTab(tab); } diff --git a/frontend/src/index.js b/frontend/src/index.js index 09aec942..1aba2d0e 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -4,6 +4,10 @@ import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; +import {check_software_updates} from "./utils/SWUpdates"; + +check_software_updates(); + ReactDOM.render( diff --git a/frontend/src/project_defaults.js b/frontend/src/project_defaults.js new file mode 100644 index 00000000..6edd8c12 --- /dev/null +++ b/frontend/src/project_defaults.js @@ -0,0 +1,7 @@ + + +const api_url = "http://localhost:5000/api"; + +const static_url = "http://localhost:5000/static"; + +export {api_url, static_url}; \ No newline at end of file diff --git a/frontend/src/structure/Content.js b/frontend/src/structure/Content.js index 21a6b816..368ad154 100644 --- a/frontend/src/structure/Content.js +++ b/frontend/src/structure/Content.js @@ -4,7 +4,7 @@ import React, { Component} from 'react'; import {Tabs, Tab} from 'react-bootstrap'; import Home from './tabs/Home.js'; -import Drawings from './tabs/Drawings'; +import Drawings from './tabs/drawings/Drawings'; import Playlists from './tabs/Playlists'; import ManualControl from './tabs/ManualControl'; import Settings from './tabs/Settings'; diff --git a/frontend/src/structure/Toasts.js b/frontend/src/structure/Toasts.js index 72be648c..7b81365e 100644 --- a/frontend/src/structure/Toasts.js +++ b/frontend/src/structure/Toasts.js @@ -1,4 +1,5 @@ import React, { Component} from 'react'; + import Toast from 'react-bootstrap/Toast'; import {show_toast} from "../SAC"; diff --git a/frontend/src/structure/tabs/Drawings.js b/frontend/src/structure/tabs/Drawings.js deleted file mode 100644 index 858e1503..00000000 --- a/frontend/src/structure/tabs/Drawings.js +++ /dev/null @@ -1,19 +0,0 @@ -import React, { Component } from 'react'; -import Section from '../../components/Section'; - -class Drawings extends Component{ - - uploadDrawingHandler(){ - - } - - render(){ - return
- Drawings -
- } -} - -export default Drawings; \ No newline at end of file diff --git a/frontend/src/structure/tabs/Home.js b/frontend/src/structure/tabs/Home.js index a8963c86..8012628e 100644 --- a/frontend/src/structure/tabs/Home.js +++ b/frontend/src/structure/tabs/Home.js @@ -1,10 +1,13 @@ import React, { Component } from 'react'; +import Carousel from 'react-multi-carousel'; +import 'react-multi-carousel/lib/styles.css'; import Section from '../../components/Section.js'; import PlaceholderCard from '../../components/PlaceholderCard.js'; -import Carousel from 'react-multi-carousel'; -import 'react-multi-carousel/lib/styles.css'; +import UploadDrawingsModal from './drawings/UploadDrawing.js'; +import DrawingCard from './drawings/DrawingCard'; +import DrawingDataDownloader from './drawings/DrawingDataDownloader'; class Home extends Component{ constructor(props){ @@ -27,27 +30,45 @@ class Home extends Component{ items: 1 } }; + this.state = {show_upload: false, show_create_playlist: false, elements: []} + this.dhandler = new DrawingDataDownloader(this.setElements.bind(this)); + } + componentDidMount(){ + this.dhandler.requestDrawings(); } - uploadDrawingHandler(){ - // TODO - console.log("Upload drawing"); + handleFileUploaded(){ + this.dhandler.requestDrawings(); + window.show_toast("Updating drawing previews..."); + } + + + setElements(elements){ + this.setState({elements : elements}); } newPlaylistHandler(){ console.log("Create playlist") } + renderDrawings(list){ + let result; + if (list.length>0){ + result = list.map((item, index) => {return }); + }else{ + result = [1,2,3,4,5,6,7].map((item, index)=>{return }); + } + return result; + } render(){ return
+ sectionButtonHandler={()=>this.setState({show_upload: true})}> - {[1,2,3,4,5,6,7].map((item, index)=>{ - return })} + {this.renderDrawings(this.state.elements)}
+ {this.setState({show_upload: false})}} + handleFileUploaded={this.handleFileUploaded.bind(this)}/>
} } diff --git a/frontend/src/structure/tabs/drawings/DrawingCard.js b/frontend/src/structure/tabs/drawings/DrawingCard.js new file mode 100644 index 00000000..ca8f9804 --- /dev/null +++ b/frontend/src/structure/tabs/drawings/DrawingCard.js @@ -0,0 +1,52 @@ +import './DrawingCard.scss'; + +import React, { Component } from 'react'; +import { Card, Modal } from 'react-bootstrap'; + +import {static_url} from '../../../project_defaults'; + +class DrawingCard extends Component{ + constructor(props){ + super(props); + this.state = {show_details: false}; + } + + getImgUrl(){ + return static_url + "/Drawings/" + this.props.element.id + "/" + this.props.element.id + ".jpg"; + } + + render(){ + return
+ this.setState({show_details: true})}> +
+ Not available +
+
+
+ {this.props.element.filename} +
+
+
+
+ this.setState({show_details: false})} + size="lg" + centered> + + {this.props.element.filename} + + +
+ + +
+
+ Not available +
+
+
+
+ } +} + +export default DrawingCard; \ No newline at end of file diff --git a/frontend/src/structure/tabs/drawings/DrawingCard.scss b/frontend/src/structure/tabs/drawings/DrawingCard.scss new file mode 100644 index 00000000..6826a1f8 --- /dev/null +++ b/frontend/src/structure/tabs/drawings/DrawingCard.scss @@ -0,0 +1,4 @@ + +.modal-drawing-preview{ + width: 80%; +} \ No newline at end of file diff --git a/frontend/src/structure/tabs/drawings/DrawingDataDownloader.js b/frontend/src/structure/tabs/drawings/DrawingDataDownloader.js new file mode 100644 index 00000000..4dee9cb8 --- /dev/null +++ b/frontend/src/structure/tabs/drawings/DrawingDataDownloader.js @@ -0,0 +1,21 @@ +import {api_url} from "../../../project_defaults"; + +class DrawingDataDownloader{ + // need to pass a callback as argument that will be called when the data is ready + constructor(data_callback){ + this.cb = data_callback; + } + + requestDrawings(){ + fetch(api_url+"/drawings/") + .then(response => response.json()) + .then(data => { + this.cb(data); + }).catch(error => { + console.log("There was an error"); + console.log(error); + }) + } +} + +export default DrawingDataDownloader; \ No newline at end of file diff --git a/frontend/src/structure/tabs/drawings/Drawings.js b/frontend/src/structure/tabs/drawings/Drawings.js new file mode 100644 index 00000000..9c82be2a --- /dev/null +++ b/frontend/src/structure/tabs/drawings/Drawings.js @@ -0,0 +1,61 @@ +import React, { Component } from 'react'; +import { Container, Row, Col } from 'react-bootstrap'; +import Section from '../../../components/Section'; +import DrawingDataDownloader from './DrawingDataDownloader'; +import UploadDrawingsModal from './UploadDrawing'; +import DrawingCard from './DrawingCard'; + +class Drawings extends Component{ + constructor(props){ + super(props); + this.state = {show_upload: false, loaded: false, drawings: []} + this.dhandler = new DrawingDataDownloader(this.addElements.bind(this)); + } + + componentDidMount(){ + this.dhandler.requestDrawings(); + } + + addElements(data){ + this.setState({drawings: data, loaded: true}); + } + + handleFileUploaded(){ + this.dhandler.requestDrawings(); + } + + renderDrawings(drawings){ + return drawings.map((d, index)=>{ + return + + + }); + } + // todo load more on page scroll + + render(){ + return
this.setState({show_upload: true})}> + +
+
+

Loading...

+
+
+ + + + {this.renderDrawings(this.state.drawings)} + + + + {this.setState({show_upload: false})}} + handleFileUploaded={this.handleFileUploaded.bind(this)}/> +
+ } +} + +export default Drawings; \ No newline at end of file diff --git a/frontend/src/structure/tabs/drawings/UploadDrawing.js b/frontend/src/structure/tabs/drawings/UploadDrawing.js new file mode 100644 index 00000000..f20f1094 --- /dev/null +++ b/frontend/src/structure/tabs/drawings/UploadDrawing.js @@ -0,0 +1,65 @@ +import "./UploadDrawing.scss"; + +import React, { Component } from 'react'; + +import {api_url} from "../../../project_defaults"; + +import Dropzone from 'react-dropzone'; +import Modal from 'react-bootstrap/Modal'; + +class UploadDrawingsModal extends Component{ + + static defaultProps = { + playlist: 0, + show: false + } + + handleClose(){ + this.props.handleClose(); + } + + handleFiles(files){ + let promises = files.map(f => { + let data = new FormData(); + data.append("file", f); + data.append("filename", f.name); + return fetch(api_url + "/upload/" + this.props.playlist, { + method: "POST", + body: data + }).then((response => { + if (response.status === 200){ + window.show_toast("Drawing \""+f.name+"\" uploaded successfully"); + }else{ + window.show_toast("There was a problem when uploading \""+f.name+"\""); + } + })); + }); + // wait until all file have been laoaded to refresh the list + Promise.all(promises) + .then(()=>{this.props.handleFileUploaded()}); + + this.handleClose(); + } + + render(){ + return + + Upload new drawing + + + + {({getRootProps, getInputProps, isDragActive}) => (
+ +
Drag and drop the .gcode/.nc file here
or click to open the file explorer +
+
)} +
+
+
+ } +} + +export default UploadDrawingsModal; \ No newline at end of file diff --git a/frontend/src/structure/tabs/drawings/UploadDrawing.scss b/frontend/src/structure/tabs/drawings/UploadDrawing.scss new file mode 100644 index 00000000..05b4ab86 --- /dev/null +++ b/frontend/src/structure/tabs/drawings/UploadDrawing.scss @@ -0,0 +1,31 @@ +@import "../../../colors"; + +.animated-background{ + border: 3px dashed $dark; + border-radius: 5px; + animation: pulse 1.5s infinite; + animation-direction: alternate; +} + +.animated-background:hover, #upload_dropzone:hover > .animated-background, .animated-background.dragactive{ + animation: pulse-hover 0.7s infinite; + animation-direction: alternate; +} + +@keyframes pulse { + 0% { + background-color: $white; + } + 100% { + background-color: $primary; + } +} + +@keyframes pulse-hover { + 0% { + background-color: $white; + } + 100% { + background-color: $light; + } +} diff --git a/frontend/yarn.lock b/frontend/yarn.lock index ab212fdc..544c2587 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -2416,6 +2416,11 @@ atob@^2.1.2: resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== +attr-accept@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.2.tgz#646613809660110749e92f2c10833b70968d929b" + integrity sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg== + autoprefixer@^9.6.1: version "9.8.6" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.8.6.tgz#3b73594ca1bf9266320c5acf1588d74dea74210f" @@ -5039,6 +5044,13 @@ file-loader@6.1.1: loader-utils "^2.0.0" schema-utils "^3.0.0" +file-selector@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.2.2.tgz#76186ac94ea01a18262a1e9ee36a8815911bc0b4" + integrity sha512-tMZc0lkFzhOGlZUAkQ5iljPORvDX+nWEI+9C5nj9KT7Ax8bAUUtI/GYM8JFIjyKfKlQkJRC84D0UgxwDqRGvRQ== + dependencies: + tslib "^2.0.3" + file-uri-to-path@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -9477,6 +9489,15 @@ react-dom@^17.0.1: object-assign "^4.1.1" scheduler "^0.20.1" +react-dropzone@^11.2.1: + version "11.2.1" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-11.2.1.tgz#7544439ed2e27d1e4ac8efff5c6290b758cc29f5" + integrity sha512-AVWKQKKd4M8vIYzRC7QvvyzsGMrz6UAtAYW2WvSlEmstHKXhHL3CAq9LUzALfzMcDd2mxmntSNcpxij0w7U4qA== + dependencies: + attr-accept "^2.2.1" + file-selector "^0.2.2" + prop-types "^15.7.2" + react-error-overlay@^6.0.8: version "6.0.8" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.8.tgz#474ed11d04fc6bda3af643447d85e9127ed6b5de" @@ -11302,6 +11323,11 @@ tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== +tslib@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c" + integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ== + tsutils@^3.17.1: version "3.17.1" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759" diff --git a/requirements.txt b/requirements.txt index 359af669..e64a80d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,7 @@ click==7.1.2 colorama==0.4.3 ecdsa==0.15 Flask==1.1.2 +Flask-Cors==3.0.9 Flask-Migrate==2.5.3 Flask-Minify==0.27 Flask-SocketIO==4.3.0 @@ -31,7 +32,7 @@ mccabe==0.6.1 packaging==20.4 pid==3.0.3 Pillow==7.1.2 -pip==20.2.3 +pip==20.2.4 pluggy==0.13.1 ply==3.11 psutil==5.7.0 diff --git a/server/__init__.py b/server/__init__.py index 66ec3758..c4ddad97 100644 --- a/server/__init__.py +++ b/server/__init__.py @@ -2,6 +2,7 @@ from flask_socketio import SocketIO from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate +from flask_cors import CORS import os import sys @@ -42,6 +43,7 @@ app.config['SECRET_KEY'] = 'secret!' # TODO put a key here app.config['UPLOAD_FOLDER'] = "./server/static/Drawings" socketio = SocketIO(app, cors_allowed_origins="*") +CORS(app) # setting up cors for react # database file_path = os.path.join(os.path.abspath(os.getcwd()), "database.db") @@ -60,6 +62,7 @@ import server.database.models import server.views.drawings_management, server.views.settings +import server.api.drawings import server.sockets_interface.socketio_callbacks from server.sockets_interface.socketio_emits import SocketioEmits diff --git a/server/api/drawings.py b/server/api/drawings.py new file mode 100644 index 00000000..bc5fbb27 --- /dev/null +++ b/server/api/drawings.py @@ -0,0 +1,74 @@ +from server import app, socketio, db +from server.database.models import UploadedFiles, Playlists +from flask import render_template, request, url_for, redirect +from werkzeug.utils import secure_filename +from server.utils.gcode_converter import gcode_to_image +from server.database.models import Playlists +from server.database.playlist_elements import DrawingElement + +import traceback +import datetime + +import os +import logging +import shutil +import json + +ALLOWED_EXTENSIONS = ["gcode", "nc"] + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +# Upload route for the dropzone to load new drawings +@app.route('/api/upload/', methods=['GET','POST']) +def api_upload(playlist): + if request.method == "POST": + if 'file' in request.files: + file = request.files['file'] + if file and file.filename!= '' and allowed_file(file.filename): + # TODO move this into a thread because on the pi0w it is too slow and some drawings are not loaded in time + filename = secure_filename(file.filename) + new_file = UploadedFiles(filename = filename) + db.session.add(new_file) + db.session.commit() + # create a folder for each drawing. The folder will contain the .gcode file, the preview and additionally some settings for the drawing + folder = app.config["UPLOAD_FOLDER"] +"/" + str(new_file.id) +"/" + try: + os.mkdir(folder) + except: + app.logger.error("The folder for '{}' already exists".format(new_file.id)) + file.save(os.path.join(folder, str(new_file.id)+".gcode")) + # create the preview image + try: + with open(os.path.join(folder, str(new_file.id)+".gcode")) as file: + image = gcode_to_image(file) + image.save(os.path.join(folder, str(new_file.id)+".jpg")) + except: + app.logger.error("Error during image creation") + app.logger.error(traceback.print_exc()) + shutil.copy2(app.config["UPLOAD_FOLDER"]+"/placeholder.jpg", os.path.join(folder, str(new_file.id)+".jpg")) + + playlist = int(playlist) + if (playlist): + pl = Playlists.get_playlist(playlist) + pl.add_element(DrawingElement(new_file.id)) + pl.save() + + app.logger.info("File added") + return "1" + return "0" + + +# return by default first 20 drawings +@app.route('/api/drawings/') +def api_drawings(): + return api_drawings_number(20) + +# return the first n drawings +@app.route('/api/drawings/') +def api_drawings_number(number): + rows = db.session.query(UploadedFiles).order_by(UploadedFiles.edit_date.desc()).limit(str(number)) + res = [] + for r in rows: + res.append({"id": r.id, "filename": r.filename}) + return json.dumps(res) \ No newline at end of file diff --git a/server/views/drawings_management.py b/server/views/drawings_management.py index 55977530..d2aa4cf2 100644 --- a/server/views/drawings_management.py +++ b/server/views/drawings_management.py @@ -17,12 +17,6 @@ def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS -@app.route('/preview') -def preview(): - result = db.session.query(UploadedFiles).order_by(UploadedFiles.edit_date.desc()).limit(4) - pl_result = db.session.query(Playlists).order_by(Playlists.edit_date.desc()) - return render_template("management/grid_element.html", drawings = result, parent_template = "management/preview.html", playlists = pl_result) - # Upload route for the dropzone to load new drawings @app.route('/upload/', methods=['GET','POST']) def upload(playlist):