Commit 819d4ad3 authored by NGPixel's avatar NGPixel

Image upload process + right-click menu UI

parent 90afe796
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -91,10 +91,23 @@ let vueImage = new Vue({
fetchFromUrlDiscard: (ev) => {
vueImage.fetchFromUrlShow = false;
},
/**
* Select a folder
*
* @param {string} fldName The folder name
* @return {Void} Void
*/
selectFolder: (fldName) => {
vueImage.currentFolder = fldName;
vueImage.loadImages();
},
/**
* Refresh folder list and load images from root
*
* @return {Void} Void
*/
refreshFolders: () => {
vueImage.isLoading = true;
vueImage.isLoadingText = 'Fetching folders list...';
......@@ -107,6 +120,12 @@ let vueImage = new Vue({
});
});
},
/**
* Loads images in selected folder
*
* @return {Void} Void
*/
loadImages: () => {
vueImage.isLoading = true;
vueImage.isLoadingText = 'Fetching images...';
......@@ -114,14 +133,127 @@ let vueImage = new Vue({
socket.emit('uploadsGetImages', { folder: vueImage.currentFolder }, (data) => {
vueImage.images = data;
vueImage.isLoading = false;
vueImage.attachContextMenus();
});
});
},
/**
* Select an image
*
* @param {String} imageId The image identifier
* @return {Void} Void
*/
selectImage: (imageId) => {
vueImage.currentImage = imageId;
},
/**
* Set image alignment
*
* @param {String} align The alignment
* @return {Void} Void
*/
selectAlignment: (align) => {
vueImage.currentAlign = align;
},
/**
* Attach right-click context menus to images and folders
*
* @return {Void} Void
*/
attachContextMenus: () => {
let moveFolders = _.map(vueImage.folders, (f) => {
return {
name: (f !== '') ? f : '/ (root)',
icon: 'fa-folder'
};
});
$.contextMenu('destroy', '.editor-modal-imagechoices > figure');
$.contextMenu({
selector: '.editor-modal-imagechoices > figure',
appendTo: '.editor-modal-imagechoices',
position: (opt, x, y) => {
$(opt.$trigger).addClass('is-contextopen');
let trigPos = $(opt.$trigger).position();
let trigDim = { w: $(opt.$trigger).width() / 2, h: $(opt.$trigger).height() / 2 }
opt.$menu.css({ top: trigPos.top + trigDim.h, left: trigPos.left + trigDim.w });
},
events: {
hide: (opt) => {
$(opt.$trigger).removeClass('is-contextopen');
}
},
items: {
rename: {
name: "Rename",
icon: "fa-edit",
callback: (key, opt) => {
alert("Clicked on " + key);
}
},
move: {
name: "Move to...",
icon: "fa-folder-open-o",
items: moveFolders
},
delete: {
name: "Delete",
icon: "fa-trash",
callback: (key, opt) => {
alert("Clicked on " + key);
}
}
}
});
}
}
});
$('#btn-editor-uploadimage input').on('change', (ev) => {
$(ev.currentTarget).simpleUpload("/uploads/img", {
name: 'imgfile',
data: {
folder: vueImage.currentFolder
},
limit: 20,
expect: 'json',
allowedExts: ["jpg", "jpeg", "gif", "png", "webp"],
allowedTypes: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
maxFileSize: 3145728, // max 3 MB
init: () => {
vueImage.isLoading = true;
vueImage.isLoadingText = 'Preparing to upload...';
},
progress: function(progress) {
vueImage.isLoadingText = 'Uploading...' + Math.round(progress) + '%';
},
success: (data) => {
if(data.ok) {
} else {
alerts.pushError('Upload error', data.msg);
}
},
error: function(error) {
vueImage.isLoading = false;
alerts.pushError(error.message, this.upload.file.name);
},
finish: () => {
vueImage.isLoading = false;
}
});
});
\ No newline at end of file
......@@ -14,6 +14,7 @@ $warning: $orange;
@import 'bulma';
@import './libs/twemoji-awesome';
@import './libs/animate.min.css';
@import './libs/jquery-contextmenu';
@import './components/_alerts';
@import './components/_editor';
......
......@@ -17,10 +17,12 @@
a {
color: #FFF !important;
border: none;
transition: background-color 0.4s ease;
&.active, &:hover {
&.active, &:hover, &:focus {
background-color: rgba(0,0,0,0.5);
border-color: #888;
outline: none;
}
}
......@@ -65,6 +67,33 @@
}
#btn-editor-uploadimage {
position: relative;
overflow: hidden;
> label {
display: block;
opacity: 0;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
input[type=file] {
opacity: 0;
position: absolute;
top: -9999px;
left: -9999px;
}
}
}
.editor-modal-imagechoices {
display: flex;
flex-wrap: wrap;
......@@ -127,6 +156,20 @@
}
&.is-contextopen {
background-color: $warning;
color: #FFF;
> img {
border-color: darken($warning, 10%);
}
> span > strong {
color: #FFF;
}
}
}
}
......
@charset "UTF-8";
/*!
* jQuery contextMenu - Plugin for simple contextMenu handling
*
* Version: v2.2.5-dev
*
* Authors: Björn Brala (SWIS.nl), Rodney Rehm, Addy Osmani (patches for FF)
* Web: http://swisnl.github.io/jQuery-contextMenu/
*
* Copyright (c) 2011-2016 SWIS BV and contributors
*
* Licensed under
* MIT License http://www.opensource.org/licenses/mit-license
*
* Date: 2016-08-27T11:09:08.919Z
*/
.context-menu-icon {
display: list-item;
font-family: inherit;
}
.context-menu-icon::before {
position: absolute;
top: 50%;
left: 0;
width: 2em;
font-family: FontAwesome;
font-size: 14px;
font-style: normal;
font-weight: normal;
line-height: 1;
color: $primary;
text-align: center;
-webkit-transform: translateY(-50%);
-ms-transform: translateY(-50%);
-o-transform: translateY(-50%);
transform: translateY(-50%);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.context-menu-icon.context-menu-hover:before {
color: #fff;
}
.context-menu-icon.context-menu-disabled::before {
color: #bbb;
}
.context-menu-list {
position: absolute;
display: inline-block;
min-width: 13em;
max-width: 26em;
padding: 0 0;
margin: .3em;
font-family: inherit;
font-size: 14px;
list-style-type: none;
background: #fff;
border: 1px solid $primary;
border-radius: .2em;
-webkit-box-shadow: 0 2px 5px rgba(0, 0, 0, .25);
box-shadow: 0 2px 5px rgba(0, 0, 0, .25);
}
.context-menu-item {
position: relative;
padding: 7px 2em;
color: #69707a;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background-color: #fff;
font-size: 14px;
text-align: left;
}
.context-menu-separator {
padding: 0;
margin: .35em 0;
border-bottom: 1px solid #e6e6e6;
}
.context-menu-item.context-menu-hover {
color: #fff;
cursor: pointer;
background-color: $primary;
}
.context-menu-item.context-menu-disabled {
color: #bbb;
cursor: default;
background-color: #fff;
}
.context-menu-input.context-menu-hover {
cursor: default;
}
.context-menu-submenu:after {
position: absolute;
top: 50%;
right: .5em;
z-index: 1;
width: 0;
height: 0;
content: '';
border-color: transparent transparent transparent #2f2f2f;
border-style: solid;
border-width: .25em 0 .25em .25em;
-webkit-transform: translateY(-50%);
-ms-transform: translateY(-50%);
-o-transform: translateY(-50%);
transform: translateY(-50%);
}
.context-menu-item > .context-menu-list {
top: .3em;
/* re-positioned by js */
right: -.3em;
display: none;
}
.context-menu-item.context-menu-visible > .context-menu-list {
display: block;
}
.context-menu-accesskey {
text-decoration: underline;
}
\ No newline at end of file
......@@ -2,7 +2,13 @@
var express = require('express');
var router = express.Router();
var _ = require('lodash');
var readChunk = require('read-chunk'),
fileType = require('file-type'),
Promise = require('bluebird'),
fs = Promise.promisifyAll(require('fs-extra')),
path = require('path'),
_ = require('lodash');
var validPathRe = new RegExp("^([a-z0-9\\/-]+\\.[a-z0-9]+)$");
var validPathThumbsRe = new RegExp("^([0-9]+\\.png)$");
......@@ -31,6 +37,54 @@ router.get('/t/*', (req, res, next) => {
});
router.post('/img', lcdata.uploadImgHandler, (req, res, next) => {
let destFolder = _.chain(req.body.folder).trim().toLower().value();
let destFolderPath = lcdata.validateUploadsFolder(destFolder);
Promise.map(req.files, (f) => {
let destFilename = '';
let destFilePath = '';
return lcdata.validateUploadsFilename(f.originalname, destFolder).then((fname) => {
destFilename = fname;
destFilePath = path.resolve(destFolderPath, destFilename);
return readChunk(f.path, 0, 262);
}).then((buf) => {
//-> Check MIME type by magic number
let mimeInfo = fileType(buf);
if(!_.includes(['image/png', 'image/jpeg', 'image/gif', 'image/webp'], mimeInfo.mime)) {
return Promise.reject(new Error('Invalid file type.'));
}
return true;
}).then(() => {
//-> Move file to final destination
return fs.moveAsync(f.path, destFilePath, { clobber: false });
}).then(() => {
return {
filename: destFilename,
filesize: f.size
};
});
}, {concurrency: 3}).then((results) => {
res.json({ ok: true, results });
}).catch((err) => {
res.json({ ok: false, msg: err.message });
});
});
router.get('/*', (req, res, next) => {
let fileName = req.params[0];
......
......@@ -23,7 +23,7 @@ var paths = {
'./node_modules/jquery/dist/jquery.min.js',
'./node_modules/vue/dist/vue.min.js',
'./node_modules/jquery-smooth-scroll/jquery.smooth-scroll.min.js',
'./node_modules/jquery-contextmenu/dist/jquery.ui.position.min.js',
'./node_modules/jquery-simple-upload/simpleUpload.min.js',
'./node_modules/jquery-contextmenu/dist/jquery.contextMenu.min.js',
'./node_modules/sticky-js/dist/sticky.min.js',
'./node_modules/simplemde/dist/simplemde.min.js',
......
......@@ -4,6 +4,7 @@ var path = require('path'),
loki = require('lokijs'),
Promise = require('bluebird'),
fs = Promise.promisifyAll(require('fs-extra')),
multer = require('multer'),
_ = require('lodash');
var regFolderName = new RegExp("^[a-z0-9][a-z0-9\-]*[a-z0-9]$");
......@@ -20,6 +21,8 @@ module.exports = {
_uploadsFolders: [],
_uploadsDb: null,
uploadImgHandler: null,
/**
* Initialize Local Data Storage model
*
......@@ -33,8 +36,7 @@ module.exports = {
self._uploadsPath = path.resolve(ROOTPATH, appconfig.datadir.repo, 'uploads');
self._uploadsThumbsPath = path.resolve(ROOTPATH, appconfig.datadir.db, 'thumbs');
// Start in full or bare mode
// Finish initialization tasks
switch(mode) {
case 'agent':
......@@ -42,6 +44,7 @@ module.exports = {
break;
case 'server':
self.createBaseDirectories(appconfig);
self.initMulter(appconfig);
break;
case 'ws':
self.initDb(appconfig);
......@@ -100,6 +103,42 @@ module.exports = {
},
/**
* Init Multer upload handlers
*
* @param {Object} appconfig The application config
* @return {boolean} Void
*/
initMulter(appconfig) {
this.uploadImgHandler = multer({
storage: multer.diskStorage({
destination: (req, f, cb) => {
cb(null, path.resolve(ROOTPATH, appconfig.datadir.db, 'temp-upload'))
}
}),
fileFilter: (req, f, cb) => {
//-> Check filesize (3 MB max)
if(f.size > 3145728) {
return cb(null, false);
}
//-> Check MIME type (quick check only)
if(!_.includes(['image/png', 'image/jpeg', 'image/gif', 'image/webp'], f.mimetype)) {
return cb(null, false);
}
cb(null, true);
}
}).array('imgfile', 20);
return true;
},
/**
* Gets the thumbnails folder path.
*
* @return {String} The thumbs path.
......@@ -122,6 +161,7 @@ module.exports = {
fs.ensureDirSync(path.resolve(ROOTPATH, appconfig.datadir.db));
fs.ensureDirSync(path.resolve(ROOTPATH, appconfig.datadir.db, './cache'));
fs.ensureDirSync(path.resolve(ROOTPATH, appconfig.datadir.db, './thumbs'));
fs.ensureDirSync(path.resolve(ROOTPATH, appconfig.datadir.db, './temp-upload'));
fs.ensureDirSync(path.resolve(ROOTPATH, appconfig.datadir.repo));
fs.ensureDirSync(path.resolve(ROOTPATH, appconfig.datadir.repo, './uploads'));
......@@ -184,6 +224,50 @@ module.exports = {
},
/**
* Check if folder is valid and exists
*
* @param {String} folderName The folder name
* @return {Boolean} True if valid
*/
validateUploadsFolder(folderName) {
folderName = (_.includes(this._uploadsFolders, folderName)) ? folderName : '';
return path.resolve(this._uploadsPath, folderName);
},
/**
* Check if filename is valid and unique
*
* @param {String} f The filename
* @param {String} fld The containing folder
* @return {Promise<String>} Promise of the accepted filename
*/
validateUploadsFilename(f, fld) {
let fObj = path.parse(f);
let fname = _.chain(fObj.name).trim().toLower().kebabCase().value().replace(/[^a-z0-9\-]+/g, '');
let fext = _.toLower(fObj.ext);
if(!_.includes(['.jpg', '.jpeg', '.png', '.gif', '.webp'], fext)) {
fext = '.png';
}
f = fname + fext;
let fpath = path.resolve(this._uploadsPath, fld, f);
return fs.statAsync(fpath).then((s) => {
throw new Error('File ' + f + ' already exists.');
}).catch((err) => {
if(err.code === 'ENOENT') {
return f;
}
throw err;
});
},
/**
* Sets the uploads files.
*
* @param {Array<Object>} arrFiles The uploads files
......
......@@ -52,6 +52,7 @@
"express-validator": "^2.20.10",
"farmhash": "^1.2.1",
"file-type": "^3.8.0",
"filesize.js": "^1.0.2",
"fs-extra": "^0.30.0",
"git-wrapper2-promise": "^0.2.9",
"highlight.js": "^9.7.0",
......@@ -59,7 +60,7 @@
"i18next-express-middleware": "^1.0.2",
"i18next-node-fs-backend": "^0.1.2",
"js-yaml": "^3.6.1",
"lodash": "^4.16.1",
"lodash": "^4.16.2",
"lokijs": "^1.4.1",
"markdown-it": "^8.0.0",
"markdown-it-abbr": "^1.0.4",
......@@ -72,6 +73,7 @@
"markdown-it-task-lists": "^1.4.1",
"moment": "^2.15.1",
"moment-timezone": "^0.5.5",
"multer": "^1.2.0",
"passport": "^0.3.2",
"passport-local": "^1.0.0",
"pug": "^2.0.0-beta6",
......@@ -84,13 +86,13 @@
"snyk": "^1.19.1",
"socket.io": "^1.4.8",
"sticky-js": "^1.0.7",
"validator": "^5.7.0",
"validator": "^6.0.0",
"validator-as-promised": "^1.0.2",
"winston": "^2.2.0"
},
"devDependencies": {
"ace-builds": "^1.2.5",
"babel-preset-es2015": "^6.14.0",
"babel-preset-es2015": "^6.16.0",
"bulma": "^0.1.2",
"chai": "^3.5.0",
"chai-as-promised": "^5.3.0",
......@@ -99,11 +101,11 @@
"font-awesome": "^4.6.3",
"gulp": "^3.9.1",
"gulp-babel": "^6.1.2",
"gulp-clean-css": "^2.0.12",
"gulp-clean-css": "^2.0.13",
"gulp-concat": "^2.6.0",
"gulp-gzip": "^1.4.0",
"gulp-include": "^2.3.1",
"gulp-nodemon": "^2.1.0",
"gulp-nodemon": "^2.2.1",
"gulp-plumber": "^1.1.0",
"gulp-sass": "^2.3.2",
"gulp-tar": "^1.9.0",
......@@ -112,14 +114,15 @@
"istanbul": "^0.4.5",
"jquery": "^3.1.1",
"jquery-contextmenu": "^2.2.4",
"jquery-simple-upload": "^1.0.0",
"jquery-smooth-scroll": "^2.0.0",
"merge-stream": "^1.0.0",
"mocha": "^3.0.2",
"mocha": "^3.1.0",
"mocha-lcov-reporter": "^1.2.0",
"nodemon": "^1.10.2",
"sticky-js": "^1.0.5",
"sticky-js": "^1.1.0",
"twemoji-awesome": "^1.0.4",
"vue": "^1.0.27"
"vue": "^1.0.28"
},
"snyk": true
}
......@@ -17,9 +17,11 @@
span.icon.is-small: i.fa.fa-folder
span New Folder
.control.has-addons
a.button.is-info.is-outlined(v-on:click="uploadImage")
a.button.is-info.is-outlined#btn-editor-uploadimage(v-on:click="uploadImage")
span.icon.is-small: i.fa.fa-upload
span Upload Image
label
input(type="file", multiple)
a.button.is-info.is-outlined(v-on:click="fetchFromUrl")
span.icon.is-small: i.fa.fa-download
span Fetch from URL
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment