Explorar el Código

Image upload process + right-click menu UI

NGPixel hace 8 años
padre
commit
819d4ad346

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
assets/css/app.css


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
assets/js/app.js


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
assets/js/libs.js


+ 132 - 0
client/js/components/editor-image.js

@@ -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;
+		}
+
+	});
+
 });

+ 1 - 0
client/scss/app.scss

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

+ 45 - 2
client/scss/components/_editor.scss

@@ -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;
 		}
 
 	}
@@ -63,6 +65,33 @@
 		opacity: 1;
 	}
 
+}
+
+#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 {
@@ -127,6 +156,20 @@
 
 		}
 
+		&.is-contextopen {
+			background-color: $warning;
+			color: #FFF;
+
+			> img {
+				border-color: darken($warning, 10%);
+			}
+
+			> span > strong {
+				color: #FFF;
+			}
+
+		}
+
 	}
 
 }

+ 131 - 0
client/scss/libs/jquery-contextmenu.scss

@@ -0,0 +1,131 @@
+@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;
+}

+ 55 - 1
controllers/uploads.js

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

+ 1 - 1
gulpfile.js

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

+ 86 - 2
models/localdata.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);
@@ -99,6 +102,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.
 	 *
@@ -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'));
@@ -183,6 +223,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.
 	 *

+ 11 - 8
package.json

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

+ 3 - 1
views/modals/editor-image.pug

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

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio