Pārlūkot izejas kodu

Merge branch 'profile_pic'

sbkwgh 7 gadi atpakaļ
vecāks
revīzija
1643a2af0f

+ 19 - 0
frontend/src/assets/scss/elementStyles.scss

@@ -38,6 +38,25 @@ b, strong {
 	font-weight: 700;
 }
 
+.picture_circle {
+	border-radius: 100%;
+	background-position: center center;
+	background-size: cover;
+	background-repeat: no-repeat;
+	position: relative;
+
+	&:after {
+		content: '';
+		position: absolute;
+		width: calc(100% - 0.25rem);
+		height: calc(100% - 0.25rem);
+		left: 0;
+		top: 0;
+		border: 0.125rem solid rgba(150,150,150,0.5);
+		border-radius: 100%;
+	}
+}
+
 .button {
 	border: 1px solid $color__gray--darker;
 	display: inline-block;

+ 19 - 5
frontend/src/components/AvatarIcon.vue

@@ -4,8 +4,11 @@
 			<template v-if='ajaxUser'>
 				<div class='avatar_icon__header'>
 					<div
-						class='avatar_icon__icon avatar_icon__icon--small'
-						:style='{ "background-color": user.color }'
+						class='avatar_icon__icon avatar_icon__icon--small picture_circle'
+						:style='{
+							"background-color": user.color,
+							"background-image": user.picture ? "url(" + user.picture + ")" : null,
+						}'
 						@click='goToUser'
 					>
 						{{userLetter}}
@@ -23,9 +26,9 @@
 		</template>
 		<div
 			slot='display'
-			class='avatar_icon__icon'
+			class='avatar_icon__icon picture_circle'
 			:class='{"avatar_icon__icon--small": size === "small"}'
-			:style='{ "background-color": userColor }'
+			:style='{ "background-color": userColor, "background-image": userPicture, }'
 			@click.stop='goToUser'
 		>
 			{{userLetter}}
@@ -49,7 +52,11 @@
 		computed: {
 			userLetter () {
 				if(this.user) {
-					return this.user.username[0].toLowerCase()
+					if(this.userPicture) {
+						return ''
+					} else {
+						return this.user.username[0].toLowerCase()
+					}
 				} else {
 					return ''
 				}
@@ -60,6 +67,13 @@
 				} else {
 					return null
 				}
+			},
+			userPicture () {
+				if(this.user && this.user.picture) {
+					return "url(" + this.user.picture + ")"
+				} else {
+					return null
+				}
 			}
 		},
 		methods: {

+ 175 - 1
frontend/src/components/routes/SettingsGeneral.vue

@@ -1,5 +1,53 @@
 <template>
 	<div class='route_container'>
+		<modal-window v-model='picture.showProfilePictureModal' width='25rem' @input='hideProflePictureModal'>
+			<div
+				class='profile_picture_modal__overlay'
+				:class='{
+					"profile_picture_modal__overlay--show": picture.loading
+				}'
+			>
+				<loading-icon></loading-icon>
+			</div>
+			<div
+				class='profile_picture_modal'
+				:class='{ "profile_picture_modal--picture.dragging": picture.dragging  }'
+				@dragover='handleDragOver'
+				@drkagend='picture.dragging = false'
+				@drkgleave='picture.dragging = false'
+				@drop='handleFileDrop'
+			>
+				<div class='h3'>Add a profile picture</div>
+				<p class='p--condensed'>
+					Drag and drop an image or
+					<label class='button profile_picture_modal__upload_button'>
+						<input type='file' accept='image/*' @change='processImage($event.target.files[0])'>
+						upload a file
+					</label>
+				</p>
+				<div class='profile_picture_modal__drag_area'>
+					<span
+						v-if='!picture.dataURL'
+						class='fa fa-cloud-upload profile_picture_modal__drag_area__icon'
+						:class='{ "profile_picture_modal__drag_area__icon--picture.dragging": picture.dragging }' 
+					></span>
+					<div
+						class='profile_picture_modal__drag_area__image picture_circle'
+						:style='{ "background-image": "url(" + picture.dataURL + ")" }'
+						v-else
+					></div>
+				</div>
+				<div class='profile_picture_modal__buttons'>
+					<button class='button button--modal button--borderless' @click='hideProflePictureModal'>Cancel</button>
+					<button
+						class='button button--modal button--green'
+						:class='{ "button--disabled": !picture.dataURL }'
+						@click='uploadProfilePicture'
+					>Upload picture</button>
+				</div>
+			</div>
+		</modal-window>
+
 		<div class='h1'>General settings</div>
 		<p>
 			<div class='h3'>About me</div>
@@ -20,12 +68,28 @@
 				Save description
 			</loading-button>
 		</p>
+		<p>
+			<div class='h3'>Profile picture</div>
+			<p class='p--condensed'>
+				This will be displayed by your posts on the site
+			</p>
+			<p
+				class='p--condensed profile_picture_preview picture_circle'
+				:style='{ "background-image": "url(" + picture.current + ")" }'
+				v-if='picture.current'
+			></p>
+			<button class='button' @click='picture.showProfilePictureModal = true'>
+				{{picture.current ? "Change" : "Add" }} profile picture
+			</button>
+		</p>
 	</div>
 </template>
 
 <script>
 	import FancyTextarea from '../FancyTextarea'
 	import LoadingButton from '../LoadingButton'
+	import LoadingIcon from '../LoadingIcon'
+	import ModalWindow from '../ModalWindow'
 
 	import AjaxErrorHandler from '../../assets/js/errorHandler'
 	import logger from '../../assets/js/logger'
@@ -34,7 +98,9 @@
 		name: 'settingsGeneral',
 		components: {
 			FancyTextarea,
-			LoadingButton
+			LoadingButton,
+			LoadingIcon,
+			ModalWindow
 		},
 		data () {
 			return {
@@ -42,6 +108,14 @@
 					value: '',
 					loading: false,
 					error: ''
+				},
+
+				picture: {
+					current: null,
+					showProfilePictureModal: false,
+					dragging: false,
+					dataURL: null,
+					loading: false
 				}
 			}
 		},
@@ -65,6 +139,58 @@
 							this.description.error = error.message
 						})
 					})
+			},
+			uploadProfilePicture () {
+				this.picture.loading = true
+
+				this.axios
+					.post('/api/v1/user/' + this.$store.state.username + '/picture', {
+						picture: this.picture.dataURL
+					})
+					.then(res => {
+						this.hideProflePictureModal()
+						this.picture.current = this.picture.dataURL
+					})
+					.catch(e => {
+						this.picture.loading = false
+
+						AjaxErrorHandler(this.$store)(e)
+					})
+
+			},
+			hideProflePictureModal () {
+				this.picture.showProfilePictureModal = false
+				
+				//Wait for transition to complete
+				setTimeout(() => {
+					this.picture.dataURL = null
+					this.picture.loading = false
+				}, 200)
+			},	
+			handleDragOver (e) {
+				e.preventDefault()
+				this.picture.dragging = true
+			},
+			handleFileDrop (e) {
+				e.preventDefault()
+				this.picture.dragging = false
+				
+				if(e.dataTransfer && e.dataTransfer.items) {
+					let file = e.dataTransfer.items[0]
+
+					if(file.type.match('^image/')) {
+						this.processImage(file.getAsFile())
+					}
+				}
+			},
+			processImage (file) {
+				let reader = new FileReader()
+
+				reader.readAsDataURL(file)
+
+				reader.addEventListener('load', () => {
+					this.picture.dataURL = reader.result
+				})
 			}
 		},
 		created () {
@@ -75,6 +201,7 @@
 					.get('/api/v1/user/' + this.$store.state.username)
 					.then(res => {
 						this.description.value = res.data.description || ''
+						this.picture.current = res.data.picture
 					})
 					.catch(e => {
 						AjaxErrorHandler(this.$store)(e)
@@ -87,6 +214,53 @@
 </script>
 
 <style lang='scss' scoped>
+	@import '../../assets/scss/variables.scss';
+
+	.profile_picture_preview {
+		height: 5rem;
+		width: 5rem;
+	}
+
+	.profile_picture_modal {
+		padding: 1rem;
+		transition: all 0.2s;
+
+		@at-root #{&}--picture.dragging {
+			//background-color: $color__lightgray--primary;
+		}
+
+		@at-root #{&}__overlay {
+			@include loading-overlay(rgba(0, 0, 0, 0.5), 0.125rem);
+		}
+
+		@at-root #{&}__upload_button input[type="file"] {
+			display: none;
+		}
+
+		@at-root #{&}__drag_area {
+			padding: 1rem;
+			text-align: center;
+
+			@at-root #{&}__image {
+				width: 5rem;
+				height: 5rem;
+				display: inline-block;
+				margin-top: -1rem;
+			}
+
+			@at-root #{&}__icon {
+				font-size: 6rem;
+				color: $color__gray--darker;
+				transition: all 0.2s;
+
+				@at-root #{&}--picture.dragging {
+					transform: translateY(-0.5rem) scale(1.1);
+					color: $color__gray--darkest;
+				}
+			}
+		}
+	}
+
 	@media (max-width: 420px) {
 		.h1 {
 			display: none;

+ 22 - 4
frontend/src/components/routes/User.vue

@@ -2,10 +2,13 @@
 	<div class='route_container user_route'>
 		<div class='user_header'>
 			<div
-				class='user_header__icon'
-				:style='{ "background-color": (user || {}).color }'
+				class='user_header__icon picture_circle'
+				:style='{
+					"background-color": userColor,
+					"background-image": userPicture,
+				}'
 			>
-				{{username[0]}}
+				{{userPicture ? '' : username[0]}}
 			</div>
 			<div class='user_header__info'>
 				<span class='user_header__username'>{{username}}</span>
@@ -51,6 +54,22 @@
 				this.selected = this.getIndexFromRoute(to.path)
 			}
 		},
+		computed: {
+			userColor () {
+				if(this.user) {
+					return this.user.color
+				} else {
+					return null
+				}
+			},
+			userPicture () {
+				if(this.user && this.user.picture) {
+					return 'url(' + this.user.picture + ')'
+				} else {
+					return null
+				}
+			}
+		},
 		methods: {
 			getIndexFromRoute (path) {
 				let selectedIndex
@@ -107,7 +126,6 @@
 			line-height: 4rem;
 			@include text($font--role-emphasis, 3rem)
 			text-align: center;
-			border-radius: 100%;
 			background-color: $color__gray--darkest;
 			color: #fff;
 		}

+ 13 - 0
migrations/20171203220305-add-picture-column.js

@@ -0,0 +1,13 @@
+'use strict';
+
+module.exports = {
+  up: (queryInterface, Sequelize) => {
+    return queryInterface.addColumn('users', 'picture', {
+      type: Sequelize.TEXT('long')
+    })
+  },
+
+  down: (queryInterface, Sequelize) => {
+    return queryInterface.dropColumn('users', 'picture')
+  }
+};

+ 3 - 3
models/post.js

@@ -53,12 +53,12 @@ module.exports = (sequelize, DataTypes) => {
 				let models = sequelize.models
 
 				return [
-					{ model: models.User, attributes: ['username', 'createdAt', 'id', 'color'] },
-					{ model: models.User, as: 'Likes', attributes: ['username', 'createdAt', 'id', 'color'] },
+					{ model: models.User, attributes: ['username', 'createdAt', 'id', 'color', 'picture'] },
+					{ model: models.User, as: 'Likes', attributes: ['username', 'createdAt', 'id', 'color', 'picture'] },
 					{ model: models.Thread, include: [models.Category]} ,
 					{
 						model: models.Post, as: 'Replies', include:
-						[{ model: models.User, attributes: ['username', 'id', 'color'] }]	
+						[{ model: models.User, attributes: ['username', 'id', 'color', 'picture'] }]	
 					}
 				]
 			},

+ 4 - 4
models/thread.js

@@ -101,7 +101,7 @@ module.exports = (sequelize, DataTypes) => {
 				let models = sequelize.models
 
 				return [
-					{ model: models.User, attributes: ['username', 'createdAt', 'color', 'updatedAt', 'id'] }, 
+					{ model: models.User, attributes: ['username', 'createdAt', 'color', 'picture', 'updatedAt', 'id'] }, 
 					models.Category,
 					{ 
 						model: models.Post, 
@@ -110,11 +110,11 @@ module.exports = (sequelize, DataTypes) => {
 						limit,
 						include: [
 							{ model: models.Thread, attributes: ['slug'] }, 
-							{ model: models.User, as: 'Likes', attributes: ['username', 'createdAt', 'id', 'color'] },
-							{ model: models.User, attributes: ['username', 'createdAt', 'id', 'color'] }, 
+							{ model: models.User, as: 'Likes', attributes: ['username', 'createdAt', 'id', 'color', 'picture'] },
+							{ model: models.User, attributes: ['username', 'createdAt', 'id', 'color', 'picture'] }, 
 							{
 								model: models.Post, as: 'Replies', include:
-								[{ model: models.User, attributes: ['username', 'id', 'color'] }]	
+								[{ model: models.User, attributes: ['username', 'id', 'color', 'picture'] }]	
 							}
 						]
 					}

+ 17 - 0
models/user.js

@@ -59,6 +59,23 @@ module.exports = (sequelize, DataTypes) => {
 		admin: {
 			type: DataTypes.BOOLEAN,
 			defaultValue: false
+		},
+		picture: {
+			type: DataTypes.TEXT('long'),
+			validate: {
+				isString (val) {
+					if(typeof val !== 'string') {
+						throw new sequelize.ValidationError('password must be a string')
+					}
+				},
+				isValidBase64 (val) {
+					let base64Regexp = /^data:image\/(png|jpeg|jpg|gif);base64,[A-Za-z0-9+\/=]+$/g
+
+					if(!val.match(base64Regexp)) {
+						throw new sequelize.ValidationError('image must be valid base64')
+					}
+				}
+			}
 		}
 	}, {
 		instanceMethods: {

+ 1 - 1
routes/category.js

@@ -37,7 +37,7 @@ router.get('/:category', async (req, res) => {
 				where: {},
 				include: [
 					Category,
-					{ model: User, attributes: ['username', 'createdAt', 'id', 'color'] }, 
+					{ model: User, attributes: ['username', 'createdAt', 'id', 'color', 'picture'] }, 
 					{
 						model: Post, limit: 1, order: [['id', order]], include:
 						[{ model: User, attributes: ['username', 'id'] }]

+ 30 - 0
routes/user.js

@@ -183,6 +183,36 @@ router.all('*', (req, res, next) => {
 	}
 })
 
+router.post('/:username/picture', async (req, res) => {
+	try {
+		if(req.session.username !== req.params.username) {
+			throw Errors.requestNotAuthorized
+		} else {
+			let user = await User.findById(req.session.UserId)
+			await user.update({ picture: req.body.picture })
+
+			res.json(user.toJSON())
+		}
+	} catch (e) {
+		if(e === Errors.requestNotAuthorized) {
+			res.status(401)
+			res.json({
+				errors: [e]
+			})
+		} else if(e instanceof Sequelize.ValidationError) {
+			res.status(400)
+			res.json(e)
+		} else {
+			console.log(e)
+
+			res.status(500)
+			res.json({
+				errors: [Errors.unknown]
+			})
+		}
+	}
+})
+
 router.put('/:username', async (req, res) => {
 	try {
 		if(req.session.username !== req.params.username) {

+ 1 - 1
server.js

@@ -20,7 +20,7 @@ let session = expressSession({
 })
 
 app.use(compression())
-app.use(bodyParser.json())
+app.use(bodyParser.json({ limit: '5mb' }))
 app.use(bodyParser.urlencoded({ extended: true }))
 app.use(session)
 

+ 148 - 0
test/profile_picture.js

@@ -0,0 +1,148 @@
+process.env.NODE_ENV = 'test'
+
+let chai = require('chai')
+let server = require('../server')
+let should = chai.should()
+
+let { sequelize, Thread, Post, User } = require('../models')
+
+const Errors = require('../lib/errors.js')
+
+chai.use(require('chai-http'))
+chai.use(require('chai-things'))
+
+let expect = chai.expect
+
+describe('User', () => {
+	let admin = chai.request.agent(server)
+	let user = chai.request.agent(server)
+
+	let picture = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAFCAIAAADzBuo/AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAANSURBVBhXYxisgIEBAACbAAEriBitAAAAAElFTkSuQmCC";
+
+	//Wait for app to start before commencing
+	before((done) => {
+		if(server.locals.appStarted) done()
+			
+		server.on('appStarted', () => {
+			done()
+		})
+	})
+
+	describe('POST /:user/picture', () => {
+		before(async () => {
+			try {
+				let accounts = []
+
+				accounts.push(
+					admin
+					.post('/api/v1/user')
+					.set('content-type', 'application/json')
+					.send({
+						username: 'adminaccount',
+						password: 'password',
+						admin: true
+					})
+				)
+				accounts.push(
+					user
+					.post('/api/v1/user')
+					.set('content-type', 'application/json')
+					.send({
+						username: 'useraccount1',
+						password: 'password'
+					})
+				)
+		
+				await Promise.all(accounts)
+
+				return true
+			} catch (e) {
+				return e
+			}
+		})
+
+		it('should add a picture', async () => {
+			let res = await user
+				.post('/api/v1/user/useraccount1/picture')
+				.set('content-type', 'application/json')
+				.send({ picture })
+
+			res.should.be.json
+			res.should.have.status(200)
+
+			let foundUser = await User.findById(1)
+			foundUser.should.have.property('picture', picture)
+		})
+		it('should not add a picture if not logged in', done => {
+			chai.request(server)
+				.post('/api/v1/user/useraccount1/picture')
+				.set('content-type', 'application/json')
+				.send({ picture })
+				.end((err, res) => {
+					res.should.be.json
+					res.should.have.status(401)
+					res.body.errors.should.contain.something.that.deep.equals(Errors.requestNotAuthorized)
+
+					done()
+				})
+		})
+		it('should not add a picture if not same user', done => {
+			user
+				.post('/api/v1/user/adminaccount/picture')
+				.set('content-type', 'application/json')
+				.send({ picture })
+				.end((err, res) => {
+					res.should.be.json
+					res.should.have.status(401)
+					res.body.errors.should.contain.something.that.deep.equals(Errors.requestNotAuthorized)
+
+					done()
+				})
+		})
+		it('should not add a picture if user does not exist', done => {
+			user
+				.post('/api/v1/user/notanaccount/picture')
+				.set('content-type', 'application/json')
+				.send({ picture })
+				.end((err, res) => {
+					res.should.be.json
+					res.should.have.status(401)
+					res.body.errors.should.contain.something.that.deep.equals(Errors.requestNotAuthorized)
+
+					done()
+				})
+		})
+		it('should not add a picture if not validated base64', done => {
+			user
+				.post('/api/v1/user/useraccount1/picture')
+				.set('content-type', 'application/json')
+				.send({ picture: 'not base64' })
+				.end((err, res) => {
+					res.should.be.json
+					res.should.have.status(400)
+					res.body.errors.should.contain.something.that.has.property('message', 'image must be valid base64')
+
+					done()
+				})
+		})
+		it('should not add a picture if not an image mime type', done => {
+			user
+				.post('/api/v1/user/useraccount1/picture')
+				.set('content-type', 'application/json')
+				.send({ picture: 'data:text/html;base64,iVBORw0KGgoAAAANSUhEUgAAAAoA' })
+				.end((err, res) => {
+					res.should.be.json
+					res.should.have.status(400)
+					res.body.errors.should.contain.something.that.has.property('message', 'image must be valid base64')
+
+					done()
+				})
+		})
+		//it('should not add a picture if too large file size')
+
+	})
+
+	after(() => {
+		sequelize.sync({ force: true })
+	})
+})