#160 picture uploader

This commit is contained in:
Avris 2021-01-06 15:21:20 +01:00
parent b1b65bd789
commit 3a0010fb22
15 changed files with 444 additions and 2 deletions

27
components/ImageThumb.vue Normal file
View File

@ -0,0 +1,27 @@
<template>
<a :href="getUrl('big')" target="_blank" rel="noopener"
@click.prevent="$eventHub.$emit('lightbox', getUrl('big'))"
>
<img :src="getUrl('thumb')" class="border rounded-lg"/>
</a>
</template>
<script>
export default {
props: {
id: {required: true},
},
methods: {
getUrl(size) {
return `${process.env.BUCKET}/images/${this.id}-${size}.png`;
},
},
}
</script>
<style lang="scss" scoped>
img {
height: 8rem;
width: 8rem;
}
</style>

View File

@ -0,0 +1,89 @@
<template>
<div
:class="['uploader-container', 'p-2', 'form-control', drag ? 'drag' : '']"
@dragover="drag=true" @dragleave="drag=false"
>
<input type="file"
:name="name + (multiple ? '[]' : '')"
:multiple="multiple"
:disabled="uploading"
@change="filesChange($event.target.name, $event.target.files)"
accept="image/*">
<p v-if="errorMessage" class="text-danger">
<Icon v="exclamation-circle"/>
<T>{{errorMessage}}</T>
</p>
<p v-else-if="uploading">
<Spinner/>
</p>
<p v-else>
<Icon v="upload"/>
<T>images.upload.instruction</T>
</p>
</div>
</template>
<script>
export default {
props: {
multiple: {type: Boolean},
name: {'default': 'images'},
},
data() {
return {
uploading: false,
drag: false,
errorMessage: '',
}
},
methods: {
async filesChange(fieldName, fileList) {
if (!fileList.length) {
return;
}
const formData = new FormData();
for (let file of fileList) {
formData.append(fieldName, file, file.name);
}
await this.save(formData);
},
async save(formData) {
this.uploading = true;
this.errorMessage = '';
try {
const ids = await this.$axios.$post('/images/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
this.$emit('uploaded', ids);
} catch {
this.errorMessage = 'error.generic';
}
this.uploading = false;
},
},
}
</script>
<style lang="scss" scoped>
@import "../assets/style";
.uploader-container {
position: relative;
cursor: pointer;
&:hover, &.drag {
background: lighten($primary, 50%);
}
}
input[type="file"] {
opacity: 0;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
</style>

View File

@ -0,0 +1,43 @@
<template>
<div class="form-group">
<ul class="list-unstyled">
<li v-for="image in images" class="mb-4">
<ImageThumb :id="image"/>
<a href="#" @click.prevent="removeFile(image)" class="small">
<Icon v="trash"/>
<T>crud.remove</T>
</a>
</li>
</ul>
<ImageUploader :multiple="multiple" :name="name" @uploaded="addFiles"/>
</div>
</template>
<script>
export default {
props: {
value: {},
multiple: {type: Boolean},
name: {'default': 'images'},
},
data() {
return {
images: this.value,
}
},
watch: {
value() {
this.images = this.value;
}
},
methods: {
addFiles(files) {
this.$emit('input', [...this.images, ...files]);
},
async removeFile(id) {
await this.$confirm(this.$t('crud.removeConfirm'), 'danger');
this.$emit('input', this.images.filter(i => i !== id));
},
},
}
</script>

108
components/Lightbox.vue Normal file
View File

@ -0,0 +1,108 @@
<template>
<div :class="['lightbox-wrapper', currentUrl === null ? 'd-none' : '']" ref="wrapper" @click.self="hide">
<div :class="['lightbox-inner', center ? 'align-items-center' : '']" ref="inner" @click.self="hide">
<img class="lightbox-image" :src="currentUrl" ref="image" @load="loaded">
</div>
<span class="lightbox-menu">
<span class="lightbox-close fal fa-times" @click="hide"></span>
</span>
</div>
</template>
<script>
export default {
data() {
return {
currentUrl: null,
center: false,
};
},
mounted() {
this.$eventHub.$on('lightbox', this.show);
if (!process.client) {
return;
}
document.addEventListener('resize', e => {
if (!this.currentUrl) {
return;
}
this.center = this.$refs.image.offsetHeight < this.$refs.inner.offsetHeight;
});
document.body.addEventListener('keyup', e => {
if (!this.currentUrl) {
return;
}
if (e.keyCode === 27) { // ESC
this.hide();
e.preventDefault();
e.stopPropagation();
}
});
},
methods: {
show(url) {
this.currentUrl = url;
this.$refs.inner.focus();
},
hide() {
this.currentUrl = null;
this.$refs.inner.blur();
this.center = false;
},
loaded() {
if (this.$refs.image.offsetHeight < this.$refs.inner.offsetHeight) {
this.center = true;
}
},
},
};
</script>
<style lang="scss" scoped>
@import "../assets/style";
.lightbox-wrapper {
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.75);
z-index: 10000;
padding: 2*$spacer 2*$spacer;
@include media-breakpoint-up('sm') {
padding: 2*$spacer 4*$spacer;
}
.lightbox-inner {
width: 100%;
height: 100%;
overflow-y: auto;
display: flex;
justify-content: center;
align-items: flex-start;
img {
max-width: 100%;
}
}
.lightbox-menu {
position: absolute;
top: $spacer;
right: $spacer;
font-size: 2*$spacer;
color: $white;
}
.lightbox-close {
cursor: pointer;
}
}
</style>

View File

@ -9,6 +9,7 @@
<Footer/>
</div>
<Confirm ref="confirm"/>
<Lightbox/>
</div>
</template>

View File

@ -458,6 +458,14 @@ localise:
short: 'Adding language versions'
long: 'Want to create a new language version? Check out'
longLink: 'this manual!'
images:
upload:
instruction: 'Click here or drag your pics here' # TODO
error:
generic: 'Something went wrong, please try again…' # TODO
flags:
Abrosexual: 'Abrosexual'
Achillean: 'Aquilean{inflection}'

View File

@ -459,3 +459,10 @@ localise:
short: 'Adding language versions'
long: 'Want to create a new language version? Check out'
longLink: 'this manual!'
images:
upload:
instruction: 'Click here or drag your pics here'
error:
generic: 'Something went wrong, please try again…'

View File

@ -467,6 +467,13 @@ localise:
long: '¿Quieres añadir una versión en otra lengua? ¡Consulta'
longLink: 'este manual!'
images:
upload:
instruction: 'Click here or drag your pics here' # TODO
error:
generic: 'Something went wrong, please try again…' # TODO
flags:
Abrosexual: 'Abrosexual'
Achillean: 'Aquilean{inflection}'

View File

@ -1039,6 +1039,13 @@ localise:
long: 'Chcesz dodać nową wersję językową? Informacje znajdziesz w'
longLink: 'tej instrukcji.'
images:
upload:
instruction: 'Kliknij tutaj lub upuść tu obrazek'
error:
generic: 'Coś poszło nie tak, spróbuj ponownie…'
flags:
Abrosexual: 'Abroseksualn{adjective_n}'
Achillean: 'Achillejs{adjective_n_k}'

View File

@ -135,6 +135,7 @@ export default {
LOCALE: config.locale,
LOCALES: locales,
FLAGS: buildFlags(),
BUCKET: `https://${process.env.AWS_S3_BUCKET}.s3-${process.env.AWS_REGION}.amazonaws.com`,
},
serverMiddleware: ['~/server/index.js'],
axios: {

View File

@ -30,6 +30,7 @@
"luxon": "^1.25.0",
"mailer": "^0.6.7",
"markdown-loader": "^6.0.0",
"multer": "^1.4.2",
"nuxt": "^2.14.7",
"sha1": "^1.1.1",
"sql-template-strings": "^2.2.2",

View File

@ -4,6 +4,7 @@ import config from '../data/config.suml';
import {buildDict} from "../src/helpers";
export default ({ app, store }) => {
Vue.prototype.$eventHub = new Vue();
Vue.prototype.$base = process.env.BASE_URL;
Vue.prototype.$t = t;
Vue.prototype.$translateForPronoun = (str, pronoun) =>

View File

@ -48,6 +48,8 @@ app.use(require('./routes/terms').default);
app.use(require('./routes/pronounce').default);
app.use(require('./routes/census').default);
app.use(require('./routes/images').default);
export default {
path: '/api',
handler: app,

80
server/routes/images.js Normal file
View File

@ -0,0 +1,80 @@
import { Router } from 'express';
import {ulid} from "ulid";
import multer from 'multer';
import {loadImage, createCanvas} from 'canvas';
import awsConfig from '../aws';
import S3 from 'aws-sdk/clients/s3';
const sizes = {
big: [1600, false],
thumb: [240, true],
}
const resizeImage = (image, width, height, sx = null, sy = null) => {
const canvas = createCanvas(width, height);
if (sx === null) {
canvas.getContext('2d').drawImage(image, 0, 0, width, height);
} else {
canvas.getContext('2d').drawImage(image, sx, sy, width, height, 0, 0, width, height);
}
return canvas;
}
const cropToSquare = (image) => {
return image.width > image.height
? resizeImage(image, image.height, image.height, (image.width - image.height) / 2, 0)
: resizeImage(image, image.width, image.width, 0, (image.height - image.width) / 2);
}
const scaleDownTo = (image, size) => {
if (image.width > image.height) {
return image.width > size
? resizeImage(image, size, image.height * size / image.width)
: image;
}
return image.height > size
? resizeImage(image, image.width * size / image.height, size)
: image;
}
const router = Router();
router.post('/images/upload', multer({limits: {fileSize: 5 * 1024 * 1024}}).any('images[]', 12), async (req, res) => {
const s3 = new S3(awsConfig);
const ids = [];
for (let file of req.files) {
const id = ulid();
const image = await loadImage(file.buffer);
for (let s in sizes) {
if (!sizes.hasOwnProperty(s)) { continue; }
const [size, square] = sizes[s];
let canvas = createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
if (square) {
console.log('crop to square');
canvas = cropToSquare(canvas);
}
canvas = scaleDownTo(canvas, size);
await s3.putObject({
Key: `images/${id}-${s}.png`,
Body: canvas.toBuffer('image/png'),
ContentType: 'image/png',
ACL: 'public-read',
}).promise();
}
ids.push(id);
}
return res.json(ids);
});
export default router;

View File

@ -1842,6 +1842,11 @@ anymatch@~3.1.1:
normalize-path "^3.0.0"
picomatch "^2.0.4"
append-field@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56"
integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=
aproba@^1.0.3, aproba@^1.1.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
@ -2335,6 +2340,14 @@ builtin-status-codes@^3.0.0:
resolved "https://registry.yarnpkg.com/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz#85982878e21b98e1c66425e03d0174788f569ee8"
integrity sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=
busboy@^0.2.11:
version "0.2.14"
resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453"
integrity sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=
dependencies:
dicer "0.2.5"
readable-stream "1.1.x"
bytes@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
@ -2802,7 +2815,7 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
concat-stream@^1.5.0:
concat-stream@^1.5.0, concat-stream@^1.5.2:
version "1.6.2"
resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
@ -3431,6 +3444,14 @@ detect-libc@^1.0.2:
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
dicer@0.2.5:
version "0.2.5"
resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f"
integrity sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=
dependencies:
readable-stream "1.1.x"
streamsearch "0.1.2"
diffie-hellman@^5.0.0:
version "5.0.3"
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.3.tgz#40e8ee98f55a2149607146921c63e1ae5f3d2875"
@ -5135,6 +5156,11 @@ is-wsl@^1.1.0:
resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d"
integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0=
isarray@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
@ -5893,6 +5919,20 @@ ms@2.1.2, ms@^2.1.1:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
multer@^1.4.2:
version "1.4.2"
resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.2.tgz#2f1f4d12dbaeeba74cb37e623f234bf4d3d2057a"
integrity sha512-xY8pX7V+ybyUpbYMxtjM9KAiD9ixtg5/JkeKUTD6xilfDv0vzzOFcCp4Ljb1UU3tSOM3VTZtKo63OmzOrGi3Cg==
dependencies:
append-field "^1.0.0"
busboy "^0.2.11"
concat-stream "^1.5.2"
mkdirp "^0.5.1"
object-assign "^4.1.1"
on-finished "^2.3.0"
type-is "^1.6.4"
xtend "^4.0.0"
mustache@^2.3.0:
version "2.3.2"
resolved "https://registry.yarnpkg.com/mustache/-/mustache-2.3.2.tgz#a6d4d9c3f91d13359ab889a812954f9230a3d0c5"
@ -7517,6 +7557,16 @@ read-cache@^1.0.0:
string_decoder "~1.1.1"
util-deprecate "~1.0.1"
readable-stream@1.1.x:
version "1.1.14"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
dependencies:
core-util-is "~1.0.0"
inherits "~2.0.1"
isarray "0.0.1"
string_decoder "~0.10.x"
readable-stream@^3.1.1, readable-stream@^3.6.0:
version "3.6.0"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
@ -8292,6 +8342,11 @@ stream-shift@^1.0.0:
resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
streamsearch@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
strict-uri-encode@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"
@ -8355,6 +8410,11 @@ string_decoder@^1.0.0, string_decoder@^1.1.1:
dependencies:
safe-buffer "~5.2.0"
string_decoder@~0.10.x:
version "0.10.31"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
string_decoder@~1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
@ -8705,7 +8765,7 @@ type-fest@^0.8.0, type-fest@^0.8.1:
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
type-is@~1.6.17, type-is@~1.6.18:
type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
version "1.6.18"
resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==