Improve user experience with interactive client

Based on Vue.js, mainly because it can be used without any compilation.
This commit is contained in:
Fabian Müller 2023-05-29 23:19:32 +02:00
parent 7bb8fb0287
commit 4669017d0c
7 changed files with 15690 additions and 19 deletions

9
COPYING.MIT.download Normal file
View 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
View 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.

View File

@ -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.

View File

@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -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>