Improve user experience with interactive client
Based on Vue.js, mainly because it can be used without any compilation.
This commit is contained in:
parent
7bb8fb0287
commit
4669017d0c
9
COPYING.MIT.download
Normal file
9
COPYING.MIT.download
Normal file
@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2016 dandavis
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
21
COPYING.MIT.vuejs
Normal file
21
COPYING.MIT.vuejs
Normal file
@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-present Yuxi Evan You
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
@ -18,3 +18,6 @@ license. A copy of the license can be found in `COPYING.CC0`.
|
||||
|
||||
The FabLab "cube logo" is not subject to copyright protection and is considered to be in the public domain. The
|
||||
drawings derived from it which this project uses are in the public domain as well.
|
||||
|
||||
The client code further uses [Vue.js](https://vuejs.org/) and [download](https://github.com/rndme/download), both
|
||||
licensed under the MIT license. See `COPYING.MIT.vuejs` and `COPYING.MIT.download` respectively.
|
||||
|
39
app/app.py
39
app/app.py
@ -66,8 +66,28 @@ class Generator:
|
||||
return self.GENERATED_STL_FILE_NAME
|
||||
|
||||
|
||||
@app.route("/generate/<name>")
|
||||
async def generate_rest(name: str):
|
||||
@app.route("/generate", methods=["POST"])
|
||||
async def generate():
|
||||
try:
|
||||
# support both modern JSON body requests as well as classic forms
|
||||
form_data = await request.form
|
||||
json_data = await request.json
|
||||
|
||||
if form_data:
|
||||
data = form_data
|
||||
|
||||
elif json_data:
|
||||
data = json_data
|
||||
|
||||
else:
|
||||
raise ValueError
|
||||
|
||||
name = data["name"]
|
||||
|
||||
except (TypeError, KeyError, ValueError):
|
||||
abort(400)
|
||||
return
|
||||
|
||||
async with semaphore:
|
||||
with tempfile.TemporaryDirectory(prefix="fablab-bottle-clip-generator-") as tempdir:
|
||||
generator = Generator(name, tempdir)
|
||||
@ -98,24 +118,17 @@ async def generate_rest(name: str):
|
||||
|
||||
# using secure_filename allows us to send the file to the user with some safe yet reasonably
|
||||
# identifiable filename
|
||||
return await send_file(
|
||||
response = await send_file(
|
||||
bytes_io,
|
||||
mimetype="model/stl",
|
||||
as_attachment=True,
|
||||
attachment_filename=out_filename,
|
||||
)
|
||||
|
||||
# avoid the need for a content-disposition parser in the client code
|
||||
response.headers["download-filename"] = out_filename
|
||||
|
||||
# aside from the RESTful API above, we need a "traditional" HTML forms compatible end point
|
||||
@app.route("/generate")
|
||||
async def generate_form():
|
||||
try:
|
||||
name = request.args["name"]
|
||||
except KeyError:
|
||||
abort(400)
|
||||
return
|
||||
|
||||
return await generate_rest(name)
|
||||
return response
|
||||
|
||||
|
||||
@app.route("/")
|
||||
|
172
app/static/download.js
Normal file
172
app/static/download.js
Normal file
@ -0,0 +1,172 @@
|
||||
//download.js v4.21, by dandavis; 2008-2018. [MIT] see http://danml.com/download.html for tests/usage
|
||||
// v1 landed a FF+Chrome compatible way of downloading strings to local un-named files, upgraded to use a hidden frame and optional mime
|
||||
// v2 added named files via a[download], msSaveBlob, IE (10+) support, and window.URL support for larger+faster saves than dataURLs
|
||||
// v3 added dataURL and Blob Input, bind-toggle arity, and legacy dataURL fallback was improved with force-download mime and base64 support. 3.1 improved safari handling.
|
||||
// v4 adds AMD/UMD, commonJS, and plain browser support
|
||||
// v4.1 adds url download capability via solo URL argument (same domain/CORS only)
|
||||
// v4.2 adds semantic variable names, long (over 2MB) dataURL support, and hidden by default temp anchors
|
||||
// https://github.com/rndme/download
|
||||
|
||||
(function (root, factory) {
|
||||
if (typeof define === 'function' && define.amd) {
|
||||
// AMD. Register as an anonymous module.
|
||||
define([], factory);
|
||||
} else if (typeof exports === 'object') {
|
||||
// Node. Does not work with strict CommonJS, but
|
||||
// only CommonJS-like environments that support module.exports,
|
||||
// like Node.
|
||||
module.exports = factory();
|
||||
} else {
|
||||
// Browser globals (root is window)
|
||||
root.download = factory();
|
||||
}
|
||||
}(this, function () {
|
||||
|
||||
return function download(data, strFileName, strMimeType) {
|
||||
|
||||
var self = window, // this script is only for browsers anyway...
|
||||
defaultMime = "application/octet-stream", // this default mime also triggers iframe downloads
|
||||
mimeType = strMimeType || defaultMime,
|
||||
payload = data,
|
||||
url = !strFileName && !strMimeType && payload,
|
||||
anchor = document.createElement("a"),
|
||||
toString = function(a){return String(a);},
|
||||
myBlob = (self.Blob || self.MozBlob || self.WebKitBlob || toString),
|
||||
fileName = strFileName || "download",
|
||||
blob,
|
||||
reader;
|
||||
myBlob= myBlob.call ? myBlob.bind(self) : Blob ;
|
||||
|
||||
if(String(this)==="true"){ //reverse arguments, allowing download.bind(true, "text/xml", "export.xml") to act as a callback
|
||||
payload=[payload, mimeType];
|
||||
mimeType=payload[0];
|
||||
payload=payload[1];
|
||||
}
|
||||
|
||||
|
||||
if(url && url.length< 2048){ // if no filename and no mime, assume a url was passed as the only argument
|
||||
fileName = url.split("/").pop().split("?")[0];
|
||||
anchor.href = url; // assign href prop to temp anchor
|
||||
if(anchor.href.indexOf(url) !== -1){ // if the browser determines that it's a potentially valid url path:
|
||||
var ajax=new XMLHttpRequest();
|
||||
ajax.open( "GET", url, true);
|
||||
ajax.responseType = 'blob';
|
||||
ajax.onload= function(e){
|
||||
download(e.target.response, fileName, defaultMime);
|
||||
};
|
||||
setTimeout(function(){ ajax.send();}, 0); // allows setting custom ajax headers using the return:
|
||||
return ajax;
|
||||
} // end if valid url?
|
||||
} // end if url?
|
||||
|
||||
|
||||
//go ahead and download dataURLs right away
|
||||
if(/^data:([\w+-]+\/[\w+.-]+)?[,;]/.test(payload)){
|
||||
|
||||
if(payload.length > (1024*1024*1.999) && myBlob !== toString ){
|
||||
payload=dataUrlToBlob(payload);
|
||||
mimeType=payload.type || defaultMime;
|
||||
}else{
|
||||
return navigator.msSaveBlob ? // IE10 can't do a[download], only Blobs:
|
||||
navigator.msSaveBlob(dataUrlToBlob(payload), fileName) :
|
||||
saver(payload) ; // everyone else can save dataURLs un-processed
|
||||
}
|
||||
|
||||
}else{//not data url, is it a string with special needs?
|
||||
if(/([\x80-\xff])/.test(payload)){
|
||||
var i=0, tempUiArr= new Uint8Array(payload.length), mx=tempUiArr.length;
|
||||
for(i;i<mx;++i) tempUiArr[i]= payload.charCodeAt(i);
|
||||
payload=new myBlob([tempUiArr], {type: mimeType});
|
||||
}
|
||||
}
|
||||
blob = payload instanceof myBlob ?
|
||||
payload :
|
||||
new myBlob([payload], {type: mimeType}) ;
|
||||
|
||||
|
||||
function dataUrlToBlob(strUrl) {
|
||||
var parts= strUrl.split(/[:;,]/),
|
||||
type= parts[1],
|
||||
indexDecoder = strUrl.indexOf("charset")>0 ? 3: 2,
|
||||
decoder= parts[indexDecoder] == "base64" ? atob : decodeURIComponent,
|
||||
binData= decoder( parts.pop() ),
|
||||
mx= binData.length,
|
||||
i= 0,
|
||||
uiArr= new Uint8Array(mx);
|
||||
|
||||
for(i;i<mx;++i) uiArr[i]= binData.charCodeAt(i);
|
||||
|
||||
return new myBlob([uiArr], {type: type});
|
||||
}
|
||||
|
||||
function saver(url, winMode){
|
||||
|
||||
if ('download' in anchor) { //html5 A[download]
|
||||
anchor.href = url;
|
||||
anchor.setAttribute("download", fileName);
|
||||
anchor.className = "download-js-link";
|
||||
anchor.innerHTML = "downloading...";
|
||||
anchor.style.display = "none";
|
||||
anchor.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
this.removeEventListener('click', arguments.callee);
|
||||
});
|
||||
document.body.appendChild(anchor);
|
||||
setTimeout(function() {
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
if(winMode===true){setTimeout(function(){ self.URL.revokeObjectURL(anchor.href);}, 250 );}
|
||||
}, 66);
|
||||
return true;
|
||||
}
|
||||
|
||||
// handle non-a[download] safari as best we can:
|
||||
if(/(Version)\/(\d+)\.(\d+)(?:\.(\d+))?.*Safari\//.test(navigator.userAgent)) {
|
||||
if(/^data:/.test(url)) url="data:"+url.replace(/^data:([\w\/\-\+]+)/, defaultMime);
|
||||
if(!window.open(url)){ // popup blocked, offer direct download:
|
||||
if(confirm("Displaying New Document\n\nUse Save As... to download, then click back to return to this page.")){ location.href=url; }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
//do iframe dataURL download (old ch+FF):
|
||||
var f = document.createElement("iframe");
|
||||
document.body.appendChild(f);
|
||||
|
||||
if(!winMode && /^data:/.test(url)){ // force a mime that will download:
|
||||
url="data:"+url.replace(/^data:([\w\/\-\+]+)/, defaultMime);
|
||||
}
|
||||
f.src=url;
|
||||
setTimeout(function(){ document.body.removeChild(f); }, 333);
|
||||
|
||||
}//end saver
|
||||
|
||||
|
||||
|
||||
|
||||
if (navigator.msSaveBlob) { // IE10+ : (has Blob, but not a[download] or URL)
|
||||
return navigator.msSaveBlob(blob, fileName);
|
||||
}
|
||||
|
||||
if(self.URL){ // simple fast and modern way using Blob and URL:
|
||||
saver(self.URL.createObjectURL(blob), true);
|
||||
}else{
|
||||
// handle non-Blob()+non-URL browsers:
|
||||
if(typeof blob === "string" || blob.constructor===toString ){
|
||||
try{
|
||||
return saver( "data:" + mimeType + ";base64," + self.btoa(blob) );
|
||||
}catch(y){
|
||||
return saver( "data:" + mimeType + "," + encodeURIComponent(blob) );
|
||||
}
|
||||
}
|
||||
|
||||
// Blob but not URL support:
|
||||
reader=new FileReader();
|
||||
reader.onload=function(e){
|
||||
saver(this.result);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
}
|
||||
return true;
|
||||
}; /* end download() */
|
||||
}));
|
15377
app/static/vue.esm-browser.js
Normal file
15377
app/static/vue.esm-browser.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -3,20 +3,96 @@
|
||||
<head>
|
||||
<title>FabLab Bottle Clip Generator</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='pico.css') }}">
|
||||
<style>
|
||||
.error {
|
||||
background-color: rgb(183, 28, 28);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<script src="{{ url_for('static', filename='download.js') }}"></script>
|
||||
|
||||
<script type="module">
|
||||
import {createApp} from '{{ url_for("static", filename="vue.esm-browser.js") }}'
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
name: "",
|
||||
fetching: false,
|
||||
error: false,
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
// disable classic non-JS submission if JavaScript is available
|
||||
this.$refs.form.onsubmit = function(event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
async generate() {
|
||||
console.log("generate")
|
||||
|
||||
this.fetching = true
|
||||
this.error = false
|
||||
|
||||
const response = await fetch("{{ url_for('generate') }}", {
|
||||
mode: "cors",
|
||||
method: "post",
|
||||
body: JSON.stringify({
|
||||
"name": this.name,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
let contentType = response.headers.get("content-type")
|
||||
let downloadFilename = response.headers.get("download-filename")
|
||||
|
||||
if (response.status !== 200 || contentType !== "model/stl" || downloadFilename === "") {
|
||||
this.error = true
|
||||
} else {
|
||||
// for now, we'll just download the file
|
||||
// in the future, we'll be adding a 3D viewer to allow users to preview the file directly
|
||||
download(await response.text(), downloadFilename, contentType)
|
||||
}
|
||||
|
||||
this.fetching = false
|
||||
}
|
||||
},
|
||||
}).mount('#app')
|
||||
</script>
|
||||
|
||||
<main class="container">
|
||||
<center>
|
||||
<img src="{{ url_for('static', filename='logo.svg') }}" style="max-width: 350px; margin-bottom: 30px;">
|
||||
|
||||
<h1>FabLab Bottle Clip Generator</h1>
|
||||
<p>Bitte gib deinen Namen in das Formular ein und drücke auf <b><i>Generieren</i></b>, um eine STL-Datei zu erhalten.</p>
|
||||
<form action="{{ url_for('generate_form') }}">
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" id="name" name="name" style="text-align: center;" placeholder="Name">
|
||||
<input type="submit" value="Generieren">
|
||||
</form>
|
||||
|
||||
<div id="app">
|
||||
<!-- JS free alternative, less pretty but gets the job done -->
|
||||
<p>Bitte gib deinen Namen in das Formular ein und drücke auf <b><i>Generieren</i></b>, um eine STL-Datei zu erhalten.</p>
|
||||
{# hidden is used in a noscript situation, but as it also is the default state due to #}
|
||||
<div ref="errorMessage" v-show="error" style="display: none" v-show="true" class="error">Fehler beim Generieren der Datei, bitte Namen prüfen und erneut versuchen.</div>
|
||||
<form ref="form" action="{{ url_for('generate') }}" method="post">
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" id="name" name="name" style="text-align: center;" placeholder="Name" v-model="name" :disabled="fetching" :aria-invalid="name === ''">
|
||||
<button v-if="!fetching" ref="submitButton" type="submit" @click="generate" :disabled="name === ''">Generieren</button>
|
||||
<button v-if="fetching" style="display: none" v-show="true" type="button" disabled="true" aria-busy="true">Generiere...</button>
|
||||
</form>
|
||||
<p style="font-size: 85%">Bitte beachte, dass das Generieren einige Sekunden in Anspruch nimmt! <i>Geduld ist eine Tugend!</i></p>
|
||||
</div>
|
||||
</center>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
Loading…
Reference in New Issue
Block a user