Ver código fonte

Merge branch 'experimental' of github.com:Musare/MusareNode into experimental

Jonathan 5 anos atrás
pai
commit
c8d2967284

+ 41 - 5
backend/logic/actions/users.js

@@ -434,14 +434,13 @@ module.exports = {
 					status: 'success',
 					data: {
 						_id: account._id,
+						name: account.name,
 						username: account.username,
+						location: account.location,
+						bio: account.bio,
 						role: account.role,
-						email: account.email.address,
 						avatar: account.avatar,
-						createdAt: account.createdAt,
-						statistics: account.statistics,
-						liked: account.liked,
-						disliked: account.disliked
+						createdAt: account.createdAt
 					}
 				});
 			}
@@ -518,6 +517,7 @@ module.exports = {
 						address: user.email.address
 					},
 					username: user.username,
+					name: user.name,
 					location: user.location,
 					bio: user.bio
 				};
@@ -662,6 +662,42 @@ module.exports = {
 		});
 	}),
 
+	/**
+	 * Updates a user's name
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} updatingUserId - the updating user's id
+	 * @param {String} newBio - the new name
+	 * @param {Function} cb - gets called with the result
+	 */
+	updateName: hooks.loginRequired((session, updatingUserId, newName, cb) => {
+		async.waterfall([
+			(next) => {
+				if (updatingUserId === session.userId) return next(null, true);
+				db.models.user.findOne({_id: session.userId}, next);
+			},
+
+			(user, next) => {
+				if (user !== true && (!user || user.role !== 'admin')) return next('Invalid permissions.');
+				db.models.user.findOne({ _id: updatingUserId }, next);
+			},
+
+			(user, next) => {
+				if (!user) return next('User not found.');
+				db.models.user.updateOne({ _id: updatingUserId }, {$set: { name: newName }}, {runValidators: true}, next);
+			}
+		], async (err) => {
+			if (err && err !== true) {
+				err = await utils.getError(err);
+				logger.error("UPDATE_NAME", `Couldn't update name for user "${updatingUserId}" to name "${newName}". "${err}"`);
+				cb({status: 'failure', message: err});
+			} else {
+				logger.success("UPDATE_NAME", `Updated name for user "${updatingUserId}" to name "${newName}".`);
+				cb({ status: 'success', message: 'Name updated successfully' });
+			}
+		});
+	}),
+
 	/**
 	 * Updates a user's location
 	 *

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

@@ -30,6 +30,7 @@ module.exports = {
 	liked: [{ type: String }],
 	disliked: [{ type: String }],
 	favoriteStations: [{ type: String }],
+	name: { type: String, default: "" },
 	location: { type: String, default: "" },
 	bio: { type: String, default: "" },
 	createdAt: { type: Date, default: Date.now() }

+ 31 - 7
backend/logic/io.js

@@ -116,6 +116,22 @@ module.exports = class extends coreClass {
 						logger.info('IO_DISCONNECTION', `User disconnected. IP: ${socket.ip}.${sessionInfo}`);
 					});
 
+					socket.use((data, next) => {
+						if (data.length === 0) return next(new Error("Not enough arguments specified."));
+						else if (typeof data[0] !== "string") return next(new Error("First argument must be a string."));
+						else {
+							const namespaceAction = data[0];
+							if (!namespaceAction || namespaceAction.indexOf(".") === -1 || namespaceAction.indexOf(".") !== namespaceAction.lastIndexOf(".")) return next(new Error("Invalid first argument"));
+							const namespace = data[0].split(".")[0];
+							const action = data[0].split(".")[1];
+							if (!namespace) return next(new Error("Invalid namespace."));
+							else if (!action) return next(new Error("Invalid action."));
+							else if (!actions[namespace]) return next(new Error("Namespace not found."));
+							else if (!actions[namespace][action]) return next(new Error("Action not found."));
+							else return next();
+						}
+					});
+
 					// catch errors on the socket (internal to socket.io)
 					socket.on('error', console.error);
 
@@ -149,13 +165,21 @@ module.exports = class extends coreClass {
 									// make sure the sockets sessionId isn't set if there is no session
 									if (socket.session.sessionId && session === null) delete socket.session.sessionId;
 
-									// call the action, passing it the session, and the arguments socket.io passed us
-									actions[namespace][action].apply(null, [socket.session].concat(args).concat([
-										(result) => {
-											// respond to the socket with our message
-											if (typeof cb === 'function') return cb(result);
-										}
-									]));
+									try {
+										// call the action, passing it the session, and the arguments socket.io passed us
+										actions[namespace][action].apply(null, [socket.session].concat(args).concat([
+											(result) => {
+												// respond to the socket with our message
+												if (typeof cb === 'function') return cb(result);
+											}
+										]));
+									} catch(err) {
+										this.logger.error("IO_ACTION_ERROR", `Some type of exception occurred in the action ${namespace}.${action}. Error message: ${err.message}`);
+										if (typeof cb === 'function') return cb({
+											status: "error",
+											message: "An error occurred while executing the specified action."
+										});
+									}
 								});
 							})
 						})

+ 9 - 1
frontend/components/Admin/Users.vue

@@ -88,7 +88,15 @@ export default {
 		},
 		init() {
 			this.socket.emit("users.index", result => {
-				if (result.status === "success") this.users = result.data;
+				if (result.status === "success") {
+					this.users = result.data;
+					if (this.$route.query.userId) {
+						const user = this.users.find(
+							user => user._id === this.$route.query.userId
+						);
+						if (user) this.edit(user);
+					}
+				}
 			});
 			this.socket.emit("apis.joinAdminRoom", "users", () => {});
 		},

+ 39 - 0
frontend/components/User/Settings.vue

@@ -4,6 +4,22 @@
 		<main-header />
 		<div class="container">
 			<!--Implement Validation-->
+			<label class="label">Name</label>
+			<div class="control is-grouped">
+				<p class="control is-expanded has-icon has-icon-right">
+					<input
+						v-model="user.name"
+						class="input"
+						type="text"
+						placeholder="Change name"
+					/>
+				</p>
+				<p class="control">
+					<button class="button is-success" @click="changeName()">
+						Save changes
+					</button>
+				</p>
+			</div>
 			<label class="label">Username</label>
 			<div class="control is-grouped">
 				<p class="control is-expanded has-icon has-icon-right">
@@ -307,6 +323,29 @@ export default {
 				}
 			);
 		},
+		changeName() {
+			const { name } = this.user;
+			if (!validation.isLength(name, 1, 64))
+				return new Toast({
+					content: "Name must have between 1 and 64 characters.",
+					timeout: 8000
+				});
+
+			return this.socket.emit(
+				"users.updateName",
+				this.userId,
+				name,
+				res => {
+					if (res.status !== "success")
+						new Toast({ content: res.message, timeout: 8000 });
+					else
+						new Toast({
+							content: "Successfully changed name",
+							timeout: 4000
+						});
+				}
+			);
+		},
 		changeLocation() {
 			const { location } = this.user;
 			if (!validation.isLength(location, 0, 50))

+ 355 - 110
frontend/components/User/Show.vue

@@ -1,78 +1,119 @@
 <template>
 	<div v-if="isUser">
 		<metadata v-bind:title="`Profile | ${user.username}`" />
+		<edit-playlist v-if="modals.editPlaylist" />
+		<create-playlist v-if="modals.createPlaylist" />
 		<main-header />
-		<div class="container">
-			<img
-				class="avatar"
-				:src="
-					user.avatar
-						? `${user.avatar}?d=${notes}&s=250`
-						: '/assets/notes.png'
-				"
-				onerror="this.src='/assets/notes.png'; this.onerror=''"
-			/>
-			<h2 class="has-text-centered username">@{{ user.username }}</h2>
-			<h5>A member since {{ user.createdAt }}</h5>
-			<div
-				v-if="role === 'admin' && userId !== user._id"
-				class="admin-functionality"
-			>
-				<a
-					v-if="user.role == 'default'"
-					class="button is-small is-info is-outlined"
-					@click="changeRank('admin')"
-					>Promote to Admin</a
+		<div class="info-section">
+			<div class="picture-name-row">
+				<img
+					class="profile-picture"
+					:src="
+						user.avatar
+							? `${user.avatar}?d=${notes}&s=250`
+							: '/assets/notes.png'
+					"
+					onerror="this.src='/assets/notes.png'; this.onerror=''"
+				/>
+				<div>
+					<div class="name-role-row">
+						<p class="name">{{ user.name }}</p>
+						<span class="role admin" v-if="user.role === 'admin'"
+							>admin</span
+						>
+					</div>
+					<p class="username">@{{ user.username }}</p>
+				</div>
+			</div>
+			<div class="buttons" v-if="userId === user._id || role === 'admin'">
+				<router-link
+					:to="`/admin/users?userId=${user._id}`"
+					class="button is-primary"
+					v-if="role === 'admin'"
 				>
-				<a
-					v-if="user.role == 'admin'"
-					class="button is-small is-danger is-outlined"
-					@click="changeRank('default')"
-					>Demote to User</a
+					Edit
+				</router-link>
+				<router-link
+					to="/settings"
+					class="button is-primary"
+					v-if="userId === user._id"
 				>
+					Settings
+				</router-link>
 			</div>
-			<nav class="level">
-				<div class="level-item has-text-centered">
-					<p class="heading">
-						Rank
-					</p>
-					<p class="title role">
-						{{ user.role }}
-					</p>
-				</div>
-				<div class="level-item has-text-centered">
-					<p class="heading">
-						Songs Requested
-					</p>
-					<p class="title">
-						{{ user.statistics.songsRequested }}
-					</p>
+			<div class="bio-row" v-if="user.bio">
+				<i class="material-icons">notes</i>
+				<p>{{ user.bio }}</p>
+			</div>
+			<div
+				class="date-location-row"
+				v-if="user.createdAt || user.location"
+			>
+				<div class="date" v-if="user.createdAt">
+					<i class="material-icons">calendar_today</i>
+					<p>{{ user.createdAt }}</p>
 				</div>
-				<div class="level-item has-text-centered">
-					<p class="heading">
-						Likes
-					</p>
-					<p class="title">
-						{{ user.liked.length }}
-					</p>
+				<div class="location" v-if="user.location">
+					<i class="material-icons">location_on</i>
+					<p>{{ user.location }}</p>
 				</div>
-				<div class="level-item has-text-centered">
-					<p class="heading">
-						Dislikes
-					</p>
-					<p class="title">
-						{{ user.disliked.length }}
-					</p>
+			</div>
+		</div>
+		<div class="bottom-section">
+			<div class="buttons">
+				<button
+					:class="{ active: activeTab === 'recentActivity' }"
+					@click="switchTab('recentActivity')"
+				>
+					Recent activity
+				</button>
+				<button
+					:class="{ active: activeTab === 'playlists' }"
+					@click="switchTab('playlists')"
+					v-if="user._id === userId"
+				>
+					Playlists
+				</button>
+			</div>
+			<div
+				class="content recent-activity-tab"
+				v-if="activeTab === 'recentActivity'"
+			>
+				Content here
+			</div>
+			<div class="content playlists-tab" v-if="activeTab === 'playlists'">
+				<div
+					class="playlist"
+					v-for="playlist in playlists"
+					:key="playlist._id"
+				>
+					<span>{{ playlist.displayName }}</span>
+					<button
+						class="button is-primary"
+						@click="editPlaylistClick(playlist._id)"
+					>
+						Edit
+					</button>
 				</div>
-			</nav>
+				<button
+					class="button is-primary"
+					@click="
+						openModal({
+							sector: 'station',
+							modal: 'createPlaylist'
+						})
+					"
+				>
+					Make new playlist
+				</button>
+			</div>
 		</div>
 		<main-footer />
 	</div>
 </template>
 
 <script>
-import { mapState } from "vuex";
-import Toast from "toasters";
+import { mapState, mapActions } from "vuex";
 import { format, parseISO } from "date-fns";
 
 import MainHeader from "../MainHeader.vue";
@@ -80,17 +121,27 @@ import MainFooter from "../MainFooter.vue";
 import io from "../../io";
 
 export default {
-	components: { MainHeader, MainFooter },
+	components: {
+		MainHeader,
+		MainFooter,
+		CreatePlaylist: () => import("../Modals/Playlists/Create.vue"),
+		EditPlaylist: () => import("../Modals/Playlists/Edit.vue")
+	},
 	data() {
 		return {
 			user: {},
 			notes: "",
-			isUser: false
+			isUser: false,
+			activeTab: "recentActivity",
+			playlists: []
 		};
 	},
 	computed: mapState({
 		role: state => state.user.auth.role,
-		userId: state => state.user.auth.userId
+		userId: state => state.user.auth.userId,
+		...mapState("modals", {
+			modals: state => state.modals.station
+		})
 	}),
 	mounted() {
 		lofig.get("frontendDomain").then(frontendDomain => {
@@ -112,28 +163,103 @@ export default {
 							"MMMM do yyyy"
 						);
 						this.isUser = true;
+
+						if (this.user._id === this.userId) {
+							this.socket.emit("playlists.indexForUser", res => {
+								if (res.status === "success")
+									this.playlists = res.data;
+							});
+							this.socket.on(
+								"event:playlist.create",
+								playlist => {
+									this.playlists.push(playlist);
+								}
+							);
+							this.socket.on(
+								"event:playlist.delete",
+								playlistId => {
+									this.playlists.forEach(
+										(playlist, index) => {
+											if (playlist._id === playlistId) {
+												this.playlists.splice(index, 1);
+											}
+										}
+									);
+								}
+							);
+							this.socket.on("event:playlist.addSong", data => {
+								this.playlists.forEach((playlist, index) => {
+									if (playlist._id === data.playlistId) {
+										this.playlists[index].songs.push(
+											data.song
+										);
+									}
+								});
+							});
+							this.socket.on(
+								"event:playlist.removeSong",
+								data => {
+									this.playlists.forEach(
+										(playlist, index) => {
+											if (
+												playlist._id === data.playlistId
+											) {
+												this.playlists[
+													index
+												].songs.forEach(
+													(song, index2) => {
+														if (
+															song._id ===
+															data.songId
+														) {
+															this.playlists[
+																index
+															].songs.splice(
+																index2,
+																1
+															);
+														}
+													}
+												);
+											}
+										}
+									);
+								}
+							);
+							this.socket.on(
+								"event:playlist.updateDisplayName",
+								data => {
+									this.playlists.forEach(
+										(playlist, index) => {
+											if (
+												playlist._id === data.playlistId
+											) {
+												this.playlists[
+													index
+												].displayName =
+													data.displayName;
+											}
+										}
+									);
+								}
+							);
+						}
 					}
 				}
 			);
 		});
 	},
 	methods: {
-		changeRank(newRank) {
-			this.socket.emit(
-				"users.updateRole",
-				this.user._id,
-				newRank === "admin" ? "admin" : "default",
-				res => {
-					if (res.status === "error")
-						new Toast({ content: res.message, timeout: 2000 });
-					else this.user.role = newRank;
-					new Toast({
-						content: `User ${this.$route.params.username}'s rank has been changed to: ${newRank}`,
-						timeout: 2000
-					});
-				}
-			);
-		}
+		switchTab(tabName) {
+			this.activeTab = tabName;
+		},
+		editPlaylistClick(playlistId) {
+			console.log(playlistId);
+			this.editPlaylist(playlistId);
+			this.openModal({ sector: "station", modal: "editPlaylist" });
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("user/playlists", ["editPlaylist"])
 	}
 };
 </script>
@@ -141,48 +267,167 @@ export default {
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
-.night-mode {
-	.level .title {
-		color: #ccc;
+.info-section {
+	width: 912px;
+	margin-left: auto;
+	margin-right: auto;
+	margin-top: 32px;
+	padding: 24px;
+
+	.picture-name-row {
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+		column-gap: 32px;
+		justify-content: center;
+		margin-bottom: 24px;
 	}
-}
 
-.container {
-	padding: 25px;
-}
+	.profile-picture {
+		width: 100px;
+		height: 100px;
+		border-radius: 100%;
+	}
 
-.avatar {
-	border-radius: 50%;
-	width: 250px;
-	height: 250px;
-	display: block;
-	margin: auto;
-	background: #fff;
-}
+	.name-role-row {
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+		column-gap: 12px;
+	}
 
-h5 {
-	text-align: center;
-	margin-bottom: 25px;
-	font-size: 17px;
-}
+	.name {
+		font-size: 34px;
+		line-height: 40px;
+		color: $dark-grey-3;
+	}
 
-.role {
-	text-transform: capitalize;
-}
+	.role {
+		padding: 2px 24px;
+		color: $white;
+		text-transform: uppercase;
+		font-size: 12px;
+		line-height: 14px;
+		height: 18px;
+		border-radius: 5px;
+
+		&.admin {
+			background-color: $red;
+		}
+	}
+
+	.username {
+		font-size: 24px;
+		line-height: 28px;
+		color: $dark-grey;
+	}
+
+	.buttons {
+		width: 388px;
+		display: flex;
+		flex-direction: row;
+		column-gap: 20px;
+		margin-left: auto;
+		margin-right: auto;
+		margin-bottom: 24px;
+
+		.button {
+			flex: 1;
+			font-size: 17px;
+			line-height: 20px;
+		}
+	}
+
+	.bio-row,
+	.date-location-row {
+		i {
+			font-size: 24px;
+			color: $dark-grey-2;
+			margin-right: 12px;
+		}
+
+		p {
+			font-size: 17px;
+			line-height: 20px;
+			color: $dark-grey-2;
+			word-break: break-word;
+		}
+	}
+
+	.bio-row {
+		max-width: 608px;
+		margin-bottom: 24px;
+		margin-left: auto;
+		margin-right: auto;
+		display: flex;
+		width: max-content;
+	}
 
-.level {
-	margin-top: 40px;
+	.date-location-row {
+		max-width: 608px;
+		margin-left: auto;
+		margin-right: auto;
+		margin-bottom: 24px;
+		display: flex;
+		width: max-content;
+		margin-bottom: 24px;
+		column-gap: 48px;
+	}
+
+	.date,
+	.location {
+		display: flex;
+	}
 }
 
-.admin-functionality {
-	text-align: center;
-	margin: 0 auto;
+.bottom-section {
+	width: 962px;
+	margin-left: auto;
+	margin-right: auto;
+	margin-top: 32px;
+	padding: 24px;
+	display: flex;
+	column-gap: 64px;
+
+	.buttons {
+		height: 100%;
+		width: 250px;
+
+		button {
+			outline: none;
+			border: none;
+			box-shadow: none;
+			color: $musareBlue;
+			font-size: 22px;
+			line-height: 26px;
+			padding: 7px 0 7px 12px;
+			width: 100%;
+			text-align: left;
+			cursor: pointer;
+			border-radius: 5px;
+			background-color: transparent;
+
+			&.active {
+				color: $white;
+				background-color: $musareBlue;
+			}
+		}
+	}
+
+	.content {
+		outline: 1px solid black;
+		width: 600px;
+	}
 }
 
-@media (max-width: 350px) {
-	.username {
-		font-size: 2.9rem;
-		word-wrap: break-all;
+.night-mode {
+	.name,
+	.username,
+	.bio-row i,
+	.bio-row p,
+	.date-location-row i,
+	.date-location-row p {
+		color: $light-grey;
 	}
 }
 </style>

+ 10 - 0
frontend/io.js

@@ -1,3 +1,5 @@
+import Toast from "toasters";
+
 const callbacks = {
 	general: {
 		temp: [],
@@ -82,6 +84,14 @@ export default {
 			callbacks.onConnectError.persist.forEach(cb => cb());
 		});
 
+		this.socket.on("error", err => {
+			console.log("IO: SOCKET ERROR", err);
+			new Toast({
+				content: err,
+				timeout: 8000
+			});
+		});
+
 		this.ready = true;
 
 		callbacks.general.temp.forEach(callback => callback(this.socket));