Prechádzať zdrojové kódy

Merge remote-tracking branch 'origin/jonathan-activities' into polishing

Owen Diffey 4 rokov pred
rodič
commit
06007636f8
37 zmenil súbory, kde vykonal 1247 pridanie a 1018 odobranie
  1. 49 15
      backend/logic/actions/activities.js
  2. 7 7
      backend/logic/actions/playlists.js
  3. 1 0
      backend/logic/actions/punishments.js
  4. 55 24
      backend/logic/actions/stations.js
  5. 5 3
      backend/logic/actions/users.js
  6. 2 1
      backend/logic/db/schemas/user.js
  7. 11 16
      backend/logic/io.js
  8. 6 3
      frontend/src/App.vue
  9. 3 3
      frontend/src/components/modals/AddSongToQueue.vue
  10. 1 1
      frontend/src/components/modals/CreateCommunityStation.vue
  11. 1 1
      frontend/src/components/modals/EditNews.vue
  12. 23 2
      frontend/src/components/modals/EditPlaylist/index.vue
  13. 1 1
      frontend/src/components/modals/EditStation.vue
  14. 1 1
      frontend/src/components/modals/EditUser.vue
  15. 1 1
      frontend/src/components/modals/Report.vue
  16. 2 2
      frontend/src/components/modals/ViewPunishment.vue
  17. 2 2
      frontend/src/components/modals/ViewReport.vue
  18. 78 0
      frontend/src/components/ui/ActivityItem.vue
  19. 0 25
      frontend/src/components/ui/SearchQueryItem.vue
  20. 7 2
      frontend/src/main.js
  21. 2 2
      frontend/src/mixins/SaveButton.vue
  22. 1 1
      frontend/src/pages/Admin/tabs/News.vue
  23. 0 2
      frontend/src/pages/Admin/tabs/Playlists.vue
  24. 1 1
      frontend/src/pages/Admin/tabs/Punishments.vue
  25. 1 1
      frontend/src/pages/Admin/tabs/Reports.vue
  26. 1 3
      frontend/src/pages/Admin/tabs/Users.vue
  27. 5 5
      frontend/src/pages/Home.vue
  28. 0 859
      frontend/src/pages/Profile.vue
  29. 389 0
      frontend/src/pages/Profile/index.vue
  30. 228 0
      frontend/src/pages/Profile/tabs/Playlists.vue
  31. 241 0
      frontend/src/pages/Profile/tabs/RecentActivity.vue
  32. 6 6
      frontend/src/pages/Settings/tabs/Account.vue
  33. 28 9
      frontend/src/pages/Settings/tabs/Preferences.vue
  34. 10 10
      frontend/src/pages/Settings/tabs/Profile.vue
  35. 7 2
      frontend/src/pages/Station/components/Sidebar/Users.vue
  36. 27 3
      frontend/src/pages/Station/index.vue
  37. 44 4
      frontend/src/store/modules/user.js

+ 49 - 15
backend/logic/actions/activities.js

@@ -5,8 +5,24 @@ import { isLoginRequired } from "./hooks";
 import moduleManager from "../../index";
 
 const DBModule = moduleManager.modules.db;
+const CacheModule = moduleManager.modules.cache;
+const IOModule = moduleManager.modules.io;
 const UtilsModule = moduleManager.modules.utils;
 
+CacheModule.runJob("SUB", {
+	channel: "activity.hide",
+	cb: res => {
+		IOModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(response =>
+			response.sockets.forEach(socket => socket.emit("event:activity.hide", res.activityId))
+		);
+
+		IOModule.runJob("EMIT_TO_ROOM", {
+			room: `profile-${res.userId}-activities`,
+			args: ["event:activity.hide", res.activityId]
+		});
+	}
+});
+
 export default {
 	/**
 	 * Gets a set of activities
@@ -17,15 +33,29 @@ export default {
 	 * @param {Function} cb - callback
 	 */
 	async getSet(session, userId, set, cb) {
-		const activityModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "activity"
-			},
-			this
-		);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
 		async.waterfall(
 			[
+				next => {
+					// activities should only be viewed if public/owned by the user
+					if (session.userId !== userId) {
+						return userModel
+							.findById(userId)
+							.then(user => {
+								if (user) {
+									if (user.preferences.activityLogPublic) return next();
+									return next("User's activity log isn't public.");
+								}
+								return next("User does not exist.");
+							})
+							.catch(next);
+					}
+
+					return next();
+				},
+
 				next => {
 					activityModel
 						.find({ userId, hidden: false })
@@ -56,13 +86,8 @@ export default {
 	 * @param cb
 	 */
 	hideActivity: isLoginRequired(async function hideActivity(session, activityId, cb) {
-		const activityModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "activity"
-			},
-			this
-		);
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
 		async.waterfall(
 			[
 				next => {
@@ -76,8 +101,17 @@ export default {
 					return cb({ status: "failure", message: err });
 				}
 
+				CacheModule.runJob("PUB", {
+					channel: "activity.hide",
+					value: {
+						userId: session.userId,
+						activityId
+					}
+				});
+
 				this.log("SUCCESS", "ACTIVITIES_HIDE_ACTIVITY", `Successfully hid activity ${activityId}.`);
-				return cb({ status: "success" });
+
+				return cb({ status: "success", message: "Successfully hid activity." });
 			}
 		);
 	})

+ 7 - 7
backend/logic/actions/playlists.js

@@ -24,7 +24,7 @@ CacheModule.runJob("SUB", {
 
 		if (playlist.privacy === "public")
 			IOModule.runJob("EMIT_TO_ROOM", {
-				room: `profile-${playlist.createdBy}`,
+				room: `profile-${playlist.createdBy}-playlists`,
 				args: ["event:playlist.create", playlist]
 			});
 	}
@@ -40,7 +40,7 @@ CacheModule.runJob("SUB", {
 		});
 
 		IOModule.runJob("EMIT_TO_ROOM", {
-			room: `profile-${res.userId}`,
+			room: `profile-${res.userId}-playlists`,
 			args: ["event:playlist.delete", res.playlistId]
 		});
 	}
@@ -74,7 +74,7 @@ CacheModule.runJob("SUB", {
 
 		if (res.privacy === "public")
 			IOModule.runJob("EMIT_TO_ROOM", {
-				room: `profile-${res.userId}`,
+				room: `profile-${res.userId}-playlists`,
 				args: [
 					"event:playlist.addSong",
 					{
@@ -100,7 +100,7 @@ CacheModule.runJob("SUB", {
 
 		if (res.privacy === "public")
 			IOModule.runJob("EMIT_TO_ROOM", {
-				room: `profile-${res.userId}`,
+				room: `profile-${res.userId}-playlists`,
 				args: [
 					"event:playlist.removeSong",
 					{
@@ -126,7 +126,7 @@ CacheModule.runJob("SUB", {
 
 		if (res.privacy === "public")
 			IOModule.runJob("EMIT_TO_ROOM", {
-				room: `profile-${res.userId}`,
+				room: `profile-${res.userId}-playlists`,
 				args: [
 					"event:playlist.updateDisplayName",
 					{
@@ -151,12 +151,12 @@ CacheModule.runJob("SUB", {
 
 		if (res.playlist.privacy === "public")
 			return IOModule.runJob("EMIT_TO_ROOM", {
-				room: `profile-${res.userId}`,
+				room: `profile-${res.userId}-playlists`,
 				args: ["event:playlist.create", res.playlist]
 			});
 
 		return IOModule.runJob("EMIT_TO_ROOM", {
-			room: `profile-${res.userId}`,
+			room: `profile-${res.userId}-playlists`,
 			args: ["event:playlist.delete", res.playlist._id]
 		});
 	}

+ 1 - 0
backend/logic/actions/punishments.js

@@ -17,6 +17,7 @@ CacheModule.runJob("SUB", {
 			room: "admin.punishments",
 			args: ["event:admin.punishment.added", data.punishment]
 		});
+
 		IOModule.runJob("SOCKETS_FROM_IP", { ip: data.ip }, this).then(sockets => {
 			sockets.forEach(socket => {
 				socket.disconnect(true);

+ 55 - 24
backend/logic/actions/stations.js

@@ -33,6 +33,7 @@ CacheModule.runJob("SUB", {
 			room: `station.${stationId}`,
 			args: ["event:userCount.updated", count]
 		});
+
 		StationsModule.runJob("GET_STATION", { stationId }).then(async station => {
 			if (station.privacy === "public")
 				IOModule.runJob("EMIT_TO_ROOM", {
@@ -43,6 +44,7 @@ CacheModule.runJob("SUB", {
 				const sockets = await IOModule.runJob("GET_ROOM_SOCKETS", {
 					room: "home"
 				});
+
 				Object.keys(sockets).forEach(socketKey => {
 					const socket = sockets[socketKey];
 					const { session } = socket;
@@ -214,18 +216,22 @@ CacheModule.runJob("SUB", {
 
 CacheModule.runJob("SUB", {
 	channel: "station.nameUpdate",
-	cb: response => {
-		const { stationId } = response;
-		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+	cb: res => {
+		const { stationId, name } = res;
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(station =>
 			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
 				room: `home`,
 				station
 			}).then(response => {
 				const { socketsThatCan } = response;
-				socketsThatCan.forEach(socket => {
-					socket.emit("event:station.updateName", { stationId, name: station.name });
-				});
-			});
+				socketsThatCan.forEach(socket => socket.emit("event:station.updateName", { stationId, name }));
+			})
+		);
+
+		IOModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.updateName", { stationId, name }]
 		});
 	}
 });
@@ -233,17 +239,23 @@ CacheModule.runJob("SUB", {
 CacheModule.runJob("SUB", {
 	channel: "station.displayNameUpdate",
 	cb: response => {
-		const { stationId } = response;
-		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+		const { stationId, displayName } = response;
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(station =>
 			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
 				room: `home`,
 				station
 			}).then(response => {
 				const { socketsThatCan } = response;
-				socketsThatCan.forEach(socket => {
-					socket.emit("event:station.updateDisplayName", { stationId, displayName: station.displayName });
-				});
-			});
+				socketsThatCan.forEach(socket =>
+					socket.emit("event:station.updateDisplayName", { stationId, displayName })
+				);
+			})
+		);
+
+		IOModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.updateDisplayName", { stationId, displayName }]
 		});
 	}
 });
@@ -251,17 +263,23 @@ CacheModule.runJob("SUB", {
 CacheModule.runJob("SUB", {
 	channel: "station.descriptionUpdate",
 	cb: response => {
-		const { stationId } = response;
-		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
+		const { stationId, description } = response;
+
+		StationsModule.runJob("GET_STATION", { stationId }).then(station =>
 			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
 				room: `home`,
 				station
 			}).then(response => {
 				const { socketsThatCan } = response;
-				socketsThatCan.forEach(socket => {
-					socket.emit("event:station.updateDescription", { stationId, description: station.description });
-				});
-			});
+				socketsThatCan.forEach(socket =>
+					socket.emit("event:station.updateDescription", { stationId, description })
+				);
+			})
+		);
+
+		IOModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.updateDescription", { stationId, description }]
 		});
 	}
 });
@@ -273,7 +291,7 @@ CacheModule.runJob("SUB", {
 		StationsModule.runJob("GET_STATION", { stationId }).then(station => {
 			IOModule.runJob("EMIT_TO_ROOM", {
 				room: `station.${stationId}`,
-				args: ["event:theme.updated", station.theme]
+				args: ["event:station.themeUpdated", station.theme]
 			});
 			StationsModule.runJob("GET_SOCKETS_THAT_CAN_KNOW_ABOUT_STATION", {
 				room: `home`,
@@ -281,7 +299,7 @@ CacheModule.runJob("SUB", {
 			}).then(response => {
 				const { socketsThatCan } = response;
 				socketsThatCan.forEach(socket => {
-					socket.emit("event:station.updateTheme", { stationId, theme: station.theme });
+					socket.emit("event:station.themeUpdated", { stationId, theme: station.theme });
 				});
 			});
 		});
@@ -1091,9 +1109,13 @@ export default {
 					this.log("ERROR", "STATIONS_LEAVE", `Leaving station "${stationId}" failed. "${err}"`);
 					return cb({ status: "failure", message: err });
 				}
+
 				this.log("SUCCESS", "STATIONS_LEAVE", `Left station "${stationId}" successfully.`);
+
 				IOModule.runJob("SOCKET_LEAVE_ROOMS", { socketId: session });
+
 				delete StationsModule.userList[session.socketId];
+
 				return cb({
 					status: "success",
 					message: "Successfully left station.",
@@ -1148,15 +1170,18 @@ export default {
 					);
 					return cb({ status: "failure", message: err });
 				}
+
 				this.log(
 					"SUCCESS",
 					"STATIONS_UPDATE_NAME",
 					`Updated station "${stationId}" name to "${newName}" successfully.`
 				);
+
 				CacheModule.runJob("PUB", {
 					channel: "station.nameUpdate",
-					value: { stationId }
+					value: { stationId, name: newName }
 				});
+
 				return cb({
 					status: "success",
 					message: "Successfully updated the name."
@@ -1211,15 +1236,18 @@ export default {
 					);
 					return cb({ status: "failure", message: err });
 				}
+
 				this.log(
 					"SUCCESS",
 					"STATIONS_UPDATE_DISPLAY_NAME",
 					`Updated station "${stationId}" displayName to "${newDisplayName}" successfully.`
 				);
+
 				CacheModule.runJob("PUB", {
 					channel: "station.displayNameUpdate",
-					value: { stationId }
+					value: { stationId, displayName: newDisplayName }
 				});
+
 				return cb({
 					status: "success",
 					message: "Successfully updated the display name."
@@ -1274,15 +1302,18 @@ export default {
 					);
 					return cb({ status: "failure", message: err });
 				}
+
 				this.log(
 					"SUCCESS",
 					"STATIONS_UPDATE_DESCRIPTION",
 					`Updated station "${stationId}" description to "${newDescription}" successfully.`
 				);
+
 				CacheModule.runJob("PUB", {
 					channel: "station.descriptionUpdate",
-					value: { stationId }
+					value: { stationId, description: newDescription }
 				});
+
 				return cb({
 					status: "success",
 					message: "Successfully updated the description."

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

@@ -21,7 +21,7 @@ const PlaylistsModule = moduleManager.modules.playlists;
 CacheModule.runJob("SUB", {
 	channel: "user.updatePreferences",
 	cb: res => {
-		IOModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }).then(response => {
+		IOModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(response => {
 			response.sockets.forEach(socket => {
 				socket.emit("keep.event:user.preferences.changed", res.preferences);
 			});
@@ -39,7 +39,7 @@ CacheModule.runJob("SUB", {
 		});
 
 		IOModule.runJob("EMIT_TO_ROOM", {
-			room: `profile-${res.userId}`,
+			room: `profile-${res.userId}-playlists`,
 			args: ["event:user.orderOfPlaylists.changed", res.orderOfPlaylists]
 		});
 	}
@@ -714,6 +714,7 @@ export default {
 	 * @param {object} preferences - object containing preferences
 	 * @param {boolean} preferences.nightmode - whether or not the user is using the night mode theme
 	 * @param {boolean} preferences.autoSkipDisliked - whether to automatically skip disliked songs
+	 * @param {boolean} preferences.activityLogPublic - whether or not a user's activity log can be publicly viewed
 	 * @param {Function} cb - gets called with the result
 	 */
 	updatePreferences: isLoginRequired(async function updatePreferences(session, preferences, cb) {
@@ -728,7 +729,8 @@ export default {
 							$set: {
 								preferences: {
 									nightmode: preferences.nightmode,
-									autoSkipDisliked: preferences.autoSkipDisliked
+									autoSkipDisliked: preferences.autoSkipDisliked,
+									activityLogPublic: preferences.activityLogPublic
 								}
 							}
 						},

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

@@ -43,7 +43,8 @@ export default {
 	preferences: {
 		orderOfPlaylists: [{ type: mongoose.Schema.Types.ObjectId }],
 		nightmode: { type: Boolean, default: false, required: true },
-		autoSkipDisliked: { type: Boolean, default: true, required: true }
+		autoSkipDisliked: { type: Boolean, default: true, required: true },
+		activityLogPublic: { type: Boolean, default: false, required: true }
 	},
 	documentVersion: { type: Number, default: 1, required: true }
 };

+ 11 - 16
backend/logic/io.js

@@ -148,21 +148,15 @@ class _IOModule extends CoreClass {
 					1,
 					(id, next) => {
 						const { session } = ns.connected[id];
-						CacheModule.runJob(
-							"HGET",
-							{
-								table: "sessions",
-								key: session.sessionId
-							},
-							this
-						)
-							.then(session => {
-								if (session && session.userId === payload.userId) sockets.push(ns.connected[id]);
-								next();
-							})
-							.catch(err => {
-								next(err);
-							});
+
+						if (session.sessionId) {
+							CacheModule.runJob("HGET", { table: "sessions", key: session.sessionId }, this)
+								.then(session => {
+									if (session && session.userId === payload.userId) sockets.push(ns.connected[id]);
+									next();
+								})
+								.catch(err => next(err));
+						} else next();
 					},
 					err => {
 						if (err) return reject(err);
@@ -191,6 +185,7 @@ class _IOModule extends CoreClass {
 					Object.keys(ns.connected),
 					(id, next) => {
 						const { session } = ns.connected[id];
+
 						CacheModule.runJob(
 							"HGET",
 							{
@@ -206,7 +201,7 @@ class _IOModule extends CoreClass {
 							.catch(() => next());
 					},
 					() => {
-						resolve({ sockets });
+						resolve(sockets);
 					}
 				);
 			}

+ 6 - 3
frontend/src/App.vue

@@ -43,7 +43,7 @@ export default {
 		userId: state => state.user.auth.userId,
 		banned: state => state.user.auth.banned,
 		modals: state => state.modalVisibility.modals,
-		currentlyActive: state => state.modals.currentlyActive,
+		currentlyActive: state => state.modalVisibility.currentlyActive,
 		nightmode: state => state.user.preferences.nightmode
 	}),
 	watch: {
@@ -98,6 +98,7 @@ export default {
 			shift: false,
 			ctrl: false,
 			handler: () => {
+				console.log(this.currentlyActive);
 				if (Object.keys(this.currentlyActive).length !== 0)
 					this.closeCurrentModal();
 			}
@@ -144,8 +145,9 @@ export default {
 			this.socket.emit("users.getPreferences", res => {
 				if (res.status === "success") {
 					this.changeAutoSkipDisliked(res.data.autoSkipDisliked);
-
 					this.changeNightmode(res.data.nightmode);
+					this.changeActivityLogPublic(res.data.activityLogPublic);
+
 					if (this.nightmode) this.enableNightMode();
 					else this.disableNightMode();
 				}
@@ -173,7 +175,8 @@ export default {
 		...mapActions("modalVisibility", ["closeCurrentModal"]),
 		...mapActions("user/preferences", [
 			"changeNightmode",
-			"changeAutoSkipDisliked"
+			"changeAutoSkipDisliked",
+			"changeActivityLogPublic"
 		])
 	}
 };

+ 3 - 3
frontend/src/pages/Station/AddSongToQueue.vue → frontend/src/components/modals/AddSongToQueue.vue

@@ -212,9 +212,9 @@ import Toast from "toasters";
 
 import SearchYoutube from "../../mixins/SearchYoutube.vue";
 
-import PlaylistItem from "../../components/ui/PlaylistItem.vue";
-import SearchQueryItem from "../../components/ui/SearchQueryItem.vue";
-import Modal from "../../components/Modal.vue";
+import PlaylistItem from "../ui/PlaylistItem.vue";
+import SearchQueryItem from "../ui/SearchQueryItem.vue";
+import Modal from "../Modal.vue";
 
 import io from "../../io";
 

+ 1 - 1
frontend/src/pages/Home/CreateCommunityStation.vue → frontend/src/components/modals/CreateCommunityStation.vue

@@ -42,7 +42,7 @@
 import { mapActions } from "vuex";
 
 import Toast from "toasters";
-import Modal from "../../components/Modal.vue";
+import Modal from "../Modal.vue";
 import io from "../../io";
 import validation from "../../validation";
 

+ 1 - 1
frontend/src/pages/Admin/EditNews.vue → frontend/src/components/modals/EditNews.vue

@@ -169,7 +169,7 @@ import { mapActions, mapState } from "vuex";
 import Toast from "toasters";
 import io from "../../io";
 
-import Modal from "../../components/Modal.vue";
+import Modal from "../Modal.vue";
 
 export default {
 	components: { Modal },

+ 23 - 2
frontend/src/components/modals/EditPlaylist/index.vue

@@ -364,6 +364,7 @@ export default {
 			playlist: { songs: [] }
 		};
 	},
+
 	computed: {
 		...mapState("user/playlists", {
 			editing: state => state.editing
@@ -378,13 +379,23 @@ export default {
 			};
 		}
 	},
+	watch: {
+		"search.songs.results": function checkIfSongInPlaylist(songs) {
+			songs.forEach((searchItem, index) =>
+				this.playlist.songs.find(song => {
+					if (song.songId === searchItem.id)
+						this.search.songs.results[index].isAddedToQueue = true;
+
+					return song.songId === searchItem.id;
+				})
+			);
+		}
+	},
 	mounted() {
 		io.getSocket(socket => {
 			this.socket = socket;
 
 			this.socket.emit("playlists.getPlaylist", this.editing, res => {
-				console.log(res);
-
 				if (res.status === "success") {
 					this.playlist = res.data;
 					this.playlist.songs.sort((a, b) => a.position - b.position);
@@ -400,10 +411,20 @@ export default {
 
 			this.socket.on("event:playlist.removeSong", data => {
 				if (this.playlist._id === data.playlistId) {
+					// remove song from array of playlists
 					this.playlist.songs.forEach((song, index) => {
 						if (song.songId === data.songId)
 							this.playlist.songs.splice(index, 1);
 					});
+
+					// if this song is in search results, mark it available to add to the playlist again
+					this.search.songs.results.forEach((searchItem, index) => {
+						if (data.songId === searchItem.id) {
+							this.search.songs.results[
+								index
+							].isAddedToQueue = false;
+						}
+					});
 				}
 			});
 

+ 1 - 1
frontend/src/components/modals/EditStation.vue

@@ -753,7 +753,7 @@ export default {
 					if (this.originalStation)
 						this.originalStation.locked = res.data;
 					return new Toast({
-						content: `Toggled queue lock succesfully to ${res.data}`,
+						content: `Toggled queue lock successfully to ${res.data}`,
 						timeout: 4000
 					});
 				}

+ 1 - 1
frontend/src/pages/Admin/EditUser.vue → frontend/src/components/modals/EditUser.vue

@@ -92,7 +92,7 @@ import { mapState, mapActions } from "vuex";
 
 import Toast from "toasters";
 import io from "../../io";
-import Modal from "../../components/Modal.vue";
+import Modal from "../Modal.vue";
 import validation from "../../validation";
 
 export default {

+ 1 - 1
frontend/src/pages/Station/Report.vue → frontend/src/components/modals/Report.vue

@@ -185,7 +185,7 @@
 import { mapState, mapActions } from "vuex";
 
 import Toast from "toasters";
-import Modal from "../../components/Modal.vue";
+import Modal from "../Modal.vue";
 import io from "../../io";
 
 export default {

+ 2 - 2
frontend/src/pages/Admin/ViewPunishment.vue → frontend/src/components/modals/ViewPunishment.vue

@@ -78,8 +78,8 @@ import { format, formatDistance, parseISO } from "date-fns"; // eslint-disable-l
 
 import Toast from "toasters";
 import io from "../../io";
-import Modal from "../../components/Modal.vue";
-import UserIdToUsername from "../../components/common/UserIdToUsername.vue";
+import Modal from "../Modal.vue";
+import UserIdToUsername from "../common/UserIdToUsername.vue";
 
 export default {
 	components: { Modal, UserIdToUsername },

+ 2 - 2
frontend/src/pages/Admin/ViewReport.vue → frontend/src/components/modals/ViewReport.vue

@@ -95,8 +95,8 @@ import Toast from "toasters";
 
 import io from "../../io";
 
-import UserIdToUsername from "../../components/common/UserIdToUsername.vue";
-import Modal from "../../components/Modal.vue";
+import UserIdToUsername from "../common/UserIdToUsername.vue";
+import Modal from "../Modal.vue";
 
 export default {
 	components: { Modal, UserIdToUsername },

+ 78 - 0
frontend/src/components/ui/ActivityItem.vue

@@ -0,0 +1,78 @@
+<template>
+	<div class="item activity-item universal-item">
+		<div class="thumbnail">
+			<img :src="activity.thumbnail" :alt="activity.message" />
+			<i class="material-icons activity-type-icon">{{ activity.icon }}</i>
+		</div>
+		<div class="left-part">
+			<p class="item-title">
+				{{ activity.activityType }}
+				<!-- v-html="activity.message" -->
+			</p>
+			<p class="item-description">
+				{{
+					formatDistance(parseISO(activity.createdAt), new Date(), {
+						addSuffix: true
+					})
+				}}
+			</p>
+		</div>
+		<div class="universal-item-actions">
+			<slot name="actions" />
+		</div>
+	</div>
+</template>
+
+<script>
+import { formatDistance, parseISO } from "date-fns";
+
+export default {
+	props: {
+		activity: {
+			type: Object,
+			default: () => {}
+		}
+	},
+	methods: { formatDistance, parseISO }
+};
+</script>
+
+<style lang="scss" scoped>
+.activity-item {
+	height: 72px;
+	border: 0.5px var(--light-grey-3) solid;
+	border-radius: 3px;
+
+	.thumbnail {
+		position: relative;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		width: 70.5px;
+		height: 70.5px;
+
+		img {
+			opacity: 0.4;
+		}
+
+		.activity-type-icon {
+			position: absolute;
+			color: var(--dark-grey);
+			font-size: 30px;
+		}
+	}
+
+	.left-part {
+		flex: 1;
+		padding: 12px;
+
+		.item-title {
+			margin: 0;
+		}
+	}
+
+	.universal-item-actions a {
+		border-bottom: 0;
+	}
+}
+</style>

+ 0 - 25
frontend/src/components/ui/SearchQueryItem.vue

@@ -6,31 +6,6 @@
 				<h4 class="item-title" :title="result.title">
 					{{ result.title }}
 				</h4>
-
-				<!-- <p
-					id="song-request-time"
-					v-if="
-						station.type === 'community' &&
-							station.partyMode === true
-					"
-				>
-					Requested by
-					<strong>
-						<user-id-to-username
-							:user-id="song.requestedBy"
-							:link="true"
-						/>
-						{{
-							formatDistance(
-								parseISO(song.requestedAt),
-								new Date(),
-								{
-									addSuffix: true
-								}
-							)
-						}}
-					</strong>
-				</p> -->
 			</div>
 		</div>
 		<div class="universal-item-actions">

+ 7 - 2
frontend/src/main.js

@@ -54,7 +54,7 @@ const router = new VueRouter({
 	routes: [
 		{
 			path: "/",
-			component: () => import("./pages/Home/index.vue")
+			component: () => import("./pages/Home.vue")
 		},
 		{
 			path: "/404",
@@ -84,7 +84,7 @@ const router = new VueRouter({
 		{
 			name: "profile",
 			path: "/u/:username",
-			component: () => import("./pages/Profile.vue")
+			component: () => import("./pages/Profile/index.vue")
 		},
 		{
 			path: "/settings",
@@ -166,6 +166,11 @@ lofig.get("serverDomain").then(serverDomain => {
 				"user/preferences/changeNightmode",
 				preferences.nightmode
 			);
+
+			store.dispatch(
+				"user/preferences/changeActivityLogPublic",
+				preferences.activityLogPublic
+			);
 		});
 	});
 });

+ 2 - 2
frontend/src/pages/Settings/mixins/SaveButton.vue → frontend/src/mixins/SaveButton.vue

@@ -30,7 +30,7 @@ export default {
 		}
 	},
 	methods: {
-		successfulSave() {
+		handleSuccessfulSave() {
 			if (this.saveStatus !== "save-success") {
 				this.saveStatus = "save-success";
 				setTimeout(() => {
@@ -38,7 +38,7 @@ export default {
 				}, 2000);
 			}
 		},
-		failedSave() {
+		handleFailedSave() {
 			if (this.saveStatus !== "save-failure") {
 				this.saveStatus = "save-failure";
 				setTimeout(() => {

+ 1 - 1
frontend/src/pages/Admin/tabs/News.vue

@@ -220,7 +220,7 @@ import { mapActions, mapState } from "vuex";
 import Toast from "toasters";
 import io from "../../../io";
 
-import EditNews from "../EditNews.vue";
+import EditNews from "../../../components/modals/EditNews.vue";
 
 export default {
 	components: { EditNews },

+ 0 - 2
frontend/src/pages/Admin/tabs/Playlists.vue

@@ -80,8 +80,6 @@ export default {
 		})
 	},
 	mounted() {
-		console.log("mounted");
-
 		io.getSocket(socket => {
 			this.socket = socket;
 			if (this.socket.connected) this.init();

+ 1 - 1
frontend/src/pages/Admin/tabs/Punishments.vue

@@ -97,7 +97,7 @@
 import { mapState, mapActions } from "vuex";
 import Toast from "toasters";
 
-import ViewPunishment from "../ViewPunishment.vue";
+import ViewPunishment from "../../../components/modals/ViewPunishment.vue";
 import io from "../../../io";
 
 export default {

+ 1 - 1
frontend/src/pages/Admin/tabs/Reports.vue

@@ -73,7 +73,7 @@ import { formatDistance } from "date-fns";
 import Toast from "toasters";
 import io from "../../../io";
 
-import ViewReport from "../ViewReport.vue";
+import ViewReport from "../../../components/modals/ViewReport.vue";
 import UserIdToUsername from "../../../components/common/UserIdToUsername.vue";
 
 export default {

+ 1 - 3
frontend/src/pages/Admin/tabs/Users.vue

@@ -68,7 +68,7 @@
 <script>
 import { mapState, mapActions } from "vuex";
 
-import EditUser from "../EditUser.vue";
+import EditUser from "../../../components/modals/EditUser.vue";
 import ProfilePicture from "../../../components/ui/ProfilePicture.vue";
 import io from "../../../io";
 
@@ -86,8 +86,6 @@ export default {
 		})
 	},
 	mounted() {
-		console.log("mounted");
-
 		io.getSocket(socket => {
 			this.socket = socket;
 			if (this.socket.connected) this.init();

+ 5 - 5
frontend/src/pages/Home/index.vue → frontend/src/pages/Home.vue

@@ -431,12 +431,12 @@
 import { mapState, mapActions } from "vuex";
 import Toast from "toasters";
 
-import MainHeader from "../../components/layout/MainHeader.vue";
-import MainFooter from "../../components/layout/MainFooter.vue";
-import CreateCommunityStation from "./CreateCommunityStation.vue";
-import UserIdToUsername from "../../components/common/UserIdToUsername.vue";
+import MainHeader from "../components/layout/MainHeader.vue";
+import MainFooter from "../components/layout/MainFooter.vue";
+import CreateCommunityStation from "../components/modals/CreateCommunityStation.vue";
+import UserIdToUsername from "../components/common/UserIdToUsername.vue";
 
-import io from "../../io";
+import io from "../io";
 
 export default {
 	components: {

+ 0 - 859
frontend/src/pages/Profile.vue

@@ -1,859 +0,0 @@
-<template>
-	<div v-if="isUser">
-		<metadata :title="`Profile | ${user.username}`" />
-		<edit-playlist v-if="modals.editPlaylist" />
-		<create-playlist v-if="modals.createPlaylist" />
-		<main-header />
-		<div class="container">
-			<div class="info-section">
-				<div class="picture-name-row">
-					<profile-picture :avatar="user.avatar" :name="user.name" />
-					<div>
-						<div class="name-role-row">
-							<p class="name">{{ user.name }}</p>
-							<span
-								class="role admin"
-								v-if="user.role === 'admin'"
-								>admin</span
-							>
-						</div>
-						<h2 class="username">@{{ user.username }}</h2>
-					</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'"
-					>
-						Edit
-					</router-link>
-					<router-link
-						to="/settings"
-						class="button is-primary"
-						v-if="userId === user._id"
-					>
-						Settings
-					</router-link>
-				</div>
-				<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="location" v-if="user.location">
-						<i class="material-icons">location_on</i>
-						<p>{{ user.location }}</p>
-					</div>
-				</div>
-			</div>
-			<div class="bottom-section">
-				<div class="buttons">
-					<button
-						:class="{ active: tab === 'recent-activity' }"
-						@click="showTab('recent-activity')"
-					>
-						Recent activity
-					</button>
-					<button
-						:class="{ active: tab === 'playlists' }"
-						@click="showTab('playlists')"
-					>
-						Playlists
-					</button>
-				</div>
-				<div
-					class="content recent-activity-tab"
-					v-if="tab === 'recent-activity'"
-				>
-					<div v-if="activities.length > 0">
-						<h4 class="section-title">Recent activity</h4>
-
-						<p class="section-description">
-							This is a log of all actions
-							{{
-								userId === user._id
-									? "you have"
-									: `${user.name} has`
-							}}
-							taken recently.
-						</p>
-
-						<hr class="section-horizontal-rule" />
-
-						<div
-							class="item activity-item universal-item"
-							v-for="activity in sortedActivities"
-							:key="activity._id"
-						>
-							<div class="thumbnail">
-								<img :src="activity.thumbnail" alt="" />
-								<i class="material-icons activity-type-icon">{{
-									activity.icon
-								}}</i>
-							</div>
-							<div class="left-part">
-								<p
-									class="item-title"
-									v-html="activity.message"
-								></p>
-								<p class="item-description">
-									{{
-										formatDistance(
-											parseISO(activity.createdAt),
-											new Date(),
-											{ addSuffix: true }
-										)
-									}}
-								</p>
-							</div>
-							<div class="universal-item-actions">
-								<a
-									href="#"
-									@click.prevent="hideActivity(activity._id)"
-								>
-									<i class="material-icons hide-icon"
-										>visibility_off</i
-									>
-								</a>
-							</div>
-						</div>
-					</div>
-					<div v-else>
-						<h3>No recent activity.</h3>
-					</div>
-				</div>
-				<div class="content playlists-tab" v-if="tab === 'playlists'">
-					<div v-if="playlists.length > 0">
-						<h4 class="section-title">
-							{{ user._id === userId ? "My" : null }}
-							Playlists
-						</h4>
-
-						<p class="section-description">
-							View
-							{{
-								userId === user._id
-									? "and manage your personal"
-									: `${user.name}'s`
-							}}
-							playlists.
-						</p>
-
-						<hr class="section-horizontal-rule" />
-
-						<draggable
-							class="menu-list scrollable-list"
-							v-if="playlists.length > 0"
-							v-model="playlists"
-							v-bind="dragOptions"
-							@start="drag = true"
-							@end="drag = false"
-							@change="savePlaylistOrder"
-						>
-							<transition-group
-								type="transition"
-								:name="
-									!drag ? 'draggable-list-transition' : null
-								"
-							>
-								<div
-									class="item item-draggable"
-									v-for="playlist in playlists"
-									:key="playlist._id"
-								>
-									<playlist-item
-										v-if="
-											playlist.privacy === 'public' ||
-												(playlist.privacy ===
-													'private' &&
-													playlist.createdBy ===
-														userId)
-										"
-										:playlist="playlist"
-									>
-										<div slot="actions">
-											<i
-												v-if="user._id === userId"
-												@click="
-													showPlaylist(playlist._id)
-												"
-												class="material-icons edit-icon"
-												>edit</i
-											>
-											<i
-												v-else
-												@click="
-													showPlaylist(playlist._id)
-												"
-												class="material-icons view-icon"
-												>visibility</i
-											>
-										</div>
-									</playlist-item>
-								</div>
-							</transition-group>
-						</draggable>
-
-						<button
-							v-if="user._id === userId"
-							class="button is-primary"
-							id="create-new-playlist-button"
-							@click="
-								openModal({
-									sector: 'station',
-									modal: 'createPlaylist'
-								})
-							"
-						>
-							Create new playlist
-						</button>
-					</div>
-					<div v-else>
-						<h3>No playlists here.</h3>
-					</div>
-				</div>
-			</div>
-		</div>
-		<main-footer />
-	</div>
-</template>
-
-<script>
-import { mapState, mapActions } from "vuex";
-import { format, formatDistance, parseISO } from "date-fns";
-import Toast from "toasters";
-import draggable from "vuedraggable";
-
-import TabQueryHandler from "../mixins/TabQueryHandler.vue";
-
-import ProfilePicture from "../components/ui/ProfilePicture.vue";
-import PlaylistItem from "../components/ui/PlaylistItem.vue";
-import SortablePlaylists from "../mixins/SortablePlaylists.vue";
-import MainHeader from "../components/layout/MainHeader.vue";
-import MainFooter from "../components/layout/MainFooter.vue";
-
-import io from "../io";
-
-export default {
-	components: {
-		MainHeader,
-		MainFooter,
-		PlaylistItem,
-		ProfilePicture,
-		CreatePlaylist: () => import("../components/modals/CreatePlaylist.vue"),
-		EditPlaylist: () =>
-			import("../components/modals/EditPlaylist/index.vue"),
-		draggable
-	},
-	mixins: [SortablePlaylists, TabQueryHandler],
-	data() {
-		return {
-			user: {},
-			isUser: false,
-			tab: "recent-activity",
-			playlists: [],
-			activities: []
-		};
-	},
-	computed: {
-		...mapState({
-			role: state => state.user.auth.role,
-			userId: state => state.user.auth.userId,
-			...mapState("modalVisibility", {
-				modals: state => state.modals.station
-			})
-		}),
-		sortedActivities() {
-			const { activities } = this;
-			return activities.sort(
-				(x, y) => new Date(y.createdAt) - new Date(x.createdAt)
-			);
-		}
-	},
-	mounted() {
-		if (
-			this.$route.query.tab === "recent-activity" ||
-			this.$route.query.tab === "playlists"
-		)
-			this.tab = this.$route.query.tab;
-
-		io.getSocket(socket => {
-			this.socket = socket;
-
-			this.socket.emit(
-				"users.findByUsername",
-				this.$route.params.username,
-				res => {
-					if (res.status === "error") this.$router.go("/404");
-					else {
-						this.user = res.data;
-
-						this.user.createdAt = format(
-							parseISO(this.user.createdAt),
-							"MMMM do yyyy"
-						);
-
-						this.isUser = true;
-
-						if (this.user._id !== this.userId) {
-							this.socket.emit(
-								"apis.joinRoom",
-								`profile-${res.data._id}`,
-								() => {}
-							);
-						}
-
-						this.socket.emit(
-							"playlists.indexForUser",
-							this.user._id,
-							res => {
-								if (res.status === "success")
-									this.playlists = res.data;
-								this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database
-							}
-						);
-
-						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.songId === 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;
-									}
-								});
-							}
-						);
-
-						this.socket.on("event:playlist.updatePrivacy", data => {
-							this.playlists.forEach((playlist, index) => {
-								if (playlist._id === data.playlist._id) {
-									this.playlists[index].privacy =
-										data.playlist.privacy;
-								}
-							});
-						});
-
-						this.socket.on(
-							"event:user.orderOfPlaylists.changed",
-							orderOfPlaylists => {
-								const sortedPlaylists = [];
-
-								this.playlists.forEach(playlist => {
-									sortedPlaylists[
-										orderOfPlaylists.indexOf(playlist._id)
-									] = playlist;
-								});
-
-								this.playlists = sortedPlaylists;
-								this.orderOfPlaylists = this.calculatePlaylistOrder();
-							}
-						);
-
-						if (this.user._id === this.userId) {
-							this.socket.emit(
-								"activities.getSet",
-								this.userId,
-								1,
-								res => {
-									if (res.status === "success") {
-										for (
-											let a = 0;
-											a < res.data.length;
-											a += 1
-										) {
-											this.formatActivity(
-												res.data[a],
-												activity => {
-													this.activities.unshift(
-														activity
-													);
-												}
-											);
-										}
-									}
-								}
-							);
-
-							this.socket.on(
-								"event:activity.create",
-								activity => {
-									console.log(activity);
-									this.formatActivity(activity, activity => {
-										this.activities.unshift(activity);
-									});
-								}
-							);
-						}
-					}
-				}
-			);
-		});
-	},
-	methods: {
-		formatDistance,
-		parseISO,
-		showPlaylist(playlistId) {
-			this.editPlaylist(playlistId);
-			this.openModal({ sector: "station", modal: "editPlaylist" });
-		},
-		hideActivity(activityId) {
-			this.socket.emit("activities.hideActivity", activityId, res => {
-				if (res.status === "success") {
-					this.activities = this.activities.filter(
-						activity => activity._id !== activityId
-					);
-				} else {
-					new Toast({ content: res.message, timeout: 3000 });
-				}
-			});
-		},
-		formatActivity(res, cb) {
-			console.log("activity", res);
-
-			const icons = {
-				created_account: "account_circle",
-				created_station: "radio",
-				deleted_station: "delete",
-				created_playlist: "playlist_add_check",
-				deleted_playlist: "delete_sweep",
-				liked_song: "favorite",
-				added_song_to_playlist: "playlist_add",
-				added_songs_to_playlist: "playlist_add"
-			};
-
-			const activity = {
-				...res,
-				thumbnail: "",
-				message: "",
-				icon: ""
-			};
-
-			const plural = activity.payload.length > 1;
-
-			activity.icon = icons[activity.activityType];
-
-			if (activity.activityType === "created_account") {
-				activity.message = "Welcome to Musare!";
-				return cb(activity);
-			}
-			if (activity.activityType === "created_station") {
-				this.socket.emit(
-					"stations.getStationForActivity",
-					activity.payload[0],
-					res => {
-						if (res.status === "success") {
-							activity.message = `Created the station <strong>${res.data.title}</strong>`;
-							activity.thumbnail = res.data.thumbnail;
-							return cb(activity);
-						}
-						activity.message = "Created a station";
-						return cb(activity);
-					}
-				);
-			}
-			if (activity.activityType === "deleted_station") {
-				activity.message = `Deleted a station`;
-				return cb(activity);
-			}
-			if (activity.activityType === "created_playlist") {
-				this.socket.emit(
-					"playlists.getPlaylistForActivity",
-					activity.payload[0],
-					res => {
-						if (res.status === "success") {
-							activity.message = `Created the playlist <strong>${res.data.title}</strong>`;
-							// activity.thumbnail = res.data.thumbnail;
-							return cb(activity);
-						}
-						activity.message = "Created a playlist";
-						return cb(activity);
-					}
-				);
-			}
-			if (activity.activityType === "deleted_playlist") {
-				activity.message = `Deleted a playlist`;
-				return cb(activity);
-			}
-			if (activity.activityType === "liked_song") {
-				if (plural) {
-					activity.message = `Liked ${activity.payload.length} songs.`;
-					return cb(activity);
-				}
-				this.socket.emit(
-					"songs.getSongForActivity",
-					activity.payload[0],
-					res => {
-						if (res.status === "success") {
-							activity.message = `Liked the song <strong>${res.data.title}</strong>`;
-							activity.thumbnail = res.data.thumbnail;
-							return cb(activity);
-						}
-						activity.message = "Liked a song";
-						return cb(activity);
-					}
-				);
-			}
-			if (activity.activityType === "added_song_to_playlist") {
-				this.socket.emit(
-					"songs.getSongForActivity",
-					activity.payload[0].songId,
-					song => {
-						console.log(song);
-						this.socket.emit(
-							"playlists.getPlaylistForActivity",
-							activity.payload[0].playlistId,
-							playlist => {
-								if (song.status === "success") {
-									if (playlist.status === "success")
-										activity.message = `Added the song <strong>${song.data.title}</strong> to the playlist <strong>${playlist.data.title}</strong>`;
-									else
-										activity.message = `Added the song <strong>${song.data.title}</strong> to a playlist`;
-									activity.thumbnail = song.data.thumbnail;
-									return cb(activity);
-								}
-								if (playlist.status === "success") {
-									activity.message = `Added a song to the playlist <strong>${playlist.data.title}</strong>`;
-									return cb(activity);
-								}
-								activity.message = "Added a song to a playlist";
-								return cb(activity);
-							}
-						);
-					}
-				);
-			}
-			if (activity.activityType === "added_songs_to_playlist") {
-				activity.message = `Added ${activity.payload.length} songs to a playlist`;
-				return cb(activity);
-			}
-			return false;
-		},
-		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("user/playlists", ["editPlaylist"])
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-@media only screen and (max-width: 750px) {
-	.info-section {
-		margin-top: 0 !important;
-
-		.picture-name-row {
-			flex-direction: column !important;
-		}
-
-		.name-role-row {
-			margin-top: 24px;
-		}
-
-		.buttons .button:not(:last-of-type) {
-			margin-bottom: 10px;
-			margin-right: 5px;
-		}
-
-		.date-location-row {
-			flex-direction: column;
-			width: auto !important;
-		}
-
-		.date-location-row > div:nth-child(2),
-		.buttons .button:nth-child(2) {
-			margin-left: 0 !important;
-		}
-	}
-
-	.bottom-section {
-		flex-direction: column;
-	}
-
-	.content {
-		margin: 24px 0;
-	}
-}
-
-.info-section {
-	width: 912px;
-	max-width: 100%;
-	margin-left: auto;
-	margin-right: auto;
-	margin-top: 32px;
-	padding: 24px;
-
-	.picture-name-row {
-		display: flex;
-		flex-direction: row;
-		align-items: center;
-		justify-content: center;
-		margin-bottom: 24px;
-
-		.profile-picture {
-			margin-right: 32px;
-		}
-	}
-
-	.name-role-row {
-		display: flex;
-		flex-direction: row;
-		align-items: center;
-	}
-
-	.name {
-		font-size: 34px;
-		line-height: 40px;
-		color: var(--dark-grey-4);
-	}
-
-	.role {
-		padding: 2px 24px;
-		color: var(--white);
-		text-transform: uppercase;
-		font-size: 12px;
-		line-height: 14px;
-		height: 18px;
-		border-radius: 5px;
-		margin-left: 12px;
-
-		&.admin {
-			background-color: var(--red);
-		}
-	}
-
-	.username {
-		font-size: 24px;
-		line-height: 28px;
-		color: var(--dark-grey);
-		margin: 0;
-	}
-
-	.buttons {
-		width: 388px;
-		max-width: 100%;
-		display: flex;
-		flex-direction: row;
-		margin-left: auto;
-		margin-right: auto;
-		margin-bottom: 24px;
-
-		.button {
-			flex: 1;
-			font-size: 17px;
-			line-height: 20px;
-
-			&:nth-child(2) {
-				margin-left: 20px;
-			}
-		}
-	}
-
-	.bio-row,
-	.date-location-row {
-		i {
-			font-size: 24px;
-			color: var(--dark-grey-2);
-			margin-right: 12px;
-		}
-
-		p {
-			font-size: 17px;
-			line-height: 20px;
-			color: var(--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;
-	}
-
-	.date-location-row {
-		max-width: 608px;
-		margin-left: auto;
-		margin-right: auto;
-		margin-bottom: 24px;
-		display: flex;
-		width: max-content;
-		margin-bottom: 24px;
-
-		> div:nth-child(2) {
-			margin-left: 48px;
-		}
-	}
-
-	.date,
-	.location {
-		display: flex;
-	}
-}
-
-.bottom-section {
-	width: 962px;
-	max-width: 100%;
-	margin-left: auto;
-	margin-right: auto;
-	padding: 24px;
-	display: flex;
-
-	.buttons {
-		height: 100%;
-		width: 250px;
-		margin-right: 64px;
-
-		button {
-			outline: none;
-			border: none;
-			box-shadow: none;
-			color: var(--primary-color);
-			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: var(--white);
-				background-color: var(--primary-color);
-			}
-		}
-	}
-
-	.content {
-		width: 600px;
-		max-width: 100%;
-		background-color: var(--white);
-		padding: 30px 50px;
-		border-radius: 3px;
-
-		h3 {
-			font-weight: 400;
-		}
-
-		.item {
-			overflow: hidden;
-
-			&:not(:last-of-type) {
-				margin-bottom: 10px;
-			}
-
-			/** temp code - will be put into a separate component */
-
-			&.activity-item {
-				height: 72px;
-				border: 0.5px var(--light-grey-3) solid;
-				border-radius: 3px;
-
-				.thumbnail {
-					position: relative;
-					display: flex;
-					align-items: center;
-					justify-content: center;
-					width: 70.5px;
-					height: 70.5px;
-
-					img {
-						opacity: 0.4;
-					}
-
-					.activity-type-icon {
-						position: absolute;
-						color: var(--dark-grey);
-						font-size: 30px;
-					}
-				}
-
-				.left-part {
-					flex: 1;
-					padding: 12px;
-
-					.item-title {
-						margin: 0;
-					}
-				}
-
-				.universal-item-actions a {
-					border-bottom: 0;
-				}
-			}
-		}
-
-		#create-new-playlist-button {
-			margin-top: 30px;
-			width: 100%;
-		}
-	}
-}
-
-.night-mode {
-	.name,
-	.username,
-	.bio-row i,
-	.bio-row p,
-	.date-location-row i,
-	.date-location-row p,
-	.item .left-part .top-text,
-	.item .left-part .bottom-text,
-	.bottom-section
-		.content
-		.item.activity-item
-		.thumbnail
-		.activity-type-icon {
-		color: var(--light-grey-2);
-	}
-}
-</style>

+ 389 - 0
frontend/src/pages/Profile/index.vue

@@ -0,0 +1,389 @@
+<template>
+	<div v-if="isUser">
+		<metadata :title="`Profile | ${user.username}`" />
+		<main-header />
+		<div class="container">
+			<div class="info-section">
+				<div class="picture-name-row">
+					<profile-picture :avatar="user.avatar" :name="user.name" />
+					<div>
+						<div class="name-role-row">
+							<p class="name">{{ user.name }}</p>
+							<span
+								class="role admin"
+								v-if="user.role === 'admin'"
+								>admin</span
+							>
+						</div>
+						<h2 class="username">@{{ user.username }}</h2>
+					</div>
+				</div>
+				<div
+					class="buttons"
+					v-if="myUserId === userId || role === 'admin'"
+				>
+					<router-link
+						:to="`/admin/users?userId=${user._id}`"
+						class="button is-primary"
+						v-if="role === 'admin'"
+					>
+						Edit
+					</router-link>
+					<router-link
+						to="/settings"
+						class="button is-primary"
+						v-if="myUserId === userId"
+					>
+						Settings
+					</router-link>
+				</div>
+				<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="location" v-if="user.location">
+						<i class="material-icons">location_on</i>
+						<p>{{ user.location }}</p>
+					</div>
+				</div>
+			</div>
+			<div class="bottom-section">
+				<div class="buttons">
+					<button
+						:class="{ active: tab === 'recent-activity' }"
+						@click="showTab('recent-activity')"
+					>
+						Recent activity
+					</button>
+					<button
+						:class="{ active: tab === 'playlists' }"
+						@click="showTab('playlists')"
+					>
+						Playlists
+					</button>
+				</div>
+				<playlists :user-id="userId" v-show="tab === 'playlists'" />
+				<recent-activity
+					:user-id="userId"
+					v-show="tab === 'recent-activity'"
+				/>
+			</div>
+		</div>
+		<main-footer />
+	</div>
+</template>
+
+<script>
+import { mapState } from "vuex";
+import { format, parseISO } from "date-fns";
+
+import TabQueryHandler from "../../mixins/TabQueryHandler.vue";
+
+import RecentActivity from "./tabs/RecentActivity.vue";
+import Playlists from "./tabs/Playlists.vue";
+
+import ProfilePicture from "../../components/ui/ProfilePicture.vue";
+import MainHeader from "../../components/layout/MainHeader.vue";
+import MainFooter from "../../components/layout/MainFooter.vue";
+
+import io from "../../io";
+
+export default {
+	components: {
+		MainHeader,
+		MainFooter,
+		ProfilePicture,
+		RecentActivity,
+		Playlists
+	},
+	mixins: [TabQueryHandler],
+	data() {
+		return {
+			user: {},
+			userId: "",
+			isUser: false,
+			tab: "recent-activity"
+		};
+	},
+	computed: {
+		...mapState({
+			role: state => state.user.auth.role,
+			myUserId: state => state.user.auth.userId
+		})
+	},
+	mounted() {
+		if (
+			this.$route.query.tab === "recent-activity" ||
+			this.$route.query.tab === "playlists"
+		)
+			this.tab = this.$route.query.tab;
+
+		io.getSocket(socket => {
+			this.socket = socket;
+
+			this.socket.emit(
+				"users.findByUsername",
+				this.$route.params.username,
+				res => {
+					if (res.status === "error") this.$router.go("/404");
+					else {
+						this.user = res.data;
+
+						this.user.createdAt = format(
+							parseISO(this.user.createdAt),
+							"MMMM do yyyy"
+						);
+
+						this.isUser = true;
+						this.userId = this.user._id;
+					}
+				}
+			);
+		});
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+@media only screen and (max-width: 750px) {
+	.info-section {
+		margin-top: 0 !important;
+
+		.picture-name-row {
+			flex-direction: column !important;
+		}
+
+		.name-role-row {
+			margin-top: 24px;
+		}
+
+		.buttons .button:not(:last-of-type) {
+			margin-bottom: 10px;
+			margin-right: 5px;
+		}
+
+		.date-location-row {
+			flex-direction: column;
+			width: auto !important;
+		}
+
+		.date-location-row > div:nth-child(2),
+		.buttons .button:nth-child(2) {
+			margin-left: 0 !important;
+		}
+	}
+
+	.bottom-section {
+		flex-direction: column;
+	}
+
+	.content {
+		margin: 24px 0;
+	}
+}
+
+.info-section {
+	width: 912px;
+	max-width: 100%;
+	margin-left: auto;
+	margin-right: auto;
+	margin-top: 32px;
+	padding: 24px;
+
+	.picture-name-row {
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+		justify-content: center;
+		margin-bottom: 24px;
+
+		.profile-picture {
+			margin-right: 32px;
+		}
+	}
+
+	.name-role-row {
+		display: flex;
+		flex-direction: row;
+		align-items: center;
+	}
+
+	.name {
+		font-size: 34px;
+		line-height: 40px;
+		color: var(--dark-grey-4);
+	}
+
+	.role {
+		padding: 2px 24px;
+		color: var(--white);
+		text-transform: uppercase;
+		font-size: 12px;
+		line-height: 14px;
+		height: 18px;
+		border-radius: 5px;
+		margin-left: 12px;
+
+		&.admin {
+			background-color: var(--red);
+		}
+	}
+
+	.username {
+		font-size: 24px;
+		line-height: 28px;
+		color: var(--dark-grey);
+		margin: 0;
+	}
+
+	.buttons {
+		width: 388px;
+		max-width: 100%;
+		display: flex;
+		flex-direction: row;
+		margin-left: auto;
+		margin-right: auto;
+		margin-bottom: 24px;
+
+		.button {
+			flex: 1;
+			font-size: 17px;
+			line-height: 20px;
+
+			&:nth-child(2) {
+				margin-left: 20px;
+			}
+		}
+	}
+
+	.bio-row,
+	.date-location-row {
+		i {
+			font-size: 24px;
+			color: var(--dark-grey-2);
+			margin-right: 12px;
+		}
+
+		p {
+			font-size: 17px;
+			line-height: 20px;
+			color: var(--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;
+	}
+
+	.date-location-row {
+		max-width: 608px;
+		margin-left: auto;
+		margin-right: auto;
+		margin-bottom: 24px;
+		display: flex;
+		width: max-content;
+		margin-bottom: 24px;
+
+		> div:nth-child(2) {
+			margin-left: 48px;
+		}
+	}
+
+	.date,
+	.location {
+		display: flex;
+	}
+}
+
+.bottom-section {
+	width: 962px;
+	max-width: 100%;
+	margin-left: auto;
+	margin-right: auto;
+	padding: 24px;
+	display: flex;
+
+	.buttons {
+		height: 100%;
+		width: 250px;
+		margin-right: 64px;
+
+		button {
+			outline: none;
+			border: none;
+			box-shadow: none;
+			color: var(--primary-color);
+			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: var(--white);
+				background-color: var(--primary-color);
+			}
+		}
+	}
+
+	.content /deep/ {
+		width: 600px;
+		max-width: 100%;
+		background-color: var(--white);
+		padding: 30px 50px;
+		border-radius: 3px;
+
+		h3 {
+			font-weight: 400;
+		}
+
+		.item {
+			overflow: hidden;
+
+			&:not(:last-of-type) {
+				margin-bottom: 10px;
+			}
+		}
+
+		#create-new-playlist-button {
+			margin-top: 30px;
+			width: 100%;
+		}
+	}
+}
+
+.night-mode {
+	.name,
+	.username,
+	.bio-row i,
+	.bio-row p,
+	.date-location-row i,
+	.date-location-row p,
+	.item .left-part .top-text,
+	.item .left-part .bottom-text,
+	.bottom-section
+		.content
+		.item.activity-item
+		.thumbnail
+		.activity-type-icon {
+		color: var(--light-grey-2);
+	}
+}
+</style>

+ 228 - 0
frontend/src/pages/Profile/tabs/Playlists.vue

@@ -0,0 +1,228 @@
+<template>
+	<div class="content playlists-tab">
+		<edit-playlist v-if="modals.editPlaylist" />
+		<create-playlist v-if="modals.createPlaylist" />
+
+		<div v-if="playlists.length > 0">
+			<h4 class="section-title">
+				{{ myUserId === userId ? "My" : null }}
+				Playlists
+			</h4>
+
+			<p class="section-description">
+				View
+				{{
+					userId === myUserId
+						? "and manage your personal"
+						: `${username}'s`
+				}}
+				playlists.
+			</p>
+
+			<hr class="section-horizontal-rule" />
+
+			<draggable
+				class="menu-list scrollable-list"
+				v-if="playlists.length > 0"
+				v-model="playlists"
+				v-bind="dragOptions"
+				@start="drag = true"
+				@end="drag = false"
+				@change="savePlaylistOrder"
+			>
+				<transition-group
+					type="transition"
+					:name="!drag ? 'draggable-list-transition' : null"
+				>
+					<div
+						class="item item-draggable"
+						v-for="playlist in playlists"
+						:key="playlist._id"
+					>
+						<playlist-item
+							v-if="
+								playlist.privacy === 'public' ||
+									(playlist.privacy === 'private' &&
+										playlist.createdBy === userId)
+							"
+							:playlist="playlist"
+						>
+							<div slot="actions">
+								<i
+									v-if="myUserId === userId"
+									@click="showPlaylist(playlist._id)"
+									class="material-icons edit-icon"
+									>edit</i
+								>
+								<i
+									v-else
+									@click="showPlaylist(playlist._id)"
+									class="material-icons view-icon"
+									>visibility</i
+								>
+							</div>
+						</playlist-item>
+					</div>
+				</transition-group>
+			</draggable>
+
+			<button
+				v-if="myUserId === userId"
+				class="button is-primary"
+				id="create-new-playlist-button"
+				@click="
+					openModal({
+						sector: 'station',
+						modal: 'createPlaylist'
+					})
+				"
+			>
+				Create new playlist
+			</button>
+		</div>
+		<div v-else>
+			<h3>No playlists here.</h3>
+		</div>
+	</div>
+</template>
+
+<script>
+import draggable from "vuedraggable";
+import { mapActions, mapState } from "vuex";
+
+import io from "../../../io";
+
+import SortablePlaylists from "../../../mixins/SortablePlaylists.vue";
+import PlaylistItem from "../../../components/ui/PlaylistItem.vue";
+
+export default {
+	components: {
+		PlaylistItem,
+		draggable,
+		CreatePlaylist: () =>
+			import("../../../components/modals/CreatePlaylist.vue"),
+		EditPlaylist: () =>
+			import("../../../components/modals/EditPlaylist/index.vue")
+	},
+	mixins: [SortablePlaylists],
+	props: {
+		userId: {
+			type: String,
+			default: ""
+		}
+	},
+	computed: {
+		...mapState({
+			...mapState("modalVisibility", {
+				modals: state => state.modals.station
+			}),
+			myUserId: state => state.user.auth.userId,
+			username: state => state.user.auth.username
+		}),
+		playlists: {
+			get() {
+				return this.$store.state.user.playlists.playlists;
+			},
+			set(playlists) {
+				this.$store.commit("user/playlists/setPlaylists", playlists);
+			}
+		}
+	},
+	mounted() {
+		if (
+			this.$route.query.tab === "recent-activity" ||
+			this.$route.query.tab === "playlists"
+		)
+			this.tab = this.$route.query.tab;
+
+		io.getSocket(socket => {
+			this.socket = socket;
+
+			if (this.myUserId !== this.userId) {
+				this.socket.emit(
+					"apis.joinRoom",
+					`profile-${this.userId}-playlists`,
+					() => {}
+				);
+			}
+
+			this.socket.emit("playlists.indexForUser", this.userId, res => {
+				if (res.status === "success") this.setPlaylists(res.data);
+				this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database
+			});
+
+			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.songId === 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;
+					}
+				});
+			});
+
+			this.socket.on("event:playlist.updatePrivacy", data => {
+				this.playlists.forEach((playlist, index) => {
+					if (playlist._id === data.playlist._id) {
+						this.playlists[index].privacy = data.playlist.privacy;
+					}
+				});
+			});
+
+			this.socket.on(
+				"event:user.orderOfPlaylists.changed",
+				orderOfPlaylists => {
+					const sortedPlaylists = [];
+
+					this.playlists.forEach(playlist => {
+						sortedPlaylists[
+							orderOfPlaylists.indexOf(playlist._id)
+						] = playlist;
+					});
+
+					this.playlists = sortedPlaylists;
+					this.orderOfPlaylists = this.calculatePlaylistOrder();
+				}
+			);
+		});
+	},
+	methods: {
+		showPlaylist(playlistId) {
+			this.editPlaylist(playlistId);
+			this.openModal({ sector: "station", modal: "editPlaylist" });
+		},
+		...mapActions("modalVisibility", ["openModal"]),
+		...mapActions("user/playlists", ["editPlaylist", "setPlaylists"])
+	}
+};
+</script>

+ 241 - 0
frontend/src/pages/Profile/tabs/RecentActivity.vue

@@ -0,0 +1,241 @@
+<template>
+	<div class="content recent-activity-tab">
+		<div v-if="activities.length > 0">
+			<h4 class="section-title">Recent activity</h4>
+
+			<p class="section-description">
+				This is a log of all actions
+				{{ userId === myUserId ? "you have" : `${username} has` }}
+				taken recently.
+			</p>
+
+			<hr class="section-horizontal-rule" />
+
+			<activity-item
+				class="item activity-item universal-item"
+				v-for="activity in sortedActivities"
+				:key="activity._id"
+				:activity="activity"
+			>
+				<div slot="actions">
+					<a
+						v-if="userId === myUserId"
+						href="#"
+						@click.prevent="hideActivity(activity._id)"
+					>
+						<i class="material-icons hide-icon">visibility_off</i>
+					</a>
+				</div>
+			</activity-item>
+		</div>
+		<div v-else>
+			<h3>No recent activity.</h3>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapState, mapActions } from "vuex";
+import Toast from "toasters";
+
+import io from "../../../io";
+
+import ActivityItem from "../../../components/ui/ActivityItem.vue";
+
+export default {
+	components: { ActivityItem },
+	props: {
+		userId: {
+			type: String,
+			default: ""
+		}
+	},
+	computed: {
+		sortedActivities() {
+			const { activities } = this;
+			return activities.sort(
+				(x, y) => new Date(y.createdAt) - new Date(x.createdAt)
+			);
+		},
+		...mapState({
+			activities: state => state.user.activities.activities,
+			...mapState("modalVisibility", {
+				modals: state => state.modals.station
+			}),
+			myUserId: state => state.user.auth.userId,
+			username: state => state.user.auth.username
+		})
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+
+			if (this.myUserId !== this.userId) {
+				this.socket.emit(
+					"apis.joinRoom",
+					`profile-${this.userId}-activities`,
+					() => {}
+				);
+			}
+
+			this.socket.emit("activities.getSet", this.userId, 1, res => {
+				if (res.status === "success") {
+					// for (let a = 0; a < res.data.length; a += 1) {
+					// 	this.formatActivity(res.data[a], activity => {
+					// 		this.activities.unshift(activity);
+					// 	});
+					// }
+					this.getSetOfActivities({
+						activities: res.data,
+						set: 1
+					});
+				}
+			});
+
+			this.socket.on("event:activity.create", activity => {
+				console.log("activity created (socket event): ", activity);
+				this.formatActivity(activity, activity => {
+					this.activities.unshift(activity);
+				});
+			});
+
+			this.socket.on("event:activity.hide", activityId => {
+				this.removeActivity(activityId);
+			});
+		});
+	},
+	methods: {
+		hideActivity(activityId) {
+			this.socket.emit("activities.hideActivity", activityId, res => {
+				if (res.status !== "success")
+					new Toast({ content: res.message, timeout: 3000 });
+			});
+		},
+		formatActivity(res, cb) {
+			console.log("activity", res);
+
+			const icons = {
+				created_account: "account_circle",
+				created_station: "radio",
+				deleted_station: "delete",
+				created_playlist: "playlist_add_check",
+				deleted_playlist: "delete_sweep",
+				liked_song: "favorite",
+				added_song_to_playlist: "playlist_add",
+				added_songs_to_playlist: "playlist_add"
+			};
+
+			const activity = {
+				...res,
+				thumbnail: "",
+				message: "",
+				icon: ""
+			};
+
+			const plural = activity.payload.length > 1;
+
+			activity.icon = icons[activity.activityType];
+
+			if (activity.activityType === "created_account") {
+				activity.message = "Welcome to Musare!";
+				return cb(activity);
+			}
+			if (activity.activityType === "created_station") {
+				this.socket.emit(
+					"stations.getStationForActivity",
+					activity.payload[0],
+					res => {
+						if (res.status === "success") {
+							activity.message = `Created the station <strong>${res.data.title}</strong>`;
+							activity.thumbnail = res.data.thumbnail;
+							return cb(activity);
+						}
+						activity.message = "Created a station";
+						return cb(activity);
+					}
+				);
+			}
+			if (activity.activityType === "deleted_station") {
+				activity.message = `Deleted a station`;
+				return cb(activity);
+			}
+			if (activity.activityType === "created_playlist") {
+				this.socket.emit(
+					"playlists.getPlaylistForActivity",
+					activity.payload[0],
+					res => {
+						if (res.status === "success") {
+							activity.message = `Created the playlist <strong>${res.data.title}</strong>`;
+							// activity.thumbnail = res.data.thumbnail;
+							return cb(activity);
+						}
+						activity.message = "Created a playlist";
+						return cb(activity);
+					}
+				);
+			}
+			if (activity.activityType === "deleted_playlist") {
+				activity.message = `Deleted a playlist`;
+				return cb(activity);
+			}
+			if (activity.activityType === "liked_song") {
+				if (plural) {
+					activity.message = `Liked ${activity.payload.length} songs.`;
+					return cb(activity);
+				}
+				this.socket.emit(
+					"songs.getSongForActivity",
+					activity.payload[0],
+					res => {
+						if (res.status === "success") {
+							activity.message = `Liked the song <strong>${res.data.title}</strong>`;
+							activity.thumbnail = res.data.thumbnail;
+							return cb(activity);
+						}
+						activity.message = "Liked a song";
+						return cb(activity);
+					}
+				);
+			}
+			if (activity.activityType === "added_song_to_playlist") {
+				this.socket.emit(
+					"songs.getSongForActivity",
+					activity.payload[0].songId,
+					song => {
+						console.log(song);
+						this.socket.emit(
+							"playlists.getPlaylistForActivity",
+							activity.payload[0].playlistId,
+							playlist => {
+								if (song.status === "success") {
+									if (playlist.status === "success")
+										activity.message = `Added the song <strong>${song.data.title}</strong> to the playlist <strong>${playlist.data.title}</strong>`;
+									else
+										activity.message = `Added the song <strong>${song.data.title}</strong> to a playlist`;
+									activity.thumbnail = song.data.thumbnail;
+									return cb(activity);
+								}
+								if (playlist.status === "success") {
+									activity.message = `Added a song to the playlist <strong>${playlist.data.title}</strong>`;
+									return cb(activity);
+								}
+								activity.message = "Added a song to a playlist";
+								return cb(activity);
+							}
+						);
+					}
+				);
+			}
+			if (activity.activityType === "added_songs_to_playlist") {
+				activity.message = `Added ${activity.payload.length} songs to a playlist`;
+				return cb(activity);
+			}
+			return false;
+		},
+		...mapActions("user/activities", [
+			"getSetOfActivities",
+			"removeActivity"
+		])
+	}
+};
+</script>

+ 6 - 6
frontend/src/pages/Settings/tabs/Account.vue

@@ -72,7 +72,7 @@ import validation from "../../../validation";
 import io from "../../../io";
 
 import InputHelpBox from "../../../components/ui/InputHelpBox.vue";
-import SaveButton from "../mixins/SaveButton.vue";
+import SaveButton from "../../../mixins/SaveButton.vue";
 
 export default {
 	components: { InputHelpBox },
@@ -158,7 +158,7 @@ export default {
 			if (emailAddressChanged) this.changeEmail();
 
 			if (!usernameChanged && !emailAddressChanged) {
-				this.failedSave();
+				this.handleFailedSave();
 
 				new Toast({
 					content: "Please make a change before saving.",
@@ -191,7 +191,7 @@ export default {
 				res => {
 					if (res.status !== "success") {
 						new Toast({ content: res.message, timeout: 8000 });
-						this.failedSave();
+						this.handleFailedSave();
 					} else {
 						new Toast({
 							content: "Successfully changed email address",
@@ -203,7 +203,7 @@ export default {
 							value: email
 						});
 
-						this.successfulSave();
+						this.handleSuccessfulSave();
 					}
 				}
 			);
@@ -233,7 +233,7 @@ export default {
 				res => {
 					if (res.status !== "success") {
 						new Toast({ content: res.message, timeout: 8000 });
-						this.failedSave();
+						this.handleFailedSave();
 					} else {
 						new Toast({
 							content: "Successfully changed username",
@@ -245,7 +245,7 @@ export default {
 							value: username
 						});
 
-						this.successfulSave();
+						this.handleSuccessfulSave();
 					}
 				}
 			);

+ 28 - 9
frontend/src/pages/Settings/tabs/Preferences.vue

@@ -24,6 +24,17 @@
 				<p>Automatically vote to skip disliked songs</p>
 			</label>
 		</p>
+		<p class="control is-expanded checkbox-control">
+			<input
+				type="checkbox"
+				id="activityLogPublic"
+				v-model="localActivityLogPublic"
+			/>
+			<label for="activityLogPublic">
+				<span></span>
+				<p>Allow my activity log to be viewed publicly</p>
+			</label>
+		</p>
 		<transition name="saving-changes-transition" mode="out-in">
 			<button
 				class="button save-changes"
@@ -42,19 +53,21 @@ import { mapState, mapActions } from "vuex";
 import Toast from "toasters";
 
 import io from "../../../io";
-import SaveButton from "../mixins/SaveButton.vue";
+import SaveButton from "../../../mixins/SaveButton.vue";
 
 export default {
 	mixins: [SaveButton],
 	data() {
 		return {
 			localNightmode: false,
-			localAutoSkipDisliked: false
+			localAutoSkipDisliked: false,
+			localActivityLogPublic: false
 		};
 	},
 	computed: mapState({
 		nightmode: state => state.user.preferences.nightmode,
-		autoSkipDisliked: state => state.user.preferences.autoSkipDisliked
+		autoSkipDisliked: state => state.user.preferences.autoSkipDisliked,
+		activityLogPublic: state => state.user.preferences.activityLogPublic
 	}),
 	mounted() {
 		io.getSocket(socket => {
@@ -64,12 +77,15 @@ export default {
 				if (res.status === "success") {
 					this.localNightmode = res.data.nightmode;
 					this.localAutoSkipDisliked = res.data.autoSkipDisliked;
+					this.localActivityLogPublic = res.data.activityLogPublic;
 				}
 			});
 
 			socket.on("keep.event:user.preferences.changed", preferences => {
+				console.log("changed");
 				this.localNightmode = preferences.nightmode;
 				this.localAutoSkipDisliked = preferences.autoSkipDisliked;
+				this.localActivityLogPublic = preferences.activityLogPublic;
 			});
 		});
 	},
@@ -77,14 +93,15 @@ export default {
 		saveChanges() {
 			if (
 				this.localNightmode === this.nightmode &&
-				this.localAutoSkipDisliked === this.autoSkipDisliked
+				this.localAutoSkipDisliked === this.autoSkipDisliked &&
+				this.localActivityLogPublic === this.activityLogPublic
 			) {
 				new Toast({
 					content: "Please make a change before saving.",
 					timeout: 5000
 				});
 
-				return this.failedSave();
+				return this.handleFailedSave();
 			}
 
 			this.saveStatus = "disabled";
@@ -93,13 +110,14 @@ export default {
 				"users.updatePreferences",
 				{
 					nightmode: this.localNightmode,
-					autoSkipDisliked: this.localAutoSkipDisliked
+					autoSkipDisliked: this.localAutoSkipDisliked,
+					activityLogPublic: this.localActivityLogPublic
 				},
 				res => {
 					if (res.status !== "success") {
 						new Toast({ content: res.message, timeout: 8000 });
 
-						return this.failedSave();
+						return this.handleFailedSave();
 					}
 
 					new Toast({
@@ -107,13 +125,14 @@ export default {
 						timeout: 4000
 					});
 
-					return this.successfulSave();
+					return this.handleSuccessfulSave();
 				}
 			);
 		},
 		...mapActions("user/preferences", [
 			"changeNightmode",
-			"changeAutoSkipDisliked"
+			"changeAutoSkipDisliked",
+			"changeActivityLogPublic"
 		])
 	}
 };

+ 10 - 10
frontend/src/pages/Settings/tabs/Profile.vue

@@ -88,7 +88,7 @@ import validation from "../../../validation";
 import io from "../../../io";
 
 import ProfilePicture from "../../../components/ui/ProfilePicture.vue";
-import SaveButton from "../mixins/SaveButton.vue";
+import SaveButton from "../../../mixins/SaveButton.vue";
 
 export default {
 	components: { ProfilePicture },
@@ -139,7 +139,7 @@ export default {
 				!locationChanged &&
 				!nameChanged
 			) {
-				this.failedSave();
+				this.handleFailedSave();
 
 				new Toast({
 					content: "Please make a change before saving.",
@@ -165,7 +165,7 @@ export default {
 				res => {
 					if (res.status !== "success") {
 						new Toast({ content: res.message, timeout: 8000 });
-						this.failedSave();
+						this.handleFailedSave();
 					} else {
 						new Toast({
 							content: "Successfully changed name",
@@ -177,7 +177,7 @@ export default {
 							value: name
 						});
 
-						this.successfulSave();
+						this.handleSuccessfulSave();
 					}
 				}
 			);
@@ -200,7 +200,7 @@ export default {
 				res => {
 					if (res.status !== "success") {
 						new Toast({ content: res.message, timeout: 8000 });
-						this.failedSave();
+						this.handleFailedSave();
 					} else {
 						new Toast({
 							content: "Successfully changed location",
@@ -212,7 +212,7 @@ export default {
 							value: location
 						});
 
-						this.successfulSave();
+						this.handleSuccessfulSave();
 					}
 				}
 			);
@@ -235,7 +235,7 @@ export default {
 				res => {
 					if (res.status !== "success") {
 						new Toast({ content: res.message, timeout: 8000 });
-						this.failedSave();
+						this.handleFailedSave();
 					} else {
 						new Toast({
 							content: "Successfully changed bio",
@@ -247,7 +247,7 @@ export default {
 							value: bio
 						});
 
-						this.successfulSave();
+						this.handleSuccessfulSave();
 					}
 				}
 			);
@@ -264,7 +264,7 @@ export default {
 				res => {
 					if (res.status !== "success") {
 						new Toast({ content: res.message, timeout: 8000 });
-						this.failedSave();
+						this.handleFailedSave();
 					} else {
 						new Toast({
 							content: "Successfully updated avatar type",
@@ -276,7 +276,7 @@ export default {
 							value: avatar
 						});
 
-						this.successfulSave();
+						this.handleSuccessfulSave();
 					}
 				}
 			);

+ 7 - 2
frontend/src/pages/Station/components/Sidebar/Users.vue

@@ -6,7 +6,8 @@
 			<h6
 				class="has-text-centered"
 				v-if="
-					users &&
+					users.loggedIn &&
+						users.loggedOut &&
 						((users.loggedIn.length === 1 &&
 							users.loggedOut.length === 0) ||
 							(users.loggedIn.length === 0 &&
@@ -18,7 +19,11 @@
 			</h6>
 			<h6
 				class="has-text-centered"
-				v-else-if="users && users.loggedOut.length > 0"
+				v-else-if="
+					users.loggedIn &&
+						users.loggedOut &&
+						users.loggedOut.length > 0
+				"
 				key="logged-out-users"
 			>
 				{{ users.loggedOut.length }}

+ 27 - 3
frontend/src/pages/Station/index.vue

@@ -518,13 +518,13 @@ export default {
 		ContentLoader,
 		MainHeader,
 		MainFooter,
-		SongQueue: () => import("./AddSongToQueue.vue"),
+		SongQueue: () => import("../../components/modals/AddSongToQueue.vue"),
 		EditPlaylist: () =>
 			import("../../components/modals/EditPlaylist/index.vue"),
 		CreatePlaylist: () =>
 			import("../../components/modals/CreatePlaylist.vue"),
 		EditStation: () => import("../../components/modals/EditStation.vue"),
-		Report: () => import("./Report.vue"),
+		Report: () => import("../../components/modals/Report.vue"),
 		Z404,
 		FloatingBox,
 		CurrentlyPlaying,
@@ -769,10 +769,34 @@ export default {
 				}
 			});
 
-			this.socket.on("event:theme.updated", theme => {
+			this.socket.on("event:station.themeUpdated", theme => {
 				this.station.theme = theme;
 			});
 
+			this.socket.on("event:station.updateName", res => {
+				this.station.name = res.name;
+				// eslint-disable-next-line no-restricted-globals
+				history.pushState(
+					{},
+					null,
+					`${res.name}?${Object.keys(this.$route.query)
+						.map(key => {
+							return `${encodeURIComponent(
+								key
+							)}=${encodeURIComponent(this.$route.query[key])}`;
+						})
+						.join("&")}`
+				);
+			});
+
+			this.socket.on("event:station.updateDisplayName", res => {
+				this.station.displayName = res.displayName;
+			});
+
+			this.socket.on("event:station.updateDescription", res => {
+				this.station.description = res.description;
+			});
+
 			this.socket.on("event:newOfficialPlaylist", playlist => {
 				if (this.station.type === "official")
 					this.updateSongsList(playlist);

+ 44 - 4
frontend/src/store/modules/user.js

@@ -199,18 +199,51 @@ const modules = {
 			}
 		}
 	},
+	activities: {
+		namespaced: true,
+		state: {
+			activities: [],
+			sets: []
+		},
+		actions: {
+			getSetOfActivities: ({ commit }, data) =>
+				commit("getSetOfActivities", data),
+			removeActivity: ({ commit }, activityId) =>
+				commit("removeActivity", activityId)
+		},
+		mutations: {
+			getSetOfActivities(state, data) {
+				const { activities, set } = data;
+
+				if (!state.sets.includes(set)) {
+					state.activities.push(...activities);
+					state.sets.push(set);
+				}
+			},
+			removeActivity(state, activityId) {
+				state.activities = state.activities.filter(
+					activity => activity._id !== activityId
+				);
+			}
+		}
+	},
 	playlists: {
 		namespaced: true,
 		state: {
-			editing: ""
+			editing: "",
+			playlists: []
 		},
-		getters: {},
 		actions: {
-			editPlaylist: ({ commit }, id) => commit("editPlaylist", id)
+			editPlaylist: ({ commit }, id) => commit("editPlaylist", id),
+			setPlaylists: ({ commit }, playlists) =>
+				commit("setPlaylists", playlists)
 		},
 		mutations: {
 			editPlaylist(state, id) {
 				state.editing = id;
+			},
+			setPlaylists(state, playlists) {
+				state.playlists = playlists;
 			}
 		}
 	},
@@ -218,7 +251,8 @@ const modules = {
 		namespaced: true,
 		state: {
 			nightmode: false,
-			autoSkipDisliked: true
+			autoSkipDisliked: true,
+			activityLogPublic: false
 		},
 		actions: {
 			changeNightmode: ({ commit }, nightmode) => {
@@ -226,6 +260,9 @@ const modules = {
 			},
 			changeAutoSkipDisliked: ({ commit }, autoSkipDisliked) => {
 				commit("changeAutoSkipDisliked", autoSkipDisliked);
+			},
+			changeActivityLogPublic: ({ commit }, activityLogPublic) => {
+				commit("changeActivityLogPublic", activityLogPublic);
 			}
 		},
 		mutations: {
@@ -234,6 +271,9 @@ const modules = {
 			},
 			changeAutoSkipDisliked(state, autoSkipDisliked) {
 				state.autoSkipDisliked = autoSkipDisliked;
+			},
+			changeActivityLogPublic(state, activityLogPublic) {
+				state.activityLogPublic = activityLogPublic;
 			}
 		}
 	}