@@ -0,0 +1,6 @@ | |||
form { | |||
max-width: 40rem; | |||
margin: 2rem auto; | |||
font-size: 2rem; | |||
text-align: center; | |||
} |
@@ -0,0 +1,62 @@ | |||
[data-target="upload-images.input"] { | |||
width: 0.1px; | |||
height: 0.1px; | |||
opacity: 0; | |||
overflow: hidden; | |||
position: absolute; | |||
z-index: -1; | |||
} | |||
[data-target="upload-images.input"] + label { | |||
font-size: 1.25em; | |||
font-weight: 700; | |||
color: white; | |||
background-color: black; | |||
display: inline-block; | |||
padding: 1rem; | |||
} | |||
[data-target="upload-images.input"]:focus + label, | |||
[data-target="upload-images.input"] + label:hover { | |||
background-color: red; | |||
} | |||
[data-target="upload-images.input"] + label { | |||
cursor: pointer; /* "hand" cursor */ | |||
} | |||
[data-target="upload-images.input"]:focus + label { | |||
outline: thin dotted; | |||
outline: -webkit-focus-ring-color auto 5px; | |||
} | |||
[data-target="upload-images.input"]:focus-within + label { | |||
outline: thin dotted; | |||
outline: -webkit-focus-ring-color auto 5px; | |||
} | |||
[data-target="upload-images.input"] + label * { | |||
pointer-events: none; | |||
} | |||
[data-target="upload-images.output"] img { | |||
max-width: 30rem; | |||
} | |||
[data-target="upload-images.output"].oversized { | |||
background: red; | |||
height: 4rem; | |||
padding: 1rem 2rem; | |||
} | |||
[data-target="upload-images.dropzone"] { | |||
border: 5px solid black; | |||
margin: 2rem auto; | |||
min-width: 30rem; | |||
} | |||
[data-target="upload-images.dropzone"].hidden { | |||
display: none; | |||
} | |||
[data-target="upload-images.dropzone"]:hover, | |||
[data-target="upload-images.dropzone"].active { | |||
border-color: red; | |||
cursor: pointer; /* "hand" cursor */ | |||
} | |||
[data-target="upload-images.submit"] { | |||
width: 30rem; | |||
font-size: 2rem; | |||
} |
@@ -0,0 +1,61 @@ | |||
<!doctype html><!-- This is a valid HTML5 document. --> | |||
<!-- Screen readers, SEO, extensions and so on. --> | |||
<html lang=en> | |||
<!-- Has to be within the first 1024 bytes, hence before the <title> | |||
See: https://www.w3.org/TR/2012/CR-html5-20121217/document-metadata.html#charset --> | |||
<meta charset=utf-8> | |||
<!-- Why no `X-UA-Compatible` meta: https://stackoverflow.com/a/6771584 --> | |||
<!-- The viewport meta is quite crowded and we are responsible for that. | |||
See: https://codepen.io/tigt/post/meta-viewport-for-2015 --> | |||
<meta name=viewport content="width=device-width,minimum-scale=1,initial-scale=1,shrink-to-fit=no"> | |||
<!-- Avoid indexation of the content as it is private. --> | |||
<meta name="robots" content="noindex, nofollow"> | |||
<!-- Prevent access via referrers, only the domain will be available. --> | |||
<meta content="origin-when-cross-origin" name="referrer"> | |||
<!-- Required to make a valid HTML5 document. --> | |||
<title>Upload images form</title> | |||
<!-- Lightest blank gif, avoids an extra query to the server. --> | |||
<link rel=icon href="data:;base64,iVBORw0KGgo="> | |||
<link rel="stylesheet" href="/static/css/upload_images.css"> | |||
<link rel="stylesheet" href="/static/css/custom.css"> | |||
<script src="/static/js/stimulus.js"></script> | |||
<script type="text/javascript"> | |||
// In your real world application, you probably do not want to do that. | |||
;(() => window.application = Stimulus.Application.start())() | |||
</script> | |||
<script src="/static/js/upload_images_controller.js"></script> | |||
<form | |||
action="/upload_images" method="post" enctype="multipart/form-data" | |||
data-controller="upload-images" | |||
data-upload-images-max-size-kb="500"> | |||
<fieldset> | |||
<input | |||
type="file" name="images" id="images" | |||
accept="image/*" multiple | |||
data-target="upload-images.input" | |||
data-action="upload-images#handleImages"> | |||
<label for="images" | |||
data-target="upload-images.label"> | |||
Click to upload image(s) or drag & drop below | |||
</label> | |||
<output data-target="upload-images.output"></output> | |||
<canvas | |||
data-target="upload-images.dropzone" | |||
data-action="click->upload-images#openFileSelector | |||
drop->upload-images#handleImages | |||
dragenter->upload-images#addActiveClass | |||
dragover->upload-images#addActiveClass | |||
focus->upload-images#addActiveClass | |||
dragleave->upload-images#removeActiveClass | |||
blur->upload-images#removeActiveClass" | |||
></canvas> | |||
</fieldset> | |||
<input type="submit" name="submit" | |||
data-target="upload-images.submit" | |||
data-action="upload-images#submit"> | |||
</form> | |||
</html> |
@@ -0,0 +1,113 @@ | |||
/* | |||
Resources: | |||
- https://developer.mozilla.org/en-US/docs/Web/API/File/Using_files_from_web_applications | |||
- https://tympanus.net/codrops/2015/09/15/styling-customizing-file-inputs-smart-way/ | |||
- https://eloquentjavascript.net/18_http.html | |||
*/ | |||
application.register( | |||
'upload-images', | |||
class extends Stimulus.Controller { | |||
static get targets() { | |||
return ['input', 'output', 'dropzone'] | |||
} | |||
openFileSelector() { | |||
this.inputTarget.click() | |||
} | |||
addActiveClass(event) { | |||
event.preventDefault() | |||
this.dropzoneTarget.classList.add('active') | |||
} | |||
removeActiveClass() { | |||
this.dropzoneTarget.classList.remove('active') | |||
} | |||
handleImages(event) { | |||
event.preventDefault() | |||
let images = [] | |||
if (event.type == 'change') { | |||
images = event.target.files | |||
} else if (event.type == 'drop') { | |||
images = event.dataTransfer.files | |||
} | |||
Array.from(images).forEach(image => { | |||
const imageName = image.name | |||
const imageSize = image.size | |||
const isOversized = | |||
Math.round(imageSize / 1024) > | |||
Number(this.data.get('maxSizeKb')) | |||
if (isOversized) { | |||
this.outputTarget.classList.add('oversized') | |||
this.outputTarget.innerText = `Oversized!` | |||
this.inputTarget.value = '' | |||
return | |||
} else { | |||
this.outputTarget.classList.remove('oversized') | |||
this.dropzoneTarget.classList.add('hidden') | |||
} | |||
const figure = document.createElement('figure') | |||
const img = document.createElement('img') | |||
img.file = image // Required for future upload, fragile? | |||
img.src = window.URL.createObjectURL(image) | |||
img.onload = event => | |||
window.URL.revokeObjectURL(event.target.src) | |||
img.alt = 'Picture preview' | |||
figure.appendChild(img) | |||
const figcaption = document.createElement('figcaption') | |||
figcaption.innerText = imageName | |||
figure.appendChild(figcaption) | |||
this.outputTarget.appendChild(figure) | |||
}) | |||
} | |||
submit(event) { | |||
event.preventDefault() | |||
const images = this.outputTarget.querySelectorAll('img') | |||
Array.from(images).forEach(image => { | |||
const formdata = new FormData() | |||
formdata.append('images', image.file) | |||
const options = { | |||
method: this.element.method, | |||
body: formdata | |||
} | |||
console.log(`Uploading "${image.file.name}"`) | |||
this._request( | |||
this.element.action, | |||
options, | |||
this._displayPercentage | |||
) | |||
.then(response => console.log('Uploaded!')) | |||
.catch(console.log.bind(console)) | |||
}) | |||
} | |||
_request(url, options = {}, onProgress) { | |||
// See https://github.com/github/fetch/issues/89#issuecomment-256610849 | |||
return new Promise((resolve, reject) => { | |||
const xhr = new XMLHttpRequest() | |||
xhr.open(options.method || 'get', url) | |||
for (let header in options.headers || {}) | |||
xhr.setRequestHeader(header, options.headers[header]) | |||
xhr.onload = event => resolve(event.target.responseText) | |||
xhr.onerror = reject | |||
if (xhr.upload && onProgress) xhr.upload.onprogress = onProgress | |||
xhr.send(options.body) | |||
}) | |||
} | |||
_displayPercentage(event) { | |||
if (event.lengthComputable) { | |||
const percentage = Math.round( | |||
(event.loaded * 100) / event.total | |||
) | |||
console.log(percentage) | |||
} | |||
} | |||
} | |||
) |
@@ -0,0 +1 @@ | |||
roll==0.11.0 |
@@ -0,0 +1,28 @@ | |||
from http import HTTPStatus | |||
from pathlib import Path | |||
from roll import Roll | |||
from roll.extensions import simple_server, static | |||
app = Roll() | |||
static(app) | |||
@app.route("/") | |||
async def home(request, response): | |||
response.headers["Content-Type"] = "text/html; charset=utf-8" | |||
response.body = open("index.html").read() | |||
@app.route("/upload_images", methods=["POST"]) | |||
async def upload_images(request, response): | |||
images = request.files.list("images") | |||
for image in images: | |||
filepath = Path("uploaded_images") / image.filename | |||
filepath.write_bytes(image.read()) | |||
response.status = HTTPStatus.FOUND | |||
response.headers["Location"] = "/" | |||
if __name__ == "__main__": | |||
simple_server(app) |