Ver Fonte

feat(Settings): polished Settings page

Signed-off-by: Jonathan <theflametrooper@gmail.com>
Jonathan há 4 anos atrás
pai
commit
0e8eea65c0

+ 19 - 7
backend/logic/actions/users.js

@@ -361,7 +361,10 @@ export default {
 							email: user.email.address
 						})
 						.then(url => {
-							user.avatar = url;
+							user.avatar = {
+								type: "gravatar",
+								url
+							};
 							next(null, user);
 						});
 				},
@@ -373,8 +376,8 @@ export default {
 
 				// respond with the new user
 				(newUser, next) => {
-					verifyEmailSchema(email, username, verificationToken, () => {
-						next(null, newUser);
+					verifyEmailSchema(email, username, verificationToken, err => {
+						next(err, newUser);
 					});
 				}
 			],
@@ -558,6 +561,7 @@ export default {
 	 */
 	findByUsername: async (session, username, cb) => {
 		const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+
 		async.waterfall(
 			[
 				next => {
@@ -651,6 +655,7 @@ export default {
 	 */
 	findBySession: async (session, cb) => {
 		const userModel = await db.runJob("GET_MODEL", { modelName: "user" });
+
 		async.waterfall(
 			[
 				next => {
@@ -685,6 +690,7 @@ export default {
 					console.log("ERROR", "FIND_BY_SESSION", `User not found. "${err}"`);
 					return cb({ status: "failure", message: err });
 				}
+
 				const data = {
 					email: {
 						address: user.email.address
@@ -695,8 +701,10 @@ export default {
 					location: user.location,
 					bio: user.bio
 				};
+
 				if (user.services.password && user.services.password.password) data.password = true;
 				if (user.services.github && user.services.github.id) data.github = true;
+
 				console.log("SUCCESS", "FIND_BY_SESSION", `User found. "${user.username}".`);
 				return cb({
 					status: "success",
@@ -802,12 +810,14 @@ export default {
 	updateEmail: isLoginRequired(async (session, updatingUserId, newEmail, cb) => {
 		newEmail = newEmail.toLowerCase();
 		const verificationToken = await utils.runJob("GENERATE_RANDOM_STRING", { length: 64 });
+
 		const userModel = await db.runJob("GET_MODEL", {
 			modelName: "user"
 		});
 		const verifyEmailSchema = await mail.runJob("GET_SCHEMA", {
 			schemaName: "verifyEmail"
 		});
+
 		async.waterfall(
 			[
 				next => {
@@ -865,8 +875,8 @@ export default {
 				},
 
 				(user, next) => {
-					verifyEmailSchema(newEmail, user.username, verificationToken, () => {
-						next();
+					verifyEmailSchema(newEmail, user.username, verificationToken, err => {
+						next(err);
 					});
 				}
 			],
@@ -1089,6 +1099,7 @@ export default {
 		const userModel = await db.runJob("GET_MODEL", {
 			modelName: "user"
 		});
+
 		async.waterfall(
 			[
 				next => {
@@ -1103,10 +1114,10 @@ export default {
 
 				(user, next) => {
 					if (!user) return next("User not found.");
-					return userModel.updateOne(
+					return userModel.findOneAndUpdate(
 						{ _id: updatingUserId },
 						{ $set: { "avatar.type": newType } },
-						{ runValidators: true },
+						{ new: true, runValidators: true },
 						next
 					);
 				}
@@ -1127,6 +1138,7 @@ export default {
 					"UPDATE_AVATAR_TYPE",
 					`Updated avatar type for user "${updatingUserId}" to type "${newType}".`
 				);
+
 				return cb({
 					status: "success",
 					message: "Avatar type updated successfully"

+ 4 - 5
backend/logic/app.js

@@ -256,9 +256,7 @@ class AppModule extends CoreClass {
 								.runJob("GENERATE_RANDOM_STRING", {
 									length: 12
 								})
-								.then(_id => {
-									next(null, user, _id);
-								});
+								.then(_id => next(null, user, _id));
 						},
 
 						(user, _id, next) => {
@@ -316,8 +314,9 @@ class AppModule extends CoreClass {
 							mail.runJob("GET_SCHEMA", {
 								schemaName: "verifyEmail"
 							}).then(verifyEmailSchema => {
-								verifyEmailSchema(address, body.login, user.email.verificationToken);
-								next(null, user._id);
+								verifyEmailSchema(address, body.login, user.email.verificationToken, err => {
+									next(err, user._id);
+								});
 							});
 						}
 					],

+ 2 - 0
backend/logic/db/index.js

@@ -28,6 +28,8 @@ class DBModule extends CoreClass {
 
 			const mongoUrl = config.get("mongo").url;
 
+			mongoose.set("useFindAndModify", false);
+
 			mongoose
 				.connect(mongoUrl, {
 					useNewUrlParser: true,

+ 1 - 1
backend/logic/db/schemas/user.js

@@ -7,7 +7,7 @@ export default {
 		address: String
 	},
 	avatar: {
-		type: { type: String, enum: ["gravatar", "initials"] },
+		type: { type: String, enum: ["gravatar", "initials"], required: true },
 		url: { type: String, required: false }
 	},
 	services: {

+ 12 - 3
backend/logic/mail/index.js

@@ -1,5 +1,6 @@
 /* eslint-disable global-require */
-import mailgun from "mailgun-js";
+import config from "config";
+import Mailgun from "mailgun-js";
 
 import CoreClass from "../../core";
 
@@ -20,14 +21,22 @@ class MailModule extends CoreClass {
 			passwordRequest: await importSchema("passwordRequest")
 		};
 
+		this.enabled = config.get("apis.mailgun.enabled");
+
+		if (this.enabled)
+			this.mailgun = new Mailgun({
+				apiKey: config.get("apis.mailgun.key"),
+				domain: config.get("apis.mailgun.domain")
+			});
+
 		return new Promise(resolve => resolve());
 	}
 
 	SEND_MAIL(payload) {
-		// data, cb
+		// data
 		return new Promise(resolve => {
 			if (this.enabled)
-				mailgun.messages().send(payload.data, () => {
+				this.mailgun.messages().send(payload.data, () => {
 					resolve();
 				});
 			else resolve();

+ 15 - 23
backend/logic/utils.js

@@ -86,29 +86,23 @@ class UtilsModule extends CoreClass {
 		});
 	}
 
-	GENERATE_RANDOM_STRING(payload) {
+	async GENERATE_RANDOM_STRING(payload) {
 		// length
-		return new Promise((resolve, reject) => {
-			const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split("");
-
-			let randomNum;
 
-			try {
-				randomNum = this.runJob("GET_RANDOM_NUMBER", {
-					min: 0,
-					max: chars.length - 1
-				});
-			} catch (err) {
-				return reject(err);
-			}
-
-			const result = [];
-			for (let i = 0; i < payload.length; i += 1) {
-				result.push(chars[randomNum]);
-			}
+		const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split("");
+		const result = [];
+		for (let i = 0; i < payload.length; i += 1) {
+			result.push(
+				chars[
+					this.runJob("GET_RANDOM_NUMBER", {
+						min: 0,
+						max: chars.length - 1
+					})
+				]
+			);
+		}
 
-			return resolve(result.join(""));
-		});
+		return new Promise(resolve => resolve(result.join("")));
 	}
 
 	async GET_SOCKET_FROM_ID(payload) {
@@ -120,9 +114,7 @@ class UtilsModule extends CoreClass {
 
 	GET_RANDOM_NUMBER(payload) {
 		// min, max
-		return new Promise(resolve => {
-			resolve(Math.floor(Math.random() * (payload.max - payload.min + 1)) + payload.min);
-		});
+		return Math.floor(Math.random() * (payload.max - payload.min + 1)) + payload.min;
 	}
 
 	CONVERT_TIME(payload) {

+ 2 - 1
frontend/dist/index.css

@@ -3,7 +3,8 @@ html {
 }
 
 body {
-	background-color: rgb(245, 245, 245);
+	/* background-color: rgb(245, 245, 245); */
+	background-color: rgb(249 249 249);
 }
 
 .app {

+ 63 - 1
frontend/src/App.vue

@@ -437,6 +437,15 @@ button.delete:focus {
 			background-color: darken($blue, 5%) !important;
 		}
 	}
+
+	&.is-warning {
+		background-color: $yellow !important;
+
+		&:hover,
+		&:focus {
+			background-color: darken($yellow, 5%) !important;
+		}
+	}
 }
 
 .input,
@@ -444,6 +453,46 @@ button.delete:focus {
 	height: 36px;
 }
 
+.input {
+	margin-top: 3px;
+}
+
+.help {
+	margin-top: 0 !important;
+	margin-bottom: 5px !important;
+	font-size: 12px;
+}
+
+.fadein-helpbox-enter-active {
+	transition-duration: 0.3s;
+	transition-timing-function: ease-in;
+}
+
+.fadein-helpbox-leave-active {
+	transition-duration: 0.3s;
+	transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+}
+
+.fadein-helpbox-enter-to,
+.fadein-helpbox-leave {
+	max-height: 100px;
+	overflow: hidden;
+}
+
+.fadein-helpbox-enter,
+.fadein-helpbox-leave-to {
+	overflow: hidden;
+	max-height: 0;
+}
+
+.control {
+	margin-bottom: 5px !important;
+
+	&.control:not(:first-of-type) {
+		margin: 15px 0;
+	}
+}
+
 .input-with-button {
 	.control {
 		margin-right: 0px !important;
@@ -453,6 +502,7 @@ button.delete:focus {
 		height: 36px;
 		border-radius: 3px 0 0 3px;
 		border-right: 0;
+		border-colour: $light-grey-2;
 	}
 
 	.button {
@@ -483,6 +533,18 @@ h4.section-title {
 
 .section-description {
 	font-size: 16px;
-	margin-bottom: 5px;
+	margin-bottom: 10px !important;
+}
+
+.section-horizontal-rule {
+	margin: 15px 0 30px 0;
+}
+
+.margin-top-zero {
+	margin-top: 0 !important;
+}
+
+.margin-bottom-zero {
+	margin-bottom: 0 !important;
 }
 </style>

+ 4 - 4
frontend/src/components/layout/MainFooter.vue

@@ -1,7 +1,7 @@
 <template>
 	<footer class="footer">
 		<div class="container">
-			<div class="content has-text-centered">
+			<div class="footer-content has-text-centered">
 				<div id="footer-social-icons">
 					<a
 						class="icon"
@@ -83,7 +83,7 @@ export default {
 .night-mode {
 	footer.footer,
 	footer.footer .container,
-	footer.footer .container .content {
+	footer.footer .container .footer-content {
 		background-color: #222;
 	}
 
@@ -92,11 +92,11 @@ export default {
 	}
 }
 
-.content a:not(.button) {
+.footer-content a:not(.button) {
 	border: 0;
 }
 
-.content {
+.footer-content {
 	display: flex;
 	align-items: center;
 	flex-direction: column;

+ 4 - 3
frontend/src/components/modals/Login.vue

@@ -25,8 +25,8 @@
 			<section class="modal-card-body">
 				<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
 				<form>
-					<label class="label">Email</label>
 					<p class="control">
+						<label class="label">Email</label>
 						<input
 							v-model="email"
 							class="input"
@@ -34,8 +34,8 @@
 							placeholder="Email..."
 						/>
 					</p>
-					<label class="label">Password</label>
 					<p class="control">
+						<label class="label">Password</label>
 						<input
 							v-model="password"
 							class="input"
@@ -46,6 +46,7 @@
 							"
 						/>
 					</p>
+					<br />
 					<p>
 						By logging in/registering you agree to our
 						<router-link to="/terms"> Terms of Service </router-link
@@ -133,7 +134,7 @@ export default {
 	}
 
 	.label,
-	p {
+	p:not(.help) {
 		color: #ddd;
 	}
 }

+ 38 - 31
frontend/src/components/modals/Register.vue

@@ -23,9 +23,8 @@
 				/>
 			</header>
 			<section class="modal-card-body">
-				<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
-				<label class="label">Email</label>
 				<p class="control">
+					<label class="label">Email</label>
 					<input
 						v-model="email.value"
 						class="input"
@@ -35,16 +34,18 @@
 						autofocus
 					/>
 				</p>
-				<p
-					class="help"
-					v-if="email.entered"
-					:class="email.valid ? 'is-success' : 'is-danger'"
-				>
-					{{ email.message }}
-				</p>
-				<br />
-				<label class="label">Username</label>
+				<transition name="fadein-helpbox">
+					<p
+						class="help"
+						v-if="email.entered"
+						:class="email.valid ? 'is-success' : 'is-danger'"
+					>
+						{{ email.message }}
+					</p>
+				</transition>
+
 				<p class="control">
+					<label class="label">Username</label>
 					<input
 						v-model="username.value"
 						class="input"
@@ -53,16 +54,18 @@
 						@blur="onInputBlur('username')"
 					/>
 				</p>
-				<p
-					class="help"
-					v-if="username.entered"
-					:class="username.valid ? 'is-success' : 'is-danger'"
-				>
-					{{ username.message }}
-				</p>
-				<br />
-				<label class="label">Password</label>
+				<transition name="fadein-helpbox">
+					<p
+						class="help"
+						v-if="username.entered"
+						:class="username.valid ? 'is-success' : 'is-danger'"
+					>
+						{{ username.message }}
+					</p>
+				</transition>
+
 				<p class="control">
+					<label class="label">Password</label>
 					<input
 						v-model="password.value"
 						class="input"
@@ -72,14 +75,18 @@
 						@keypress="$parent.submitOnEnter(submitModal, $event)"
 					/>
 				</p>
-				<p
-					class="help"
-					v-if="password.entered"
-					:class="password.valid ? 'is-success' : 'is-danger'"
-				>
-					{{ password.message }}
-				</p>
+				<transition name="fadein-helpbox">
+					<p
+						class="help"
+						v-if="password.entered"
+						:class="password.valid ? 'is-success' : 'is-danger'"
+					>
+						{{ password.message }}
+					</p>
+				</transition>
+
 				<br />
+
 				<p>
 					By logging in/registering you agree to our
 					<router-link to="/terms"> Terms of Service </router-link
@@ -143,7 +150,7 @@ export default {
 		};
 	},
 	watch: {
-		// eslint-disable-next-line func-names
+		// eslint-disable-next-line
 		"username.value": function(value) {
 			if (!validation.isLength(value, 2, 32)) {
 				this.username.message =
@@ -158,7 +165,7 @@ export default {
 				this.username.valid = true;
 			}
 		},
-		// eslint-disable-next-line func-names
+		// eslint-disable-next-line
 		"email.value": function(value) {
 			if (!validation.isLength(value, 3, 254)) {
 				this.email.message =
@@ -175,7 +182,7 @@ export default {
 				this.email.valid = true;
 			}
 		},
-		// eslint-disable-next-line func-names
+		// eslint-disable-next-line
 		"password.value": function(value) {
 			if (!validation.isLength(value, 6, 200)) {
 				this.password.message =
@@ -269,7 +276,7 @@ export default {
 	}
 
 	.label,
-	p {
+	p:not(.help) {
 		color: #ddd;
 	}
 }

+ 42 - 40
frontend/src/pages/ResetPassword.vue

@@ -60,17 +60,19 @@
 									>
 								</p>
 							</div>
-							<p
-								class="help"
-								v-if="validation.email.entered"
-								:class="
-									validation.email.valid
-										? 'is-success'
-										: 'is-danger'
-								"
-							>
-								{{ validation.email.message }}
-							</p>
+							<transition name="fadein-helpbox">
+								<p
+									class="help"
+									v-if="validation.email.entered"
+									:class="
+										validation.email.valid
+											? 'is-success'
+											: 'is-danger'
+									"
+								>
+									{{ validation.email.message }}
+								</p>
+							</transition>
 						</div>
 					</div>
 
@@ -120,9 +122,7 @@
 
 					<!-- Step 3 -- Set new password -->
 					<div class="content-box" v-if="step === 3" :key="step">
-						<h2 class="content-box-title">
-							Set a new password
-						</h2>
+						<h2 class="content-box-title">Set a new password</h2>
 						<p class="content-box-description">
 							Create a new password for your account.
 						</p>
@@ -139,17 +139,20 @@
 									@blur="onInputBlur('newPassword')"
 								/>
 							</p>
-							<p
-								class="help"
-								v-if="validation.newPassword.entered"
-								:class="
-									validation.newPassword.valid
-										? 'is-success'
-										: 'is-danger'
-								"
-							>
-								{{ validation.newPassword.message }}
-							</p>
+
+							<transition name="fadein-helpbox">
+								<p
+									class="help"
+									v-if="validation.newPassword.entered"
+									:class="
+										validation.newPassword.valid
+											? 'is-success'
+											: 'is-danger'
+									"
+								>
+									{{ validation.newPassword.message }}
+								</p>
+							</transition>
 
 							<p
 								id="new-password-again-input"
@@ -168,17 +171,20 @@
 									@blur="onInputBlur('newPasswordAgain')"
 								/>
 							</p>
-							<p
-								class="help"
-								v-if="validation.newPasswordAgain.entered"
-								:class="
-									validation.newPasswordAgain.valid
-										? 'is-success'
-										: 'is-danger'
-								"
-							>
-								{{ validation.newPasswordAgain.message }}
-							</p>
+
+							<transition name="fadein-helpbox">
+								<p
+									class="help"
+									v-if="validation.newPasswordAgain.entered"
+									:class="
+										validation.newPasswordAgain.valid
+											? 'is-success'
+											: 'is-danger'
+									"
+								>
+									{{ validation.newPasswordAgain.message }}
+								</p>
+							</transition>
 
 							<a
 								id="change-password-button"
@@ -438,10 +444,6 @@ p {
 	margin: 0;
 }
 
-.help {
-	margin-bottom: 5px;
-}
-
 .container {
 	padding: 25px;
 

+ 127 - 64
frontend/src/pages/Settings/index.vue

@@ -3,40 +3,47 @@
 		<metadata title="Settings" />
 		<main-header />
 		<div class="container">
-			<div class="nav-links">
-				<router-link
-					:class="{ active: activeTab === 'profile' }"
-					to="#profile"
-				>
-					Profile
-				</router-link>
-				<router-link
-					:class="{ active: activeTab === 'account' }"
-					to="#account"
-				>
-					Account
-				</router-link>
-				<router-link
-					:class="{ active: activeTab === 'security' }"
-					to="#security"
-				>
-					Security
-				</router-link>
-				<router-link
-					:class="{ active: activeTab === 'preferences' }"
-					to="#preferences"
-				>
-					Preferences
-				</router-link>
+			<h1 id="page-title">Settings</h1>
+			<div id="sidebar-with-content">
+				<div class="nav-links">
+					<router-link
+						:class="{ active: activeTab === 'profile' }"
+						to="#profile"
+					>
+						Profile
+					</router-link>
+					<router-link
+						:class="{ active: activeTab === 'account' }"
+						to="#account"
+					>
+						Account
+					</router-link>
+					<router-link
+						:class="{ active: activeTab === 'security' }"
+						to="#security"
+					>
+						Security
+					</router-link>
+					<router-link
+						:class="{ active: activeTab === 'preferences' }"
+						to="#preferences"
+					>
+						Preferences
+					</router-link>
+				</div>
+				<profile-settings
+					v-if="activeTab === 'profile'"
+				></profile-settings>
+				<account-settings
+					v-if="activeTab === 'account'"
+				></account-settings>
+				<security-settings
+					v-if="activeTab === 'security'"
+				></security-settings>
+				<preferences-settings
+					v-if="activeTab === 'preferences'"
+				></preferences-settings>
 			</div>
-			<profile-settings v-if="activeTab === 'profile'"></profile-settings>
-			<account-settings v-if="activeTab === 'account'"></account-settings>
-			<security-settings
-				v-if="activeTab === 'security'"
-			></security-settings>
-			<preferences-settings
-				v-if="activeTab === 'preferences'"
-			></preferences-settings>
 		</div>
 		<main-footer />
 	</div>
@@ -92,19 +99,31 @@ export default {
 				});
 
 				this.socket.on("event:user.linkPassword", () =>
-					this.updateOriginalUser("password", true)
+					this.updateOriginalUser({
+						property: "password",
+						value: true
+					})
 				);
 
 				this.socket.on("event:user.unlinkPassword", () =>
-					this.updateOriginalUser("password", false)
+					this.updateOriginalUser({
+						property: "password",
+						value: false
+					})
 				);
 
 				this.socket.on("event:user.linkGithub", () =>
-					this.updateOriginalUser("github", true)
+					this.updateOriginalUser({
+						property: "github",
+						value: true
+					})
 				);
 
 				this.socket.on("event:user.unlinkGithub", () =>
-					this.updateOriginalUser("github", false)
+					this.updateOriginalUser({
+						property: "github",
+						value: false
+					})
 				);
 			});
 		}
@@ -113,32 +132,74 @@ export default {
 };
 </script>
 
-<style lang="scss" scoped>
+<style lang="scss">
 @import "../../styles/global.scss";
 
-.night-mode .content {
-	background-color: #222;
-	padding: 20px;
-	border-radius: 3px;
+.night-mode {
+	h1,
+	h2,
+	h3,
+	h4,
+	h5,
+	h6 {
+		color: #fff !important;
+	}
+
+	p:not(.help),
+	label {
+		color: #ddd !important;
+	}
+
+	.content {
+		background-color: #222 !important;
+	}
+}
+
+.character-counter {
+	display: flex;
+	justify-content: flex-end;
+	height: 0;
 }
 
 .container {
+	margin-top: 32px;
+	padding: 24px;
+
+	.content {
+		background-color: #fff;
+		padding: 50px;
+		border-radius: 3px;
+	}
+
+	#page-title {
+		margin-top: 0;
+		font-size: 35px;
+	}
+
+	#sidebar-with-content {
+		display: flex;
+		flex-direction: column;
+	}
+
 	@media only screen and (min-width: 900px) {
-		width: 962px;
-		margin: 0 auto;
-		flex-direction: row;
+		#page-title {
+			margin: 0;
+			font-size: 40px;
+		}
+
+		#sidebar-with-content {
+			width: 962px;
+			margin: 0 auto;
+			margin-top: 30px;
+			flex-direction: row;
 
-		.content {
-			width: 600px;
-			margin-top: 0px;
+			.content {
+				width: 600px;
+				margin-top: 0px !important;
+			}
 		}
 	}
 
-	margin-top: 32px;
-	padding: 24px;
-	display: flex;
-	flex-direction: column;
-
 	.nav-links {
 		width: 250px;
 		margin-right: 64px;
@@ -169,29 +230,31 @@ export default {
 		margin: 24px 0;
 		height: fit-content;
 
+		.save-changes {
+			margin-top: 15px;
+		}
+
 		label {
 			font-size: 14px;
 			color: $dark-grey-2;
-			padding-bottom: 4px;
-		}
-
-		input {
-			height: 32px;
 		}
 
 		textarea {
 			height: 96px;
 		}
 
-		input,
-		textarea {
-			border-radius: 3px;
-			border: 1px solid $light-grey-2;
-		}
-
 		button {
 			width: 100%;
 		}
 	}
 }
+
+.saved-changes-transition-enter-active {
+	transition: all 0.2s ease;
+}
+
+.saved-changes-transition-enter {
+	transform: translateX(20px);
+	opacity: 0;
+}
 </style>

+ 81 - 28
frontend/src/pages/Settings/tabs/Account.vue

@@ -1,53 +1,81 @@
 <template>
 	<div class="content account-tab">
-		<p class="control is-expanded">
+		<h4 class="section-title">Change account details</h4>
+
+		<p class="section-description">
+			Keep these details up-to-date.
+		</p>
+
+		<hr class="section-horizontal-rule" />
+
+		<p class="control is-expanded margin-top-zero">
 			<label for="username">Username</label>
 			<input
 				class="input"
 				id="username"
 				type="text"
-				placeholder="Username"
+				placeholder="Enter username here..."
 				v-model="modifiedUser.username"
+				maxlength="32"
+				autocomplete="off"
 				@blur="onInputBlur('username')"
 			/>
+			<span v-if="modifiedUser.username" class="character-counter"
+				>{{ modifiedUser.username.length }}/32</span
+			>
 		</p>
-		<p
-			class="help"
-			v-if="validation.username.entered"
-			:class="validation.username.valid ? 'is-success' : 'is-danger'"
-		>
-			{{ validation.username.message }}
-		</p>
+		<transition name="fadein-helpbox">
+			<p
+				class="help"
+				v-if="validation.username.entered"
+				:class="validation.username.valid ? 'is-success' : 'is-danger'"
+			>
+				{{ validation.username.message }}
+			</p>
+		</transition>
 		<p class="control is-expanded">
 			<label for="email">Email</label>
 			<input
 				class="input"
 				id="email"
 				type="text"
-				placeholder="Email"
+				placeholder="Enter email address here..."
 				v-if="modifiedUser.email"
 				v-model="modifiedUser.email.address"
 				@blur="onInputBlur('email')"
 			/>
 		</p>
-		<p
-			class="help"
-			v-if="validation.email.entered"
-			:class="validation.email.valid ? 'is-success' : 'is-danger'"
-		>
-			{{ validation.email.message }}
-		</p>
-		<button class="button is-primary" @click="saveChangesToAccount()">
-			Save changes
-		</button>
+		<transition name="fadein-helpbox">
+			<p
+				class="help"
+				v-if="validation.email.entered"
+				:class="validation.email.valid ? 'is-success' : 'is-danger'"
+			>
+				{{ validation.email.message }}
+			</p>
+		</transition>
+		<transition name="saved-changes-transition" mode="out-in">
+			<button
+				class="button is-primary save-changes"
+				v-if="!savedChanges"
+				@click="saveChanges()"
+				key="save"
+			>
+				Save changes
+			</button>
+			<button class="button is-success save-changes" key="saved" v-else>
+				<i class="material-icons icon-with-button">done</i>Saved Changes
+			</button>
+		</transition>
 	</div>
 </template>
 
 <script>
-import { mapState } from "vuex";
+import { mapState, mapActions } from "vuex";
 import Toast from "toasters";
 
 import validation from "../../../validation";
+import io from "../../../io";
 
 export default {
 	data() {
@@ -63,7 +91,8 @@ export default {
 					valid: false,
 					message: "Please enter a valid email address."
 				}
-			}
+			},
+			savedChanges: false
 		};
 	},
 	computed: mapState({
@@ -74,7 +103,7 @@ export default {
 	watch: {
 		// prettier-ignore
 		// eslint-disable-next-line func-names
-		"user.username": function (value) {
+		"modifiedUser.username": function (value) {
 		if (!validation.isLength(value, 2, 32)) {
 			this.validation.username.message =
 				"Username must have between 2 and 32 characters.";
@@ -93,7 +122,7 @@ export default {
 		},
 		// prettier-ignore
 		// eslint-disable-next-line func-names
-		"user.email.address": function (value) {
+		"modifiedUser.email.address": function (value) {
 			if (!validation.isLength(value, 3, 254)) {
 				this.validation.email.message =
 					"Email must have between 3 and 254 characters.";
@@ -110,11 +139,22 @@ export default {
 			}
 		}
 	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+	},
 	methods: {
+		showSavedAnimation() {
+			this.savedChanges = true;
+			setTimeout(() => {
+				this.savedChanges = false;
+			}, 2000);
+		},
 		onInputBlur(inputName) {
 			this.validation[inputName].entered = true;
 		},
-		saveChangesToAccount() {
+		saveChanges() {
 			if (this.modifiedUser.username !== this.originalUser.username)
 				this.changeUsername();
 			if (
@@ -151,7 +191,13 @@ export default {
 							content: "Successfully changed email address",
 							timeout: 4000
 						});
-						this.originalUser.email.address = email;
+
+						this.updateOriginalUser({
+							property: "email.address",
+							value: email
+						});
+
+						if (!this.savedChanges) this.showSavedAnimation();
 					}
 				}
 			);
@@ -182,11 +228,18 @@ export default {
 							content: "Successfully changed username",
 							timeout: 4000
 						});
-						this.originalUser.username = username;
+
+						this.updateOriginalUser({
+							property: "username",
+							value: username
+						});
+
+						if (!this.savedChanges) this.showSavedAnimation();
 					}
 				}
 			);
-		}
+		},
+		...mapActions("settings", ["updateOriginalUser"])
 	}
 };
 </script>

+ 2 - 2
frontend/src/pages/Settings/tabs/Preferences.vue

@@ -7,7 +7,7 @@
 				<p>Use nightmode</p>
 			</label>
 		</p>
-		<button class="button is-primary" @click="saveChangesPreferences()">
+		<button class="button is-primary save-changes" @click="saveChanges()">
 			Save changes
 		</button>
 	</div>
@@ -26,7 +26,7 @@ export default {
 		nightmode: state => state.user.preferences.nightmode
 	}),
 	methods: {
-		saveChangesPreferences() {
+		saveChanges() {
 			if (this.localNightmode !== this.nightmode)
 				this.changeNightmodeLocal();
 		},

+ 134 - 27
frontend/src/pages/Settings/tabs/Profile.vue

@@ -1,14 +1,52 @@
 <template>
 	<div class="content profile-tab">
-		<p class="control is-expanded">
+		<h4 class="section-title">
+			Change Profile
+		</h4>
+		<p class="section-description">
+			Edit your public profile so other users can find out more about you.
+		</p>
+
+		<hr class="section-horizontal-rule" />
+
+		<div
+			class="control is-expanded avatar-selection-outer-container"
+			v-if="modifiedUser.avatar"
+		>
+			<label>Avatar</label>
+			<div id="avatar-selection-inner-container">
+				<div class="profile-picture">
+					<img
+						:src="
+							modifiedUser.avatar.url &&
+							modifiedUser.avatar.type === 'gravatar'
+								? `${modifiedUser.avatar.url}?d=${notesUri}&s=250`
+								: '/assets/notes.png'
+						"
+						onerror="this.src='/assets/notes.png'; this.onerror=''"
+					/>
+				</div>
+				<div class="select">
+					<select v-model="modifiedUser.avatar.type">
+						<option value="gravatar">Using Gravatar</option>
+						<option value="initials">Based on initials</option>
+					</select>
+				</div>
+			</div>
+		</div>
+		<p class="control is-expanded margin-top-zero">
 			<label for="name">Name</label>
 			<input
 				class="input"
 				id="name"
 				type="text"
-				placeholder="Name"
+				placeholder="Enter name here..."
+				maxlength="64"
 				v-model="modifiedUser.name"
 			/>
+			<span v-if="modifiedUser.name" class="character-counter"
+				>{{ modifiedUser.name.length }}/64</span
+			>
 		</p>
 		<p class="control is-expanded">
 			<label for="location">Location</label>
@@ -16,34 +54,44 @@
 				class="input"
 				id="location"
 				type="text"
-				placeholder="Location"
+				placeholder="Enter location here..."
+				maxlength="50"
 				v-model="modifiedUser.location"
 			/>
+			<span v-if="modifiedUser.location" class="character-counter"
+				>{{ modifiedUser.location.length }}/50</span
+			>
 		</p>
 		<p class="control is-expanded">
 			<label for="bio">Bio</label>
 			<textarea
 				class="textarea"
 				id="bio"
-				placeholder="Bio"
+				placeholder="Enter bio here..."
+				maxlength="200"
+				autocomplete="off"
 				v-model="modifiedUser.bio"
 			/>
+			<span
+				v-if="modifiedUser.bio"
+				class="character-counter"
+				style="height: initial"
+				>{{ modifiedUser.bio.length }}/200</span
+			>
 		</p>
-		<div class="control is-expanded avatar-select">
-			<label>Avatar</label>
-			<div class="select">
-				<select
-					v-if="modifiedUser.avatar"
-					v-model="modifiedUser.avatar.type"
-				>
-					<option value="gravatar">Using Gravatar</option>
-					<option value="initials">Based on initials</option>
-				</select>
-			</div>
-		</div>
-		<button class="button is-primary" @click="saveChangesToProfile()">
-			Save changes
-		</button>
+		<transition name="saved-changes-transition" mode="out-in">
+			<button
+				class="button is-primary save-changes"
+				v-if="!savedChanges"
+				@click="saveChanges()"
+				key="save"
+			>
+				Save changes
+			</button>
+			<button class="button is-success save-changes" key="saved" v-else>
+				<i class="material-icons icon-with-button">done</i>Saved Changes
+			</button>
+		</transition>
 	</div>
 </template>
 
@@ -55,18 +103,31 @@ import validation from "../../../validation";
 import io from "../../../io";
 
 export default {
+	data() {
+		return {
+			notesUri: "",
+			savedChanges: false
+		};
+	},
 	computed: mapState({
 		userId: state => state.user.auth.userId,
 		originalUser: state => state.settings.originalUser,
 		modifiedUser: state => state.settings.modifiedUser
 	}),
 	mounted() {
+		lofig.get("frontendDomain").then(frontendDomain => {
+			this.frontendDomain = frontendDomain;
+			this.notesUri = encodeURI(
+				`${this.frontendDomain}/assets/notes.png`
+			);
+		});
+
 		io.getSocket(socket => {
 			this.socket = socket;
 		});
 	},
 	methods: {
-		saveChangesToProfile() {
+		saveChanges() {
 			if (this.modifiedUser.name !== this.originalUser.name)
 				this.changeName();
 			if (this.modifiedUser.location !== this.originalUser.location)
@@ -76,6 +137,12 @@ export default {
 			if (this.modifiedUser.avatar.type !== this.originalUser.avatar.type)
 				this.changeAvatarType();
 		},
+		showSavedAnimation() {
+			this.savedChanges = true;
+			setTimeout(() => {
+				this.savedChanges = false;
+			}, 2000);
+		},
 		changeName() {
 			const { name } = this.modifiedUser;
 
@@ -98,7 +165,12 @@ export default {
 							timeout: 4000
 						});
 
-						this.updateOriginalUser("name", name);
+						this.updateOriginalUser({
+							property: "name",
+							value: name
+						});
+
+						if (!this.savedChanges) this.showSavedAnimation();
 					}
 				}
 			);
@@ -125,7 +197,12 @@ export default {
 							timeout: 4000
 						});
 
-						this.updateOriginalUser("location", location);
+						this.updateOriginalUser({
+							property: "location",
+							value: location
+						});
+
+						if (!this.savedChanges) this.showSavedAnimation();
 					}
 				}
 			);
@@ -152,18 +229,23 @@ export default {
 							timeout: 4000
 						});
 
-						this.updateOriginalUser("bio", bio);
+						this.updateOriginalUser({
+							property: "bio",
+							value: bio
+						});
+
+						if (!this.savedChanges) this.showSavedAnimation();
 					}
 				}
 			);
 		},
 		changeAvatarType() {
-			const { type } = this.modifiedUser.avatar;
+			const { avatar } = this.modifiedUser;
 
 			return this.socket.emit(
 				"users.updateAvatarType",
 				this.userId,
-				type,
+				avatar.type,
 				res => {
 					if (res.status !== "success")
 						new Toast({ content: res.message, timeout: 8000 });
@@ -173,7 +255,12 @@ export default {
 							timeout: 4000
 						});
 
-						this.updateOriginalUser("avatar.type", type);
+						this.updateOriginalUser({
+							property: "avatar",
+							value: avatar
+						});
+
+						if (!this.savedChanges) this.showSavedAnimation();
 					}
 				}
 			);
@@ -190,7 +277,7 @@ export default {
 	margin-bottom: 15px;
 }
 
-.avatar-select {
+.avatar-selection-outer-container {
 	display: flex;
 	flex-direction: column;
 	align-items: flex-start;
@@ -198,5 +285,25 @@ export default {
 	.select:after {
 		border-color: $musare-blue;
 	}
+
+	#avatar-selection-inner-container {
+		display: flex;
+		align-items: center;
+		margin-top: 5px;
+
+		.profile-picture {
+			line-height: 1;
+			cursor: pointer;
+
+			img {
+				background-color: #fff;
+				width: 50px;
+				height: 50px;
+				border-radius: 100%;
+				border: 2px solid $light-grey;
+				margin-right: 10px;
+			}
+		}
+	}
 }
 </style>

+ 70 - 87
frontend/src/pages/Settings/tabs/Security.vue

@@ -1,18 +1,15 @@
 <template>
 	<div class="content security-tab">
 		<div v-if="isPasswordLinked">
-			<h4 class="section-title">
-				Change password
-			</h4>
+			<h4 class="section-title">Change password</h4>
 
 			<p class="section-description">
-				To change your password, you will need to know your previous
-				password.
+				You will need to know your previous password.
 			</p>
 
-			<br />
+			<hr class="section-horizontal-rule" />
 
-			<p class="control is-expanded">
+			<p class="control is-expanded margin-top-zero">
 				<label for="previous-password">Previous password</label>
 				<input
 					class="input"
@@ -23,70 +20,69 @@
 				/>
 			</p>
 
-			<label for="new-password">New password</label>
-			<div class="control is-grouped input-with-button">
-				<p id="new-password-again-input" class="control is-expanded">
-					<input
-						class="input"
-						id="new-password"
-						type="password"
-						placeholder="Enter new password here..."
-						v-model="validation.newPassword.value"
-						@keyup.enter="changePassword()"
-						@blur="onInputBlur('newPassword')"
-					/>
-				</p>
-				<p class="control">
-					<a
-						id="change-password-button"
-						class="button is-success"
-						href="#"
-						@click.prevent="changePassword()"
-					>
-						Change password</a
-					>
+			<p id="new-password-again-input" class="control is-expanded">
+				<label for="new-password">New password</label>
+				<input
+					class="input"
+					id="new-password"
+					type="password"
+					placeholder="Enter new password here..."
+					v-model="validation.newPassword.value"
+					@keyup.enter="changePassword()"
+					@blur="onInputBlur('newPassword')"
+				/>
+			</p>
+
+			<transition name="fadein-helpbox">
+				<p
+					class="help"
+					v-if="validation.newPassword.entered"
+					:class="
+						validation.newPassword.valid
+							? 'is-success'
+							: 'is-danger'
+					"
+				>
+					{{ validation.newPassword.message }}
 				</p>
-			</div>
-			<p
-				class="help"
-				v-if="validation.newPassword.entered"
-				:class="
-					validation.newPassword.valid ? 'is-success' : 'is-danger'
-				"
-			>
-				{{ validation.newPassword.message }}
+			</transition>
+
+			<p class="control">
+				<button
+					id="change-password-button"
+					class="button is-success"
+					@click.prevent="changePassword()"
+				>
+					Change password
+				</button>
 			</p>
 
-			<hr style="margin: 30px 0;" />
+			<br /><br />
 		</div>
 
 		<div v-if="!isPasswordLinked">
-			<h4 class="section-title">
-				Add a password
-			</h4>
+			<h4 class="section-title">Add a password</h4>
 			<p class="section-description">
 				Add a password, as an alternative to signing in with GitHub.
 			</p>
 
-			<br />
+			<hr class="section-horizontal-rule" />
 
 			<router-link to="/set_password" class="button is-default" href="#"
 				><i class="material-icons icon-with-button">create</i>Set
 				Password
 			</router-link>
 
-			<hr style="margin: 30px 0;" />
+			<br /><br />
 		</div>
 
 		<div v-if="!isGithubLinked">
-			<h4 class="section-title">
-				Link GitHub
-			</h4>
+			<h4 class="section-title">Link GitHub</h4>
 			<p class="section-description">
 				Link your Musare account with GitHub.
 			</p>
 
-			<br />
+			<hr style="margin: 30px 0" />
 
 			<a
 				class="button is-github"
@@ -98,34 +94,37 @@
 				&nbsp; Link GitHub to account
 			</a>
 
-			<hr style="margin: 30px 0;" />
+			<br /><br />
 		</div>
 
 		<div v-if="isPasswordLinked && isGithubLinked">
-			<h4 class="section-title">
-				Remove login methods
-			</h4>
+			<h4 class="section-title">Remove login methods</h4>
 			<p class="section-description">
 				Remove your password as a login method or unlink GitHub.
 			</p>
 
-			<br />
-
-			<a
-				v-if="isPasswordLinked"
-				class="button is-danger"
-				href="#"
-				@click.prevent="unlinkPassword()"
-				><i class="material-icons icon-with-button">close</i>Remove
-				password
-			</a>
-
-			<a class="button is-danger" href="#" @click.prevent="unlinkGitHub()"
-				><i class="material-icons icon-with-button">link_off</i>Remove
-				GitHub from account
-			</a>
+			<hr class="section-horizontal-rule" />
+
+			<div id="remove-login-method-buttons">
+				<a
+					v-if="isPasswordLinked"
+					class="button is-danger"
+					href="#"
+					@click.prevent="unlinkPassword()"
+					><i class="material-icons icon-with-button">close</i>Remove
+					password
+				</a>
+
+				<a
+					class="button is-danger"
+					href="#"
+					@click.prevent="unlinkGitHub()"
+					><i class="material-icons icon-with-button">link_off</i
+					>Remove GitHub from account
+				</a>
+			</div>
 
-			<hr style="margin: 30px 0;" />
+			<br /><br />
 		</div>
 
 		<div>
@@ -134,7 +133,7 @@
 				Remove all currently logged-in sessions for your account.
 			</p>
 
-			<br />
+			<hr class="section-horizontal-rule" />
 
 			<a
 				class="button is-warning"
@@ -262,23 +261,7 @@ export default {
 </script>
 
 <style lang="scss" scoped>
-.section-description {
-	margin-bottom: 0 !important;
-}
-
-.night-mode {
-	h1,
-	h2,
-	h3,
-	h4,
-	h5,
-	h6 {
-		color: #fff !important;
-	}
-
-	p,
-	label {
-		color: #ddd !important;
-	}
+#change-password-button {
+	margin-top: 10px;
 }
 </style>

+ 5 - 4
frontend/src/store/modules/settings.js

@@ -11,8 +11,8 @@ const getters = {
 };
 
 const actions = {
-	updateOriginalUser: ({ commit }, property, value) => {
-		commit("updateOriginalUser", property, value);
+	updateOriginalUser: ({ commit }, payload) => {
+		commit("updateOriginalUser", payload);
 	},
 	setUser: ({ commit }, user) => {
 		commit("setUser", user);
@@ -20,8 +20,9 @@ const actions = {
 };
 
 const mutations = {
-	updateOriginalUser(state, property, value) {
-		state.originalUser[property] = value;
+	updateOriginalUser(state, payload) {
+		const { property, value } = payload;
+		state.originalUser[property] = JSON.parse(JSON.stringify(value));
 	},
 	setUser(state, user) {
 		state.originalUser = user;

+ 6 - 3
frontend/src/styles/colors.scss

@@ -2,15 +2,18 @@ $musare-blue: hsl(199, 98%, 48%);
 $teal: hsl(171, 100%, 41%);
 $purple: hsl(302, 56%, 36%);
 $light-purple: hsl(263, 49%, 70%);
-$yellow: hsl(46, 93%, 53%);
+// $yellow: hsl(46, 93%, 53%);
+$yellow: #f1c40f;
 $light-pink: hsl(351, 57%, 75%);
 $dark-pink: hsl(351, 79%, 60%);
 $light-orange: hsl(22, 100%, 50%);
 $dark-orange: hsl(12, 100%, 49%);
 $lime: hsl(114, 69%, 49%);
-$green: hsl(114, 76%, 36%);
+// $green: hsl(114, 76%, 36%);
+$green: #44bd32;
 $blue: hsl(214, 82%, 48%);
-$red: hsl(0, 86%, 49%);
+// $red: hsl(0, 86%, 49%);
+$red: #e74c3c;
 $white: hsl(0, 0%, 100%);
 $black: hsl(0, 0%, 0%);
 $light-grey: hsl(0, 0%, 96%);