1
0
Эх сурвалжийг харах

Merge remote-tracking branch 'origin/polishing' into owen

Owen Diffey 4 жил өмнө
parent
commit
8a0e7f89aa
96 өөрчлөгдсөн 6601 нэмэгдсэн , 2617 устгасан
  1. 3 1
      backend/core.js
  2. 11 9
      backend/logic/actions/apis.js
  3. 308 139
      backend/logic/actions/reports.js
  4. 100 139
      backend/logic/actions/songs.js
  5. 30 83
      backend/logic/actions/stations.js
  6. 29 28
      backend/logic/actions/users.js
  7. 11 1
      backend/logic/activities.js
  8. 2 11
      backend/logic/db/index.js
  9. 2 1
      backend/logic/db/schemas/activity.js
  10. 9 4
      backend/logic/db/schemas/report.js
  11. 1 5
      backend/logic/db/schemas/station.js
  12. 67 0
      backend/logic/migration/migrations/migration12.js
  13. 60 0
      backend/logic/migration/migrations/migration13.js
  14. 65 0
      backend/logic/migration/migrations/migration14.js
  15. 24 3
      backend/logic/songs.js
  16. 15 13
      backend/logic/stations.js
  17. 21 0
      backend/logic/ws.js
  18. 4 0
      frontend/dist/index.css
  19. 502 175
      frontend/package-lock.json
  20. 8 9
      frontend/package.json
  21. 203 92
      frontend/src/App.vue
  22. 36 4
      frontend/src/components/ActivityItem.vue
  23. 74 49
      frontend/src/components/AddToPlaylistDropdown.vue
  24. 42 32
      frontend/src/components/Confirm.vue
  25. 32 9
      frontend/src/components/Modal.vue
  26. 3 1
      frontend/src/components/PlaylistItem.vue
  27. 60 58
      frontend/src/components/Queue.vue
  28. 101 0
      frontend/src/components/ReportInfoItem.vue
  29. 1 0
      frontend/src/components/SaveButton.vue
  30. 62 54
      frontend/src/components/SongItem.vue
  31. 2 0
      frontend/src/components/SongThumbnail.vue
  32. 2 4
      frontend/src/components/layout/MainFooter.vue
  33. 70 14
      frontend/src/components/layout/MainHeader.vue
  34. 14 2
      frontend/src/components/modals/CreatePlaylist.vue
  35. 36 34
      frontend/src/components/modals/EditNews.vue
  36. 18 104
      frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue
  37. 117 0
      frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue
  38. 194 175
      frontend/src/components/modals/EditPlaylist/index.vue
  39. 26 1
      frontend/src/components/modals/EditSong/Tabs/Discogs.vue
  40. 412 12
      frontend/src/components/modals/EditSong/Tabs/Reports.vue
  41. 125 0
      frontend/src/components/modals/EditSong/Tabs/Youtube.vue
  42. 180 125
      frontend/src/components/modals/EditSong/index.vue
  43. 4 4
      frontend/src/components/modals/EditUser.vue
  44. 958 0
      frontend/src/components/modals/ImportAlbum.vue
  45. 16 6
      frontend/src/components/modals/Login.vue
  46. 4 3
      frontend/src/components/modals/ManageStation/Tabs/Blacklist.vue
  47. 85 80
      frontend/src/components/modals/ManageStation/Tabs/Playlists.vue
  48. 4 4
      frontend/src/components/modals/ManageStation/Tabs/Search.vue
  49. 182 172
      frontend/src/components/modals/ManageStation/Tabs/Settings.vue
  50. 477 0
      frontend/src/components/modals/ManageStation/Tabs/Songs.vue
  51. 1 2
      frontend/src/components/modals/ManageStation/index.vue
  52. 6 3
      frontend/src/components/modals/Register.vue
  53. 486 124
      frontend/src/components/modals/Report.vue
  54. 11 12
      frontend/src/components/modals/RequestSong.vue
  55. 2 3
      frontend/src/components/modals/ViewPunishment.vue
  56. 277 98
      frontend/src/components/modals/ViewReport.vue
  57. 5 4
      frontend/src/components/modals/WhatIsNew.vue
  58. 56 76
      frontend/src/main.js
  59. 3 5
      frontend/src/mixins/ScrollAndFetchHandler.vue
  60. 20 20
      frontend/src/mixins/SortablePlaylists.vue
  61. 20 11
      frontend/src/pages/Admin/index.vue
  62. 44 27
      frontend/src/pages/Admin/tabs/HiddenSongs.vue
  63. 4 1
      frontend/src/pages/Admin/tabs/News.vue
  64. 10 3
      frontend/src/pages/Admin/tabs/Playlists.vue
  65. 4 1
      frontend/src/pages/Admin/tabs/Punishments.vue
  66. 97 60
      frontend/src/pages/Admin/tabs/Reports.vue
  67. 22 16
      frontend/src/pages/Admin/tabs/Stations.vue
  68. 54 32
      frontend/src/pages/Admin/tabs/UnverifiedSongs.vue
  69. 5 2
      frontend/src/pages/Admin/tabs/Users.vue
  70. 51 32
      frontend/src/pages/Admin/tabs/VerifiedSongs.vue
  71. 66 67
      frontend/src/pages/Home.vue
  72. 38 39
      frontend/src/pages/Profile/Tabs/Playlists.vue
  73. 3 3
      frontend/src/pages/Profile/Tabs/RecentActivity.vue
  74. 17 6
      frontend/src/pages/Profile/index.vue
  75. 9 3
      frontend/src/pages/ResetPassword.vue
  76. 15 69
      frontend/src/pages/Settings/Tabs/Preferences.vue
  77. 28 20
      frontend/src/pages/Settings/Tabs/Profile.vue
  78. 19 18
      frontend/src/pages/Settings/index.vue
  79. 77 78
      frontend/src/pages/Station/Sidebar/Playlists.vue
  80. 1 1
      frontend/src/pages/Station/Sidebar/Users.vue
  81. 1 2
      frontend/src/pages/Station/Sidebar/index.vue
  82. 190 74
      frontend/src/pages/Station/index.vue
  83. 6 7
      frontend/src/store/index.js
  84. 9 2
      frontend/src/store/modules/admin.js
  85. 2 1
      frontend/src/store/modules/modalVisibility.js
  86. 0 3
      frontend/src/store/modules/modals/editPlaylist.js
  87. 15 4
      frontend/src/store/modules/modals/editSong.js
  88. 66 0
      frontend/src/store/modules/modals/importAlbum.js
  89. 8 2
      frontend/src/store/modules/modals/manageStation.js
  90. 4 21
      frontend/src/store/modules/modals/viewReport.js
  91. 13 0
      frontend/src/store/modules/station.js
  92. 19 1
      frontend/src/store/modules/user.js
  93. 1 0
      frontend/src/ws.js
  94. 1 1
      frontend/webpack.common.js
  95. 2 2
      frontend/webpack.dev.js
  96. 1 1
      tools/docker/setup-mongo.sh

+ 3 - 1
backend/core.js

@@ -625,7 +625,9 @@ export default class CoreClass {
 							}
 						} else if (
 							job.parentJob &&
-							job.parentJob.childJobs.find(childJob => childJob.status !== "FINISHED") === undefined
+							job.parentJob.childJobs.find(childJob =>
+								childJob ? childJob.status !== "FINISHED" : true
+							) === undefined
 						) {
 							if (job.parentJob.status !== "WAITING_ON_CHILD_JOB") {
 								this.log(

+ 11 - 9
backend/logic/actions/apis.js

@@ -119,20 +119,21 @@ export default {
 	 * Joins a room
 	 *
 	 * @param {object} session - user session
-	 * @param {string} page - the room to join
+	 * @param {string} room - the room to join
 	 * @param {Function} cb - callback
 	 */
-	joinRoom(session, page, cb) {
+	joinRoom(session, room, cb) {
 		if (
-			page === "home" ||
-			page === "news" ||
-			page.startsWith("profile.") ||
-			page.startsWith("manage-station.") ||
-			page.startsWith("edit-song.")
+			room === "home" ||
+			room === "news" ||
+			room.startsWith("profile.") ||
+			room.startsWith("manage-station.") ||
+			room.startsWith("edit-song.") ||
+			room.startsWith("view-report.")
 		) {
 			WSModule.runJob("SOCKET_JOIN_ROOM", {
 				socketId: session.socketId,
-				room: page
+				room
 			})
 				.then(() => {})
 				.catch(err => {
@@ -155,7 +156,8 @@ export default {
 			room === "home" ||
 			room.startsWith("profile.") ||
 			room.startsWith("manage-station.") ||
-			room.startsWith("edit-song.")
+			room.startsWith("edit-song.") ||
+			room.startsWith("view-report.")
 		) {
 			WSModule.runJob("SOCKET_LEAVE_ROOM", {
 				socketId: session.socketId,

+ 308 - 139
backend/logic/actions/reports.js

@@ -11,68 +11,100 @@ const SongsModule = moduleManager.modules.songs;
 const CacheModule = moduleManager.modules.cache;
 const ActivitiesModule = moduleManager.modules.activities;
 
-const reportableIssues = [
-	{
-		name: "Video",
-		reasons: ["Doesn't exist", "It's private", "It's not available in my country"]
-	},
-	{
-		name: "Title",
-		reasons: ["Incorrect", "Inappropriate"]
-	},
-	{
-		name: "Duration",
-		reasons: ["Skips too soon", "Skips too late", "Starts too soon", "Skips too late"]
-	},
-	{
-		name: "Artists",
-		reasons: ["Incorrect", "Inappropriate"]
-	},
-	{
-		name: "Thumbnail",
-		reasons: ["Incorrect", "Inappropriate", "Doesn't exist"]
-	}
-];
+CacheModule.runJob("SUB", {
+	channel: "report.issue.toggle",
+	cb: data =>
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`edit-song.${data.songId}`, `view-report.${data.reportId}`],
+			args: [
+				"event:admin.report.issue.toggled",
+				{ data: { issueId: data.issueId, reportId: data.reportId, resolved: data.resolved } }
+			]
+		})
+});
 
 CacheModule.runJob("SUB", {
 	channel: "report.resolve",
-	cb: reportId => {
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: "admin.reports",
+	cb: ({ reportId, songId }) =>
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: ["admin.reports", `edit-song.${songId}`, `view-report.${reportId}`],
 			args: ["event:admin.report.resolved", { data: { reportId } }]
-		});
-	}
+		})
 });
 
 CacheModule.runJob("SUB", {
 	channel: "report.create",
 	cb: report => {
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: "admin.reports",
-			args: ["event:admin.report.created", { data: { report } }]
+		console.log(report);
+
+		DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+			userModel
+				.findById(report.createdBy)
+				.select({ avatar: -1, name: -1, username: -1 })
+				.exec((err, { avatar, name, username }) => {
+					report.createdBy = {
+						avatar,
+						name,
+						username,
+						_id: report.createdBy
+					};
+
+					WSModule.runJob("EMIT_TO_ROOMS", {
+						rooms: ["admin.reports", `edit-song.${report.song._id}`],
+						args: ["event:admin.report.created", { data: { report } }]
+					});
+				});
 		});
 	}
 });
 
 export default {
 	/**
-	 * Gets all reports
+	 * Gets all reports that haven't been yet resolved
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
 	index: isAdminRequired(async function index(session, cb) {
-		const reportModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "report"
-			},
-			this
-		);
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
 		async.waterfall(
 			[
-				next => {
-					reportModel.find({ resolved: false }).sort({ released: "desc" }).exec(next);
+				next => reportModel.find({ resolved: false }).sort({ createdAt: "desc" }).exec(next),
+				(_reports, next) => {
+					const reports = [];
+
+					async.each(
+						_reports,
+						(report, cb) => {
+							console.log(typeof report.createdBy);
+
+							userModel
+								.findById(report.createdBy)
+								.select({ avatar: -1, name: -1, username: -1 })
+								.exec((err, user) => {
+									if (!user)
+										reports.push({
+											...report._doc,
+											createdBy: { _id: report.createdBy }
+										});
+									else
+										reports.push({
+											...report._doc,
+											createdBy: {
+												avatar: user.avatar,
+												name: user.name,
+												username: user.username,
+												_id: report.createdBy
+											}
+										});
+
+									return cb(err);
+								});
+						},
+						err => next(err, reports)
+					);
 				}
 			],
 			async (err, reports) => {
@@ -81,6 +113,7 @@ export default {
 					this.log("ERROR", "REPORTS_INDEX", `Indexing reports failed. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
+
 				this.log("SUCCESS", "REPORTS_INDEX", "Indexing reports successful.");
 				return cb({ status: "success", data: { reports } });
 			}
@@ -95,18 +128,33 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	findOne: isAdminRequired(async function findOne(session, reportId, cb) {
-		const reportModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "report"
-			},
-			this
-		);
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
 		async.waterfall(
 			[
-				next => {
-					reportModel.findOne({ _id: reportId }).exec(next);
-				}
+				next => reportModel.findOne({ _id: reportId }).exec(next),
+				(report, next) =>
+					userModel
+						.findById(report.createdBy)
+						.select({ avatar: -1, name: -1, username: -1 })
+						.exec((err, user) => {
+							if (!user)
+								next(err, {
+									...report._doc,
+									createdBy: { _id: report.createdBy }
+								});
+							else
+								next(err, {
+									...report._doc,
+									createdBy: {
+										avatar: user.avatar,
+										name: user.name,
+										username: user.username,
+										_id: report.createdBy
+									}
+								});
+						})
 			],
 			async (err, report) => {
 				if (err) {
@@ -114,6 +162,7 @@ export default {
 					this.log("ERROR", "REPORTS_FIND_ONE", `Finding report "${reportId}" failed. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
+
 				this.log("SUCCESS", "REPORTS_FIND_ONE", `Finding report "${reportId}" successful.`);
 				return cb({ status: "success", data: { report } });
 			}
@@ -128,28 +177,45 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	getReportsForSong: isAdminRequired(async function getReportsForSong(session, songId, cb) {
-		const reportModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "report"
-			},
-			this
-		);
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
 		async.waterfall(
 			[
-				next => {
-					reportModel
-						.find({ song: { _id: songId }, resolved: false })
-						.sort({ released: "desc" })
-						.exec(next);
-				},
+				next =>
+					reportModel.find({ "song._id": songId, resolved: false }).sort({ createdAt: "desc" }).exec(next),
 
 				(_reports, next) => {
 					const reports = [];
-					for (let i = 0; i < _reports.length; i += 1) {
-						data.push(_reports[i]._id);
-					}
-					next(null, reports);
+
+					async.each(
+						_reports,
+						(report, cb) => {
+							userModel
+								.findById(report.createdBy)
+								.select({ avatar: -1, name: -1, username: -1 })
+								.exec((err, user) => {
+									if (!user)
+										reports.push({
+											...report._doc,
+											createdBy: { _id: report.createdBy }
+										});
+									else
+										reports.push({
+											...report._doc,
+											createdBy: {
+												avatar: user.avatar,
+												name: user.name,
+												username: user.username,
+												_id: report.createdBy
+											}
+										});
+
+									return cb(err);
+								});
+						},
+						err => next(err, reports)
+					);
 				}
 			],
 			async (err, reports) => {
@@ -158,6 +224,7 @@ export default {
 					this.log("ERROR", "GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" failed. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
+
 				this.log("SUCCESS", "GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" successful.`);
 				return cb({ status: "success", data: { reports } });
 			}
@@ -165,36 +232,107 @@ export default {
 	}),
 
 	/**
-	 * Resolves a report
+	 * Gets all a users reports for a specific songId
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} songId - the id of the song
+	 * @param {Function} cb - gets called with the result
+	 */
+	myReportsForSong: isLoginRequired(async function myReportsForSong(session, songId, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		async.waterfall(
+			[
+				next =>
+					reportModel
+						.find({ "song._id": songId, createdBy: session.userId, resolved: false })
+						.sort({ createdAt: "desc" })
+						.exec(next),
+
+				(_reports, next) => {
+					const reports = [];
+
+					async.each(
+						_reports,
+						(report, cb) => {
+							userModel
+								.findById(report.createdBy)
+								.select({ avatar: -1, name: -1, username: -1 })
+								.exec((err, user) => {
+									if (!user)
+										reports.push({
+											...report._doc,
+											createdBy: { _id: report.createdBy }
+										});
+									else
+										reports.push({
+											...report._doc,
+											createdBy: {
+												avatar: user.avatar,
+												name: user.name,
+												username: user.username,
+												_id: report.createdBy
+											}
+										});
+
+									return cb(err);
+								});
+						},
+						err => next(err, reports)
+					);
+				}
+			],
+			async (err, reports) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"MY_REPORTS_FOR_SONG",
+						`Indexing reports of user ${session.userId} for song "${songId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"MY_REPORTS_FOR_SONG",
+					`Indexing reports of user ${session.userId} for song "${songId}" successful.`
+				);
+
+				return cb({ status: "success", data: { reports } });
+			}
+		);
+	}),
+
+	/**
+	 * Resolves a report as a whole
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} reportId - the id of the report that is getting resolved
 	 * @param {Function} cb - gets called with the result
 	 */
 	resolve: isAdminRequired(async function resolve(session, reportId, cb) {
-		const reportModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "report"
-			},
-			this
-		);
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
 		async.waterfall(
 			[
 				next => {
-					reportModel.findOne({ _id: reportId }).exec(next);
+					reportModel.findById(reportId).exec(next);
 				},
 
 				(report, next) => {
 					if (!report) return next("Report not found.");
+
 					report.resolved = true;
+
 					return report.save(err => {
 						if (err) return next(err.message);
-						return next();
+						return next(null, report.song._id);
 					});
 				}
 			],
-			async err => {
+			async (err, songId) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log(
@@ -204,11 +342,14 @@ export default {
 					);
 					return cb({ status: "error", message: err });
 				}
+
 				CacheModule.runJob("PUB", {
 					channel: "report.resolve",
-					value: reportId
+					value: { reportId, songId }
 				});
+
 				this.log("SUCCESS", "REPORTS_RESOLVE", `User "${session.userId}" resolved report "${reportId}".`);
+
 				return cb({
 					status: "success",
 					message: "Successfully resolved Report"
@@ -218,22 +359,83 @@ export default {
 	}),
 
 	/**
-	 * Creates a new report
+	 * Resolves/Unresolves an issue within a report
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {object} data - the object of the report data
+	 * @param {string} reportId - the id of the report that is getting resolved
+	 * @param {string} issueId - the id of the issue within the report
 	 * @param {Function} cb - gets called with the result
 	 */
-	create: isLoginRequired(async function create(session, data, cb) {
+	toggleIssue: isAdminRequired(async function toggleIssue(session, reportId, issueId, cb) {
 		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
-		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 
 		async.waterfall(
 			[
 				next => {
-					songModel.findOne({ youtubeId: data.youtubeId }).exec(next);
+					reportModel.findById(reportId).exec(next);
 				},
 
+				(report, next) => {
+					if (!report) return next("Report not found.");
+
+					const issue = report.issues.find(issue => issue._id.toString() === issueId);
+					issue.resolved = !issue.resolved;
+
+					return report.save(err => {
+						if (err) return next(err.message);
+						return next(null, issue.resolved, report.song._id);
+					});
+				}
+			],
+			async (err, resolved, songId) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"REPORTS_TOGGLE_ISSUE",
+						`Resolving an issue within report "${reportId}" failed by user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				CacheModule.runJob("PUB", {
+					channel: "report.issue.toggle",
+					value: { reportId, issueId, songId, resolved }
+				});
+
+				this.log(
+					"SUCCESS",
+					"REPORTS_TOGGLE_ISSUE",
+					`User "${session.userId}" resolved an issue in report "${reportId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully resolved issue within report"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Creates a new report
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {object} report - the object of the report data
+	 * @param {string} report.youtubeId - the youtube id of the song that is being reported
+	 * @param {Array} report.issues - all issues reported (custom or defined)
+	 * @param {Function} cb - gets called with the result
+	 */
+	create: isLoginRequired(async function create(session, report, cb) {
+		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+		const { youtubeId } = report;
+
+		async.waterfall(
+			[
+				next => songModel.findOne({ youtubeId }).exec(next),
+
 				(song, next) => {
 					if (!song) return next("Song not found.");
 
@@ -245,54 +447,24 @@ export default {
 				(song, next) => {
 					if (!song) return next("Song not found.");
 
-					delete data.youtubeId;
-					data.song = {
+					delete report.youtubeId;
+					report.song = {
 						_id: song._id,
 						youtubeId: song.youtubeId
 					};
 
-					for (let z = 0; z < data.issues.length; z += 1) {
-						if (reportableIssues.filter(issue => issue.name === data.issues[z].name).length > 0) {
-							for (let r = 0; r < reportableIssues.length; r += 1) {
-								if (
-									reportableIssues[r].reasons.every(
-										reason => data.issues[z].reasons.indexOf(reason) < -1
-									)
-								) {
-									return cb({
-										status: "error",
-										message: "Invalid data"
-									});
-								}
-							}
-						} else
-							return cb({
-								status: "error",
-								message: "Invalid data"
-							});
-					}
-
 					return next(null, { title: song.title, artists: song.artists, thumbnail: song.thumbnail });
 				},
 
-				(song, next) => {
-					const issues = [];
-
-					for (let r = 0; r < data.issues.length; r += 1) {
-						if (!data.issues[r].reasons.length <= 0) issues.push(data.issues[r]);
-					}
-
-					data.issues = issues;
-
-					next(null, song);
-				},
-
-				(song, next) => {
-					data.createdBy = session.userId;
-					data.createdAt = Date.now();
-
-					reportModel.create(data, (err, report) => next(err, report, song));
-				}
+				(song, next) =>
+					reportModel.create(
+						{
+							createdBy: session.userId,
+							createdAt: Date.now(),
+							...report
+						},
+						(err, report) => next(err, report, song)
+					)
 			],
 			async (err, report, song) => {
 				if (err) {
@@ -300,31 +472,28 @@ export default {
 					this.log(
 						"ERROR",
 						"REPORTS_CREATE",
-						`Creating report for "${data.song._id}" failed by user "${session.userId}". "${err}"`
+						`Creating report for "${report.song._id}" failed by user "${session.userId}". "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
 
-				CacheModule.runJob("PUB", {
-					channel: "report.create",
-					value: report
-				});
-
 				ActivitiesModule.runJob("ADD_ACTIVITY", {
-					userId: report.createdBy,
+					userId: session.userId,
 					type: "song__report",
 					payload: {
-						message: `Reported song <youtubeId>${song.title} by ${song.artists.join(", ")}</youtubeId>`,
-						youtubeId: data.song.youtubeId,
+						message: `Created a <reportId>${report._id}</reportId> for song <youtubeId>${song.title}</youtubeId>`,
+						youtubeId: report.song.youtubeId,
+						reportId: report._id,
 						thumbnail: song.thumbnail
 					}
 				});
 
-				this.log(
-					"SUCCESS",
-					"REPORTS_CREATE",
-					`User "${session.userId}" created report for "${data.youtubeId}".`
-				);
+				CacheModule.runJob("PUB", {
+					channel: "report.create",
+					value: report
+				});
+
+				this.log("SUCCESS", "REPORTS_CREATE", `User "${session.userId}" created report for "${youtubeId}".`);
 
 				return cb({
 					status: "success",

+ 100 - 139
backend/logic/actions/songs.js

@@ -20,17 +20,12 @@ CacheModule.runJob("SUB", {
 			modelName: "song"
 		});
 
-		songModel.findOne({ _id: songId }, (err, song) => {
-			WSModule.runJob("EMIT_TO_ROOM", {
-				room: "admin.unverifiedSongs",
-				args: ["event:admin.unverifiedSong.created", { data: { song } }]
-			});
-
-			WSModule.runJob("EMIT_TO_ROOM", {
-				room: `edit-song.${songId}`,
+		songModel.findOne({ _id: songId }, (err, song) =>
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: ["admin.unverifiedSongs", `edit-song.${songId}`],
 				args: ["event:admin.unverifiedSong.created", { data: { song } }]
-			});
-		});
+			})
+		);
 	}
 });
 
@@ -64,17 +59,13 @@ CacheModule.runJob("SUB", {
 	channel: "song.newVerifiedSong",
 	cb: async songId => {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" });
-		songModel.findOne({ _id: songId }, (err, song) => {
-			WSModule.runJob("EMIT_TO_ROOM", {
-				room: "admin.songs",
-				args: ["event:admin.verifiedSong.created", { data: { song } }]
-			});
 
-			WSModule.runJob("EMIT_TO_ROOM", {
-				room: `edit-song.${songId}`,
+		songModel.findOne({ _id: songId }, (err, song) =>
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: ["admin.songs", `edit-song.${songId}`],
 				args: ["event:admin.verifiedSong.created", { data: { song } }]
-			});
-		});
+			})
+		);
 	}
 });
 
@@ -108,17 +99,12 @@ CacheModule.runJob("SUB", {
 			modelName: "song"
 		});
 
-		songModel.findOne({ _id: songId }, (err, song) => {
-			WSModule.runJob("EMIT_TO_ROOM", {
-				room: "admin.hiddenSongs",
-				args: ["event:admin.hiddenSong.created", { data: { song } }]
-			});
-
-			WSModule.runJob("EMIT_TO_ROOM", {
-				room: `edit-song.${songId}`,
+		songModel.findOne({ _id: songId }, (err, song) =>
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: ["admin.hiddenSongs", `edit-song.${songId}`],
 				args: ["event:admin.hiddenSong.created", { data: { song } }]
-			});
-		});
+			})
+		);
 	}
 });
 
@@ -357,38 +343,6 @@ export default {
 		);
 	}),
 
-	/**
-	 * Gets a song from the YouTube song id
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} youtubeId - the YouTube song id
-	 * @param {Function} cb
-	 */
-	getSong: isAdminRequired(function getSong(session, youtubeId, cb) {
-		async.waterfall(
-			[
-				next => {
-					SongsModule.runJob("GET_SONG_FROM_YOUTUBE_ID", { youtubeId }, this)
-						.then(song => {
-							next(null, song);
-						})
-						.catch(err => {
-							next(err);
-						});
-				}
-			],
-			async (err, song) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "SONGS_GET_SONG", `Failed to get song ${youtubeId}. "${err}"`);
-					return cb({ status: "error", message: err });
-				}
-				this.log("SUCCESS", "SONGS_GET_SONG", `Got song ${youtubeId} successfully.`);
-				return cb({ status: "success", data: { song } });
-			}
-		);
-	}),
-
 	/**
 	 * Gets a song from the Musare song id
 	 *
@@ -396,7 +350,7 @@ export default {
 	 * @param {string} songId - the song id
 	 * @param {Function} cb
 	 */
-	getSongFromSongId: isAdminRequired(function getSong(session, songId, cb) {
+	getSongFromSongId: isAdminRequired(function getSongFromSongId(session, songId, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -417,62 +371,6 @@ export default {
 		);
 	}),
 
-	/**
-	 * Obtains basic metadata of a song in order to format an activity
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} youtubeId - the youtube song id
-	 * @param {Function} cb - callback
-	 */
-	getSongForActivity(session, youtubeId, cb) {
-		async.waterfall(
-			[
-				next => {
-					SongsModule.runJob("GET_SONG_FROM_YOUTUBE_ID", { youtubeId }, this)
-						.then(response => next(null, response.song))
-						.catch(next);
-				}
-			],
-			async (err, song) => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-
-					this.log(
-						"ERROR",
-						"SONGS_GET_SONG_FOR_ACTIVITY",
-						`Failed to obtain metadata of song ${youtubeId} for activity formatting. "${err}"`
-					);
-
-					return cb({ status: "error", message: err });
-				}
-
-				if (song) {
-					this.log(
-						"SUCCESS",
-						"SONGS_GET_SONG_FOR_ACTIVITY",
-						`Obtained metadata of song ${youtubeId} for activity formatting successfully.`
-					);
-
-					return cb({
-						status: "success",
-						data: {
-							title: song.title,
-							thumbnail: song.thumbnail
-						}
-					});
-				}
-
-				this.log(
-					"ERROR",
-					"SONGS_GET_SONG_FOR_ACTIVITY",
-					`Song ${youtubeId} does not exist so failed to obtain for activity formatting.`
-				);
-
-				return cb({ status: "error" });
-			}
-		);
-	},
-
 	/**
 	 * Updates a song
 	 *
@@ -671,11 +569,12 @@ export default {
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} youtubeId - the youtube id of the song that gets requested
+	 * @param {string} returnSong - returns the simple song
 	 * @param {Function} cb - gets called with the result
 	 */
-	request: isLoginRequired(async function add(session, youtubeId, cb) {
+	request: isLoginRequired(async function add(session, youtubeId, returnSong, cb) {
 		SongsModule.runJob("REQUEST_SONG", { youtubeId, userId: session.userId }, this)
-			.then(() => {
+			.then(response => {
 				this.log(
 					"SUCCESS",
 					"SONGS_REQUEST",
@@ -683,17 +582,18 @@ export default {
 				);
 				return cb({
 					status: "success",
-					message: "Successfully requested that song"
+					message: "Successfully requested that song",
+					song: returnSong ? response.song : null
 				});
 			})
-			.catch(async err => {
-				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+			.catch(async _err => {
+				const err = await UtilsModule.runJob("GET_ERROR", { error: _err }, this);
 				this.log(
 					"ERROR",
 					"SONGS_REQUEST",
 					`Requesting song "${youtubeId}" failed for user ${session.userId}. "${err}"`
 				);
-				return cb({ status: "error", message: err });
+				return cb({ status: "error", message: err, song: returnSong && _err.data ? _err.data.song : null });
 			});
 	}),
 
@@ -768,15 +668,16 @@ export default {
 				},
 
 				(song, next) => {
+					const oldStatus = song.status;
+
 					song.verifiedBy = session.userId;
 					song.verifiedAt = Date.now();
 					song.status = "verified";
-					song.save(err => {
-						next(err, song);
-					});
+
+					song.save(err => next(err, song, oldStatus));
 				},
 
-				(song, next) => {
+				(song, oldStatus, next) => {
 					song.genres.forEach(genre => {
 						PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
 							.then(() => {})
@@ -784,21 +685,24 @@ export default {
 					});
 
 					SongsModule.runJob("UPDATE_SONG", { songId: song._id });
-
-					next(null, song);
+					next(null, song, oldStatus);
 				}
 			],
-			async (err, song) => {
+			async (err, song, oldStatus) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-
 					this.log("ERROR", "SONGS_VERIFY", `User "${session.userId}" failed to verify song. "${err}"`);
-
 					return cb({ status: "error", message: err });
 				}
 
 				this.log("SUCCESS", "SONGS_VERIFY", `User "${session.userId}" successfully verified song "${songId}".`);
 
+				if (oldStatus === "hidden")
+					CacheModule.runJob("PUB", {
+						channel: "song.removedHiddenSong",
+						value: song._id
+					});
+
 				CacheModule.runJob("PUB", {
 					channel: "song.newVerifiedSong",
 					value: song._id
@@ -899,7 +803,7 @@ export default {
 	 * @param {boolean} musicOnly - whether to only get music from the playlist
 	 * @param {Function} cb - gets called with the result
 	 */
-	requestSet: isLoginRequired(function requestSet(session, url, musicOnly, cb) {
+	requestSet: isLoginRequired(function requestSet(session, url, musicOnly, returnSongs, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -918,22 +822,23 @@ export default {
 				},
 				(youtubeIds, next) => {
 					let successful = 0;
+					let songs = {};
 					let failed = 0;
 					let alreadyInDatabase = 0;
 
 					if (youtubeIds.length === 0) next();
 
-					async.eachLimit(
+					async.eachOfLimit(
 						youtubeIds,
 						1,
-						(youtubeId, next) => {
+						(youtubeId, index, next) => {
 							WSModule.runJob(
 								"RUN_ACTION2",
 								{
 									session,
 									namespace: "songs",
 									action: "request",
-									args: [youtubeId]
+									args: [youtubeId, returnSongs]
 								},
 								this
 							)
@@ -941,6 +846,8 @@ export default {
 									if (res.status === "success") successful += 1;
 									else failed += 1;
 									if (res.message === "This song is already in the database.") alreadyInDatabase += 1;
+									if (res.song) songs[index] = res.song;
+									else songs[index] = null;
 								})
 								.catch(() => {
 									failed += 1;
@@ -950,7 +857,12 @@ export default {
 								});
 						},
 						() => {
-							next(null, { successful, failed, alreadyInDatabase });
+							if (returnSongs)
+								songs = Object.keys(songs)
+									.sort()
+									.map(key => songs[key]);
+
+							next(null, { successful, failed, alreadyInDatabase, songs });
 						}
 					);
 				}
@@ -972,7 +884,8 @@ export default {
 				);
 				return cb({
 					status: "success",
-					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`
+					message: `Playlist is done importing. ${response.successful} were added succesfully, ${response.failed} failed (${response.alreadyInDatabase} were already in database)`,
+					songs: returnSongs ? response.songs : null
 				});
 			}
 		);
@@ -1515,6 +1428,54 @@ export default {
 		);
 	}),
 
+	/**
+	 * Gets song ratings
+	 *
+	 * @param session
+	 * @param songId - the Musare song id
+	 * @param cb
+	 */
+
+	getSongRatings: isLoginRequired(async function getSongRatings(session, songId, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("GET_SONG", { songId }, this)
+						.then(res => next(null, res.song))
+						.catch(next);
+				},
+
+				(song, next) => {
+					next(null, {
+						likes: song.likes,
+						dislikes: song.dislikes
+					});
+				}
+			],
+			async (err, ratings) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_GET_RATINGS",
+						`User "${session.userId}" failed to get ratings for ${songId}. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				const { likes, dislikes } = ratings;
+
+				return cb({
+					status: "success",
+					data: {
+						likes,
+						dislikes
+					}
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Gets user's own song ratings
 	 *

+ 30 - 83
backend/logic/actions/stations.js

@@ -168,17 +168,12 @@ CacheModule.runJob("SUB", {
 	cb: data => {
 		const { stationId, playlistId } = data;
 
-		PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }).then(playlist => {
-			WSModule.runJob("EMIT_TO_ROOM", {
-				room: `station.${stationId}`,
+		PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }).then(playlist =>
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: [`station.${stationId}`, `manage-station.${stationId}`],
 				args: ["event:station.includedPlaylist", { data: { stationId, playlist } }]
-			});
-
-			WSModule.runJob("EMIT_TO_ROOM", {
-				room: `manage-station.${stationId}`,
-				args: ["event:station.includedPlaylist", { data: { stationId, playlist } }]
-			});
-		});
+			})
+		);
 	}
 });
 
@@ -187,17 +182,12 @@ CacheModule.runJob("SUB", {
 	cb: data => {
 		const { stationId, playlistId } = data;
 
-		PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }).then(playlist => {
-			WSModule.runJob("EMIT_TO_ROOM", {
-				room: `station.${stationId}`,
-				args: ["event:station.excludedPlaylist", { data: { stationId, playlist } }]
-			});
-
-			WSModule.runJob("EMIT_TO_ROOM", {
-				room: `manage-station.${stationId}`,
+		PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }).then(playlist =>
+			WSModule.runJob("EMIT_TO_ROOMS", {
+				rooms: [`station.${stationId}`, `manage-station.${stationId}`],
 				args: ["event:station.excludedPlaylist", { data: { stationId, playlist } }]
-			});
-		});
+			})
+		);
 	}
 });
 
@@ -205,13 +195,8 @@ CacheModule.runJob("SUB", {
 	channel: "station.removedIncludedPlaylist",
 	cb: data => {
 		const { stationId, playlistId } = data;
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `station.${stationId}`,
-			args: ["event:station.removedIncludedPlaylist", { data: { stationId, playlistId } }]
-		});
-
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `manage-station.${stationId}`,
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
 			args: ["event:station.removedIncludedPlaylist", { data: { stationId, playlistId } }]
 		});
 	}
@@ -221,13 +206,8 @@ CacheModule.runJob("SUB", {
 	channel: "station.removedExcludedPlaylist",
 	cb: data => {
 		const { stationId, playlistId } = data;
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `station.${stationId}`,
-			args: ["event:station.removedExcludedPlaylist", { data: { stationId, playlistId } }]
-		});
-
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `manage-station.${stationId}`,
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
 			args: ["event:station.removedExcludedPlaylist", { data: { stationId, playlistId } }]
 		});
 	}
@@ -366,13 +346,8 @@ CacheModule.runJob("SUB", {
 			});
 		});
 
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `station.${stationId}`,
-			args: ["event:station.name.updated", { data: { stationId, name } }]
-		});
-
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `manage-station.${stationId}`,
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
 			args: ["event:station.name.updated", { data: { stationId, name } }]
 		});
 	}
@@ -395,13 +370,8 @@ CacheModule.runJob("SUB", {
 			})
 		);
 
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `station.${stationId}`,
-			args: ["event:station.displayName.updated", { data: { stationId, displayName } }]
-		});
-
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `manage-station.${stationId}`,
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
 			args: ["event:station.displayName.updated", { data: { stationId, displayName } }]
 		});
 	}
@@ -424,13 +394,8 @@ CacheModule.runJob("SUB", {
 			})
 		);
 
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `station.${stationId}`,
-			args: ["event:station.description.updated", { data: { stationId, description } }]
-		});
-
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `manage-station.${stationId}`,
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${stationId}`, `manage-station.${stationId}`],
 			args: ["event:station.description.updated", { data: { stationId, description } }]
 		});
 	}
@@ -485,13 +450,8 @@ CacheModule.runJob("SUB", {
 CacheModule.runJob("SUB", {
 	channel: "station.repositionSongInQueue",
 	cb: res => {
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `station.${res.stationId}`,
-			args: ["event:station.queue.song.repositioned", { data: { song: res.song } }]
-		});
-
-		WSModule.runJob("EMIT_TO_ROOM", {
-			room: `manage-station.${res.stationId}`,
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: [`station.${res.stationId}`, `manage-station.${res.stationId}`],
 			args: ["event:station.queue.song.repositioned", { data: { song: res.song } }]
 		});
 	}
@@ -532,20 +492,20 @@ CacheModule.runJob("SUB", {
 	cb: async stationId => {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" });
 
-		StationsModule.runJob("INITIALIZE_STATION", { stationId }).then(async response => {
-			const { station } = response;
+		StationsModule.runJob("INITIALIZE_STATION", { stationId }).then(async res => {
+			const { station } = res;
 			station.userCount = StationsModule.usersPerStationCount[stationId] || 0;
 
 			WSModule.runJob("EMIT_TO_ROOM", {
 				room: "admin.stations",
 				args: ["event:admin.station.created", { data: { station } }]
-			}).then(() => {});
+			});
 
 			if (station.privacy === "public")
 				WSModule.runJob("EMIT_TO_ROOM", {
 					room: "home",
 					args: ["event:station.created", { data: { station } }]
-				}).then(() => {});
+				});
 			else {
 				const sockets = await WSModule.runJob("GET_SOCKETS_FOR_ROOM", {
 					room: "home"
@@ -2653,12 +2613,7 @@ export default {
 				next => {
 					stationModel.findOne(
 						{
-							$or: [
-								{ name: data.name },
-								{
-									displayName: new RegExp(`^${data.displayName}$`, "i")
-								}
-							]
+							$or: [{ name: data.name }, { displayName: new RegExp(`^${data.displayName}$`, "i") }]
 						},
 						next
 					);
@@ -2851,7 +2806,6 @@ export default {
 					if (station.type !== "official") return next(null, station);
 
 					const stationId = station._id;
-					console.log(111, station, genrePlaylistIds, blacklistedGenrePlaylistIds, next);
 
 					return async.waterfall(
 						[
@@ -2861,9 +2815,7 @@ export default {
 									1,
 									(playlistId, next) => {
 										StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
-											.then(() => {
-												next();
-											})
+											.then(() => next())
 											.catch(next);
 									},
 									next
@@ -2876,9 +2828,7 @@ export default {
 									1,
 									(playlistId, next) => {
 										StationsModule.runJob("EXCLUDE_PLAYLIST", { stationId, playlistId }, this)
-											.then(() => {
-												next();
-											})
+											.then(() => next())
 											.catch(next);
 									},
 									next
@@ -3300,10 +3250,7 @@ export default {
 						.catch(err => next(err));
 				},
 
-				(station, next) => {
-					if (station.type === "official") next(null, station.queue);
-					else next(null, station.queue);
-				}
+				(station, next) => next(null, station.queue)
 			],
 			async (err, queue) => {
 				if (err) {

+ 29 - 28
backend/logic/actions/users.js

@@ -1064,22 +1064,17 @@ export default {
 		async.waterfall(
 			[
 				next => {
-					userModel.findByIdAndUpdate(
-						session.userId,
-						{
-							$set: {
-								preferences: {
-									nightmode: preferences.nightmode,
-									autoSkipDisliked: preferences.autoSkipDisliked,
-									activityLogPublic: preferences.activityLogPublic,
-									anonymousSongRequests: preferences.anonymousSongRequests,
-									activityWatch: preferences.activityWatch
-								}
-							}
-						},
-						{ new: false },
-						next
-					);
+					const $set = {};
+
+					Object.keys(preferences).forEach(preference => {
+						$set[`preferences.${preference}`] = preferences[preference];
+					});
+
+					return next(null, $set);
+				},
+
+				($set, next) => {
+					userModel.findByIdAndUpdate(session.userId, { $set }, { new: false, upsert: true }, next);
 				}
 			],
 			async (err, user) => {
@@ -1105,14 +1100,17 @@ export default {
 					}
 				});
 
-				if (preferences.nightmode !== user.preferences.nightmode)
+				if (preferences.nightmode !== undefined && preferences.nightmode !== user.preferences.nightmode)
 					ActivitiesModule.runJob("ADD_ACTIVITY", {
 						userId: session.userId,
 						type: "user__toggle_nightmode",
 						payload: { message: preferences.nightmode ? "Enabled nightmode" : "Disabled nightmode" }
 					});
 
-				if (preferences.autoSkipDisliked !== user.preferences.autoSkipDisliked)
+				if (
+					preferences.autoSkipDisliked !== undefined &&
+					preferences.autoSkipDisliked !== user.preferences.autoSkipDisliked
+				)
 					ActivitiesModule.runJob("ADD_ACTIVITY", {
 						userId: session.userId,
 						type: "user__toggle_autoskip_disliked_songs",
@@ -1123,7 +1121,10 @@ export default {
 						}
 					});
 
-				if (preferences.activityWatch !== user.preferences.activityWatch)
+				if (
+					preferences.activityWatch !== undefined &&
+					preferences.activityWatch !== user.preferences.activityWatch
+				)
 					ActivitiesModule.runJob("ADD_ACTIVITY", {
 						userId: session.userId,
 						type: "user__toggle_activity_watch",
@@ -1794,14 +1795,14 @@ export default {
 	}),
 
 	/**
-	 * Updates the type of a user's avatar
+	 * Updates a user's avatar
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {string} updatingUserId - the updating user's id
-	 * @param {string} newType - the new type
+	 * @param {string} newAvatar - the new avatar object
 	 * @param {Function} cb - gets called with the result
 	 */
-	updateAvatarType: isLoginRequired(async function updateAvatarType(session, updatingUserId, newAvatar, cb) {
+	updateAvatar: isLoginRequired(async function updateAvatarType(session, updatingUserId, newAvatar, cb) {
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
 		async.waterfall(
@@ -1831,8 +1832,8 @@ export default {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log(
 						"ERROR",
-						"UPDATE_AVATAR_TYPE",
-						`Couldn't update avatar type for user "${updatingUserId}" to type "${newAvatar.type}". "${err}"`
+						"UPDATE_AVATAR",
+						`Couldn't update avatar for user "${updatingUserId}" to type "${newAvatar.type}" and color "${newAvatar.color}". "${err}"`
 					);
 					return cb({ status: "error", message: err });
 				}
@@ -1840,18 +1841,18 @@ export default {
 				ActivitiesModule.runJob("ADD_ACTIVITY", {
 					userId: updatingUserId,
 					type: "user__edit_avatar",
-					payload: { message: `Changed avatar to use ${newAvatar.type}` }
+					payload: { message: `Changed avatar to use ${newAvatar.type} and ${newAvatar.color}` }
 				});
 
 				this.log(
 					"SUCCESS",
-					"UPDATE_AVATAR_TYPE",
-					`Updated avatar type for user "${updatingUserId}" to type "${newAvatar.type}".`
+					"UPDATE_AVATAR",
+					`Updated avatar for user "${updatingUserId}" to type "${newAvatar.type} and color ${newAvatar.color}".`
 				);
 
 				return cb({
 					status: "success",
-					message: "Avatar type updated successfully"
+					message: "Avatar updated successfully"
 				});
 			}
 		);

+ 11 - 1
backend/logic/activities.js

@@ -44,6 +44,7 @@ class _ActivitiesModule extends CoreClass {
 	 * @param {string} payload.payload.message - the main message describing the activity e.g. 50 songs added to playlist 'playlist name'
 	 * @param {string} payload.payload.thumbnail - url to a thumbnail e.g. song album art to be used when display an activity
 	 * @param {string} payload.payload.youtubeId - (optional) if relevant, the youtube id of the song related to the activity
+	 * @param {string} payload.payload.reportId - (optional) if relevant, the id of the report related to the activity
 	 * @param {string} payload.payload.playlistId - (optional) if relevant, the id of the playlist related to the activity
 	 * @param {string} payload.payload.stationId - (optional) if relevant, the id of the station related to the activity
 	 * @returns {Promise} - returns promise (reject, resolve)
@@ -300,8 +301,9 @@ class _ActivitiesModule extends CoreClass {
 	 * Removes any references to a station, playlist or song in activities
 	 *
 	 * @param {object} payload - object that contains the payload
-	 * @param {string} payload.type - type of reference. enum: ["youtubeId", "stationId", "playlistId"]
+	 * @param {string} payload.type - type of reference. enum: ["youtubeId", "stationId", "playlistId", "playlistId"]
 	 * @param {string} payload.stationId - (optional) the id of a station
+	 * @param {string} payload.reportId - (optional) the id of a report
 	 * @param {string} payload.playlistId - (optional) the id of a playlist
 	 * @param {string} payload.youtubeId - (optional) the id of a song
 	 * @returns {Promise} - returns promise (reject, resolve)
@@ -316,6 +318,7 @@ class _ActivitiesModule extends CoreClass {
 						if (
 							(payload.type !== "youtubeId" &&
 								payload.type !== "stationId" &&
+								payload.type !== "reportId" &&
 								payload.type !== "playlistId") ||
 							!payload.type
 						)
@@ -351,6 +354,13 @@ class _ActivitiesModule extends CoreClass {
 									);
 								}
 
+								if (payload.reportId) {
+									activity.payload.message = activity.payload.message.replace(
+										/<reportId>(.*)<\/reportId>/g,
+										"$1"
+									);
+								}
+
 								if (payload.playlistId) {
 									activity.payload.message = activity.payload.message.replace(
 										/<playlistId>(.*)<\/playlistId>/g,

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

@@ -11,9 +11,9 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	playlist: 4,
 	punishment: 1,
 	queueSong: 1,
-	report: 2,
+	report: 5,
 	song: 5,
-	station: 5,
+	station: 6,
 	user: 3
 };
 
@@ -262,15 +262,6 @@ class _DBModule extends CoreClass {
 
 					this.schemas.playlist.index({ createdFor: 1, type: 1 }, { unique: true });
 
-					// Report
-					this.schemas.report
-						.path("description")
-						.validate(
-							description =>
-								!description || (isLength(description, 0, 400) && regex.ascii.test(description)),
-							"Invalid description."
-						);
-
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();
 					else {
 						this.runJob("CHECK_DOCUMENT_VERSIONS", {}, null, -1)

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

@@ -50,7 +50,8 @@ export default {
 		thumbnail: { type: String, required: false },
 		youtubeId: { type: String, required: false },
 		stationId: { type: String, required: false },
-		playlistId: { type: String, required: false }
+		playlistId: { type: String, required: false },
+		reportId: { type: String, required: false }
 	},
 	documentVersion: { type: Number, default: 2, required: true }
 };

+ 9 - 4
backend/logic/db/schemas/report.js

@@ -4,14 +4,19 @@ export default {
 		_id: { type: String, required: true },
 		youtubeId: { type: String, required: true }
 	},
-	description: { type: String },
 	issues: [
 		{
-			name: String,
-			reasons: Array
+			category: {
+				type: String,
+				enum: ["custom", "video", "title", "duration", "artists", "thumbnail"],
+				required: true
+			},
+			title: { type: String, required: true },
+			description: { type: String, required: false },
+			resolved: { type: Boolean, default: false, required: true }
 		}
 	],
 	createdBy: { type: String, required: true },
 	createdAt: { type: Date, default: Date.now, required: true },
-	documentVersion: { type: Number, default: 2, required: true }
+	documentVersion: { type: Number, default: 5, required: true }
 };

+ 1 - 5
backend/logic/db/schemas/station.js

@@ -14,8 +14,6 @@ export default {
 		duration: { type: Number },
 		skipDuration: { type: Number },
 		thumbnail: { type: String },
-		likes: { type: Number, default: -1 },
-		dislikes: { type: Number, default: -1 },
 		skipVotes: [{ type: String }],
 		requestedBy: { type: String },
 		requestedAt: { type: Date },
@@ -36,8 +34,6 @@ export default {
 			duration: { type: Number },
 			skipDuration: { type: Number },
 			thumbnail: { type: String },
-			likes: { type: Number },
-			dislikes: { type: Number },
 			requestedBy: { type: String },
 			requestedAt: { type: Date },
 			status: { type: String }
@@ -49,5 +45,5 @@ export default {
 	theme: { type: String, enum: ["blue", "purple", "teal", "orange"], default: "blue" },
 	includedPlaylists: [{ type: String }],
 	excludedPlaylists: [{ type: String }],
-	documentVersion: { type: Number, default: 5, required: true }
+	documentVersion: { type: Number, default: 6, required: true }
 };

+ 67 - 0
backend/logic/migration/migrations/migration12.js

@@ -0,0 +1,67 @@
+import async from "async";
+
+/**
+ * Migration 12
+ *
+ * Migration for updated style of reports
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const reportModel = await MigrationModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 12. Finding reports with document version 2.`);
+					reportModel.find({ documentVersion: 2 }, (err, reports) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								reports.map(reporti => reporti._doc),
+								1,
+								(reporti, next) => {
+									const issues = [];
+
+									if (reporti.description !== "")
+										issues.push({ category: "custom", info: reporti.description });
+
+									reporti.issues.forEach(category =>
+										category.reasons.forEach(info => issues.push({ category: category.name, info }))
+									);
+
+									reportModel.updateOne(
+										{ _id: reporti._id },
+										{
+											$set: {
+												documentVersion: 4,
+												issues
+											},
+											$unset: {
+												description: ""
+											}
+										},
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 12. Reports found: ${reports.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 60 - 0
backend/logic/migration/migrations/migration13.js

@@ -0,0 +1,60 @@
+import async from "async";
+
+/**
+ * Migration 13
+ *
+ * Migration for allowing titles, descriptions and individual resolving for report issues
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const reportModel = await MigrationModule.runJob("GET_MODEL", { modelName: "report" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 13. Finding reports with document version 4.`);
+					reportModel.find({ documentVersion: 4 }, (err, reports) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								reports.map(reporti => reporti._doc),
+								1,
+								(reporti, next) => {
+									const { issues } = reporti;
+
+									issues.forEach(issue => {
+										issue.title = issue.info;
+										issue.resolved = reporti.resolved;
+										delete issue.info;
+									});
+
+									reportModel.updateOne(
+										{ _id: reporti._id },
+										{
+											$set: {
+												documentVersion: 5,
+												issues
+											}
+										},
+										next
+									);
+								},
+								err => {
+									this.log("INFO", `Migration 13. Reports found: ${reports.length}.`);
+									next(err);
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 65 - 0
backend/logic/migration/migrations/migration14.js

@@ -0,0 +1,65 @@
+import async from "async";
+
+/**
+ * Migration 14
+ *
+ * Migration for removing some data from stations
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 14. Finding stations with document version 5.`);
+					stationModel.find({ documentVersion: 5 }, (err, stations) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								stations.map(station => station._doc),
+								1,
+								(station, next) => {
+									const { queue, currentSong } = station;
+
+									if (currentSong && currentSong.likes) {
+										delete currentSong.likes;
+										delete currentSong.dislikes;
+									}
+
+									queue.forEach(song => {
+										delete song.likes;
+										delete song.dislikes;
+									});
+
+									stationModel.updateOne(
+										{ _id: station._id },
+										{
+											$set: {
+												documentVersion: 6,
+												queue,
+												currentSong
+											}
+										},
+										next
+									);
+								},
+								err => {
+									this.log("INFO", `Migration 14. Stations found: ${stations.length}.`);
+									next(err);
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 24 - 3
backend/logic/songs.js

@@ -11,6 +11,13 @@ let YouTubeModule;
 let StationsModule;
 let PlaylistsModule;
 
+class ErrorWithData extends Error {
+	constructor(message, data) {
+		super(message);
+		this.data = data;
+	}
+}
+
 class _SongsModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	constructor() {
@@ -907,7 +914,7 @@ class _SongsModule extends CoreClass {
 
 					// Get YouTube data from id
 					(user, song, next) => {
-						if (song) return next("This song is already in the database.");
+						if (song) return next("This song is already in the database.", song);
 						// TODO Add err object as first param of callback
 
 						const requestedBy = user.preferences.anonymousSongRequests ? null : userId;
@@ -953,7 +960,21 @@ class _SongsModule extends CoreClass {
 					}
 				],
 				async (err, song) => {
-					if (err) reject(err);
+					if (err && err !== "This song is already in the database.") return reject(err);
+
+					const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+					const trimmedSong = {
+						_id,
+						youtubeId,
+						title,
+						artists,
+						thumbnail,
+						duration,
+						status
+					};
+
+					if (err && err === "This song is already in the database.")
+						return reject(new ErrorWithData(err, { song: trimmedSong }));
 
 					SongsModule.runJob("UPDATE_SONG", { songId: song._id });
 
@@ -962,7 +983,7 @@ class _SongsModule extends CoreClass {
 						value: song._id
 					});
 
-					resolve();
+					return resolve({ song: trimmedSong });
 				}
 			);
 		});

+ 15 - 13
backend/logic/stations.js

@@ -558,11 +558,9 @@ class _StationsModule extends CoreClass {
 							]
 						})
 							.then(response => {
-								const newSongsToAdd = songsToAdd.map(song => {
-									return response.songs.find(
-										newSong => newSong._id.toString() === song._id.toString()
-									);
-								});
+								const newSongsToAdd = songsToAdd.map(song =>
+									response.songs.find(newSong => newSong._id.toString() === song._id.toString())
+								);
 								next(null, currentSongs, newSongsToAdd, currentSongIndex);
 							})
 							.catch(err => next(err));
@@ -924,8 +922,6 @@ class _StationsModule extends CoreClass {
 								title: song.title,
 								artists: song.artists,
 								duration: song.duration,
-								likes: song.likes,
-								dislikes: song.dislikes,
 								skipDuration: song.skipDuration,
 								thumbnail: song.thumbnail,
 								requestedAt: song.requestedAt,
@@ -938,10 +934,10 @@ class _StationsModule extends CoreClass {
 						$set.startedAt = Date.now();
 						$set.timePaused = 0;
 						if (station.paused) $set.pausedAt = Date.now();
-						next(null, $set, station);
+						next(null, $set, song, station);
 					},
 
-					($set, station, next) => {
+					($set, song, station, next) => {
 						StationsModule.stationModel.updateOne({ _id: station._id }, { $set }, err => {
 							if (err) return next(err);
 
@@ -953,10 +949,19 @@ class _StationsModule extends CoreClass {
 									})
 										.then()
 										.catch();
-									next(null, station);
+									next(null, station, song);
 								})
 								.catch(next);
 						});
+					},
+
+					(station, song, next) => {
+						if (station.currentSong !== null && station.currentSong.youtubeId !== undefined) {
+							station.currentSong.likes = song.likes;
+							station.currentSong.dislikes = song.dislikes;
+							station.currentSong.skipVotes = 0;
+						}
+						next(null, station);
 					}
 				],
 				async (err, station) => {
@@ -966,9 +971,6 @@ class _StationsModule extends CoreClass {
 						return reject(new Error(err));
 					}
 
-					if (station.currentSong !== null && station.currentSong.youtubeId !== undefined)
-						station.currentSong.skipVotes = 0;
-
 					// TODO Pub/Sub this
 
 					const { currentSong } = station;

+ 21 - 0
backend/logic/ws.js

@@ -325,6 +325,27 @@ class _WSModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Emits arguments to any sockets that are in specified rooms
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {Array} payload.rooms - array of strings with the name of each room e.g. ["station-page", "song.1234"]
+	 * @param {object} payload.args - any arguments to be emitted to the sockets in the specific room
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async EMIT_TO_ROOMS(payload) {
+		return new Promise(resolve =>
+			async.each(
+				payload.rooms,
+				(room, next) => {
+					WSModule.runJob("EMIT_TO_ROOM", { room, args: payload.args });
+					return next();
+				},
+				() => resolve()
+			)
+		);
+	}
+
 	/**
 	 * Allows a socket to join a 'song' room
 	 *

+ 4 - 0
frontend/dist/index.css

@@ -134,6 +134,10 @@ body {
 	position: relative;
 }
 
+#root {
+	height: 100%;
+}
+
 .content-wrapper {
 	/* padding: 60px 0 calc(230px + 60px) 0; */
 	padding-top: 60px;

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 502 - 175
frontend/package-lock.json


+ 8 - 9
frontend/package.json

@@ -22,6 +22,7 @@
     "@babel/plugin-syntax-dynamic-import": "^7.2.0",
     "@babel/plugin-transform-runtime": "^7.13.10",
     "@babel/preset-env": "^7.13.12",
+    "@vue/compiler-sfc": "^3.0.11",
     "babel-eslint": "^10.0.2",
     "babel-loader": "^8.2.2",
     "css-loader": "^5.2.4",
@@ -35,9 +36,7 @@
     "node-sass": "^4.14.1",
     "prettier": "1.18.2",
     "sass-loader": "^7.1.0",
-    "vue-hot-reload-api": "^2.3.3",
     "vue-style-loader": "^4.1.3",
-    "vue-template-compiler": "^2.6.12",
     "webpack-cli": "4.5.0",
     "webpack-dev-server": "^3.11.2"
   },
@@ -50,13 +49,13 @@
     "html-webpack-plugin": "^5.3.1",
     "marked": "^2.0.3",
     "toasters": "^2.3.1",
-    "vue": "^2.6.12",
-    "vue-content-loader": "^0.2.3",
-    "vue-loader": "^15.9.6",
-    "vue-router": "^3.5.1",
-    "vue-tippy": "^4.10.0",
-    "vuedraggable": "^2.24.3",
-    "vuex": "^3.6.2",
+    "vue": "^3.1.1",
+    "vue-content-loader": "^2.0.0",
+    "vue-loader": "^16.2.0",
+    "vue-router": "^4.0.8",
+    "vue-tippy": "^6.0.0-alpha.29",
+    "vuedraggable": "^4.0.1",
+    "vuex": "^4.0.1",
     "webpack": "5.27.2",
     "webpack-bundle-analyzer": "^4.4.0",
     "webpack-merge": "^5.7.3"

+ 203 - 92
frontend/src/App.vue

@@ -10,6 +10,7 @@
 			<what-is-new v-show="modals.whatIsNew" />
 			<login-modal v-if="modals.login" />
 			<register-modal v-if="modals.register" />
+			<create-playlist-modal v-if="modals.createPlaylist" />
 		</div>
 	</div>
 </template>
@@ -17,6 +18,7 @@
 <script>
 import { mapState, mapActions, mapGetters } from "vuex";
 import Toast from "toasters";
+import { defineAsyncComponent } from "vue";
 
 import ws from "./ws";
 import aw from "./aw";
@@ -24,10 +26,19 @@ import keyboardShortcuts from "./keyboardShortcuts";
 
 export default {
 	components: {
-		WhatIsNew: () => import("@/components/modals/WhatIsNew.vue"),
-		LoginModal: () => import("@/components/modals/Login.vue"),
-		RegisterModal: () => import("@/components/modals/Register.vue"),
-		Banned: () => import("@/pages/Banned.vue")
+		WhatIsNew: defineAsyncComponent(() =>
+			import("@/components/modals/WhatIsNew.vue")
+		),
+		LoginModal: defineAsyncComponent(() =>
+			import("@/components/modals/Login.vue")
+		),
+		RegisterModal: defineAsyncComponent(() =>
+			import("@/components/modals/Register.vue")
+		),
+		CreatePlaylistModal: defineAsyncComponent(() =>
+			import("@/components/modals/CreatePlaylist.vue")
+		),
+		Banned: defineAsyncComponent(() => import("@/pages/Banned.vue"))
 	},
 	replace: false,
 	data() {
@@ -90,6 +101,29 @@ export default {
 			this.keyIsDown = "";
 		};
 
+		// ctrl + alt + n
+		keyboardShortcuts.registerShortcut("nightmode", {
+			keyCode: 78,
+			ctrl: true,
+			alt: true,
+			handler: () => {
+				localStorage.setItem("nightmode", !this.nightmode);
+
+				if (this.loggedIn) {
+					this.socket.dispatch(
+						"users.updatePreferences",
+						{ nightmode: !this.nightmode },
+						res => {
+							if (res.status !== "success")
+								new Toast(res.message);
+						}
+					);
+				}
+
+				this.changeNightmode(!this.nightmode);
+			}
+		});
+
 		keyboardShortcuts.registerShortcut("closeModal", {
 			keyCode: 27,
 			shift: false,
@@ -123,7 +157,7 @@ export default {
 
 		this.apiDomain = await lofig.get("apiDomain");
 
-		this.$router.onReady(() => {
+		this.$router.isReady(() => {
 			if (this.$route.query.err) {
 				let { err } = this.$route.query;
 				err = err
@@ -142,6 +176,11 @@ export default {
 			}
 		});
 
+		if (localStorage.getItem("nightmode") === "true") {
+			this.changeNightmode(true);
+			this.enableNightMode();
+		}
+
 		this.socket.dispatch("users.getPreferences", res => {
 			if (res.status === "success") {
 				const { preferences } = res.data;
@@ -164,9 +203,6 @@ export default {
 		);
 	},
 	methods: {
-		submitOnEnter: (cb, event) => {
-			if (event.which === 13) cb();
-		},
 		enableNightMode: () => {
 			document
 				.getElementsByTagName("body")[0]
@@ -190,6 +226,9 @@ export default {
 </script>
 
 <style lang="scss">
+@import "tippy.js/dist/tippy.css";
+@import "tippy.js/animations/scale.css";
+
 :root {
 	--primary-color: var(--blue);
 	--blue: rgb(2, 166, 242);
@@ -260,7 +299,7 @@ export default {
 		background-color: var(--dark-grey-3) !important;
 	}
 
-	.tippy-tooltip.songActions-theme {
+	.tippy-box[data-theme~="songActions"] {
 		background-color: var(--dark-grey);
 	}
 }
@@ -317,12 +356,27 @@ textarea {
 	font-family: "Inter", Helvetica, Arial, sans-serif;
 }
 
+#page-title {
+	margin-top: 0;
+	font-size: 35px;
+	text-align: center;
+}
+
+@media only screen and (min-width: 700px) {
+	#page-title {
+		margin: 0;
+		margin-bottom: 30px;
+		font-size: 40px;
+	}
+}
+
 .upper-container {
 	height: 100%;
 }
 
 .main-container {
 	height: 100%;
+	min-height: 100vh;
 	display: flex;
 	flex-direction: column;
 
@@ -365,22 +419,22 @@ a {
 	z-index: 10000000;
 }
 
-.tippy-tooltip.dark-theme {
-	font-size: 14px;
-	padding: 5px 10px;
-}
 .night-mode {
-	.tippy-tooltip {
-		&.dark-theme {
-			border: 1px solid var(--light-grey-3);
-			box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25),
-				0 10px 10px rgba(0, 0, 0, 0.22);
-			background-color: white;
-			.tippy-content {
-				color: var(--black);
-			}
+	.tippy-box {
+		border: 1px solid var(--light-grey-3);
+		box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25),
+			0 10px 10px rgba(0, 0, 0, 0.22);
+		background-color: var(--white);
+
+		&:not([data-theme~="songActions"]) > .tippy-arrow::before {
+			border-top-color: var(--white);
+		}
+
+		.tippy-content {
+			color: var(--black);
 		}
-		&.songActions-theme {
+
+		&[data-theme~="songActions"] {
 			background-color: var(--dark-grey-2);
 			border: 0 !important;
 
@@ -393,7 +447,7 @@ a {
 				background-color: var(--white);
 			}
 		}
-		&.addToPlaylist-theme {
+		&[data-theme~="addToPlaylist"] {
 			background-color: var(--dark-grey-2);
 			border: 0 !important;
 
@@ -413,64 +467,63 @@ a {
 		}
 	}
 
-	.tippy-popper[x-placement^="top"] .tippy-tooltip {
-		&.songActions-theme,
-		&.addToPlaylist-theme {
-			.tippy-arrow {
+	.tippy-box[data-placement^="top"] {
+		&[data-theme~="songActions"],
+		&[data-theme~="addToPlaylist"] {
+			> .tippy-arrow::before {
 				border-top-color: var(--dark-grey-2);
 			}
 		}
-		&.dark-theme .tippy-arrow {
-			border-top-color: var(--white);
-		}
 	}
-	.tippy-popper[x-placement^="bottom"] .tippy-tooltip {
-		&.songActions-theme,
-		&.addToPlaylist-theme {
-			.tippy-arrow {
+
+	.tippy-box[data-placement^="bottom"] {
+		&[data-theme~="songActions"],
+		&[data-theme~="addToPlaylist"] {
+			> .tippy-arrow::before {
 				border-bottom-color: var(--dark-grey-2);
 			}
 		}
-		&.dark-theme .tippy-arrow {
-			border-bottom-color: var(--white);
-		}
 	}
-	.tippy-popper[x-placement^="left"] .tippy-tooltip {
-		&.songActions-theme,
-		&.addToPlaylist-theme {
-			.tippy-arrow {
+
+	.tippy-box[data-placement^="left"] {
+		&[data-theme~="songActions"],
+		&[data-theme~="addToPlaylist"] {
+			> .tippy-arrow::before {
 				border-left-color: var(--dark-grey-2);
 			}
 		}
-		&.dark-theme .tippy-arrow {
-			border-left-color: var(--white);
-		}
 	}
-	.tippy-popper[x-placement^="right"] .tippy-tooltip {
-		&.songActions-theme,
-		&.addToPlaylist-theme {
-			.tippy-arrow {
+
+	.tippy-box[data-placement^="right"] {
+		&[data-theme~="songActions"],
+		&[data-theme~="addToPlaylist"] {
+			> .tippy-arrow::before {
 				border-right-color: var(--dark-grey-2);
 			}
 		}
-		&.dark-theme .tippy-arrow {
-			border-right-color: var(--white);
-		}
 	}
 }
 
-.tippy-tooltip.info-theme {
+.tippy-box[data-theme~="info"] {
 	font-size: 12px;
 	letter-spacing: 1px;
 }
 
-.tippy-tooltip.confirm-theme {
+.tippy-box[data-theme~="confirm"] {
 	background-color: var(--red);
-	padding: 5px 10px;
+
+	.tippy-content {
+		padding: 0;
+	}
+
 	a {
+		padding: 15px;
+		line-height: 25px;
 		color: var(--white);
+		border-bottom: 0;
 		font-size: 15px;
 		font-weight: 600;
+
 		&:hover,
 		&:focus {
 			filter: brightness(90%);
@@ -478,7 +531,7 @@ a {
 	}
 }
 
-.tippy-tooltip.songActions-theme {
+.tippy-box[data-theme~="songActions"] {
 	font-size: 15px;
 	padding: 5px 10px;
 	border: 1px solid var(--light-grey-3);
@@ -489,16 +542,6 @@ a {
 		width: 146px;
 	}
 
-	.song-actions,
-	.addToPlaylistDropdown,
-	.song-actions > div {
-		display: inline-block;
-	}
-
-	.addToPlaylistDropdown .tippy-popper {
-		max-width: unset;
-	}
-
 	i,
 	a {
 		display: inline-block;
@@ -541,52 +584,55 @@ a {
 	}
 }
 
-.tippy-popper[x-placement^="top"] .tippy-tooltip {
-	&.songActions-theme,
-	&.addToPlaylist-theme {
-		.tippy-arrow {
+.tippy-box[data-placement^="top"] {
+	&[data-theme~="songActions"],
+	&[data-theme~="addToPlaylist"] {
+		> .tippy-arrow::before {
 			border-top-color: var(--white);
 		}
 	}
-	&.confirm-theme .tippy-arrow {
+	&[data-theme~="confirm"] > .tippy-arrow::before {
 		border-top-color: var(--red);
 	}
 }
-.tippy-popper[x-placement^="bottom"] .tippy-tooltip {
-	&.songActions-theme,
-	&.addToPlaylist-theme {
-		.tippy-arrow {
+
+.tippy-box[data-placement^="bottom"] {
+	&[data-theme~="songActions"],
+	&[data-theme~="addToPlaylist"] {
+		> .tippy-arrow::before {
 			border-bottom-color: var(--white);
 		}
 	}
-	&.confirm-theme .tippy-arrow {
+	&[data-theme~="confirm"] > .tippy-arrow::before {
 		border-bottom-color: var(--red);
 	}
 }
-.tippy-popper[x-placement^="left"] .tippy-tooltip {
-	&.songActions-theme,
-	&.addToPlaylist-theme {
-		.tippy-arrow {
+
+.tippy-box[data-placement^="left"] {
+	&[data-theme~="songActions"],
+	&[data-theme~="addToPlaylist"] {
+		> .tippy-arrow::before {
 			border-left-color: var(--white);
 		}
 	}
-	&.confirm-theme .tippy-arrow {
+	&[data-theme~="confirm"] > .tippy-arrow::before {
 		border-left-color: var(--red);
 	}
 }
-.tippy-popper[x-placement^="right"] .tippy-tooltip {
-	&.songActions-theme,
-	&.addToPlaylist-theme {
-		.tippy-arrow {
+
+.tippy-box[data-placement^="right"] {
+	&[data-theme~="songActions"],
+	&[data-theme~="addToPlaylist"] {
+		> .tippy-arrow::before {
 			border-right-color: var(--white);
 		}
 	}
-	&.confirm-theme .tippy-arrow {
+	&[data-theme~="confirm"] > .tippy-arrow::before {
 		border-right-color: var(--red);
 	}
 }
 
-.tippy-tooltip.addToPlaylist-theme {
+.tippy-box[data-theme~="addToPlaylist"] {
 	font-size: 15px;
 	padding: 5px;
 	border: 1px solid var(--light-grey-3);
@@ -675,7 +721,7 @@ a {
 			}
 		}
 	}
-	.tippy-content > div {
+	.tippy-content > span {
 		display: flex;
 		flex-direction: column;
 		button {
@@ -899,24 +945,30 @@ h4.section-title {
 			color: var(--primary-color);
 		}
 
-		.song-actions {
+		.icons-group {
 			display: flex;
+			align-items: center;
+
+			a {
+				padding: 0;
+			}
 		}
 
 		.button {
 			width: 146px;
 		}
 
-		i {
+		i,
+		span {
 			cursor: pointer;
-			color: var(--dark-grey);
+			// color: var(--dark-grey);
 
 			&:hover,
 			&:focus {
 				filter: brightness(90%);
 			}
 
-			&:not(:first-of-type) {
+			&:not(:first-child) {
 				margin-left: 5px;
 			}
 		}
@@ -1166,4 +1218,63 @@ h4.section-title {
 		font-style: italic;
 	}
 }
+.checkbox-control {
+	display: flex;
+	flex-direction: row;
+	align-items: center;
+
+	p {
+		margin-left: 10px;
+	}
+
+	.switch {
+		position: relative;
+		display: inline-block;
+		flex-shrink: 0;
+		width: 40px;
+		height: 24px;
+	}
+
+	.switch input {
+		opacity: 0;
+		width: 0;
+		height: 0;
+	}
+
+	.slider {
+		position: absolute;
+		cursor: pointer;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		background-color: #ccc;
+		transition: 0.2s;
+		border-radius: 34px;
+	}
+
+	.slider:before {
+		position: absolute;
+		content: "";
+		height: 16px;
+		width: 16px;
+		left: 4px;
+		bottom: 4px;
+		background-color: white;
+		transition: 0.2s;
+		border-radius: 50%;
+	}
+
+	input:checked + .slider {
+		background-color: var(--primary-color);
+	}
+
+	input:focus + .slider {
+		box-shadow: 0 0 1px var(--primary-color);
+	}
+
+	input:checked + .slider:before {
+		transform: translateX(16px);
+	}
+}
 </style>

+ 36 - 4
frontend/src/components/ActivityItem.vue

@@ -46,7 +46,12 @@ export default {
 	},
 	computed: {
 		formattedMessage() {
-			const { youtubeId, playlistId, stationId } = this.activity.payload;
+			const {
+				youtubeId,
+				playlistId,
+				stationId,
+				reportId
+			} = this.activity.payload;
 			let { message } = this.activity.payload;
 
 			if (youtubeId) {
@@ -56,6 +61,13 @@ export default {
 				);
 			}
 
+			if (reportId) {
+				message = message.replace(
+					/<reportId>(.*)<\/reportId>/g,
+					`<a href='#' class='activity-item-link' @click='showReport("${reportId}")'>report</a>`
+				);
+			}
+
 			if (playlistId) {
 				message = message.replace(
 					/<playlistId>(.*)<\/playlistId>/g,
@@ -72,13 +84,28 @@ export default {
 
 			return {
 				template: `<p>${message}</p>`,
-				methods: { showPlaylist: this.showPlaylist }
+				methods: {
+					showPlaylist: this.showPlaylist,
+					showReport: this.showReport
+				}
 			};
 		},
 		textOnlyMessage() {
-			const { youtubeId, playlistId, stationId } = this.activity.payload;
+			const {
+				youtubeId,
+				playlistId,
+				stationId,
+				reportId
+			} = this.activity.payload;
 			let { message } = this.activity.payload;
 
+			if (reportId) {
+				message = message.replace(
+					/<reportId>(.*)<\/reportId>/g,
+					"report"
+				);
+			}
+
 			if (youtubeId) {
 				message = message.replace(
 					/<youtubeId>(.*)<\/youtubeId>/g,
@@ -154,6 +181,10 @@ export default {
 
 			return icons[this.activity.type];
 		},
+		showReport(reportId) {
+			this.viewReport(reportId);
+			this.openModal("viewReport");
+		},
 		showPlaylist(playlistId) {
 			this.editPlaylist(playlistId);
 			this.openModal("editPlaylist");
@@ -161,7 +192,8 @@ export default {
 		...mapActions("user/playlists", ["editPlaylist"]),
 		formatDistance,
 		parseISO,
-		...mapActions("modalVisibility", ["openModal"])
+		...mapActions("modalVisibility", ["openModal"]),
+		...mapActions("modals/viewReport", ["viewReport"])
 	}
 };
 </script>

+ 74 - 49
frontend/src/components/AddToPlaylistDropdown.vue

@@ -1,10 +1,11 @@
 <template>
 	<tippy
 		class="addToPlaylistDropdown"
-		touch="true"
-		interactive="true"
+		:touch="true"
+		:interactive="true"
 		:placement="placement"
 		theme="addToPlaylist"
+		ref="dropdown"
 		trigger="click"
 		append-to="parent"
 		@show="
@@ -18,37 +19,48 @@
 			}
 		"
 	>
-		<template #trigger>
-			<slot name="button" />
-		</template>
+		<slot name="button" ref="trigger" />
+
+		<template #content>
+			<div class="nav-dropdown-items" v-if="playlists.length > 0">
+				<button
+					class="nav-item"
+					href="#"
+					v-for="(playlist, index) in playlists"
+					:key="playlist._id"
+					@click.prevent="toggleSongInPlaylist(index)"
+					:title="playlist.displayName"
+				>
+					<p class="control is-expanded checkbox-control">
+						<label class="switch">
+							<input
+								type="checkbox"
+								:id="index"
+								:checked="hasSong(playlist)"
+								@click="toggleSongInPlaylist(index)"
+							/>
+							<span class="slider round"></span>
+						</label>
+						<label :for="index">
+							<span></span>
+							<p>{{ playlist.displayName }}</p>
+						</label>
+					</p>
+				</button>
+			</div>
+			<p v-else>You haven't created any playlists.</p>
 
-		<div class="nav-dropdown-items" v-if="playlists.length > 0">
 			<button
-				class="nav-item"
-				href="#"
-				v-for="(playlist, index) in playlists"
-				:key="playlist._id"
-				@click.prevent="toggleSongInPlaylist(index)"
-				:title="playlist.displayName"
+				id="create-playlist"
+				class="button is-primary"
+				@click="createPlaylist()"
 			>
-				<p class="control is-expanded checkbox-control">
-					<label class="switch">
-						<input
-							type="checkbox"
-							:id="index"
-							:checked="hasSong(playlist)"
-							@click="toggleSongInPlaylist(index)"
-						/>
-						<span class="slider round"></span>
-					</label>
-					<label :for="index">
-						<span></span>
-						<p>{{ playlist.displayName }}</p>
-					</label>
-				</p>
+				<i class="material-icons icon-with-button">
+					edit
+				</i>
+				Create Playlist
 			</button>
-		</div>
-		<p v-else>You haven't created any playlists.</p>
+		</template>
 	</tippy>
 </template>
 
@@ -74,39 +86,29 @@ export default {
 		...mapState({
 			fetchedPlaylists: state => state.user.playlists.fetchedPlaylists
 		}),
-		playlists: {
-			get() {
-				return this.$store.state.user.playlists.playlists.filter(
-					playlist => playlist.isUserModifiable
-				);
-			},
-			set(playlists) {
-				this.$store.commit("user/playlists/setPlaylists", playlists);
-			}
+		playlists() {
+			return this.$store.state.user.playlists.playlists.filter(
+				playlist => playlist.isUserModifiable
+			);
 		}
 	},
 	mounted() {
 		if (!this.fetchedPlaylists)
-			this.socket.dispatch("playlists.indexMyPlaylists", false, res => {
+			this.socket.dispatch("playlists.indexMyPlaylists", true, res => {
 				if (res.status === "success")
-					this.setPlaylists(res.data.playlists);
+					if (!this.fetchedPlaylists)
+						this.setPlaylists(res.data.playlists);
 			});
 
 		this.socket.on(
 			"event:playlist.created",
-			res => this.playlists.push(res.data.playlist),
+			res => this.addPlaylist(res.data.playlist),
 			{ replaceable: true }
 		);
 
 		this.socket.on(
 			"event:playlist.deleted",
-			res => {
-				this.playlists.forEach((playlist, index) => {
-					if (playlist._id === res.data.playlistId) {
-						this.playlists.splice(index, 1);
-					}
-				});
-			},
+			res => this.removePlaylist(res.data.playlistId),
 			{ replaceable: true }
 		);
 
@@ -149,7 +151,22 @@ export default {
 				-1
 			);
 		},
-		...mapActions("user/playlists", ["setPlaylists"])
+		createPlaylist() {
+			this.$refs.dropdown.tippy.setProps({
+				zIndex: 0,
+				hideOnClick: false
+			});
+
+			window.addToPlaylistDropdown = this.$refs.dropdown;
+
+			this.openModal("createPlaylist");
+		},
+		...mapActions("user/playlists", [
+			"setPlaylists",
+			"addPlaylist",
+			"removePlaylist"
+		]),
+		...mapActions("modalVisibility", ["openModal"])
 	}
 };
 </script>
@@ -158,4 +175,12 @@ export default {
 .nav-dropdown-items button .control {
 	margin-bottom: 0 !important;
 }
+
+#create-playlist {
+	margin-top: 10px;
+
+	i {
+		color: #fff;
+	}
+}
 </style>

+ 42 - 32
frontend/src/components/Confirm.vue

@@ -1,22 +1,25 @@
 <template>
 	<tippy
-		interactive="true"
-		touch="true"
+		:interactive="true"
+		:touch="true"
 		:placement="placement"
 		theme="confirm"
 		ref="confirm"
 		trigger="click"
-		class="button-with-tooltip"
-		@hide="clickedOnce = false"
+		:append-to="body"
+		@hide="delayedHide()"
 	>
-		<template #trigger>
-			<div @click.shift.stop="confirm(true)" @click.exact="confirm()">
-				<slot />
-			</div>
+		<div
+			@click.shift.stop="shiftClick($event)"
+			@click.exact="click($event)"
+		>
+			<slot ref="trigger" />
+		</div>
+		<template #content>
+			<a @click="confirm($event)">
+				Click to Confirm
+			</a>
 		</template>
-		<a @click="confirm(null, $event)">
-			Click to Confirm
-		</a>
 	</tippy>
 </template>
 
@@ -28,34 +31,41 @@ export default {
 			default: "top"
 		}
 	},
+	emits: ["confirm"],
 	data() {
 		return {
-			clickedOnce: false
+			clickedOnce: false,
+			body: document.body
 		};
 	},
+
 	methods: {
-		confirm(confirm, event) {
-			if (confirm === null) {
-				/* eslint-disable no-param-reassign */
-				if (
-					event &&
-					event.type === "click" &&
-					!event.altKey &&
-					!event.ctrlKey &&
-					!event.metaKey &&
-					!event.shiftKey
-				)
-					confirm = true;
-				else confirm = false;
-			}
+		// eslint-disable-next-line no-unused-vars
+		confirm(event) {
+			if (
+				!event ||
+				event.type !== "click" ||
+				event.altKey ||
+				event.ctrlKey ||
+				event.metaKey
+			)
+				return;
 
-			if (confirm === false) {
-				this.clickedOnce = false;
-				this.$refs.confirm.tip.hide();
-			} else if (confirm === true || this.clickedOnce === true) {
+			this.clickedOnce = false;
+			this.$emit("confirm");
+			this.$refs.confirm.tippy.hide();
+		},
+		click(event) {
+			if (!this.clickedOnce) this.clickedOnce = true;
+			else this.confirm(event);
+		},
+		shiftClick(event) {
+			this.confirm(event);
+		},
+		delayedHide() {
+			setTimeout(() => {
 				this.clickedOnce = false;
-				this.$emit("confirm");
-			} else this.clickedOnce = true;
+			}, 25);
 		}
 	}
 };

+ 32 - 9
frontend/src/components/Modal.vue

@@ -89,18 +89,41 @@ p {
 }
 
 .modal-card-foot {
-	overflow: auto;
+	overflow: initial;
 
-	& > div {
-		display: flex;
-		flex-grow: 1;
-		column-gap: 16px;
+	&::v-deep {
+		& > div {
+			display: flex;
+			flex-grow: 1;
+			column-gap: 16px;
+		}
+
+		.right {
+			margin-left: auto;
+			justify-content: flex-end;
+			column-gap: 16px;
+		}
 	}
+}
+
+@media screen and (max-width: 600px) {
+	.modal-card {
+		max-height: none;
+
+		.modal-card-body {
+			// padding: 0;
+		}
+
+		.modal-card-head,
+		.modal-card-foot {
+			border-radius: 0;
+		}
+	}
+}
 
-	.right {
-		margin-left: auto;
-		justify-content: flex-end;
-		column-gap: 16px;
+@media screen and (max-height: 650px) {
+	.modal-card {
+		height: 100%;
 	}
 }
 </style>

+ 3 - 1
frontend/src/components/PlaylistItem.vue

@@ -31,7 +31,9 @@
 			</p>
 		</div>
 		<div class="universal-item-actions">
-			<slot name="actions" />
+			<div class="icons-group">
+				<slot name="actions" />
+			</div>
 		</div>
 	</div>
 </template>

+ 60 - 58
frontend/src/components/Queue.vue

@@ -1,71 +1,73 @@
 <template>
 	<div id="queue">
-		<draggable
+		<div
+			v-if="queue.length > 0"
 			:class="{
 				'actionable-button-hidden': !actionableButtonVisible,
 				'scrollable-list': true
 			}"
-			v-if="queue.length > 0"
-			v-model="queue"
-			v-bind="dragOptions"
-			@start="drag = true"
-			@end="drag = false"
-			@change="repositionSongInQueue"
 		>
-			<transition-group
-				type="transition"
-				:name="!drag ? 'draggable-list-transition' : null"
+			<draggable
+				tag="transition-group"
+				:component-data="{
+					name: !drag ? 'draggable-list-transition' : null
+				}"
+				v-model="queue"
+				item-key="_id"
+				v-bind="dragOptions"
+				@start="drag = true"
+				@end="drag = false"
+				@change="repositionSongInQueue"
 			>
-				<song-item
-					v-for="(song, index) in queue"
-					:key="`queue-${song._id}`"
-					:song="song"
-					:requested-by="
-						station.type === 'community' &&
-							station.partyMode === true
-					"
-					:class="{
-						'item-draggable': isAdminOnly() || isOwnerOnly()
-					}"
-					:disabled-actions="[]"
-				>
-					<div
-						v-if="isAdminOnly() || isOwnerOnly()"
-						class="song-actions"
-						slot="actions"
+				<template #item="{element, index}">
+					<song-item
+						:song="element"
+						:requested-by="
+							station.type === 'community' &&
+								station.partyMode === true
+						"
+						:class="{
+							'item-draggable': isAdminOnly() || isOwnerOnly()
+						}"
+						:disabled-actions="[]"
 					>
-						<confirm
-							v-if="isOwnerOnly() || isAdminOnly()"
-							placement="left"
-							@confirm="removeFromQueue(song.youtubeId)"
+						<template
+							v-if="isAdminOnly() || isOwnerOnly()"
+							#actions
 						>
+							<confirm
+								v-if="isOwnerOnly() || isAdminOnly()"
+								placement="left"
+								@confirm="removeFromQueue(element.youtubeId)"
+							>
+								<i
+									class="material-icons delete-icon"
+									content="Remove Song from Queue"
+									v-tippy
+									>delete_forever</i
+								>
+							</confirm>
 							<i
-								class="material-icons delete-icon"
-								content="Remove Song from Queue"
+								class="material-icons"
+								v-if="index > 0"
+								@click="moveSongToTop(element, index)"
+								content="Move to top of Queue"
 								v-tippy
-								>delete_forever</i
+								>vertical_align_top</i
 							>
-						</confirm>
-						<i
-							class="material-icons"
-							v-if="index > 0"
-							@click="moveSongToTop(song, index)"
-							content="Move to top of Queue"
-							v-tippy
-							>vertical_align_top</i
-						>
-						<i
-							v-if="queue.length - 1 !== index"
-							@click="moveSongToBottom(song, index)"
-							class="material-icons"
-							content="Move to bottom of Queue"
-							v-tippy
-							>vertical_align_bottom</i
-						>
-					</div>
-				</song-item>
-			</transition-group>
-		</draggable>
+							<i
+								v-if="queue.length - 1 !== index"
+								@click="moveSongToBottom(element, index)"
+								class="material-icons"
+								content="Move to bottom of Queue"
+								v-tippy
+								>vertical_align_bottom</i
+							>
+						</template>
+					</song-item>
+				</template>
+			</draggable>
+		</div>
 		<p class="nothing-here-text" v-else>
 			There are no songs currently queued
 		</p>
@@ -83,7 +85,7 @@
 			@click="openModal('manageStation') & showManageStationTab('search')"
 		>
 			<i class="material-icons icon-with-button">queue</i>
-			<span class="optional-desktop-only-text"> Add Song To Queue </span>
+			<span> Add Song To Queue </span>
 		</button>
 		<button
 			class="button is-primary tab-actionable-button"
@@ -93,7 +95,7 @@
 			@click="openModal('requestSong')"
 		>
 			<i class="material-icons icon-with-button">queue</i>
-			<span class="optional-desktop-only-text"> Request Song </span>
+			<span> Request Song </span>
 		</button>
 		<button
 			class="button is-primary tab-actionable-button disabled"
@@ -109,7 +111,7 @@
 			v-tippy="{ theme: 'info' }"
 		>
 			<i class="material-icons icon-with-button">queue</i>
-			<span class="optional-desktop-only-text"> Add Song To Queue </span>
+			<span> Add Song To Queue </span>
 		</button>
 		<div
 			id="queue-locked"

+ 101 - 0
frontend/src/components/ReportInfoItem.vue

@@ -0,0 +1,101 @@
+<template>
+	<div class="universal-item report-info-item">
+		<div class="item-icon">
+			<profile-picture
+				:avatar="createdBy.avatar"
+				:name="createdBy.name ? createdBy.name : createdBy.username"
+				v-if="createdBy.avatar"
+			/>
+			<i class="material-icons" v-else>person_remove</i>
+		</div>
+
+		<div class="item-title-description">
+			<h2 class="item-title">
+				Reported by
+				<router-link
+					v-if="createdBy.username"
+					:to="{
+						path: `/u/${createdBy.username}`
+					}"
+					:title="createdBy._id"
+					@click="closeModal('viewReport')"
+				>
+					{{ createdBy.username }}
+				</router-link>
+				<span v-else :title="createdBy._id">Deleted User</span>
+			</h2>
+			<h5 class="item-description">
+				{{
+					formatDistance(new Date(createdAt), new Date(), {
+						addSuffix: true
+					})
+				}}
+			</h5>
+		</div>
+
+		<div class="universal-item-actions">
+			<slot name="actions" />
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapActions } from "vuex";
+import { formatDistance } from "date-fns";
+
+import ProfilePicture from "@/components/ProfilePicture.vue";
+
+export default {
+	components: { ProfilePicture },
+	props: {
+		createdBy: { type: Object, default: () => {} },
+		createdAt: { type: String, default: "" }
+	},
+	methods: {
+		formatDistance,
+		...mapActions("modalVisibility", ["closeModal"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.night-mode {
+	.report-info-item {
+		background-color: var(--dark-grey-2) !important;
+		border: 0 !important;
+	}
+}
+
+.report-info-item {
+	.item-icon {
+		min-width: 45px;
+		max-width: 45px;
+		height: 45px;
+		margin-right: 10px;
+
+		.profile-picture,
+		i {
+			width: 45px;
+			height: 45px;
+		}
+
+		i {
+			font-size: 30px;
+			display: flex;
+			align-items: center;
+			justify-content: center;
+		}
+	}
+
+	.item-title {
+		font-size: 14px;
+		margin: 0;
+	}
+
+	.item-description {
+		font-size: 12px;
+		text-transform: capitalize;
+		margin: 0;
+	}
+}
+</style>

+ 1 - 0
frontend/src/components/SaveButton.vue

@@ -17,6 +17,7 @@ export default {
 	props: {
 		type: { type: String, default: "save" } // enum: ["save", "save-and-close"]
 	},
+	emits: ["clicked"],
 	data() {
 		return {
 			status: "default" // enum: ["default", "disabled", "save-failure", "save-success"],

+ 62 - 54
frontend/src/components/SongItem.vue

@@ -72,67 +72,75 @@
 			>
 				<tippy
 					v-if="loggedIn"
-					touch="true"
-					interactive="true"
+					:touch="true"
+					:interactive="true"
 					placement="left"
 					theme="songActions"
 					ref="songActions"
 					trigger="click"
 				>
-					<template #trigger>
-						<i
-							class="material-icons action-dropdown-icon"
-							content="Song Options"
-							v-tippy
-							>more_horiz</i
-						>
-					</template>
-					<a
-						v-if="disabledActions.indexOf('youtube') === -1"
-						target="_blank"
-						:href="
-							`https://www.youtube.com/watch?v=${song.youtubeId}`
-						"
-						content="View on Youtube"
-						v-tippy
-					>
-						<div class="youtube-icon"></div>
-					</a>
 					<i
-						v-if="disabledActions.indexOf('report') === -1"
-						class="material-icons report-icon"
-						@click="report(song)"
-						content="Report Song"
+						class="material-icons action-dropdown-icon"
+						content="Song Options"
 						v-tippy
+						>more_horiz</i
 					>
-						flag
-					</i>
-					<add-to-playlist-dropdown
-						v-if="disabledActions.indexOf('addToPlaylist') === -1"
-						:song="song"
-					>
-						<i
-							slot="button"
-							class="material-icons add-to-playlist-icon"
-							content="Add Song to Playlist"
-							v-tippy
-							>playlist_add</i
-						>
-					</add-to-playlist-dropdown>
-					<i
-						v-if="
-							loggedIn &&
-								userRole === 'admin' &&
-								disabledActions.indexOf('edit') === -1
-						"
-						class="material-icons edit-icon"
-						@click="edit(song)"
-						content="Edit Song"
-						v-tippy
-					>
-						edit
-					</i>
-					<slot name="actions" />
+
+					<template #content>
+						<div class="icons-group">
+							<a
+								v-if="disabledActions.indexOf('youtube') === -1"
+								target="_blank"
+								:href="
+									`https://www.youtube.com/watch?v=${song.youtubeId}`
+								"
+								content="View on Youtube"
+								v-tippy
+							>
+								<div class="youtube-icon"></div>
+							</a>
+							<i
+								v-if="disabledActions.indexOf('report') === -1"
+								class="material-icons report-icon"
+								@click="report(song)"
+								content="Report Song"
+								v-tippy
+							>
+								flag
+							</i>
+							<add-to-playlist-dropdown
+								v-if="
+									disabledActions.indexOf('addToPlaylist') ===
+										-1
+								"
+								:song="song"
+								placement="top-end"
+							>
+								<template #button>
+									<i
+										class="material-icons add-to-playlist-icon"
+										content="Add Song to Playlist"
+										v-tippy
+										>playlist_add</i
+									>
+								</template>
+							</add-to-playlist-dropdown>
+							<i
+								v-if="
+									loggedIn &&
+										userRole === 'admin' &&
+										disabledActions.indexOf('edit') === -1
+								"
+								class="material-icons edit-icon"
+								@click="edit(song)"
+								content="Edit Song"
+								v-tippy
+							>
+								edit
+							</i>
+							<slot name="actions" />
+						</div>
+					</template>
 				</tippy>
 				<a
 					v-if="
@@ -210,7 +218,7 @@ export default {
 			return null;
 		},
 		hideTippyElements() {
-			this.$refs.songActions.tip.hide();
+			this.$refs.songActions.tippy.hide();
 
 			setTimeout(
 				() =>

+ 2 - 0
frontend/src/components/SongThumbnail.vue

@@ -38,11 +38,13 @@
 						song.thumbnail === 'empty' ||
 						song.thumbnail == null)
 			"
+			loading="lazy"
 			:src="`https://img.youtube.com/vi/${song.youtubeId}/mqdefault.jpg`"
 			onerror="this.src='/assets/notes-transparent.png'"
 		/>
 		<img
 			v-else
+			loading="lazy"
 			:src="song.thumbnail"
 			onerror="this.src='/assets/notes-transparent.png'"
 		/>

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

@@ -9,10 +9,7 @@
 					><img src="/assets/blue_wordmark.png" alt="Musare"
 				/></a>
 				<div id="footer-links">
-					<a
-						:href="`${this.github}`"
-						target="_blank"
-						title="GitHub Repository"
+					<a :href="github" target="_blank" title="GitHub Repository"
 						>GitHub</a
 					>
 					<router-link title="About Musare" to="/about"
@@ -88,6 +85,7 @@ export default {
 		margin-right: auto;
 		width: 160px;
 		order: 1;
+		user-select: none;
 	}
 
 	#footer-links {

+ 70 - 14
frontend/src/components/layout/MainHeader.vue

@@ -3,8 +3,8 @@
 		<div class="nav-left">
 			<router-link v-if="!hideLogo" class="nav-item is-brand" to="/">
 				<img
-					:src="`${this.siteSettings.logo_white}`"
-					:alt="`${this.siteSettings.sitename}` || `Musare`"
+					:src="siteSettings.logo_white"
+					:alt="siteSettings.sitename || `Musare`"
 				/>
 			</router-link>
 		</div>
@@ -24,14 +24,14 @@
 		<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
 			<router-link
 				v-if="role === 'admin'"
-				class="nav-item is-tab admin"
+				class="nav-item admin"
 				to="/admin"
 			>
 				<strong>Admin</strong>
 			</router-link>
 			<span v-if="loggedIn" class="grouped">
 				<router-link
-					class="nav-item is-tab"
+					class="nav-item "
 					:to="{
 						name: 'profile',
 						params: { username }
@@ -39,10 +39,10 @@
 				>
 					Profile
 				</router-link>
-				<router-link class="nav-item is-tab" to="/settings"
+				<router-link class="nav-item" to="/settings"
 					>Settings</router-link
 				>
-				<a class="nav-item is-tab" href="#" @click="logout()">Logout</a>
+				<a class="nav-item" href="#" @click="logout()">Logout</a>
 			</span>
 			<span v-if="!loggedIn && !hideLoggedOut" class="grouped">
 				<a class="nav-item" href="#" @click="openModal('login')"
@@ -52,12 +52,29 @@
 					>Register</a
 				>
 			</span>
+			<div class="nav-item" id="nightmode-toggle">
+				<p class="is-expanded checkbox-control">
+					<label class="switch">
+						<input
+							type="checkbox"
+							id="instant-nightmode"
+							v-model="localNightmode"
+						/>
+						<span class="slider round"></span>
+					</label>
+
+					<label for="instant-nightmode">
+						<p>Nightmode</p>
+					</label>
+				</p>
+			</div>
 		</div>
 	</nav>
 </template>
 
 <script>
-import { mapState, mapActions } from "vuex";
+import Toast from "toasters";
+import { mapState, mapGetters, mapActions } from "vuex";
 
 export default {
 	props: {
@@ -67,6 +84,7 @@ export default {
 	},
 	data() {
 		return {
+			localNightmode: null,
 			isMobile: false,
 			frontendDomain: "",
 			siteSettings: {
@@ -75,20 +93,57 @@ export default {
 			}
 		};
 	},
-	computed: mapState({
-		modals: state => state.modalVisibility.modals.header,
-		role: state => state.user.auth.role,
-		loggedIn: state => state.user.auth.loggedIn,
-		username: state => state.user.auth.username
-	}),
+	computed: {
+		...mapState({
+			modals: state => state.modalVisibility.modals.header,
+			role: state => state.user.auth.role,
+			loggedIn: state => state.user.auth.loggedIn,
+			username: state => state.user.auth.username
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	watch: {
+		localNightmode(newValue, oldValue) {
+			if (oldValue === null) return;
+
+			localStorage.setItem("nightmode", this.localNightmode);
+
+			if (this.loggedIn) {
+				this.socket.dispatch(
+					"users.updatePreferences",
+					{ nightmode: this.localNightmode },
+					res => {
+						if (res.status !== "success") new Toast(res.message);
+					}
+				);
+			}
+
+			this.changeNightmode(this.localNightmode);
+		}
+	},
 	async mounted() {
+		this.localNightmode = JSON.parse(localStorage.getItem("nightmode"));
+
+		this.socket.dispatch("users.getPreferences", res => {
+			if (res.status === "success")
+				this.localNightmode = res.data.preferences.nightmode;
+		});
+
+		this.socket.on("keep.event:user.preferences.updated", res => {
+			if (res.data.preferences.nightmode !== undefined)
+				this.localNightmode = res.data.preferences.nightmode;
+		});
+
 		this.frontendDomain = await lofig.get("frontendDomain");
 		this.siteSettings = await lofig.get("siteSettings");
 	},
 
 	methods: {
 		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("user/auth", ["logout"])
+		...mapActions("user/auth", ["logout"]),
+		...mapActions("user/preferences", ["changeNightmode"])
 	}
 };
 </script>
@@ -156,6 +211,7 @@ export default {
 		img {
 			max-height: 38px;
 			color: var(--primary-color);
+			user-select: none;
 		}
 	}
 

+ 14 - 2
frontend/src/components/modals/CreatePlaylist.vue

@@ -53,6 +53,15 @@ export default {
 	computed: mapGetters({
 		socket: "websockets/getSocket"
 	}),
+	unmounted() {
+		if (window.addToPlaylistDropdown)
+			window.addToPlaylistDropdown.tippy.setProps({
+				zIndex: 9999,
+				hideOnClick: true
+			});
+
+		window.addToPlaylistDropdown = null;
+	},
 	methods: {
 		createPlaylist() {
 			const { displayName } = this.playlist;
@@ -73,9 +82,12 @@ export default {
 					new Toast(res.message);
 
 					if (res.status === "success") {
+						if (!window.addToPlaylistDropdown) {
+							this.editPlaylist(res.data.playlistId);
+							this.openModal("editPlaylist");
+						}
+
 						this.closeModal("createPlaylist");
-						this.editPlaylist(res.data.playlistId);
-						this.openModal("editPlaylist");
 					}
 				}
 			);

+ 36 - 34
frontend/src/components/modals/EditNews.vue

@@ -3,7 +3,7 @@
 		class="edit-news-modal"
 		:title="newsId ? 'Edit News' : 'Create News'"
 	>
-		<div slot="body">
+		<template #body>
 			<div id="markdown-editor-and-preview">
 				<div class="column">
 					<p><strong>Markdown</strong></p>
@@ -18,42 +18,44 @@
 					></div>
 				</div>
 			</div>
-		</div>
-		<div slot="footer">
-			<p class="control select">
-				<select v-model="status">
-					<option value="draft">Draft</option>
-					<option value="published" selected>Publish</option>
-				</select>
-			</p>
+		</template>
+		<template #footer>
+			<div>
+				<p class="control select">
+					<select v-model="status">
+						<option value="draft">Draft</option>
+						<option value="published" selected>Publish</option>
+					</select>
+				</p>
 
-			<save-button
-				ref="saveButton"
-				v-if="newsId"
-				@clicked="newsId ? update(false) : create(false)"
-			/>
+				<save-button
+					ref="saveButton"
+					v-if="newsId"
+					@clicked="newsId ? update(false) : create(false)"
+				/>
 
-			<save-button
-				ref="saveAndCloseButton"
-				type="save-and-close"
-				@clicked="newsId ? update(true) : create(true)"
-			/>
-			<div class="right" v-if="createdAt > 0">
-				<span>
-					By
-					<user-id-to-username
-						:user-id="createdBy"
-						:alt="createdBy"
-						:link="true"/></span
-				><span :title="new Date(createdAt)">
-					{{
-						formatDistance(createdAt, new Date(), {
-							addSuffix: true
-						})
-					}}
-				</span>
+				<save-button
+					ref="saveAndCloseButton"
+					type="save-and-close"
+					@clicked="newsId ? update(true) : create(true)"
+				/>
+				<div class="right" v-if="createdAt > 0">
+					<span>
+						By
+						<user-id-to-username
+							:user-id="createdBy"
+							:alt="createdBy"
+							:link="true"/></span
+					><span :title="new Date(createdAt)">
+						{{
+							formatDistance(createdAt, new Date(), {
+								addSuffix: true
+							})
+						}}
+					</span>
+				</div>
 			</div>
-		</div>
+		</template>
 	</modal>
 </template>
 

+ 18 - 104
frontend/src/components/modals/EditPlaylist/Tabs/Youtube.vue → frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue

@@ -1,37 +1,5 @@
 <template>
 	<div class="youtube-tab section">
-		<label class="label">
-			Search for a playlist from YouTube
-		</label>
-		<div class="control is-grouped input-with-button">
-			<p class="control is-expanded">
-				<input
-					class="input"
-					type="text"
-					placeholder="Enter YouTube Playlist URL here..."
-					v-model="search.playlist.query"
-					@keyup.enter="importPlaylist()"
-				/>
-			</p>
-			<p class="control has-addons">
-				<span class="select" id="playlist-import-type">
-					<select v-model="search.playlist.isImportingOnlyMusic">
-						<option :value="false">Import all</option>
-						<option :value="true">
-							Import only music
-						</option>
-					</select>
-				</span>
-				<a
-					class="button is-info"
-					@click.prevent="importPlaylist()"
-					href="#"
-					><i class="material-icons icon-with-button">publish</i
-					>Import</a
-				>
-			</p>
-		</div>
-
 		<label class="label">
 			Search for a song from YouTube
 		</label>
@@ -63,7 +31,7 @@
 				:key="result.id"
 				:result="result"
 			>
-				<div slot="actions">
+				<template #actions>
 					<transition name="search-query-actions" mode="out-in">
 						<a
 							class="button is-success"
@@ -85,7 +53,7 @@
 							Add to playlist
 						</a>
 					</transition>
-				</div>
+				</template>
 			</search-query-item>
 
 			<a
@@ -100,8 +68,7 @@
 </template>
 
 <script>
-import { mapState, mapGetters /* , mapActions */ } from "vuex";
-import Toast from "toasters";
+import { mapState, mapGetters } from "vuex";
 
 import SearchYoutube from "@/mixins/SearchYoutube.vue";
 
@@ -143,82 +110,29 @@ export default {
 				})
 			);
 		}
-	},
-	mounted() {},
-	methods: {
-		importPlaylist() {
-			let isImportingPlaylist = true;
-
-			// import query is blank
-			if (!this.search.playlist.query)
-				return new Toast("Please enter a YouTube playlist URL.");
-
-			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
-			const splitQuery = regex.exec(this.search.playlist.query);
-
-			if (!splitQuery) {
-				return new Toast({
-					content: "Please enter a valid YouTube playlist URL.",
-					timeout: 4000
-				});
-			}
-
-			// don't give starting import message instantly in case of instant error
-			setTimeout(() => {
-				if (isImportingPlaylist) {
-					new Toast(
-						"Starting to import your playlist. This can take some time to do."
-					);
-				}
-			}, 750);
-
-			return this.socket.dispatch(
-				"playlists.addSetToPlaylist",
-				this.search.playlist.query,
-				this.playlist._id,
-				this.search.playlist.isImportingOnlyMusic,
-				res => {
-					new Toast({ content: res.message, timeout: 20000 });
-					if (res.status === "success") {
-						isImportingPlaylist = false;
-						if (this.search.playlist.isImportingOnlyMusic) {
-							new Toast({
-								content: `${res.data.stats.songsInPlaylistTotal} of the ${res.data.stats.videosInPlaylistTotal} videos in the playlist were songs.`,
-								timeout: 20000
-							});
-						}
-					}
-				}
-			);
-		}
-		// ...mapActions("modals/editSong", ["selectDiscogsInfo"])
 	}
 };
 </script>
 
 <style lang="scss" scoped>
 .youtube-tab {
-	#import-from-youtube-section {
-		#playlist-import-type select {
-			border-radius: 0;
-		}
-
-		#song-query-results {
-			padding: 10px;
-			margin-top: 10px;
-			border: 1px solid var(--light-grey-3);
-			border-radius: 3px;
-			max-width: 565px;
-
-			.search-query-item:not(:last-of-type) {
-				margin-bottom: 10px;
-			}
+	#song-query-results {
+		padding: 10px;
+		margin-top: 10px;
+		border: 1px solid var(--light-grey-3);
+		border-radius: 3px;
+		max-width: 565px;
+		max-height: 500px;
+		overflow: auto;
+
+		.search-query-item:not(:last-of-type) {
+			margin-bottom: 10px;
 		}
+	}
 
-		.load-more-button {
-			width: 100%;
-			margin-top: 10px;
-		}
+	.load-more-button {
+		width: 100%;
+		margin-top: 10px;
 	}
 }
 

+ 117 - 0
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -0,0 +1,117 @@
+<template>
+	<div class="youtube-tab section">
+		<label class="label">
+			Search for a playlist from YouTube
+		</label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					placeholder="Enter YouTube Playlist URL here..."
+					v-model="search.playlist.query"
+					@keyup.enter="importPlaylist()"
+				/>
+			</p>
+			<p class="control has-addons">
+				<span class="select" id="playlist-import-type">
+					<select v-model="search.playlist.isImportingOnlyMusic">
+						<option :value="false">Import all</option>
+						<option :value="true">
+							Import only music
+						</option>
+					</select>
+				</span>
+				<a
+					class="button is-info"
+					@click.prevent="importPlaylist()"
+					href="#"
+					><i class="material-icons icon-with-button">publish</i
+					>Import</a
+				>
+			</p>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapState, mapGetters } from "vuex";
+import Toast from "toasters";
+
+import SearchYoutube from "@/mixins/SearchYoutube.vue";
+
+export default {
+	mixins: [SearchYoutube],
+	data() {
+		return {};
+	},
+	computed: {
+		...mapState("modals/editPlaylist", {
+			playlist: state => state.playlist
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		importPlaylist() {
+			let isImportingPlaylist = true;
+
+			// import query is blank
+			if (!this.search.playlist.query)
+				return new Toast("Please enter a YouTube playlist URL.");
+
+			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
+			const splitQuery = regex.exec(this.search.playlist.query);
+
+			if (!splitQuery) {
+				return new Toast({
+					content: "Please enter a valid YouTube playlist URL.",
+					timeout: 4000
+				});
+			}
+
+			// don't give starting import message instantly in case of instant error
+			setTimeout(() => {
+				if (isImportingPlaylist) {
+					new Toast(
+						"Starting to import your playlist. This can take some time to do."
+					);
+				}
+			}, 750);
+
+			return this.socket.dispatch(
+				"playlists.addSetToPlaylist",
+				this.search.playlist.query,
+				this.playlist._id,
+				this.search.playlist.isImportingOnlyMusic,
+				res => {
+					new Toast({ content: res.message, timeout: 20000 });
+					if (res.status === "success") {
+						isImportingPlaylist = false;
+						if (this.search.playlist.isImportingOnlyMusic) {
+							new Toast({
+								content: `${res.data.stats.songsInPlaylistTotal} of the ${res.data.stats.videosInPlaylistTotal} videos in the playlist were songs.`,
+								timeout: 20000
+							});
+						}
+					}
+				}
+			);
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+#playlist-import-type select {
+	border-radius: 0;
+}
+
+@media screen and (max-width: 1300px) {
+	.youtube-tab #song-query-results,
+	.section {
+		max-width: 100% !important;
+	}
+}
+</style>

+ 194 - 175
frontend/src/components/modals/EditPlaylist/index.vue

@@ -5,200 +5,204 @@
 		"
 		class="edit-playlist-modal"
 	>
-		<div
-			slot="body"
-			:class="{
-				'view-only': !isEditable(),
-				'custom-modal-body': true
-			}"
-		>
-			<div class="left-section">
-				<div id="playlist-info-section" class="section">
-					<h3>{{ playlist.displayName }}</h3>
-					<h5>Song Count: {{ playlist.songs.length }}</h5>
-					<h5>Duration: {{ totalLength() }}</h5>
-				</div>
+		<template #body>
+			<div
+				:class="{
+					'view-only': !isEditable(),
+					'custom-modal-body': true
+				}"
+			>
+				<div class="left-section">
+					<div id="playlist-info-section" class="section">
+						<h3>{{ playlist.displayName }}</h3>
+						<h5>Song Count: {{ playlist.songs.length }}</h5>
+						<h5>Duration: {{ totalLength() }}</h5>
+					</div>
 
-				<div class="section tabs-container">
-					<div class="tab-selection">
-						<button
-							class="button is-default"
-							:class="{ selected: tab === 'settings' }"
-							ref="settings-tab"
-							@click="showTab('settings')"
+					<div class="section tabs-container">
+						<div class="tab-selection">
+							<button
+								class="button is-default"
+								:class="{ selected: tab === 'settings' }"
+								ref="settings-tab"
+								@click="showTab('settings')"
+								v-if="
+									userId === playlist.createdBy ||
+										isEditable() ||
+										(playlist.type === 'genre' && isAdmin())
+								"
+							>
+								Settings
+							</button>
+							<button
+								class="button is-default"
+								:class="{ selected: tab === 'add-songs' }"
+								ref="add-songs-tab"
+								@click="showTab('add-songs')"
+								v-if="isEditable()"
+							>
+								Add Songs
+							</button>
+							<button
+								class="button is-default"
+								:class="{
+									selected: tab === 'import-playlists'
+								}"
+								ref="import-playlists-tab"
+								@click="showTab('import-playlists')"
+								v-if="isEditable()"
+							>
+								Import Playlists
+							</button>
+						</div>
+						<settings
+							class="tab"
+							v-show="tab === 'settings'"
 							v-if="
 								userId === playlist.createdBy ||
 									isEditable() ||
 									(playlist.type === 'genre' && isAdmin())
 							"
-						>
-							Settings
-						</button>
-						<button
-							class="button is-default"
-							:class="{ selected: tab === 'youtube' }"
-							ref="youtube-tab"
-							@click="showTab('youtube')"
+						/>
+						<add-songs
+							class="tab"
+							v-show="tab === 'add-songs'"
+							v-if="isEditable()"
+						/>
+						<import-playlists
+							class="tab"
+							v-show="tab === 'import-playlists'"
 							v-if="isEditable()"
-						>
-							YouTube
-						</button>
+						/>
 					</div>
-					<settings
-						class="tab"
-						v-show="tab === 'settings'"
-						v-if="
-							userId === playlist.createdBy ||
-								isEditable() ||
-								(playlist.type === 'genre' && isAdmin())
-						"
-					/>
-					<youtube
-						class="tab"
-						v-show="tab === 'youtube'"
-						v-if="isEditable()"
-					/>
 				</div>
-			</div>
-
-			<div class="right-section">
-				<div id="rearrange-songs-section" class="section">
-					<div v-if="isEditable()">
-						<h4 class="section-title">Rearrange Songs</h4>
 
-						<p class="section-description">
-							Drag and drop songs to change their order
-						</p>
-
-						<hr class="section-horizontal-rule" />
-					</div>
-
-					<aside class="menu">
-						<draggable
-							class="menu-list scrollable-list"
-							tag="ul"
-							v-if="playlistSongs.length > 0"
-							v-model="playlistSongs"
-							v-bind="dragOptions"
-							@start="drag = true"
-							@end="drag = false"
-							@change="repositionSong"
-						>
-							<transition-group
-								type="transition"
-								:name="
-									!drag ? 'draggable-list-transition' : null
-								"
+				<div class="right-section">
+					<div id="rearrange-songs-section" class="section">
+						<div v-if="isEditable()">
+							<h4 class="section-title">Rearrange Songs</h4>
+
+							<p class="section-description">
+								Drag and drop songs to change their order
+							</p>
+
+							<hr class="section-horizontal-rule" />
+						</div>
+
+						<aside class="menu">
+							<draggable
+								tag="transition-group"
+								:component-data="{
+									name: !drag
+										? 'draggable-list-transition'
+										: null
+								}"
+								v-if="playlistSongs.length > 0"
+								v-model="playlistSongs"
+								item-key="_id"
+								v-bind="dragOptions"
+								@start="drag = true"
+								@end="drag = false"
+								@change="repositionSong"
 							>
-								<li
-									v-for="(song, index) in playlistSongs"
-									:key="`key-${song._id}`"
-								>
-									<song-item
-										:song="song"
-										:class="{
-											'item-draggable': isEditable()
-										}"
-									>
-										<div
-											class="song-actions"
-											slot="actions"
+								<template #item="{element, index}">
+									<div class="menu-list scrollable-list">
+										<song-item
+											:song="element"
+											:class="{
+												'item-draggable': isEditable()
+											}"
 										>
-											<i
-												class="material-icons add-to-queue-icon"
-												v-if="
-													station.partyMode &&
-														!station.locked
-												"
-												@click="
-													addSongToQueue(
-														song.youtubeId
-													)
-												"
-												content="Add Song to Queue"
-												v-tippy
-												>queue</i
-											>
-											<confirm
-												v-if="
-													userId ===
-														playlist.createdBy ||
-														isEditable()
-												"
-												placement="left"
-												@confirm="
-													removeSongFromPlaylist(
-														song.youtubeId
-													)
-												"
-											>
+											<template #actions>
 												<i
-													class="material-icons delete-icon"
-													content="Remove Song from Playlist"
+													class="material-icons add-to-queue-icon"
+													v-if="
+														station.partyMode &&
+															!station.locked
+													"
+													@click="
+														addSongToQueue(
+															element.youtubeId
+														)
+													"
+													content="Add Song to Queue"
 													v-tippy
-													>delete_forever</i
+													>queue</i
+												>
+												<confirm
+													v-if="
+														userId ===
+															playlist.createdBy ||
+															isEditable()
+													"
+													placement="left"
+													@confirm="
+														removeSongFromPlaylist(
+															element.youtubeId
+														)
+													"
 												>
-											</confirm>
-											<i
-												class="material-icons"
-												v-if="isEditable() && index > 0"
-												@click="
-													moveSongToTop(song, index)
-												"
-												content="Move to top of Playlist"
-												v-tippy
-												>vertical_align_top</i
-											>
-											<i
-												v-if="
-													isEditable() &&
-														playlistSongs.length -
-															1 !==
+													<i
+														class="material-icons delete-icon"
+														content="Remove Song from Playlist"
+														v-tippy
+														>delete_forever</i
+													>
+												</confirm>
+												<i
+													class="material-icons"
+													v-if="
+														isEditable() &&
+															index > 0
+													"
+													@click="
+														moveSongToTop(
+															element,
 															index
-												"
-												@click="
-													moveSongToBottom(
-														song,
-														index
-													)
-												"
-												class="material-icons"
-												content="Move to bottom of Playlist"
-												v-tippy
-												>vertical_align_bottom</i
-											>
-										</div>
-									</song-item>
-								</li>
-							</transition-group>
-						</draggable>
-						<p v-else class="nothing-here-text">
-							This playlist doesn't have any songs.
-						</p>
-					</aside>
+														)
+													"
+													content="Move to top of Playlist"
+													v-tippy
+													>vertical_align_top</i
+												>
+												<i
+													v-if="
+														isEditable() &&
+															playlistSongs.length -
+																1 !==
+																index
+													"
+													@click="
+														moveSongToBottom(
+															element,
+															index
+														)
+													"
+													class="material-icons"
+													content="Move to bottom of Playlist"
+													v-tippy
+													>vertical_align_bottom</i
+												>
+											</template>
+										</song-item>
+									</div>
+								</template>
+							</draggable>
+							<p v-else class="nothing-here-text">
+								This playlist doesn't have any songs.
+							</p>
+						</aside>
+					</div>
 				</div>
 			</div>
-
-			<!--
-			
-			
-			<button
-				class="button is-info"
-				@click="shuffle()"
-				v-if="playlist.isUserModifiable"
-			>
-				Shuffle
-			</button>
-			<h5>Edit playlist details:</h5>
-			 -->
-		</div>
-		<div slot="footer">
+		</template>
+		<template #footer>
 			<a
 				class="button is-default"
 				v-if="
-					this.userId === this.playlist.createdBy ||
+					userId === playlist.createdBy ||
 						isEditable() ||
-						this.playlist.privacy === 'public'
+						playlist.privacy === 'public'
 				"
 				@click="downloadPlaylist()"
 				href="#"
@@ -226,7 +230,7 @@
 					<a class="button is-danger"> Remove Playlist </a>
 				</confirm>
 			</div>
-		</div>
+		</template>
 	</modal>
 </template>
 
@@ -240,12 +244,21 @@ import Modal from "../../Modal.vue";
 import SongItem from "../../SongItem.vue";
 
 import Settings from "./Tabs/Settings.vue";
-import Youtube from "./Tabs/Youtube.vue";
+import AddSongs from "./Tabs/AddSongs.vue";
+import ImportPlaylists from "./Tabs/ImportPlaylists.vue";
 
 import utils from "../../../../js/utils";
 
 export default {
-	components: { Modal, draggable, Confirm, SongItem, Settings, Youtube },
+	components: {
+		Modal,
+		draggable,
+		Confirm,
+		SongItem,
+		Settings,
+		AddSongs,
+		ImportPlaylists
+	},
 	data() {
 		return {
 			utils,
@@ -694,6 +707,12 @@ export default {
 		height: 100%;
 		overflow-y: auto;
 		flex-grow: 1;
+
+		#rearrange-songs-section {
+			.scrollable-list:not(:last-of-type) {
+				margin-bottom: 10px;
+			}
+		}
 	}
 }
 

+ 26 - 1
frontend/src/components/modals/EditSong/Tabs/Discogs.vue

@@ -109,6 +109,12 @@
 						Data quality:
 						<span>{{ result.dataQuality }}</span>
 					</p>
+					<button
+						class="button is-primary"
+						@click="importAlbum(result)"
+					>
+						Import album
+					</button>
 					<div class="tracks">
 						<div
 							class="track"
@@ -284,6 +290,25 @@ export default {
 </script>
 
 <style lang="scss" scoped>
+.night-mode {
+	.api-section,
+	.api-result {
+		background-color: var(--dark-grey-3) !important;
+	}
+
+	.api-result .tracks .track:hover,
+	.api-result .tracks .track:focus,
+	.selected-discogs-info {
+		background-color: var(--dark-grey-2) !important;
+	}
+
+	.label,
+	p,
+	strong {
+		color: var(--light-grey-2);
+	}
+}
+
 .discogs-tab {
 	// width: 376px;
 	// background-color: var(--light-grey);
@@ -358,7 +383,7 @@ export default {
 		}
 
 		.bottom-container-field:last-of-type {
-			margin-bottom: 0;
+			margin-bottom: 8px;
 		}
 	}
 

+ 412 - 12
frontend/src/components/modals/EditSong/Tabs/Reports.vue

@@ -1,17 +1,211 @@
 <template>
-	<div class="reports-tab">
-		Reports will display here
+	<div class="reports-tab tabs-container">
+		<div class="tab-selection">
+			<button
+				class="button is-default"
+				ref="sort-by-report-tab"
+				:class="{ selected: tab === 'sort-by-report' }"
+				@click="showTab('sort-by-report')"
+			>
+				Sort by Report
+			</button>
+			<button
+				class="button is-default"
+				ref="sort-by-category-tab"
+				:class="{ selected: tab === 'sort-by-category' }"
+				@click="showTab('sort-by-category')"
+			>
+				Sort by Category
+			</button>
+		</div>
+
+		<div class="tab" v-if="tab === 'sort-by-category'">
+			<div class="report-items" v-if="reports.length > 0">
+				<div
+					class="report-item"
+					v-for="(issues, category) in sortedByCategory"
+					:key="category"
+				>
+					<div class="report-item-header universal-item">
+						<i
+							class="material-icons"
+							:content="category"
+							v-tippy="{ theme: 'info' }"
+						>
+							{{ icons[category] }}
+						</i>
+
+						<p>{{ category }} Issues</p>
+					</div>
+					<div class="report-sub-items">
+						<div
+							class="report-sub-item report-sub-item-unresolved"
+							:class="[
+								'report',
+								issue.resolved
+									? 'report-sub-item-resolved'
+									: 'report-sub-item-unresolved'
+							]"
+							v-for="(issue, issueIndex) in issues"
+							:key="issueIndex"
+						>
+							<i
+								class="material-icons duration-icon report-sub-item-left-icon"
+								:content="issue.category"
+								v-tippy
+							>
+								{{ icons[category] }}
+							</i>
+
+							<p class="report-sub-item-info">
+								<span class="report-sub-item-title">
+									{{ issue.title }}
+								</span>
+								<span
+									class="report-sub-item-description"
+									v-if="issue.description"
+								>
+									{{ issue.description }}
+								</span>
+							</p>
+
+							<div
+								class="report-sub-item-actions universal-item-actions"
+							>
+								<i
+									class="material-icons resolve-icon"
+									content="Resolve"
+									v-tippy
+									v-if="!issue.resolved"
+									@click="
+										toggleIssue(issue.reportId, issue._id)
+									"
+								>
+									done
+								</i>
+								<i
+									class="material-icons unresolve-icon"
+									content="Unresolve"
+									v-tippy
+									v-else
+									@click="
+										toggleIssue(issue.reportId, issue._id)
+									"
+								>
+									remove
+								</i>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+			<p class="no-reports" v-else>There are no reports for this song.</p>
+		</div>
+
+		<div class="tab" v-if="tab === 'sort-by-report'">
+			<div class="report-items" v-if="reports.length > 0">
+				<div
+					class="report-item"
+					v-for="report in reports"
+					:key="report._id"
+				>
+					<report-info-item
+						:created-at="report.createdAt"
+						:created-by="report.createdBy"
+					>
+						<template #actions>
+							<i
+								class="material-icons resolve-icon"
+								content="Resolve all"
+								v-tippy
+								@click="resolve(report._id)"
+							>
+								done_all
+							</i>
+						</template>
+					</report-info-item>
+
+					<div class="report-sub-items">
+						<div
+							class="report-sub-item report-sub-item-unresolved"
+							:class="[
+								'report',
+								issue.resolved
+									? 'report-sub-item-resolved'
+									: 'report-sub-item-unresolved'
+							]"
+							v-for="(issue, issueIndex) in report.issues"
+							:key="issueIndex"
+						>
+							<i
+								class="material-icons duration-icon report-sub-item-left-icon"
+								:content="issue.category"
+								v-tippy
+							>
+								{{ icons[issue.category] }}
+							</i>
+							<p class="report-sub-item-info">
+								<span class="report-sub-item-title">
+									{{ issue.title }}
+								</span>
+								<span
+									class="report-sub-item-description"
+									v-if="issue.description"
+								>
+									{{ issue.description }}
+								</span>
+							</p>
+
+							<div
+								class="report-sub-item-actions universal-item-actions"
+							>
+								<i
+									class="material-icons resolve-icon"
+									content="Resolve"
+									v-tippy
+									v-if="!issue.resolved"
+									@click="toggleIssue(report._id, issue._id)"
+								>
+									done
+								</i>
+								<i
+									class="material-icons unresolve-icon"
+									content="Unresolve"
+									v-tippy
+									v-else
+									@click="toggleIssue(report._id, issue._id)"
+								>
+									remove
+								</i>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+			<p class="no-reports" v-else>There are no reports for this song.</p>
+		</div>
 	</div>
 </template>
 
 <script>
-import { mapState, mapGetters /* , mapActions */ } from "vuex";
-
-// import Toast from "toasters";
+import ReportInfoItem from "@/components/ReportInfoItem.vue";
+import { mapState, mapGetters, mapActions } from "vuex";
+import Toast from "toasters";
 
 export default {
+	components: { ReportInfoItem },
 	data() {
-		return {};
+		return {
+			tab: "sort-by-report",
+			icons: {
+				duration: "timer",
+				video: "tv",
+				thumbnail: "image",
+				artists: "record_voice_over",
+				title: "title",
+				custom: "lightbulb"
+			}
+		};
 	},
 	computed: {
 		...mapState("modals/editSong", {
@@ -19,13 +213,219 @@ export default {
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
-		})
+		}),
+		sortedByCategory() {
+			const categories = {};
+
+			this.reports.forEach(report =>
+				report.issues.forEach(issue => {
+					if (categories[issue.category])
+						categories[issue.category].push({
+							...issue,
+							reportId: report._id
+						});
+					else
+						categories[issue.category] = [
+							{ ...issue, reportId: report._id }
+						];
+				})
+			);
+
+			return categories;
+		}
 	},
-	mounted() {}
-	// methods: {
-	// 	...mapActions("modals/editSong", ["selectDiscogsInfo"])
-	// }
+	mounted() {
+		this.socket.on(
+			"event:admin.report.created",
+			res => this.reports.unshift(res.data.report),
+			{ modal: "editSong" }
+		);
+
+		this.socket.on(
+			"event:admin.report.resolved",
+			res => this.resolveReport(res.data.reportId),
+			{ modal: "editSong" }
+		);
+
+		this.socket.on(
+			"event:admin.report.issue.toggled",
+			res => {
+				this.reports.forEach((report, index) => {
+					if (report._id === res.data.reportId) {
+						const issue = this.reports[index].issues.find(
+							issue => issue._id.toString() === res.data.issueId
+						);
+
+						issue.resolved = res.data.resolved;
+					}
+				});
+			},
+			{ modal: "editSong" }
+		);
+	},
+	methods: {
+		showTab(tab) {
+			this.$refs[`${tab}-tab`].scrollIntoView();
+			this.tab = tab;
+		},
+		resolve(reportId) {
+			this.socket.dispatch(
+				"reports.resolve",
+				reportId,
+				res => new Toast(res.message)
+			);
+		},
+		toggleIssue(reportId, issueId) {
+			this.socket.dispatch(
+				"reports.toggleIssue",
+				reportId,
+				issueId,
+				res => {
+					if (res.status !== "success") new Toast(res.message);
+				}
+			);
+		},
+		...mapActions("modals/editSong", ["resolveReport"]),
+		...mapActions("modalVisibility", ["closeModal"])
+	}
 };
 </script>
 
-<style lang="scss" scoped></style>
+<style lang="scss" scoped>
+.night-mode {
+	.report-items .report-item {
+		background-color: var(--dark-grey-3) !important;
+	}
+
+	.report-items .report-item .report-item-header {
+		background-color: var(--dark-grey-2) !important;
+	}
+
+	.label,
+	p,
+	strong {
+		color: var(--light-grey-2);
+	}
+}
+
+.tabs-container {
+	.tab-selection {
+		display: flex;
+		overflow-x: auto;
+		.button {
+			border-radius: 0;
+			border: 0;
+			text-transform: uppercase;
+			font-size: 14px;
+			color: var(--dark-grey-3);
+			background-color: var(--light-grey-2);
+			flex-grow: 1;
+			height: 32px;
+
+			&:not(:first-of-type) {
+				margin-left: 5px;
+			}
+		}
+
+		.selected {
+			background-color: var(--primary-color) !important;
+			color: var(--white) !important;
+			font-weight: 600;
+		}
+	}
+	.tab {
+		padding: 15px 0;
+		border-radius: 0;
+	}
+}
+
+.no-reports {
+	text-align: center;
+}
+
+.report-items {
+	.report-item {
+		background-color: var(--white);
+		border: 0.5px solid var(--primary-color);
+		border-radius: 5px;
+		padding: 8px;
+
+		&:not(:first-of-type) {
+			margin-bottom: 16px;
+		}
+
+		.report-item-header {
+			justify-content: center;
+			text-transform: capitalize;
+
+			i {
+				margin-right: 5px;
+			}
+		}
+
+		.report-sub-items {
+			.report-sub-item {
+				border: 0.5px solid var(--black);
+				margin-top: -1px;
+				line-height: 24px;
+				display: flex;
+				padding: 4px;
+				display: flex;
+
+				&:first-child {
+					border-radius: 3px 3px 0 0;
+				}
+
+				&:last-child {
+					border-radius: 0 0 3px 3px;
+				}
+
+				&.report-sub-item-resolved {
+					.report-sub-item-description,
+					.report-sub-item-title {
+						text-decoration: line-through;
+					}
+				}
+
+				.report-sub-item-left-icon {
+					margin-right: 8px;
+					margin-top: auto;
+					margin-bottom: auto;
+				}
+
+				.report-sub-item-info {
+					flex: 1;
+					display: flex;
+					flex-direction: column;
+
+					.report-sub-item-title {
+						font-size: 14px;
+					}
+
+					.report-sub-item-description {
+						font-size: 12px;
+						line-height: 16px;
+					}
+				}
+
+				.report-sub-item-actions {
+					height: 24px;
+					margin-left: 8px;
+					margin-top: auto;
+					margin-bottom: auto;
+				}
+			}
+		}
+
+		.resolve-icon {
+			color: var(--green);
+			cursor: pointer;
+		}
+
+		.unresolve-icon {
+			color: var(--red);
+			cursor: pointer;
+		}
+	}
+}
+</style>

+ 125 - 0
frontend/src/components/modals/EditSong/Tabs/Youtube.vue

@@ -0,0 +1,125 @@
+<template>
+	<div class="youtube-tab">
+		<label class="label">
+			Search for a song from YouTube
+		</label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					placeholder="Enter your YouTube query here..."
+					v-model="search.songs.query"
+					autofocus
+					@keyup.enter="searchForSongs()"
+				/>
+			</p>
+			<p class="control">
+				<a
+					class="button is-info"
+					@click.prevent="searchForSongs()"
+					href="#"
+					><i class="material-icons icon-with-button">search</i
+					>Search</a
+				>
+			</p>
+		</div>
+
+		<div v-if="search.songs.results.length > 0" id="song-query-results">
+			<search-query-item
+				v-for="result in search.songs.results"
+				:key="result.id"
+				:result="result"
+			>
+				<template #actions>
+					<i
+						class="material-icons icon-selected"
+						v-if="result.id === song.youtubeId"
+						key="selected"
+						>radio_button_checked
+					</i>
+					<i
+						class="material-icons icon-not-selected"
+						v-else
+						@click.prevent="updateYoutubeId(result.id)"
+						key="not-selected"
+						>radio_button_unchecked
+					</i>
+				</template>
+			</search-query-item>
+
+			<a
+				class="button is-primary load-more-button"
+				@click.prevent="loadMoreSongs()"
+				href="#"
+			>
+				Load more...
+			</a>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapGetters, mapState, mapActions } from "vuex";
+
+import SearchYoutube from "@/mixins/SearchYoutube.vue";
+
+import SearchQueryItem from "../../../SearchQueryItem.vue";
+
+export default {
+	components: { SearchQueryItem },
+	mixins: [SearchYoutube],
+	data() {
+		return {};
+	},
+	computed: {
+		...mapState("modals/editSong", {
+			song: state => state.song
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {},
+	methods: {
+		...mapActions("modals/editSong", ["updateYoutubeId"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.night-mode {
+}
+
+.youtube-tab {
+	height: calc(100% - 32px);
+
+	#song-query-results {
+		height: calc(100% - 74px);
+		overflow: auto;
+
+		.search-query-item {
+			/deep/ .thumbnail-and-info {
+				width: calc(100% - 29px);
+			}
+
+			.icon-selected {
+				color: var(--green) !important;
+			}
+
+			.icon-not-selected {
+				color: var(--grey) !important;
+			}
+		}
+
+		.search-query-item:not(:last-of-type) {
+			margin-bottom: 10px;
+		}
+
+		.load-more-button {
+			width: 100%;
+			margin-top: 10px;
+		}
+	}
+}
+</style>

+ 180 - 125
frontend/src/components/modals/EditSong/index.vue

@@ -1,7 +1,7 @@
 <template>
 	<div>
 		<modal title="Edit Song" class="song-modal">
-			<div slot="body">
+			<template #body>
 				<div class="left-section">
 					<div class="top-section">
 						<div class="player-section">
@@ -325,65 +325,93 @@
 							>
 								Reports ({{ reports.length }})
 							</button>
+							<button
+								class="button is-default"
+								:class="{ selected: tab === 'youtube' }"
+								ref="youtube-tab"
+								@click="showTab('youtube')"
+							>
+								YouTube
+							</button>
 						</div>
 						<discogs class="tab" v-show="tab === 'discogs'" />
 						<reports class="tab" v-show="tab === 'reports'" />
+						<youtube class="tab" v-show="tab === 'youtube'" />
 					</div>
 				</div>
-			</div>
-			<div slot="footer">
-				<save-button ref="saveButton" @clicked="save(song, false)" />
-				<save-button
-					ref="saveAndCloseButton"
-					type="save-and-close"
-					@clicked="save(song, true)"
-				/>
-				<div class="right">
+			</template>
+			<template #footer>
+				<div>
+					<save-button
+						ref="saveButton"
+						@clicked="save(song, false, false)"
+					/>
+					<save-button
+						ref="saveAndCloseButton"
+						type="save-and-close"
+						@clicked="save(song, false, true)"
+					/>
 					<button
-						v-if="song.status !== 'verified'"
-						class="button is-success"
-						@click="verify(song._id)"
-						content="Verify Song"
-						v-tippy
+						class="button is-primary"
+						@click="save(song, true, true)"
 					>
-						<i class="material-icons">check_circle</i>
+						Save, verify and close
 					</button>
-					<confirm
-						v-if="song.status === 'verified'"
-						placement="left"
-						@confirm="unverify(song._id)"
+
+					<button
+						class="button is-danger"
+						@click="stopEditingSongs()"
+						v-if="modals.importAlbum && editingSongs"
 					>
+						Stop editing songs
+					</button>
+
+					<div class="right">
 						<button
-							class="button is-danger"
-							content="Unverify Song"
+							v-if="song.status !== 'verified'"
+							class="button is-success"
+							@click="verify(song._id)"
+							content="Verify Song"
 							v-tippy
 						>
-							<i class="material-icons">cancel</i>
+							<i class="material-icons">check_circle</i>
 						</button>
-					</confirm>
-					<confirm
-						v-if="song.status !== 'hidden'"
-						placement="left"
-						@confirm="hide(song._id)"
-					>
+						<confirm
+							v-if="song.status === 'verified'"
+							placement="left"
+							@confirm="unverify(song._id)"
+						>
+							<button
+								class="button is-danger"
+								content="Unverify Song"
+								v-tippy
+							>
+								<i class="material-icons">cancel</i>
+							</button>
+						</confirm>
+						<confirm
+							v-if="song.status !== 'hidden'"
+							placement="left"
+							@confirm="hide(song._id)"
+						>
+							<button
+								class="button is-danger"
+								content="Hide Song"
+								v-tippy
+							>
+								<i class="material-icons">visibility_off</i>
+							</button>
+						</confirm>
 						<button
-							class="button is-danger"
-							content="Hide Song"
+							v-if="song.status === 'hidden'"
+							class="button is-success"
+							@click="unhide(song._id)"
+							content="Unhide Song"
 							v-tippy
 						>
-							<i class="material-icons">visibility_off</i>
+							<i class="material-icons">visibility</i>
 						</button>
-					</confirm>
-					<button
-						v-if="song.status === 'hidden'"
-						class="button is-success"
-						@click="unhide(song._id)"
-						content="Unhide Song"
-						v-tippy
-					>
-						<i class="material-icons">visibility</i>
-					</button>
-					<!-- <confirm placement="left" @confirm="remove(song._id)">
+						<!-- <confirm placement="left" @confirm="remove(song._id)">
 						<button
 							class="button is-danger"
 							content="Remove Song"
@@ -392,8 +420,9 @@
 							<i class="material-icons">delete</i>
 						</button>
 					</confirm> -->
+					</div>
 				</div>
-			</div>
+			</template>
 		</modal>
 		<floating-box id="genreHelper" ref="genreHelper">
 			<template #body>
@@ -429,11 +458,22 @@ import SaveButton from "../../SaveButton.vue";
 
 import Discogs from "./Tabs/Discogs.vue";
 import Reports from "./Tabs/Reports.vue";
+import Youtube from "./Tabs/Youtube.vue";
 
 export default {
-	components: { Modal, FloatingBox, SaveButton, Confirm, Discogs, Reports },
+	components: {
+		Modal,
+		FloatingBox,
+		SaveButton,
+		Confirm,
+		Discogs,
+		Reports,
+		Youtube
+	},
 	props: {
 		youtubeId: { type: String, default: null },
+		songId: { type: String, default: null },
+		discogsAlbum: { type: Object, default: null },
 		// songType: { type: String, default: null },
 		sector: { type: String, default: "admin" }
 	},
@@ -504,6 +544,9 @@ export default {
 			originalSong: state => state.originalSong,
 			reports: state => state.reports
 		}),
+		...mapState("modals/importAlbum", {
+			editingSongs: state => state.editingSongs
+		}),
 		...mapState("modalVisibility", {
 			modals: state => state.modals
 		}),
@@ -541,7 +584,9 @@ export default {
 				// this.song = { ...song };
 				// if (this.song.discogs === undefined)
 				// 	this.song.discogs = null;
-				this.editSong(song);
+				if (this.song.discogs)
+					this.editSong({ ...song, discogs: this.song.discogs });
+				else this.editSong(song);
 
 				this.songDataLoaded = true;
 
@@ -728,7 +773,8 @@ export default {
 		this.socket.on(
 			"event:admin.hiddenSong.created",
 			res => {
-				this.song.status = res.data.song.status;
+				if (res.data.song._id === this.song._id)
+					this.song.status = res.data.song.status;
 			},
 			{ modal: "editSong" }
 		);
@@ -736,7 +782,8 @@ export default {
 		this.socket.on(
 			"event:admin.unverifiedSong.created",
 			res => {
-				this.song.status = res.data.song.status;
+				if (res.data.song._id === this.song._id)
+					this.song.status = res.data.song.status;
 			},
 			{ modal: "editSong" }
 		);
@@ -744,38 +791,11 @@ export default {
 		this.socket.on(
 			"event:admin.verifiedSong.created",
 			res => {
-				this.song.status = res.data.song.status;
-			},
-			{ modal: "editSong" }
-		);
-
-		this.socket.on(
-			"event:admin.hiddenSong.deleted",
-			() => {
-				new Toast("The song you were editing was removed");
-				this.closeModal("editSong");
-			},
-			{ modal: "editSong" }
-		);
-
-		this.socket.on(
-			"event:admin.unverifiedSong.deleted",
-			() => {
-				new Toast("The song you were editing was removed");
-				this.closeModal("editSong");
-			},
-			{ modal: "editSong" }
-		);
-
-		this.socket.on(
-			"event:admin.verifiedSong.deleted",
-			() => {
-				new Toast("The song you were editing was removed");
-				this.closeModal("editSong");
+				if (res.data.song._id === this.song._id)
+					this.song.status = res.data.song.status;
 			},
 			{ modal: "editSong" }
 		);
-
 		keyboardShortcuts.registerShortcut("editSong.pauseResumeVideo", {
 			keyCode: 101,
 			preventDefault: true,
@@ -857,7 +877,7 @@ export default {
 			ctrl: true,
 			preventDefault: true,
 			handler: () => {
-				this.save(this.song, false);
+				this.save(this.song, false, false);
 			}
 		});
 
@@ -938,16 +958,13 @@ export default {
 
 		*/
 	},
-	beforeDestroy() {
+	beforeUnmount() {
+		this.video.player.stopVideo();
 		this.playerReady = false;
 		clearInterval(this.interval);
 		clearInterval(this.activityWatchVideoDataInterval);
 
-		this.socket.dispatch(
-			"apis.leaveRoom",
-			`edit-song.${this.song._id}`,
-			() => {}
-		);
+		this.socket.dispatch("apis.leaveRoom", `edit-song.${this.song._id}`);
 
 		const shortcutNames = [
 			"editSong.pauseResume",
@@ -971,7 +988,16 @@ export default {
 		});
 	},
 	methods: {
-		save(songToCopy, close) {
+		stopEditingSongs() {
+			this.updateEditingSongs(false);
+			this.closeModal("editSong");
+		},
+		importAlbum(result) {
+			this.selectDiscogsAlbum(result);
+			this.openModal("importAlbum");
+			this.closeModal("editSong");
+		},
+		save(songToCopy, verify, close) {
 			const song = JSON.parse(JSON.stringify(songToCopy));
 
 			let saveButtonRef = this.$refs.saveButton;
@@ -1128,6 +1154,7 @@ export default {
 					saveButtonRef.handleSuccessfulSave();
 				else saveButtonRef.handleFailedSave();
 
+				if (verify) this.verify(this.song._id);
 				if (close) this.closeModal("editSong");
 			});
 		},
@@ -1401,6 +1428,10 @@ export default {
 		// 		new Toast(res.message);
 		// 	});
 		// },
+		...mapActions("modals/importAlbum", [
+			"selectDiscogsAlbum",
+			"updateEditingSongs"
+		]),
 		...mapActions({
 			showTab(dispatch, payload) {
 				this.$refs[`${payload}-tab`].scrollIntoView();
@@ -1416,61 +1447,85 @@ export default {
 			"updateSongField",
 			"updateReports"
 		]),
-		...mapActions("modalVisibility", ["closeModal"])
+		...mapActions("modalVisibility", ["closeModal", "openModal"])
 	}
 };
 </script>
 
 <style lang="scss">
-.song-modal {
-	.modal-card-title {
-		text-align: center;
-		margin-left: 24px;
-	}
-
-	.modal-card {
-		width: 1160px;
-		height: 100%;
-
-		.modal-card-body {
-			padding: 16px;
-		}
-
-		.modal-card-foot {
-			.right {
-				display: flex;
-				margin-left: auto;
-				margin-right: 0;
-			}
-		}
-	}
-}
+// .song-modal {
+// 	.modal-card-title {
+// 		text-align: center;
+// 		margin-left: 24px;
+// 	}
+
+// 	.modal-card {
+// 		width: 1160px;
+// 		height: 100%;
+
+// 		.modal-card-body {
+// 			padding: 16px;
+// 			display: flex;
+// 		}
+
+// 		.modal-card-foot {
+// 			.right {
+// 				display: flex;
+// 				margin-left: auto;
+// 				margin-right: 0;
+
+// 				// button,
+// 				// a,
+// 				// span {
+// 				// 	&:not(:last-child) {
+// 				// 		margin-right: 5px;
+// 				// 	}
+// 				// }
+// 			}
+// 		}
+// 	}
+// }
 </style>
 
 <style lang="scss" scoped>
 .night-mode {
 	.edit-section,
-	.api-section,
-	.api-result,
-	.player-footer {
+	.player-footer,
+	#tabs-container {
 		background-color: var(--dark-grey-3) !important;
 	}
+}
 
-	.api-result .tracks .track:hover,
-	.selected-discogs-info {
-		background-color: var(--dark-grey-2) !important;
-	}
+.song-modal {
+	&::v-deep {
+		.modal-card-title {
+			text-align: center;
+			margin-left: 24px;
+		}
 
-	.label,
-	p,
-	strong {
-		color: var(--light-grey-2);
-	}
-}
+		.modal-card {
+			width: 1160px;
+			height: 100%;
 
-.modal-card-body > div {
-	display: flex;
-	height: 100%;
+			.modal-card-body {
+				padding: 16px;
+				display: flex;
+
+				> div {
+					display: flex;
+					height: 100%;
+				}
+			}
+
+			.modal-card-foot {
+				.right {
+					display: flex;
+					margin-left: auto;
+					margin-right: 0;
+				}
+			}
+		}
+	}
 }
 
 .left-section {

+ 4 - 4
frontend/src/components/modals/EditUser.vue

@@ -1,7 +1,7 @@
 <template>
 	<div>
 		<modal title="Edit User">
-			<div slot="body" v-if="user && user._id">
+			<template #body v-if="user && user._id">
 				<p class="control has-addons">
 					<input
 						v-model="user.username"
@@ -60,8 +60,8 @@
 					/>
 					<a class="button is-error" @click="banUser()">Ban user</a>
 				</p>
-			</div>
-			<div slot="footer">
+			</template>
+			<template #footer>
 				<!--button class='button is-warning'>
 					<span>&nbsp;Send Verification Email</span>
 				</button>
@@ -71,7 +71,7 @@
 				<button class="button is-warning" @click="removeSessions()">
 					<span>&nbsp;Remove all sessions</span>
 				</button>
-			</div>
+			</template>
 		</modal>
 	</div>
 </template>

+ 958 - 0
frontend/src/components/modals/ImportAlbum.vue

@@ -0,0 +1,958 @@
+<template>
+	<div>
+		<modal title="Import Album" class="import-album-modal">
+			<template #body>
+				<div class="search-discogs-album">
+					<p class="control is-expanded">
+						<label class="label">Search query</label>
+						<input
+							class="input"
+							type="text"
+							ref="discogs-input"
+							v-model="discogsQuery"
+							@keyup.enter="searchDiscogsForPage(1)"
+							@change="onDiscogsQueryChange"
+							v-focus
+						/>
+					</p>
+					<button
+						class="button is-fullwidth is-info"
+						@click="searchDiscogsForPage(1)"
+					>
+						Search
+					</button>
+					<button
+						class="button is-fullwidth is-danger"
+						@click="clearDiscogsResults()"
+					>
+						Clear
+					</button>
+					<label class="label" v-if="discogs.apiResults.length > 0"
+						>API results</label
+					>
+					<div
+						class="api-results-container"
+						v-if="discogs.apiResults.length > 0"
+					>
+						<div
+							class="api-result"
+							v-for="(result, index) in discogs.apiResults"
+							:key="result.album.id"
+							tabindex="0"
+							@keydown.space.prevent
+							@keyup.enter="toggleAPIResult(index)"
+						>
+							<div class="top-container">
+								<img :src="result.album.albumArt" />
+								<div class="right-container">
+									<p class="album-title">
+										{{ result.album.title }}
+									</p>
+									<div class="bottom-row">
+										<img
+											src="/assets/arrow_up.svg"
+											v-if="result.expanded"
+											@click="toggleAPIResult(index)"
+										/>
+										<img
+											src="/assets/arrow_down.svg"
+											v-if="!result.expanded"
+											@click="toggleAPIResult(index)"
+										/>
+										<p class="type-year">
+											<span>{{ result.album.type }}</span>
+											•
+											<span>{{ result.album.year }}</span>
+										</p>
+									</div>
+								</div>
+							</div>
+							<div
+								class="bottom-container"
+								v-if="result.expanded"
+							>
+								<p class="bottom-container-field">
+									Artists:
+									<span>{{
+										result.album.artists.join(", ")
+									}}</span>
+								</p>
+								<p class="bottom-container-field">
+									Genres:
+									<span>{{
+										result.album.genres.join(", ")
+									}}</span>
+								</p>
+								<p class="bottom-container-field">
+									Data quality:
+									<span>{{ result.dataQuality }}</span>
+								</p>
+								<button
+									class="button is-primary"
+									@click="selectAlbum(result)"
+								>
+									Import album
+								</button>
+								<div class="tracks">
+									<div
+										class="track"
+										v-for="track in result.tracks"
+										:key="
+											`${track.position}-${track.title}`
+										"
+									>
+										<span>{{ track.position }}.</span>
+										<p>{{ track.title }}</p>
+									</div>
+								</div>
+							</div>
+						</div>
+					</div>
+					<button
+						v-if="
+							discogs.apiResults.length > 0 &&
+								!discogs.disableLoadMore &&
+								discogs.page < discogs.pages
+						"
+						class="button is-fullwidth is-info discogs-load-more"
+						@click="loadNextDiscogsPage()"
+					>
+						Load more...
+					</button>
+				</div>
+				<div
+					class="discogs-album"
+					v-if="discogsAlbum && discogsAlbum.album"
+				>
+					<div class="top-container">
+						<img :src="discogsAlbum.album.albumArt" />
+						<div class="right-container">
+							<p class="album-title">
+								{{ discogsAlbum.album.title }}
+							</p>
+							<div class="bottom-row">
+								<img
+									src="/assets/arrow_up.svg"
+									v-if="discogsAlbum.expanded"
+									@click="toggleDiscogsAlbum()"
+								/>
+								<img
+									src="/assets/arrow_down.svg"
+									v-if="!discogsAlbum.expanded"
+									@click="toggleDiscogsAlbum()"
+								/>
+								<p class="type-year">
+									<span>{{ discogsAlbum.album.type }}</span>
+									•
+									<span>{{ discogsAlbum.album.year }}</span>
+								</p>
+							</div>
+						</div>
+					</div>
+					<div class="bottom-container" v-if="discogsAlbum.expanded">
+						<p class="bottom-container-field">
+							Artists:
+							<span>{{
+								discogsAlbum.album.artists.join(", ")
+							}}</span>
+						</p>
+						<p class="bottom-container-field">
+							Genres:
+							<span>{{
+								discogsAlbum.album.genres.join(", ")
+							}}</span>
+						</p>
+						<p class="bottom-container-field">
+							Data quality:
+							<span>{{ discogsAlbum.dataQuality }}</span>
+						</p>
+						<div class="tracks">
+							<div
+								class="track"
+								tabindex="0"
+								v-for="track in discogsAlbum.tracks"
+								:key="`${track.position}-${track.title}`"
+							>
+								<span>{{ track.position }}.</span>
+								<p>{{ track.title }}</p>
+							</div>
+						</div>
+					</div>
+				</div>
+				<div class="break"></div>
+				<div
+					class="import-youtube-playlist"
+					v-if="discogsAlbum && discogsAlbum.album"
+				>
+					<p class="control is-expanded">
+						<input
+							class="input"
+							type="text"
+							placeholder="Enter YouTube Playlist URL here..."
+							v-model="search.playlist.query"
+							@keyup.enter="importPlaylist()"
+						/>
+					</p>
+					<button
+						class="button is-fullwidth is-info"
+						@click="importPlaylist()"
+					>
+						<i class="material-icons icon-with-button">publish</i
+						>Import
+					</button>
+					<button
+						class="button is-fullwidth is-danger"
+						@click="resetTrackSongs()"
+					>
+						Reset
+					</button>
+					<draggable
+						v-if="playlistSongs.length > 0"
+						group="songs"
+						v-model="playlistSongs"
+						item-key="_id"
+						@start="drag = true"
+						@end="drag = false"
+						@change="log"
+					>
+						<template #item="{element}">
+							<song-item
+								:key="`playlist-song-${element._id}`"
+								:song="element"
+							>
+							</song-item>
+						</template>
+					</draggable>
+				</div>
+				<div
+					class="track-boxes"
+					v-if="discogsAlbum && discogsAlbum.album"
+				>
+					<div
+						class="track-box"
+						v-for="(track, index) in discogsAlbum.tracks"
+						:key="`${track.position}-${track.title}`"
+					>
+						<div class="track-position-title">
+							<span>{{ track.position }}.</span>
+							<p>{{ track.title }}</p>
+						</div>
+						<draggable
+							class="track-box-songs-drag-area"
+							group="songs"
+							v-model="trackSongs[index]"
+							item-key="_id"
+							@start="drag = true"
+							@end="drag = false"
+							@change="log"
+						>
+							<template #item="{element}">
+								<song-item
+									:key="`track-song-${element._id}`"
+									:song="element"
+								>
+								</song-item>
+							</template>
+						</draggable>
+					</div>
+				</div>
+			</template>
+			<template #footer>
+				<button class="button is-primary" @click="tryToAutoMove()">
+					Try to auto move
+				</button>
+				<button class="button is-primary" @click="editSongs()">
+					Edit songs
+				</button>
+			</template>
+		</modal>
+	</div>
+</template>
+
+<script>
+import { mapState, mapGetters, mapActions } from "vuex";
+
+import draggable from "vuedraggable";
+import Toast from "toasters";
+
+import Modal from "../Modal.vue";
+
+import SongItem from "../SongItem.vue";
+
+export default {
+	components: { Modal, SongItem, draggable },
+	props: {
+		// songType: { type: String, default: null },
+		sector: { type: String, default: "admin" }
+	},
+	data() {
+		return {
+			stuff: false,
+			isImportingPlaylist: false,
+			trackSongs: [],
+			songsToEdit: [],
+			currentEditSongIndex: 0,
+			search: {
+				playlist: {
+					query: ""
+				}
+			},
+			discogsQuery: "",
+			discogs: {
+				apiResults: [],
+				page: 1,
+				pages: 1,
+				disableLoadMore: false
+			}
+		};
+	},
+	computed: {
+		playlistSongs: {
+			get() {
+				return this.$store.state.modals.importAlbum.playlistSongs;
+			},
+			set(playlistSongs) {
+				this.$store.commit(
+					"modals/importAlbum/updatePlaylistSongs",
+					playlistSongs
+				);
+			}
+		},
+		...mapState("modals/importAlbum", {
+			discogsAlbum: state => state.discogsAlbum,
+			editingSongs: state => state.editingSongs
+		}),
+		...mapState("modalVisibility", {
+			modals: state => state.modals
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	watch: {
+		/* eslint-disable */
+		"modals.editSong": function(value) {
+			if (!value) this.editNextSong();
+		}
+		/* eslint-enable */
+	},
+	beforeUnmount() {
+		this.selectDiscogsAlbum({});
+		this.setPlaylistSongs([]);
+	},
+	methods: {
+		editSongs() {
+			this.updateEditingSongs(true);
+			this.songsToEdit = [];
+			this.trackSongs.forEach((songs, index) => {
+				songs.forEach(song => {
+					const discogsAlbum = JSON.parse(
+						JSON.stringify(this.discogsAlbum)
+					);
+					discogsAlbum.track = discogsAlbum.tracks[index];
+					delete discogsAlbum.tracks;
+					delete discogsAlbum.expanded;
+					delete discogsAlbum.gotMoreInfo;
+
+					this.songsToEdit.push({
+						songId: song._id,
+						discogs: discogsAlbum
+					});
+				});
+			});
+			this.editNextSong();
+		},
+		editNextSong() {
+			if (this.editingSongs) {
+				setTimeout(() => {
+					this.editSong({
+						_id: this.songsToEdit[this.currentEditSongIndex].songId,
+						discogs: this.songsToEdit[this.currentEditSongIndex]
+							.discogs
+					});
+					this.currentEditSongIndex += 1;
+					this.openModal("editSong");
+				}, 500);
+			}
+		},
+		log(evt) {
+			window.console.log(evt);
+		},
+		importPlaylist() {
+			if (this.isImportingPlaylist)
+				return new Toast("A playlist is already importing.");
+			this.isImportingPlaylist = true;
+
+			// import query is blank
+			if (!this.search.playlist.query)
+				return new Toast("Please enter a YouTube playlist URL.");
+
+			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
+			const splitQuery = regex.exec(this.search.playlist.query);
+
+			if (!splitQuery) {
+				return new Toast({
+					content: "Please enter a valid YouTube playlist URL.",
+					timeout: 4000
+				});
+			}
+
+			// don't give starting import message instantly in case of instant error
+			setTimeout(() => {
+				if (this.isImportingPlaylist) {
+					new Toast(
+						"Starting to import your playlist. This can take some time to do."
+					);
+				}
+			}, 750);
+
+			return this.socket.dispatch(
+				"songs.requestSet",
+				this.search.playlist.query,
+				false,
+				true,
+				res => {
+					this.isImportingPlaylist = false;
+					const songs = res.songs.filter(
+						song => song.status !== "verified"
+					);
+					const songsAlreadyVerified =
+						res.songs.length - songs.length;
+					this.setPlaylistSongs(songs);
+					this.trackSongs = this.discogsAlbum.tracks.map(() => []);
+					this.tryToAutoMove();
+					if (songsAlreadyVerified > 0)
+						new Toast(
+							`${songsAlreadyVerified} songs were already verified, skipping those.`
+						);
+					return new Toast({ content: res.message, timeout: 20000 });
+				}
+			);
+		},
+		tryToAutoMove() {
+			const { tracks } = this.discogsAlbum;
+			const { trackSongs } = this;
+			const playlistSongs = JSON.parse(
+				JSON.stringify(this.playlistSongs)
+			);
+
+			tracks.forEach((track, index) => {
+				playlistSongs.forEach(playlistSong => {
+					if (
+						playlistSong.title
+							.toLowerCase()
+							.trim()
+							.indexOf(track.title.toLowerCase().trim()) !== -1
+					) {
+						playlistSongs.splice(
+							playlistSongs.indexOf(playlistSong),
+							1
+						);
+						trackSongs[index].push(playlistSong);
+					}
+				});
+			});
+
+			this.updatePlaylistSongs(playlistSongs);
+		},
+		resetTrackSongs() {
+			this.resetPlaylistSongs();
+			this.trackSongs = this.discogsAlbum.tracks.map(() => []);
+		},
+		selectAlbum(result) {
+			this.selectDiscogsAlbum(result);
+			this.clearDiscogsResults();
+		},
+		toggleAPIResult(index) {
+			const apiResult = this.discogs.apiResults[index];
+			if (apiResult.expanded === true) apiResult.expanded = false;
+			else if (apiResult.gotMoreInfo === true) apiResult.expanded = true;
+			else {
+				fetch(apiResult.album.resourceUrl)
+					.then(response => {
+						return response.json();
+					})
+					.then(data => {
+						apiResult.album.artists = [];
+						apiResult.album.artistIds = [];
+						const artistRegex = new RegExp(" \\([0-9]+\\)$");
+
+						apiResult.dataQuality = data.data_quality;
+						data.artists.forEach(artist => {
+							apiResult.album.artists.push(
+								artist.name.replace(artistRegex, "")
+							);
+							apiResult.album.artistIds.push(artist.id);
+						});
+						apiResult.tracks = data.tracklist.map(track => {
+							return {
+								position: track.position,
+								title: track.title
+							};
+						});
+						apiResult.expanded = true;
+						apiResult.gotMoreInfo = true;
+					});
+			}
+		},
+		clearDiscogsResults() {
+			this.discogs.apiResults = [];
+			this.discogs.page = 1;
+			this.discogs.pages = 1;
+			this.discogs.disableLoadMore = false;
+		},
+		searchDiscogsForPage(page) {
+			const query = this.discogsQuery;
+
+			this.socket.dispatch("apis.searchDiscogs", query, page, res => {
+				if (res.status === "success") {
+					if (page === 1)
+						new Toast(
+							`Successfully searched. Got ${res.data.results.length} results.`
+						);
+					else
+						new Toast(
+							`Successfully got ${res.data.results.length} more results.`
+						);
+
+					if (page === 1) {
+						this.discogs.apiResults = [];
+					}
+
+					this.discogs.pages = res.data.pages;
+
+					this.discogs.apiResults = this.discogs.apiResults.concat(
+						res.data.results.map(result => {
+							const type =
+								result.type.charAt(0).toUpperCase() +
+								result.type.slice(1);
+
+							return {
+								expanded: false,
+								gotMoreInfo: false,
+								album: {
+									id: result.id,
+									title: result.title,
+									type,
+									year: result.year,
+									genres: result.genre,
+									albumArt: result.cover_image,
+									resourceUrl: result.resource_url
+								}
+							};
+						})
+					);
+
+					this.discogs.page = page;
+					this.discogs.disableLoadMore = false;
+				} else new Toast(res.message);
+			});
+		},
+		loadNextDiscogsPage() {
+			this.discogs.disableLoadMore = true;
+			this.searchDiscogsForPage(this.discogs.page + 1);
+		},
+		onDiscogsQueryChange() {
+			this.discogs.page = 1;
+			this.discogs.pages = 1;
+			this.discogs.apiResults = [];
+			this.discogs.disableLoadMore = false;
+		},
+		...mapActions("modals/importAlbum", [
+			"toggleDiscogsAlbum",
+			"setPlaylistSongs",
+			"updatePlaylistSongs",
+			"selectDiscogsAlbum",
+			"updateEditingSongs",
+			"resetPlaylistSongs"
+		]),
+		...mapActions("modals/editSong", ["editSong"]),
+		...mapActions("modalVisibility", ["closeModal", "openModal"])
+	}
+};
+</script>
+
+<style lang="scss">
+.night-mode {
+	.search-discogs-album,
+	.discogs-album,
+	.import-youtube-playlist,
+	.track-boxes {
+		background-color: var(--dark-grey-3) !important;
+	}
+
+	.api-result {
+		background-color: var(--dark-grey-3) !important;
+	}
+
+	.api-result .tracks .track:hover,
+	.api-result .tracks .track:focus,
+	.discogs-album .tracks .track:hover,
+	.discogs-album .tracks .track:focus {
+		background-color: var(--dark-grey-2) !important;
+	}
+
+	.label,
+	p,
+	strong {
+		color: var(--light-grey-2);
+	}
+}
+
+.import-album-modal {
+	.modal-card-title {
+		text-align: center;
+		margin-left: 24px;
+	}
+
+	// .import-album-modal-body {
+	// 	display: flex;
+	// 	flex-direction: row;
+	// 	flex-wrap: wrap;
+	// 	justify-content: space-evenly;
+	// }
+
+	.modal-card {
+		width: 100%;
+		height: 100%;
+
+		.modal-card-body {
+			padding: 16px;
+			display: flex;
+			flex-direction: row;
+			flex-wrap: wrap;
+			justify-content: space-evenly;
+		}
+
+		.modal-card-foot {
+			.button {
+				margin: 0;
+			}
+
+			div div {
+				margin-right: 5px;
+			}
+			.right {
+				display: flex;
+				margin-left: auto;
+				margin-right: 0;
+			}
+		}
+	}
+}
+</style>
+
+<style lang="scss" scoped>
+.break {
+	flex-basis: 100%;
+	height: 0;
+	border: 1px solid var(--dark-grey);
+	margin-top: 16px;
+	margin-bottom: 16px;
+}
+
+.search-discogs-album {
+	width: 376px;
+	background-color: var(--light-grey);
+	border: 1px rgba(163, 224, 255, 0.75) solid;
+	border-radius: 5px;
+	padding: 16px;
+	overflow: auto;
+	height: 100%;
+
+	> label {
+		margin-top: 12px;
+	}
+
+	.top-container {
+		display: flex;
+
+		img {
+			height: 85px;
+			width: 85px;
+		}
+
+		.right-container {
+			padding: 8px;
+			display: flex;
+			flex-direction: column;
+			flex: 1;
+
+			.album-title {
+				flex: 1;
+				font-weight: 600;
+			}
+
+			.bottom-row {
+				display: flex;
+				flex-flow: row;
+				line-height: 15px;
+
+				img {
+					height: 15px;
+					align-self: end;
+					flex: 1;
+					user-select: none;
+					-moz-user-select: none;
+					-ms-user-select: none;
+					-webkit-user-select: none;
+					cursor: pointer;
+				}
+
+				p {
+					text-align: right;
+				}
+
+				.type-year {
+					font-size: 13px;
+					align-self: end;
+				}
+			}
+		}
+	}
+
+	.bottom-container {
+		padding: 12px;
+
+		.bottom-container-field {
+			line-height: 16px;
+			margin-bottom: 8px;
+			font-weight: 600;
+
+			span {
+				font-weight: 400;
+			}
+		}
+
+		.bottom-container-field:last-of-type {
+			margin-bottom: 8px;
+		}
+	}
+
+	.api-result {
+		background-color: var(--white);
+		border: 0.5px solid var(--primary-color);
+		border-radius: 5px;
+		margin-bottom: 16px;
+	}
+
+	button {
+		&:focus,
+		&:hover {
+			filter: contrast(0.75);
+		}
+	}
+
+	.tracks {
+		margin-top: 12px;
+
+		.track:first-child {
+			margin-top: 0;
+			border-radius: 3px 3px 0 0;
+		}
+
+		.track:last-child {
+			border-radius: 0 0 3px 3px;
+		}
+
+		.track {
+			border: 0.5px solid var(--black);
+			margin-top: -1px;
+			line-height: 16px;
+			display: flex;
+
+			span {
+				font-weight: 600;
+				display: inline-block;
+				margin-top: 7px;
+				margin-bottom: 7px;
+				margin-left: 7px;
+			}
+
+			p {
+				display: inline-block;
+				margin: 7px;
+				flex: 1;
+			}
+		}
+	}
+
+	.discogs-load-more {
+		margin-bottom: 8px;
+	}
+}
+
+.discogs-album {
+	width: 376px;
+	background-color: var(--light-grey);
+	border: 1px rgba(163, 224, 255, 0.75) solid;
+	border-radius: 5px;
+	padding: 16px;
+	overflow: auto;
+	height: 100%;
+
+	.top-container {
+		display: flex;
+
+		img {
+			height: 85px;
+			width: 85px;
+		}
+
+		.right-container {
+			padding: 8px;
+			display: flex;
+			flex-direction: column;
+			flex: 1;
+
+			.album-title {
+				flex: 1;
+				font-weight: 600;
+			}
+
+			.bottom-row {
+				display: flex;
+				flex-flow: row;
+				line-height: 15px;
+
+				img {
+					height: 15px;
+					align-self: end;
+					flex: 1;
+					user-select: none;
+					-moz-user-select: none;
+					-ms-user-select: none;
+					-webkit-user-select: none;
+					cursor: pointer;
+				}
+
+				p {
+					text-align: right;
+				}
+
+				.type-year {
+					font-size: 13px;
+					align-self: end;
+				}
+			}
+		}
+	}
+
+	.bottom-container {
+		padding: 12px;
+
+		.bottom-container-field {
+			line-height: 16px;
+			margin-bottom: 8px;
+			font-weight: 600;
+
+			span {
+				font-weight: 400;
+			}
+		}
+
+		.bottom-container-field:last-of-type {
+			margin-bottom: 0;
+		}
+
+		.tracks {
+			margin-top: 12px;
+
+			.track:first-child {
+				margin-top: 0;
+				border-radius: 3px 3px 0 0;
+			}
+
+			.track:last-child {
+				border-radius: 0 0 3px 3px;
+			}
+
+			.track {
+				border: 0.5px solid var(--black);
+				margin-top: -1px;
+				line-height: 16px;
+				display: flex;
+
+				span {
+					font-weight: 600;
+					display: inline-block;
+					margin-top: 7px;
+					margin-bottom: 7px;
+					margin-left: 7px;
+				}
+
+				p {
+					display: inline-block;
+					margin: 7px;
+					flex: 1;
+				}
+			}
+
+			.track:hover,
+			.track:focus {
+				background-color: var(--light-grey);
+			}
+		}
+	}
+}
+
+.import-youtube-playlist {
+	width: 376px;
+	background-color: var(--light-grey);
+	border: 1px rgba(163, 224, 255, 0.75) solid;
+	border-radius: 5px;
+	padding: 16px;
+	overflow: auto;
+	height: 100%;
+}
+
+.track-boxes {
+	width: 376px;
+	background-color: var(--light-grey);
+	border: 1px rgba(163, 224, 255, 0.75) solid;
+	border-radius: 5px;
+	padding: 16px;
+	overflow: auto;
+	height: 100%;
+
+	.track-box:first-child {
+		margin-top: 0;
+		border-radius: 3px 3px 0 0;
+	}
+
+	.track-box:last-child {
+		border-radius: 0 0 3px 3px;
+	}
+
+	.track-box {
+		border: 0.5px solid var(--black);
+		margin-top: -1px;
+		line-height: 16px;
+		display: flex;
+		flex-flow: column;
+
+		.track-position-title {
+			display: flex;
+
+			span {
+				font-weight: 600;
+				display: inline-block;
+				margin-top: 7px;
+				margin-bottom: 7px;
+				margin-left: 7px;
+			}
+
+			p {
+				display: inline-block;
+				margin: 7px;
+				flex: 1;
+			}
+		}
+
+		.track-box-songs-drag-area {
+			flex: 1;
+			min-height: 100px;
+		}
+	}
+}
+</style>

+ 16 - 6
frontend/src/components/modals/Login.vue

@@ -23,9 +23,7 @@
 								class="input"
 								type="email"
 								placeholder="Email..."
-								@keypress="
-									$parent.submitOnEnter(submitModal, $event)
-								"
+								@keypress="submitOnEnter(submitModal, $event)"
 							/>
 						</p>
 
@@ -41,9 +39,8 @@
 								type="password"
 								ref="password"
 								placeholder="Password..."
-								@keypress="
-									$parent.submitOnEnter(submitModal, $event)
-								"
+								@input="checkForAutofill($event)"
+								@keypress="submitOnEnter(submitModal, $event)"
 							/>
 							<a @click="togglePasswordVisibility()">
 								<i class="material-icons">
@@ -147,6 +144,19 @@ export default {
 		if (this.$route.path === "/login") this.isPage = true;
 	},
 	methods: {
+		checkForAutofill(event) {
+			if (
+				event.target.value !== "" &&
+				event.inputType === undefined &&
+				event.data === undefined &&
+				event.dataTransfer === undefined &&
+				event.isComposing === undefined
+			)
+				this.submitModal();
+		},
+		submitOnEnter(cb, event) {
+			if (event.which === 13) cb();
+		},
 		submitModal() {
 			this.login({
 				email: this.email,

+ 4 - 3
frontend/src/components/modals/ManageStation/Tabs/Blacklist.vue

@@ -24,11 +24,12 @@
 			<div class="tab" v-show="tab === 'playlists'">
 				<div v-if="excludedPlaylists.length > 0">
 					<playlist-item
-						:playlist="playlist"
 						v-for="playlist in excludedPlaylists"
 						:key="`key-${playlist._id}`"
+						:playlist="playlist"
+						:show-owner="true"
 					>
-						<div class="icons-group" slot="actions">
+						<template #actions>
 							<confirm @confirm="deselectPlaylist(playlist._id)">
 								<i
 									class="material-icons stop-icon"
@@ -54,7 +55,7 @@
 								v-tippy
 								>visibility</i
 							>
-						</div>
+						</template>
 					</playlist-item>
 				</div>
 				<p v-else class="has-text-centered scrollable-list">

+ 85 - 80
frontend/src/components/modals/ManageStation/Tabs/Playlists.vue

@@ -36,7 +36,7 @@
 						:playlist="playlist"
 						:show-owner="true"
 					>
-						<div class="icons-group" slot="actions">
+						<template #actions>
 							<confirm
 								v-if="isOwnerOrAdmin()"
 								@confirm="deselectPlaylist(playlist._id)"
@@ -80,7 +80,7 @@
 								v-tippy
 								>visibility</i
 							>
-						</div>
+						</template>
 					</playlist-item>
 				</div>
 				<p v-else class="has-text-centered scrollable-list">
@@ -114,7 +114,7 @@
 						:playlist="playlist"
 						:show-owner="true"
 					>
-						<div class="icons-group" slot="actions">
+						<template #actions>
 							<i
 								v-if="isExcluded(playlist._id)"
 								class="material-icons stop-icon"
@@ -191,7 +191,7 @@
 								v-tippy
 								>visibility</i
 							>
-						</div>
+						</template>
 					</playlist-item>
 					<button
 						v-if="resultsLeftCount > 0"
@@ -214,96 +214,101 @@
 				>
 					Create new playlist
 				</button>
-				<draggable
+				<div
 					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"
+					<draggable
+						tag="transition-group"
+						:component-data="{
+							name: !drag ? 'draggable-list-transition' : null
+						}"
+						item-key="_id"
+						v-model="playlists"
+						v-bind="dragOptions"
+						@start="drag = true"
+						@end="drag = false"
+						@change="savePlaylistOrder"
 					>
-						<playlist-item
-							class="item-draggable"
-							v-for="playlist in playlists"
-							:key="playlist._id"
-							:playlist="playlist"
-						>
-							<div slot="actions">
-								<i
-									v-if="isExcluded(playlist._id)"
-									class="material-icons stop-icon"
-									content="This playlist is blacklisted in this station"
-									v-tippy="{ theme: 'info' }"
-									>play_disabled</i
-								>
-								<i
-									v-if="
-										station.type === 'community' &&
-											(isOwnerOrAdmin() ||
-												station.partyMode) &&
-											!isSelected(playlist._id) &&
-											!isExcluded(playlist._id)
-									"
-									@click="selectPlaylist(playlist)"
-									class="material-icons play-icon"
-									:content="
-										station.partyMode
-											? 'Request songs from this playlist'
-											: 'Play songs from this playlist'
-									"
-									v-tippy
-									>play_arrow</i
-								>
-								<confirm
-									v-if="
-										station.type === 'community' &&
-											(isOwnerOrAdmin() ||
-												station.partyMode) &&
-											isSelected(playlist._id)
-									"
-									@confirm="deselectPlaylist(playlist._id)"
-								>
+						<template #item="{element}">
+							<playlist-item
+								class="item-draggable"
+								:playlist="element"
+							>
+								<template #actions>
 									<i
+										v-if="isExcluded(element._id)"
 										class="material-icons stop-icon"
+										content="This playlist is blacklisted in this station"
+										v-tippy="{ theme: 'info' }"
+										>play_disabled</i
+									>
+									<i
+										v-if="
+											station.type === 'community' &&
+												(isOwnerOrAdmin() ||
+													station.partyMode) &&
+												!isSelected(element._id) &&
+												!isExcluded(element._id)
+										"
+										@click="selectPlaylist(element)"
+										class="material-icons play-icon"
 										:content="
 											station.partyMode
-												? 'Stop requesting songs from this playlist'
-												: 'Stop playing songs from this playlist'
+												? 'Request songs from this playlist'
+												: 'Play songs from this playlist'
 										"
 										v-tippy
-										>stop</i
+										>play_arrow</i
 									>
-								</confirm>
-								<confirm
-									v-if="
-										isOwnerOrAdmin() &&
-											!isExcluded(playlist._id)
-									"
-									@confirm="blacklistPlaylist(playlist._id)"
-								>
+									<confirm
+										v-if="
+											station.type === 'community' &&
+												(isOwnerOrAdmin() ||
+													station.partyMode) &&
+												isSelected(element._id)
+										"
+										@confirm="deselectPlaylist(element._id)"
+									>
+										<i
+											class="material-icons stop-icon"
+											:content="
+												station.partyMode
+													? 'Stop requesting songs from this playlist'
+													: 'Stop playing songs from this playlist'
+											"
+											v-tippy
+											>stop</i
+										>
+									</confirm>
+									<confirm
+										v-if="
+											isOwnerOrAdmin() &&
+												!isExcluded(element._id)
+										"
+										@confirm="
+											blacklistPlaylist(element._id)
+										"
+									>
+										<i
+											class="material-icons stop-icon"
+											content="Blacklist Playlist"
+											v-tippy
+											>block</i
+										>
+									</confirm>
 									<i
-										class="material-icons stop-icon"
-										content="Blacklist Playlist"
+										@click="showPlaylist(element._id)"
+										class="material-icons edit-icon"
+										content="Edit Playlist"
 										v-tippy
-										>block</i
+										>edit</i
 									>
-								</confirm>
-								<i
-									@click="showPlaylist(playlist._id)"
-									class="material-icons edit-icon"
-									content="Edit Playlist"
-									v-tippy
-									>edit</i
-								>
-							</div>
-						</playlist-item>
-					</transition-group>
-				</draggable>
+								</template>
+							</playlist-item>
+						</template>
+					</draggable>
+				</div>
 				<p v-else class="has-text-centered scrollable-list">
 					You don't have any playlists!
 				</p>

+ 4 - 4
frontend/src/components/modals/ManageStation/Tabs/Search.vue

@@ -25,7 +25,7 @@
 					:key="song._id"
 					:song="song"
 				>
-					<div class="song-actions" slot="actions">
+					<template #actions>
 						<i
 							class="material-icons add-to-queue-icon"
 							v-if="station.partyMode && !station.locked"
@@ -34,7 +34,7 @@
 							v-tippy
 							>queue</i
 						>
-					</div>
+					</template>
 				</song-item>
 				<button
 					v-if="resultsLeftCount > 0"
@@ -72,7 +72,7 @@
 					:key="result.id"
 					:result="result"
 				>
-					<div slot="actions">
+					<template #actions>
 						<transition name="search-query-actions" mode="out-in">
 							<a
 								class="button is-success"
@@ -98,7 +98,7 @@
 								Add to queue
 							</a>
 						</transition>
-					</div>
+					</template>
 				</search-query-item>
 
 				<a

+ 182 - 172
frontend/src/components/modals/ManageStation/Tabs/Settings.vue

@@ -42,119 +42,121 @@
 		<div class="settings-buttons">
 			<div class="small-section">
 				<label class="label">Theme</label>
-				<tippy
-					class="button-wrapper"
-					theme="addToPlaylist"
-					interactive="true"
-					touch="true"
-					placement="bottom"
-					trigger="click"
-					append-to="parent"
-				>
-					<template #trigger>
+				<div class="button-wrapper">
+					<tippy
+						theme="addToPlaylist"
+						:interactive="true"
+						:touch="true"
+						placement="bottom"
+						trigger="click"
+						append-to="parent"
+					>
 						<button :class="station.theme">
 							<i class="material-icons">palette</i>
 							{{ station.theme }}
 						</button>
-					</template>
-					<button
-						class="blue"
-						v-if="station.theme !== 'blue'"
-						@click="updateTheme('blue')"
-					>
-						<i class="material-icons">palette</i>
-						Blue
-					</button>
-					<button
-						class="purple"
-						v-if="station.theme !== 'purple'"
-						@click="updateTheme('purple')"
-					>
-						<i class="material-icons">palette</i>
-						Purple
-					</button>
-					<button
-						class="teal"
-						v-if="station.theme !== 'teal'"
-						@click="updateTheme('teal')"
-					>
-						<i class="material-icons">palette</i>
-						Teal
-					</button>
-					<button
-						class="orange"
-						v-if="station.theme !== 'orange'"
-						@click="updateTheme('orange')"
-					>
-						<i class="material-icons">palette</i>
-						Orange
-					</button>
-				</tippy>
+
+						<template #content>
+							<button
+								class="blue"
+								v-if="station.theme !== 'blue'"
+								@click="updateTheme('blue')"
+							>
+								<i class="material-icons">palette</i>
+								Blue
+							</button>
+							<button
+								class="purple"
+								v-if="station.theme !== 'purple'"
+								@click="updateTheme('purple')"
+							>
+								<i class="material-icons">palette</i>
+								Purple
+							</button>
+							<button
+								class="teal"
+								v-if="station.theme !== 'teal'"
+								@click="updateTheme('teal')"
+							>
+								<i class="material-icons">palette</i>
+								Teal
+							</button>
+							<button
+								class="orange"
+								v-if="station.theme !== 'orange'"
+								@click="updateTheme('orange')"
+							>
+								<i class="material-icons">palette</i>
+								Orange
+							</button>
+						</template>
+					</tippy>
+				</div>
 			</div>
 			<div class="small-section">
 				<label class="label">Privacy</label>
-				<tippy
-					class="button-wrapper"
-					theme="addToPlaylist"
-					interactive="true"
-					touch="true"
-					placement="bottom"
-					trigger="click"
-					append-to="parent"
-				>
-					<template #trigger>
+				<div class="button-wrapper">
+					<tippy
+						theme="addToPlaylist"
+						:interactive="true"
+						:touch="true"
+						placement="bottom"
+						trigger="click"
+						append-to="parent"
+					>
 						<button :class="privacyButtons[station.privacy].style">
 							<i class="material-icons">{{
 								privacyButtons[station.privacy].iconName
 							}}</i>
 							{{ station.privacy }}
 						</button>
-					</template>
-					<button
-						class="green"
-						v-if="station.privacy !== 'public'"
-						@click="updatePrivacy('public')"
-					>
-						<i class="material-icons">{{
-							privacyButtons["public"].iconName
-						}}</i>
-						Public
-					</button>
-					<button
-						class="orange"
-						v-if="station.privacy !== 'unlisted'"
-						@click="updatePrivacy('unlisted')"
-					>
-						<i class="material-icons">{{
-							privacyButtons["unlisted"].iconName
-						}}</i>
-						Unlisted
-					</button>
-					<button
-						class="red"
-						v-if="station.privacy !== 'private'"
-						@click="updatePrivacy('private')"
-					>
-						<i class="material-icons">{{
-							privacyButtons["private"].iconName
-						}}</i>
-						Private
-					</button>
-				</tippy>
+
+						<template #content>
+							<button
+								class="green"
+								v-if="station.privacy !== 'public'"
+								@click="updatePrivacy('public')"
+							>
+								<i class="material-icons">{{
+									privacyButtons["public"].iconName
+								}}</i>
+								Public
+							</button>
+							<button
+								class="orange"
+								v-if="station.privacy !== 'unlisted'"
+								@click="updatePrivacy('unlisted')"
+							>
+								<i class="material-icons">{{
+									privacyButtons["unlisted"].iconName
+								}}</i>
+								Unlisted
+							</button>
+							<button
+								class="red"
+								v-if="station.privacy !== 'private'"
+								@click="updatePrivacy('private')"
+							>
+								<i class="material-icons">{{
+									privacyButtons["private"].iconName
+								}}</i>
+								Private
+							</button>
+						</template>
+					</tippy>
+				</div>
 			</div>
 			<div class="small-section">
 				<label class="label">Station Mode</label>
-				<tippy
-					v-if="station.type === 'community'"
-					class="button-wrapper"
-					theme="addToPlaylist"
-					touch="true"
-					interactive="true"
-					placement="bottom"
-					trigger="click"
-					append-to="parent"
-				>
-					<template #trigger>
+				<div class="button-wrapper" v-if="station.type === 'community'">
+					<tippy
+						theme="addToPlaylist"
+						:interactive="true"
+						:touch="true"
+						placement="bottom"
+						trigger="click"
+						append-to="parent"
+					>
 						<button
 							:class="{
 								blue: !station.partyMode,
@@ -168,24 +170,27 @@
 							}}</i>
 							{{ station.partyMode ? "Party" : "Playlist" }}
 						</button>
-					</template>
-					<button
-						class="blue"
-						v-if="station.partyMode"
-						@click="updatePartyMode(false)"
-					>
-						<i class="material-icons">playlist_play</i>
-						Playlist
-					</button>
-					<button
-						class="yellow"
-						v-if="!station.partyMode"
-						@click="updatePartyMode(true)"
-					>
-						<i class="material-icons">emoji_people</i>
-						Party
-					</button>
-				</tippy>
+
+						<template #content>
+							<button
+								class="blue"
+								v-if="station.partyMode"
+								@click="updatePartyMode(false)"
+							>
+								<i class="material-icons">playlist_play</i>
+								Playlist
+							</button>
+							<button
+								class="yellow"
+								v-if="!station.partyMode"
+								@click="updatePartyMode(true)"
+							>
+								<i class="material-icons">emoji_people</i>
+								Party
+							</button>
+						</template>
+					</tippy>
+				</div>
 				<div v-else class="button-wrapper">
 					<button
 						class="blue"
@@ -199,17 +204,15 @@
 			</div>
 			<div v-if="!station.partyMode" class="small-section">
 				<label class="label">Play Mode</label>
-				<tippy
-					v-if="station.type === 'community'"
-					class="button-wrapper"
-					theme="addToPlaylist"
-					touch="true"
-					interactive="true"
-					placement="bottom"
-					trigger="click"
-					append-to="parent"
-				>
-					<template #trigger>
+				<div class="button-wrapper" v-if="station.type === 'community'">
+					<tippy
+						theme="addToPlaylist"
+						:interactive="true"
+						:touch="true"
+						placement="bottom"
+						trigger="click"
+						append-to="parent"
+					>
 						<button class="blue">
 							<i class="material-icons">{{
 								station.playMode === "random"
@@ -222,24 +225,29 @@
 									: "Sequential"
 							}}
 						</button>
-					</template>
-					<button
-						class="blue"
-						v-if="station.playMode === 'sequential'"
-						@click="updatePlayMode('random')"
-					>
-						<i class="material-icons">shuffle</i>
-						Random
-					</button>
-					<button
-						class="blue"
-						v-if="station.playMode === 'random'"
-						@click="updatePlayMode('sequential')"
-					>
-						<i class="material-icons">format_list_numbered</i>
-						Sequential
-					</button>
-				</tippy>
+
+						<template #content>
+							<button
+								class="blue"
+								v-if="station.playMode === 'sequential'"
+								@click="updatePlayMode('random')"
+							>
+								<i class="material-icons">shuffle</i>
+								Random
+							</button>
+							<button
+								class="blue"
+								v-if="station.playMode === 'random'"
+								@click="updatePlayMode('sequential')"
+							>
+								<i class="material-icons"
+									>format_list_numbered</i
+								>
+								Sequential
+							</button>
+						</template>
+					</tippy>
+				</div>
 				<div v-else class="button-wrapper">
 					<button
 						class="blue"
@@ -258,16 +266,15 @@
 				class="small-section"
 			>
 				<label class="label">Queue lock</label>
-				<tippy
-					class="button-wrapper"
-					theme="addToPlaylist"
-					interactive="true"
-					touch="true"
-					placement="bottom"
-					trigger="click"
-					append-to="parent"
-				>
-					<template #trigger>
+				<div class="button-wrapper">
+					<tippy
+						theme="addToPlaylist"
+						:interactive="true"
+						:touch="true"
+						placement="bottom"
+						trigger="click"
+						append-to="parent"
+					>
 						<button
 							:class="{
 								green: station.locked,
@@ -279,24 +286,27 @@
 							}}</i>
 							{{ station.locked ? "Locked" : "Unlocked" }}
 						</button>
-					</template>
-					<button
-						class="green"
-						v-if="!station.locked"
-						@click="updateQueueLock(true)"
-					>
-						<i class="material-icons">lock</i>
-						Locked
-					</button>
-					<button
-						class="red"
-						v-if="station.locked"
-						@click="updateQueueLock(false)"
-					>
-						<i class="material-icons">lock_open</i>
-						Unlocked
-					</button>
-				</tippy>
+
+						<template #content>
+							<button
+								class="green"
+								v-if="!station.locked"
+								@click="updateQueueLock(true)"
+							>
+								<i class="material-icons">lock</i>
+								Locked
+							</button>
+							<button
+								class="red"
+								v-if="station.locked"
+								@click="updateQueueLock(false)"
+							>
+								<i class="material-icons">lock_open</i>
+								Unlocked
+							</button>
+						</template>
+					</tippy>
+				</div>
 			</div>
 		</div>
 	</div>

+ 477 - 0
frontend/src/components/modals/ManageStation/Tabs/Songs.vue

@@ -0,0 +1,477 @@
+<template>
+	<div class="songs">
+		<div class="tabs-container">
+			<div class="tab-selection">
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'search' }"
+					v-if="isAllowedToParty()"
+					@click="showTab('search')"
+				>
+					Search
+				</button>
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'included' }"
+					v-if="isOwnerOrAdmin() && isPlaylistMode()"
+					@click="showTab('included')"
+				>
+					Included
+				</button>
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'excluded' }"
+					v-if="isOwnerOrAdmin()"
+					@click="showTab('excluded')"
+				>
+					Excluded
+				</button>
+			</div>
+			<div
+				class="tab"
+				v-show="tab === 'search'"
+				v-if="
+					station.type === 'community' &&
+						station.partyMode &&
+						(isOwnerOrAdmin() || !station.locked)
+				"
+			>
+				<div class="musare-songs">
+					<label class="label"> Search for a song on Musare </label>
+					<div class="control is-grouped input-with-button">
+						<p class="control is-expanded">
+							<input
+								class="input"
+								type="text"
+								placeholder="Enter your song query here..."
+								v-model="musareSearch.query"
+								@keyup.enter="searchForMusareSongs(1)"
+							/>
+						</p>
+						<p class="control">
+							<a
+								class="button is-info"
+								@click="searchForMusareSongs(1)"
+								><i class="material-icons icon-with-button"
+									>search</i
+								>Search</a
+							>
+						</p>
+					</div>
+					<div v-if="musareSearch.results.length > 0">
+						<song-item
+							v-for="song in musareSearch.results"
+							:key="song._id"
+							:song="song"
+						>
+							<template #actions>
+								<i
+									class="material-icons add-to-queue-icon"
+									v-if="station.partyMode && !station.locked"
+									@click="addSongToQueue(song.youtubeId)"
+									content="Add Song to Queue"
+									v-tippy
+									>queue</i
+								>
+							</template>
+						</song-item>
+						<button
+							v-if="resultsLeftCount > 0"
+							class="button is-primary load-more-button"
+							@click="searchForMusareSongs(musareSearch.page + 1)"
+						>
+							Load {{ nextPageResultsCount }} more results
+						</button>
+					</div>
+				</div>
+				<div class="youtube-search">
+					<label class="label"> Search for a song on YouTube </label>
+					<div class="control is-grouped input-with-button">
+						<p class="control is-expanded">
+							<input
+								class="input"
+								type="text"
+								placeholder="Enter your YouTube query here..."
+								v-model="search.songs.query"
+								autofocus
+								@keyup.enter="searchForSongs()"
+							/>
+						</p>
+						<p class="control">
+							<a
+								class="button is-info"
+								@click.prevent="searchForSongs()"
+								><i class="material-icons icon-with-button"
+									>search</i
+								>Search</a
+							>
+						</p>
+					</div>
+
+					<div
+						v-if="search.songs.results.length > 0"
+						id="song-query-results"
+					>
+						<search-query-item
+							v-for="(result, index) in search.songs.results"
+							:key="result.id"
+							:result="result"
+						>
+							<template #actions>
+								<transition
+									name="search-query-actions"
+									mode="out-in"
+								>
+									<a
+										class="button is-success"
+										v-if="result.isAddedToQueue"
+										key="added-to-queue"
+									>
+										<i
+											class="material-icons icon-with-button"
+											>done</i
+										>
+										Added to queue
+									</a>
+									<a
+										class="button is-dark"
+										v-else
+										@click.prevent="
+											addSongToQueue(result.id, index)
+										"
+										key="add-to-queue"
+									>
+										<i
+											class="material-icons icon-with-button"
+											>add</i
+										>
+										Add to queue
+									</a>
+								</transition>
+							</template>
+						</search-query-item>
+
+						<a
+							class="button is-primary load-more-button"
+							@click.prevent="loadMoreSongs()"
+						>
+							Load more...
+						</a>
+					</div>
+				</div>
+			</div>
+			<div
+				class="tab"
+				v-show="tab === 'included'"
+				v-if="
+					isOwnerOrAdmin() &&
+						!(station.type === 'community' && station.partyMode)
+				"
+			>
+				<div v-if="stationPlaylist.songs.length > 0">
+					<div id="playlist-info-section">
+						<h5>Song Count: {{ stationPlaylist.songs.length }}</h5>
+						<h5>Duration: {{ totalLength(stationPlaylist) }}</h5>
+					</div>
+					<song-item
+						v-for="song in stationPlaylist.songs"
+						:key="song._id"
+						:song="song"
+					>
+					</song-item>
+				</div>
+				<p v-else class="has-text-centered scrollable-list">
+					No songs currently included. To include songs, include a
+					playlist.
+				</p>
+			</div>
+			<div
+				class="tab"
+				v-show="tab === 'excluded'"
+				v-if="isOwnerOrAdmin()"
+			>
+				<div v-if="excludedSongs.length > 0">
+					<div id="playlist-info-section" class="section">
+						<h5>Song Count: {{ excludedSongs.length }}</h5>
+					</div>
+					<song-item
+						v-for="song in excludedSongs"
+						:key="song._id"
+						:song="song"
+					>
+					</song-item>
+				</div>
+				<p v-else class="has-text-centered scrollable-list">
+					No songs currently excluded. To excluded songs, exclude a
+					playlist.
+				</p>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapState, mapGetters } from "vuex";
+
+import Toast from "toasters";
+import SearchYoutube from "@/mixins/SearchYoutube.vue";
+
+import SongItem from "@/components/SongItem.vue";
+import SearchQueryItem from "../../../SearchQueryItem.vue";
+
+import utils from "../../../../../js/utils";
+
+export default {
+	components: {
+		SongItem,
+		SearchQueryItem
+	},
+	mixins: [SearchYoutube],
+	data() {
+		return {
+			utils,
+			tab: "search",
+			musareSearch: {
+				query: "",
+				searchedQuery: "",
+				page: 0,
+				count: 0,
+				resultsLeft: 0,
+				results: []
+			}
+		};
+	},
+	computed: {
+		resultsLeftCount() {
+			return this.musareSearch.count - this.musareSearch.results.length;
+		},
+		nextPageResultsCount() {
+			return Math.min(this.musareSearch.pageSize, this.resultsLeftCount);
+		},
+		excludedSongs() {
+			return this.excludedPlaylists
+				.map(playlist => playlist.songs)
+				.flat()
+				.filter((song, index, self) => self.indexOf(song) === index);
+		},
+		excludedSongIds() {
+			return this.excludedSongs.map(excludedSong => excludedSong._id);
+		},
+		...mapState({
+			loggedIn: state => state.user.auth.loggedIn,
+			userId: state => state.user.auth.userId,
+			role: state => state.user.auth.role
+		}),
+		...mapState("modals/manageStation", {
+			parentTab: state => state.tab,
+			station: state => state.station,
+			originalStation: state => state.originalStation,
+			excludedPlaylists: state => state.excludedPlaylists,
+			stationPlaylist: state => state.stationPlaylist
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	watch: {
+		// eslint-disable-next-line func-names
+		parentTab(value) {
+			if (value === "songs") {
+				if (this.tab === "search" && this.isPlaylistMode()) {
+					this.showTab("included");
+				} else if (this.tab === "included" && this.isPartyMode()) {
+					this.showTab("search");
+				}
+			}
+		}
+	},
+	methods: {
+		showTab(tab) {
+			this.tab = tab;
+		},
+		isOwner() {
+			return (
+				this.loggedIn &&
+				this.station &&
+				this.userId === this.station.owner
+			);
+		},
+		isAdmin() {
+			return this.loggedIn && this.role === "admin";
+		},
+		isOwnerOrAdmin() {
+			return this.isOwner() || this.isAdmin();
+		},
+		isPartyMode() {
+			return (
+				this.station &&
+				this.station.type === "community" &&
+				this.station.partyMode
+			);
+		},
+		isAllowedToParty() {
+			return (
+				this.station &&
+				this.isPartyMode() &&
+				(!this.station.locked || this.isOwnerOrAdmin()) &&
+				this.loggedIn
+			);
+		},
+		isPlaylistMode() {
+			return this.station && !this.isPartyMode();
+		},
+		totalLength(playlist) {
+			let length = 0;
+			playlist.songs.forEach(song => {
+				length += song.duration;
+			});
+			return this.utils.formatTimeLong(length);
+		},
+		addSongToQueue(youtubeId, index) {
+			if (this.station.type === "community") {
+				this.socket.dispatch(
+					"stations.addToQueue",
+					this.station._id,
+					youtubeId,
+					res => {
+						if (res.status !== "success")
+							new Toast(`Error: ${res.message}`);
+						else {
+							if (index)
+								this.search.songs.results[
+									index
+								].isAddedToQueue = true;
+
+							new Toast(res.message);
+						}
+					}
+				);
+			} else {
+				this.socket.dispatch("songs.request", youtubeId, res => {
+					if (res.status !== "success")
+						new Toast(`Error: ${res.message}`);
+					else {
+						this.search.songs.results[index].isAddedToQueue = true;
+
+						new Toast(res.message);
+					}
+				});
+			}
+		},
+		searchForMusareSongs(page) {
+			if (
+				this.musareSearch.page >= page ||
+				this.musareSearch.searchedQuery !== this.musareSearch.query
+			) {
+				this.musareSearch.results = [];
+				this.musareSearch.page = 0;
+				this.musareSearch.count = 0;
+				this.musareSearch.resultsLeft = 0;
+				this.musareSearch.pageSize = 0;
+			}
+
+			this.musareSearch.searchedQuery = this.musareSearch.query;
+			this.socket.dispatch(
+				"songs.searchOfficial",
+				this.musareSearch.query,
+				page,
+				res => {
+					const { data } = res;
+					const { count, pageSize, songs } = data;
+					if (res.status === "success") {
+						this.musareSearch.results = [
+							...this.musareSearch.results,
+							...songs
+						];
+						this.musareSearch.page = page;
+						this.musareSearch.count = count;
+						this.musareSearch.resultsLeft =
+							count - this.musareSearch.results.length;
+						this.musareSearch.pageSize = pageSize;
+					} else if (res.status === "error") {
+						this.musareSearch.results = [];
+						this.musareSearch.page = 0;
+						this.musareSearch.count = 0;
+						this.musareSearch.resultsLeft = 0;
+						this.musareSearch.pageSize = 0;
+						new Toast(res.message);
+					}
+				}
+			);
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.songs {
+	.tabs-container {
+		.tab-selection {
+			display: flex;
+			overflow-x: auto;
+			.button {
+				border-radius: 0;
+				border: 0;
+				text-transform: uppercase;
+				font-size: 14px;
+				color: var(--dark-grey-3);
+				background-color: var(--light-grey-2);
+				flex-grow: 1;
+				height: 32px;
+
+				&:not(:first-of-type) {
+					margin-left: 5px;
+				}
+			}
+
+			.selected {
+				background-color: var(--primary-color) !important;
+				color: var(--white) !important;
+				font-weight: 600;
+			}
+		}
+		.tab {
+			padding: 15px 0;
+			border-radius: 0;
+			.playlist-item:not(:last-of-type),
+			.item.item-draggable:not(:last-of-type) {
+				margin-bottom: 10px;
+			}
+			.load-more-button {
+				width: 100%;
+				margin-top: 10px;
+			}
+		}
+	}
+
+	.musare-songs,
+	.universal-item:not(:last-of-type) {
+		margin-bottom: 10px;
+	}
+	.load-more-button {
+		width: 100%;
+		margin-top: 10px;
+	}
+
+	#playlist-info-section {
+		border: 1px solid var(--light-grey-3);
+		border-radius: 3px;
+		padding: 15px !important;
+		margin-bottom: 16px;
+
+		h3 {
+			font-weight: 600;
+			font-size: 30px;
+		}
+
+		h5 {
+			font-size: 18px;
+		}
+
+		h3,
+		h5 {
+			margin: 0;
+		}
+	}
+}
+</style>

+ 1 - 2
frontend/src/components/modals/ManageStation/index.vue

@@ -449,14 +449,13 @@ export default {
 			{ modal: "manageStation" }
 		);
 	},
-	beforeDestroy() {
+	beforeUnmount() {
 		this.socket.dispatch(
 			"apis.leaveRoom",
 			`manage-station.${this.stationId}`,
 			() => {}
 		);
 
-		this.repositionSongInList([]);
 		this.clearStation();
 		this.showTab("settings");
 	},

+ 6 - 3
frontend/src/components/modals/Register.vue

@@ -23,7 +23,7 @@
 							placeholder="Email..."
 							@keypress="
 								onInput('email') &
-									$parent.submitOnEnter(submitModal, $event)
+									submitOnEnter(submitModal, $event)
 							"
 							@paste="onInput('email')"
 							autofocus
@@ -47,7 +47,7 @@
 							placeholder="Username..."
 							@keypress="
 								onInput('username') &
-									$parent.submitOnEnter(submitModal, $event)
+									submitOnEnter(submitModal, $event)
 							"
 							@paste="onInput('username')"
 						/>
@@ -74,7 +74,7 @@
 							placeholder="Password..."
 							@keypress="
 								onInput('password') &
-									$parent.submitOnEnter(submitModal, $event)
+									submitOnEnter(submitModal, $event)
 							"
 							@paste="onInput('password')"
 						/>
@@ -272,6 +272,9 @@ export default {
 		});
 	},
 	methods: {
+		submitOnEnter: (cb, event) => {
+			if (event.which === 13) cb();
+		},
 		togglePasswordVisibility() {
 			if (this.$refs.password.type === "password") {
 				this.$refs.password.type = "text";

+ 486 - 124
frontend/src/components/modals/Report.vue

@@ -1,193 +1,555 @@
 <template>
-	<modal title="Report">
-		<div slot="body">
-			<div class="edit-report-wrapper">
-				<song-item
-					:song="localSong"
-					:disabled-actions="['report']"
-					header="Selected Song.."
-				/>
-				<div class="columns is-multiline">
-					<div
-						v-for="issue in issues"
-						class="column is-half"
-						:key="issue.name"
-					>
-						<label class="label">{{ issue.name }}</label>
-						<p
-							v-for="reason in issue.reasons"
-							class="control"
-							:key="reason"
-						>
-							<label class="checkbox">
-								<input
-									type="checkbox"
-									@click="toggleIssue(issue.name, reason)"
-								/>
-								{{ reason }}
-							</label>
-						</p>
-					</div>
-					<div class="column">
-						<label class="label">Other</label>
-						<textarea
-							v-model="report.description"
-							class="textarea"
-							maxlength="400"
-							placeholder="Any other details..."
+	<div>
+		<modal class="report-modal" title="Report">
+			<template #body>
+				<div class="report-modal-inner-container">
+					<div id="left-part">
+						<song-item
+							:song="song"
+							:duration="false"
+							:disabled-actions="['report']"
+							header="Selected Song.."
 						/>
-						<div class="textarea-counter">
-							{{ charactersRemaining }}
+
+						<div class="columns is-multiline">
+							<div
+								v-for="category in predefinedCategories"
+								class="column is-half"
+								:key="category.category"
+							>
+								<label class="label">{{
+									category.category
+								}}</label>
+
+								<p
+									v-for="issue in category.issues"
+									class="control checkbox-control"
+									:key="issue.title"
+								>
+									<span class="align-horizontally">
+										<span>
+											<label class="switch">
+												<input
+													type="checkbox"
+													:id="issue.title"
+													v-model="issue.enabled"
+												/>
+												<span
+													class="slider round"
+												></span>
+											</label>
+
+											<label :for="issue.title">
+												<span></span>
+												<p>{{ issue.title }}</p>
+											</label>
+										</span>
+
+										<i
+											class="material-icons"
+											content="Provide More info"
+											v-tippy
+											@click="
+												issue.showDescription = !issue.showDescription
+											"
+										>
+											info
+										</i>
+									</span>
+
+									<input
+										type="text"
+										class="input"
+										v-model="issue.description"
+										v-if="issue.showDescription"
+										placeholder="Provide more information..."
+										@keyup="issue.enabled = true"
+									/>
+								</p>
+							</div>
+							<!-- allow for multiple custom issues with plus/add button and then a input textbox -->
+							<!-- do away with textbox -->
+
+							<div class="column is-half">
+								<div id="custom-issues">
+									<div id="custom-issues-title">
+										<label class="label"
+											>Issues not listed</label
+										>
+
+										<button
+											class="button tab-actionable-button "
+											content="Add an issue that isn't listed"
+											v-tippy
+											@click="customIssues.push('')"
+										>
+											<i
+												class="material-icons icon-with-button"
+												>add</i
+											>
+											<span>
+												Add Custom Issue
+											</span>
+										</button>
+									</div>
+
+									<div
+										class="custom-issue control is-grouped input-with-button"
+										v-for="(issue, index) in customIssues"
+										:key="index"
+									>
+										<p class="control is-expanded">
+											<input
+												type="text"
+												class="input"
+												v-model="customIssues[index]"
+												placeholder="Provide information..."
+											/>
+										</p>
+										<p class="control">
+											<button
+												class="button is-danger"
+												content="Remove custom issue"
+												v-tippy
+												@click="
+													customIssues.splice(
+														index,
+														1
+													)
+												"
+											>
+												<i class="material-icons">
+													delete
+												</i>
+											</button>
+										</p>
+									</div>
+
+									<p
+										id="no-issues-listed"
+										v-if="customIssues.length <= 0"
+									>
+										<em>
+											Add any issues that aren't listed
+											above.
+										</em>
+									</p>
+								</div>
+							</div>
+						</div>
+					</div>
+					<div id="right-part" v-if="existingReports.length > 0">
+						<h4 class="section-title">Previous Reports</h4>
+
+						<p class="section-description">
+							You have made
+							{{
+								existingReports.length > 1
+									? "multiple reports"
+									: "a report"
+							}}
+							about this song already.
+						</p>
+
+						<hr class="section-horizontal-rule" />
+
+						<div class="report-items">
+							<div
+								class="report-item"
+								v-for="report in existingReports"
+								:key="report._id"
+							>
+								<report-info-item
+									:created-at="report.createdAt"
+									:created-by="report.createdBy"
+								>
+									<template #actions>
+										<i
+											class="material-icons"
+											content="View Report"
+											v-tippy
+											@click="view(report._id)"
+										>
+											open_in_full
+										</i>
+									</template>
+								</report-info-item>
+							</div>
 						</div>
 					</div>
 				</div>
-			</div>
-		</div>
-		<div slot="footer">
-			<a class="button is-success" @click="create()" href="#">
-				<i class="material-icons save-changes">done</i>
-				<span>&nbsp;Create</span>
-			</a>
-			<a class="button is-danger" href="#" @click="closeModal('report')">
-				<span>&nbsp;Cancel</span>
-			</a>
-		</div>
-	</modal>
+			</template>
+			<template #footer>
+				<a class="button is-success" @click="create()" href="#">
+					<i class="material-icons save-changes">done</i>
+					<span>&nbsp;Create</span>
+				</a>
+				<a
+					class="button is-danger"
+					href="#"
+					@click="closeModal('report')"
+				>
+					<span>&nbsp;Cancel</span>
+				</a>
+			</template>
+		</modal>
+		<view-report v-if="modals.viewReport" />
+	</div>
 </template>
 
 <script>
 import { mapState, mapGetters, mapActions } from "vuex";
-
 import Toast from "toasters";
+
+import ViewReport from "@/components/modals/ViewReport.vue";
 import SongItem from "@/components/SongItem.vue";
+import ReportInfoItem from "@/components/ReportInfoItem.vue";
 import Modal from "../Modal.vue";
 
 export default {
-	components: { Modal, SongItem },
+	components: { Modal, ViewReport, SongItem, ReportInfoItem },
 	data() {
 		return {
-			localSong: null,
-			report: {
-				resolved: false,
-				youtubeId: "",
-				description: "",
-				issues: [
-					{ name: "Video", reasons: [] },
-					{ name: "Title", reasons: [] },
-					{ name: "Duration", reasons: [] },
-					{ name: "Artists", reasons: [] },
-					{ name: "Thumbnail", reasons: [] }
-				]
+			icons: {
+				duration: "timer",
+				video: "tv",
+				thumbnail: "image",
+				artists: "record_voice_over",
+				title: "title",
+				custom: "lightbulb"
 			},
-			issues: [
+			existingReports: [],
+			customIssues: [],
+			predefinedCategories: [
 				{
-					name: "Video",
-					reasons: [
-						"Doesn't exist",
-						"It's private",
-						"It's not available in my country",
-						"Unofficial"
+					category: "video",
+					issues: [
+						{
+							enabled: false,
+							title: "Doesn't exist",
+							description: "",
+							showDescription: false
+						},
+						{
+							enabled: false,
+							title: "It's private",
+							description: "",
+							showDescription: false
+						},
+						{
+							enabled: false,
+							title: "It's not available in my country",
+							description: "",
+							showDescription: false
+						},
+						{
+							enabled: false,
+							title: "Unofficial",
+							description: "",
+							showDescription: false
+						}
 					]
 				},
 				{
-					name: "Title",
-					reasons: ["Incorrect", "Inappropriate"]
+					category: "title",
+					issues: [
+						{
+							enabled: false,
+							title: "Incorrect",
+							description: "",
+							showDescription: false
+						},
+						{
+							enabled: false,
+							title: "Inappropriate",
+							description: "",
+							showDescription: false
+						}
+					]
 				},
 				{
-					name: "Duration",
-					reasons: [
-						"Skips too soon",
-						"Skips too late",
-						"Starts too soon",
-						"Starts too late"
+					category: "duration",
+					issues: [
+						{
+							enabled: false,
+							title: "Skips too soon",
+							description: "",
+							showDescription: false
+						},
+						{
+							enabled: false,
+							title: "Skips too late",
+							description: "",
+							showDescription: false
+						},
+						{
+							enabled: false,
+							title: "Starts too soon",
+							description: "",
+							showDescription: false
+						},
+						{
+							enabled: false,
+							title: "Starts too late",
+							description: "",
+							showDescription: false
+						}
 					]
 				},
 				{
-					name: "Artists",
-					reasons: ["Incorrect", "Inappropriate"]
+					category: "artists",
+					issues: [
+						{
+							enabled: false,
+							title: "Incorrect",
+							description: "",
+							showDescription: false
+						},
+						{
+							enabled: false,
+							title: "Inappropriate",
+							description: "",
+							showDescription: false
+						}
+					]
 				},
 				{
-					name: "Thumbnail",
-					reasons: ["Incorrect", "Inappropriate", "Doesn't exist"]
+					category: "thumbnail",
+					issues: [
+						{
+							enabled: false,
+							title: "Incorrect",
+							description: "",
+							showDescription: false
+						},
+						{
+							enabled: false,
+							title: "Inappropriate",
+							description: "",
+							showDescription: false
+						},
+						{
+							enabled: false,
+							title: "Doesn't exist",
+							description: "",
+							showDescription: false
+						}
+					]
 				}
 			]
 		};
 	},
 	computed: {
-		charactersRemaining() {
-			return 400 - this.report.description.length;
-		},
 		...mapState({
 			song: state => state.modals.report.song
 		}),
+		...mapState("modalVisibility", {
+			modals: state => state.modals
+		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
 	},
 	mounted() {
-		if (this.song !== null) {
-			this.localSong = this.song;
-			this.report.youtubeId = this.song.youtubeId;
-			this.reportSong(null);
-		}
+		this.socket.dispatch("reports.myReportsForSong", this.song._id, res => {
+			if (res.status === "success") {
+				this.existingReports = res.data.reports;
+				this.existingReports.forEach(report =>
+					this.socket.dispatch(
+						"apis.joinRoom",
+						`view-report.${report._id}`
+					)
+				);
+			}
+		});
+
+		this.socket.on(
+			"event:admin.report.resolved",
+			res => {
+				this.existingReports = this.existingReports.filter(
+					report => report._id !== res.data.reportId
+				);
+			},
+			{ modal: "report" }
+		);
 	},
 	methods: {
-		create() {
-			this.socket.dispatch("reports.create", this.report, res => {
-				new Toast(res.message);
-				if (res.status === "success") this.closeModal("report");
-			});
+		view(reportId) {
+			this.viewReport(reportId);
+			this.openModal("viewReport");
 		},
-		toggleIssue(name, reason) {
-			for (let z = 0; z < this.report.issues.length; z += 1) {
-				if (this.report.issues[z].name === name) {
-					if (this.report.issues[z].reasons.indexOf(reason) > -1) {
-						this.report.issues[z].reasons.splice(
-							this.report.issues[z].reasons.indexOf(reason),
-							1
-						);
-					} else this.report.issues[z].reasons.push(reason);
+		create() {
+			const issues = [];
+
+			// any predefined issues that are enabled
+			this.predefinedCategories.forEach(category =>
+				category.issues.forEach(issue => {
+					if (issue.enabled)
+						issues.push({
+							category: category.category,
+							title: issue.title,
+							description: issue.description
+						});
+				})
+			);
+
+			// any custom issues
+			this.customIssues.forEach(issue =>
+				issues.push({ category: "custom", title: issue })
+			);
+
+			if (issues.length === 0)
+				return new Toast("Reports must have at least one issue");
+
+			return this.socket.dispatch(
+				"reports.create",
+				{
+					issues,
+					youtubeId: this.song.youtubeId
+				},
+				res => {
+					new Toast(res.message);
+					if (res.status === "success") this.closeModal("report");
 				}
-			}
+			);
 		},
-		...mapActions("modals/report", ["reportSong"]),
-		...mapActions("modalVisibility", ["closeModal"])
+		...mapActions("modalVisibility", ["openModal", "closeModal"]),
+		...mapActions("modals/viewReport", ["viewReport"])
 	}
 };
 </script>
 
 <style lang="scss">
-.edit-report-wrapper .song-item {
-	.song-info {
-		width: calc(100% - 150px);
+.report-modal {
+	.modal-card {
+		width: 1050px;
 	}
-	.thumbnail {
-		min-width: 130px;
-		width: 130px;
-		height: 130px;
+
+	.song-item {
+		.thumbnail {
+			min-width: 130px;
+			width: 130px;
+			height: 130px;
+		}
 	}
 }
 </style>
 
 <style lang="scss" scoped>
-.radio-controls .control {
-	display: flex;
-	align-items: center;
+.night-mode {
+	@media screen and (max-width: 900px) {
+		#right-part {
+			background-color: var(--dark-grey-3) !important;
+		}
+	}
 }
 
-.textarea-counter {
-	text-align: right;
-}
+.report-modal-inner-container {
+	display: flex;
+
+	@media screen and (max-width: 900px) {
+		flex-wrap: wrap-reverse;
+
+		#left-part {
+			width: 100%;
+		}
+
+		#right-part {
+			border-left: 0 !important;
+			margin-left: 0 !important;
+			width: 100%;
+			min-width: 0 !important;
+			margin-bottom: 20px;
+			padding: 20px;
+			background-color: var(--light-grey);
+		}
+	}
 
-@media screen and (min-width: 769px) {
-	.radio-controls .control-label {
-		padding-top: 0 !important;
+	#right-part {
+		border-left: 1px solid var(--light-grey-3);
+		padding-left: 20px;
+		margin-left: 20px;
+		min-width: 325px;
+
+		.report-items {
+			max-height: 485px;
+			overflow: auto;
+
+			.report-item:not(:first-of-type) {
+				margin-top: 10px;
+			}
+		}
 	}
 }
 
+.label {
+	text-transform: capitalize;
+}
+
 .columns {
 	margin-left: unset;
 	margin-right: unset;
 	margin-top: 20px;
+
+	.control {
+		display: flex;
+		flex-direction: column;
+
+		span.align-horizontally {
+			width: 100%;
+			display: flex;
+			align-items: center;
+			justify-content: space-between;
+
+			span {
+				display: flex;
+			}
+		}
+
+		i {
+			cursor: pointer;
+		}
+
+		input[type="text"] {
+			height: initial;
+			margin: 10px 0;
+		}
+	}
+}
+
+#custom-issues {
+	height: 100%;
+
+	#custom-issues-title {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		margin-bottom: 15px;
+
+		button {
+			padding: 3px 5px;
+			height: initial;
+		}
+
+		label {
+			margin: 0;
+		}
+	}
+
+	#no-issues-listed {
+		display: flex;
+		height: calc(100% - 32px - 15px);
+		align-items: center;
+		justify-content: center;
+	}
+
+	.custom-issue {
+		flex-direction: row;
+
+		input {
+			height: 36px;
+			margin: 0;
+		}
+	}
 }
 </style>

+ 11 - 12
frontend/src/components/modals/RequestSong.vue

@@ -1,6 +1,6 @@
 <template>
 	<modal title="Request Song">
-		<div slot="body">
+		<template #body>
 			<div class="vertical-padding">
 				<!-- Choosing a song from youtube -->
 
@@ -45,21 +45,21 @@
 						:key="result.id"
 						:result="result"
 					>
-						<div slot="actions">
+						<template #actions>
 							<transition
 								name="search-query-actions"
 								mode="out-in"
 							>
 								<a
 									class="button is-success"
-									v-if="result.isAddedToQueue"
+									v-if="result.isRequested"
 									href="#"
 									key="added-to-playlist"
 								>
 									<i class="material-icons icon-with-button"
 										>done</i
 									>
-									Added to queue
+									Requested song
 								</a>
 								<a
 									class="button is-dark"
@@ -73,10 +73,10 @@
 									<i class="material-icons icon-with-button"
 										>add</i
 									>
-									Add to queue
+									Request song
 								</a>
 							</transition>
-						</div>
+						</template>
 					</search-query-item>
 
 					<a
@@ -135,7 +135,7 @@
 					</div>
 				</div>
 			</div>
-		</div>
+		</template>
 	</modal>
 </template>
 
@@ -172,20 +172,18 @@ export default {
 						if (res.status !== "success")
 							new Toast(`Error: ${res.message}`);
 						else {
-							this.search.songs.results[
-								index
-							].isAddedToQueue = true;
+							this.search.songs.results[index].isRequested = true;
 
 							new Toast(res.message);
 						}
 					}
 				);
 			} else {
-				this.socket.dispatch("songs.request", youtubeId, res => {
+				this.socket.dispatch("songs.request", youtubeId, false, res => {
 					if (res.status !== "success")
 						new Toast(`Error: ${res.message}`);
 					else {
-						this.search.songs.results[index].isAddedToQueue = true;
+						this.search.songs.results[index].isRequested = true;
 
 						new Toast(res.message);
 					}
@@ -222,6 +220,7 @@ export default {
 				"songs.requestSet",
 				this.search.playlist.query,
 				this.search.playlist.isImportingOnlyMusic,
+				false,
 				res => {
 					isImportingPlaylist = false;
 					return new Toast({ content: res.message, timeout: 20000 });

+ 2 - 3
frontend/src/components/modals/ViewPunishment.vue

@@ -1,7 +1,7 @@
 <template>
 	<div>
 		<modal title="View Punishment">
-			<div slot="body" v-if="punishment && punishment._id">
+			<template #body v-if="punishment && punishment._id">
 				<article class="message">
 					<div class="message-body">
 						<strong>Type:</strong>
@@ -54,8 +54,7 @@
 						<br />
 					</div>
 				</article>
-			</div>
-			<div slot="footer"></div>
+			</template>
 		</modal>
 	</div>
 </template>

+ 277 - 98
frontend/src/components/modals/ViewReport.vue

@@ -1,151 +1,330 @@
 <template>
-	<modal title="View Report">
-		<div slot="body" v-if="report && report._id">
-			<router-link
-				v-if="$route.query.returnToSong"
-				class="button is-dark back-to-song"
-				:to="{
-					path: '/admin/songs',
-					query: { id: report.youtubeId }
-				}"
-			>
-				<i class="material-icons">keyboard_return</i> &nbsp; Edit Song
-			</router-link>
-
-			<article class="message">
-				<div class="message-body">
-					<strong>Song ID:</strong>
-					{{ report.song.youtubeId }} / {{ report.song._id }}
-					<br />
-					<strong>Author:</strong>
-					<user-id-to-username
-						:user-id="report.createdBy"
-						:alt="report.createdBy"
+	<modal class="view-report-modal" title="View Report">
+		<template #body v-if="report && report._id">
+			<div class="report-item">
+				<div id="song-and-report-items">
+					<report-info-item
+						:created-at="report.createdAt"
+						:created-by="report.createdBy"
 					/>
-					<br />
-					<strong>Time of report:</strong>
-					<span
-						:content="report.createdAt"
-						v-tippy="{ theme: 'info' }"
+
+					<song-item
+						:song="song"
+						:duration="false"
+						:disabled-actions="['report']"
+					/>
+				</div>
+
+				<div class="report-sub-items">
+					<div
+						class="report-sub-item report-sub-item-unresolved"
+						:class="[
+							'report',
+							issue.resolved
+								? 'report-sub-item-resolved'
+								: 'report-sub-item-unresolved'
+						]"
+						v-for="(issue, issueIndex) in report.issues"
+						:key="issueIndex"
 					>
-						{{
-							formatDistance(
-								new Date(report.createdAt),
-								new Date(),
-								{
-									addSuffix: true
-								}
-							)
-						}}
-					</span>
-					<br />
-					<span v-if="report.description">
-						<strong>Description:</strong>
-						{{ report.description }}
-					</span>
+						<i
+							class="material-icons duration-icon report-sub-item-left-icon"
+							:content="issue.category"
+							v-tippy
+						>
+							{{ icons[issue.category] }}
+						</i>
+						<p class="report-sub-item-info">
+							<span class="report-sub-item-title">
+								{{ issue.title }}
+							</span>
+							<span
+								class="report-sub-item-description"
+								v-if="issue.description"
+							>
+								{{ issue.description }}
+							</span>
+						</p>
+
+						<div
+							class="report-sub-item-actions universal-item-actions"
+						>
+							<i
+								class="material-icons resolve-icon"
+								content="Resolve"
+								v-tippy
+								v-if="!issue.resolved"
+								@click="toggleIssue(issue._id)"
+							>
+								done
+							</i>
+							<i
+								class="material-icons unresolve-icon"
+								content="Unresolve"
+								v-tippy
+								v-else
+								@click="toggleIssue(issue._id)"
+							>
+								remove
+							</i>
+						</div>
+					</div>
 				</div>
-			</article>
-			<table v-if="report.issues.length > 0" class="table is-narrow">
-				<thead>
-					<tr>
-						<td>Issue</td>
-						<td>Reasons</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for="issue in report.issues" :key="issue.name">
-						<td>
-							<span>{{ issue.name }}</span>
-						</td>
-						<td>
-							<span>{{ issue.reasons }}</span>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
-		<div slot="footer" v-if="report && report._id">
-			<a class="button is-primary" href="#" @click="resolve(report._id)">
-				<span>Resolve</span>
+			</div>
+		</template>
+		<template #footer v-if="report && report._id">
+			<a class="button is-primary" @click="openSong()">
+				<i
+					class="material-icons icon-with-button"
+					content="Edit Song"
+					v-tippy
+				>
+					edit
+				</i>
+				Edit Song
 			</a>
-			<a
-				class="button is-primary"
-				:href="`/admin/songs?songId=${report.song._id}`"
-				target="_blank"
-			>
-				<span>Go to song</span>
+			<a class="button is-success" href="#" @click="resolve()">
+				<i
+					class="material-icons icon-with-button"
+					content="Resolve"
+					v-tippy
+				>
+					done_all
+				</i>
+				Resolve
 			</a>
-		</div>
+		</template>
 	</modal>
 </template>
 
 <script>
 import { mapActions, mapGetters, mapState } from "vuex";
-import { formatDistance } from "date-fns";
 import Toast from "toasters";
 
-import UserIdToUsername from "../UserIdToUsername.vue";
-import Modal from "../Modal.vue";
+import Modal from "@/components/Modal.vue";
+import SongItem from "@/components/SongItem.vue";
+import ReportInfoItem from "@/components/ReportInfoItem.vue";
 
 export default {
-	components: { Modal, UserIdToUsername },
+	components: { Modal, SongItem, ReportInfoItem },
 	props: {
-		reportId: { type: String, default: "" },
 		sector: { type: String, default: "admin" }
 	},
+	data() {
+		return {
+			icons: {
+				duration: "timer",
+				video: "tv",
+				thumbnail: "image",
+				artists: "record_voice_over",
+				title: "title",
+				custom: "lightbulb"
+			},
+			report: {},
+			song: null
+		};
+	},
 	computed: {
 		...mapState("modals/viewReport", {
-			report: state => state.report
+			reportId: state => state.viewingReportId
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
 	},
 	mounted() {
-		if (this.$route.query.returnToSong) {
-			this.closeModal("editSong");
-		}
-
-		this.socket.dispatch(`reports.findOne`, this.reportId, res => {
+		this.socket.dispatch("reports.findOne", this.reportId, res => {
 			if (res.status === "success") {
 				const { report } = res.data;
-				this.viewReport(report);
+
+				this.socket.dispatch(
+					"apis.joinRoom",
+					`view-report.${report._id}`
+				);
+
+				this.report = report;
+
+				this.socket.dispatch(
+					"songs.getSongFromSongId",
+					this.report.song._id,
+					res => {
+						if (res.status === "success") this.song = res.data.song;
+						else {
+							new Toast(
+								"Cannot find the report's associated song"
+							);
+							this.closeModal("viewReport");
+						}
+					}
+				);
 			} else {
 				new Toast("Report with that ID not found");
 				this.closeModal("viewReport");
 			}
 		});
+
+		this.socket.on(
+			"event:admin.report.resolved",
+			() => this.closeModal("viewReport"),
+			{ modal: "viewReport" }
+		);
+
+		this.socket.on(
+			"event:admin.report.issue.toggled",
+			res => {
+				if (this.report._id === res.data.reportId) {
+					const issue = this.report.issues.find(
+						issue => issue._id.toString() === res.data.issueId
+					);
+
+					issue.resolved = res.data.resolved;
+				}
+			},
+			{ modal: "viewReport" }
+		);
+	},
+	beforeUnmount() {
+		this.socket.dispatch("apis.leaveRoom", `view-report.${this.reportId}`);
 	},
 	methods: {
-		formatDistance,
-		resolve(reportId) {
-			return this.resolveReport(reportId)
+		resolve() {
+			return this.resolveReport(this.reportId)
 				.then(res => {
 					if (res.status === "success") this.closeModal("viewReport");
 				})
 				.catch(err => new Toast(err.message));
 		},
-		...mapActions("modals/viewReport", ["viewReport", "resolveReport"]),
-		...mapActions("modalVisibility", ["closeModal"])
+		toggleIssue(issueId) {
+			this.socket.dispatch(
+				"reports.toggleIssue",
+				this.reportId,
+				issueId,
+				res => {
+					if (res.status !== "success") new Toast(res.message);
+				}
+			);
+		},
+		openSong() {
+			this.editSong({ _id: this.report.song._id });
+			this.openModal("editSong");
+		},
+		...mapActions("admin/reports", ["indexReports", "resolveReport"]),
+		...mapActions("modals/editSong", ["editSong"]),
+		...mapActions("modalVisibility", ["closeModal", "openModal"])
 	}
 };
 </script>
 
 <style lang="scss" scoped>
 .night-mode {
-	.message,
-	.table {
-		color: var(--light-grey-2);
-		background-color: var(--dark-grey-3);
+	.report-sub-items {
+		background-color: var(--dark-grey-2) !important;
+
+		.report-sub-item {
+			border: 0.5px solid #fff !important;
+		}
 	}
+}
 
-	.table tr:hover {
-		filter: brightness(95%);
+@media screen and (min-width: 650px) {
+	.report-info-item {
+		margin-right: 10px !important;
 	}
 }
 
-.back-to-song {
-	display: flex;
-	margin-bottom: 20px;
+.report-item {
+	#song-and-report-items {
+		display: flex;
+		flex-wrap: wrap;
+		margin-bottom: 20px;
+
+		.universal-item {
+			width: fit-content;
+			margin: 5px 0;
+		}
+	}
+
+	/deep/ .report-info-item {
+		justify-content: flex-start;
+
+		.item-title-description {
+			.item-title {
+				font-size: 20px;
+				font-family: Karla, Arial, sans-serif;
+			}
+
+			.item-description {
+				font-size: 14px;
+				font-family: Karla, Arial, sans-serif;
+			}
+		}
+	}
+
+	.report-sub-items {
+		.report-sub-item {
+			border: 0.5px solid var(--black);
+			margin-top: -1px;
+			line-height: 24px;
+			display: flex;
+			// padding: 4px;
+			padding: 8px;
+			display: flex;
+
+			&:first-child {
+				border-radius: 3px 3px 0 0;
+			}
+
+			&:last-child {
+				border-radius: 0 0 3px 3px;
+			}
+
+			&.report-sub-item-resolved {
+				.report-sub-item-description,
+				.report-sub-item-title {
+					text-decoration: line-through;
+				}
+			}
+
+			.report-sub-item-left-icon {
+				margin-right: 8px;
+				margin-top: auto;
+				margin-bottom: auto;
+			}
+
+			.report-sub-item-info {
+				flex: 1;
+				display: flex;
+				flex-direction: column;
+
+				.report-sub-item-title {
+					// font-size: 14px;
+					font-size: 16px;
+				}
+
+				.report-sub-item-description {
+					// font-size: 12px;
+					font-size: 14px;
+					line-height: 16px;
+				}
+			}
+
+			.report-sub-item-actions {
+				height: 24px;
+				margin-left: 8px;
+				margin-top: auto;
+				margin-bottom: auto;
+			}
+		}
+	}
+
+	.resolve-icon {
+		color: var(--green);
+		cursor: pointer;
+	}
+
+	.unresolve-icon {
+		color: var(--red);
+		cursor: pointer;
+	}
 }
 </style>

+ 5 - 4
frontend/src/components/modals/WhatIsNew.vue

@@ -1,13 +1,13 @@
 <template>
 	<div v-if="news !== null">
 		<modal title="News" class="what-is-news-modal">
-			<div slot="body">
+			<template #body>
 				<div
 					class="section news-item"
 					v-html="sanitize(marked(news.markdown))"
 				></div>
-			</div>
-			<div slot="footer">
+			</template>
+			<template #footer>
 				<span v-if="news.createdBy">
 					By
 					<user-id-to-username
@@ -21,9 +21,10 @@
 						})
 					}}
 				</span>
-			</div>
+			</template>
 		</modal>
 	</div>
+	<div v-else></div>
 </template>
 
 <script>

+ 56 - 76
frontend/src/main.js

@@ -1,23 +1,27 @@
-import Vue from "vue";
+/* eslint-disable vue/one-component-per-file */
+import { createApp } from "vue";
 
-import VueTippy, { TippyComponent } from "vue-tippy";
-import VueRouter from "vue-router";
+import VueTippy, { Tippy } from "vue-tippy";
+import { createRouter, createWebHistory } from "vue-router";
 
 import ws from "@/ws";
 import store from "./store";
 
-import App from "./App.vue";
+import AppComponent from "./App.vue";
 
-const REQUIRED_CONFIG_VERSION = 5;
+const REQUIRED_CONFIG_VERSION = 6;
 
 const handleMetadata = attrs => {
 	document.title = `Musare | ${attrs.title}`;
 };
 
-Vue.use(VueTippy, {
+const app = createApp(AppComponent);
+
+app.use(store);
+
+app.use(VueTippy, {
 	directive: "tippy", // => v-tippy
 	flipDuration: 0,
-	touch: false,
 	popperOptions: {
 		modifiers: {
 			preventOverflow: {
@@ -26,14 +30,12 @@ Vue.use(VueTippy, {
 		}
 	},
 	allowHTML: true,
-	animation: "scale",
-	theme: "dark",
-	arrow: true
+	defaultProps: { animation: "scale", touch: "hold" }
 });
 
-Vue.component("Tippy", TippyComponent);
+app.component("Tippy", Tippy);
 
-Vue.component("Metadata", {
+app.component("Metadata", {
 	watch: {
 		$attrs: {
 			// eslint-disable-next-line vue/no-arrow-functions-in-watch
@@ -49,10 +51,8 @@ Vue.component("Metadata", {
 	}
 });
 
-Vue.use(VueRouter);
-
-Vue.directive("scroll", {
-	inserted(el, binding) {
+app.directive("scroll", {
+	mounted(el, binding) {
 		const f = evt => {
 			clearTimeout(window.scrollDebounceId);
 			window.scrollDebounceId = setTimeout(() => {
@@ -65,15 +65,15 @@ Vue.directive("scroll", {
 	}
 });
 
-Vue.directive("focus", {
-	inserted(el) {
+app.directive("focus", {
+	mounted(el) {
 		window.focusedElementBefore = document.activeElement;
 		el.focus();
 	}
 });
 
-const router = new VueRouter({
-	mode: "history",
+const router = createRouter({
+	history: createWebHistory(),
 	routes: [
 		{
 			path: "/",
@@ -81,7 +81,7 @@ const router = new VueRouter({
 		},
 		{
 			path: "/404",
-			alias: ["*"],
+			alias: ["/:pathMatch(.*)*"],
 			component: () => import("@/pages/404.vue")
 		},
 		{
@@ -164,6 +164,8 @@ const router = new VueRouter({
 	]
 });
 
+app.use(router);
+
 lofig.folder = "../config/default.json";
 
 (async () => {
@@ -207,30 +209,37 @@ lofig.folder = "../config/default.json";
 	ws.socket.on("keep.event:user.preferences.updated", res => {
 		const { preferences } = res.data;
 
-		store.dispatch(
-			"user/preferences/changeAutoSkipDisliked",
-			preferences.autoSkipDisliked
-		);
-
-		store.dispatch(
-			"user/preferences/changeNightmode",
-			preferences.nightmode
-		);
-
-		store.dispatch(
-			"user/preferences/changeActivityLogPublic",
-			preferences.activityLogPublic
-		);
-
-		store.dispatch(
-			"user/preferences/changeAnonymousSongRequests",
-			preferences.anonymousSongRequests
-		);
-
-		store.dispatch(
-			"user/preferences/changeActivityWatch",
-			preferences.activityWatch
-		);
+		if (preferences.autoSkipDisliked !== undefined)
+			store.dispatch(
+				"user/preferences/changeAutoSkipDisliked",
+				preferences.autoSkipDisliked
+			);
+
+		if (preferences.nightmode !== undefined) {
+			localStorage.setItem("nightmode", preferences.nightmode);
+			store.dispatch(
+				"user/preferences/changeNightmode",
+				preferences.nightmode
+			);
+		}
+
+		if (preferences.activityLogPublic !== undefined)
+			store.dispatch(
+				"user/preferences/changeActivityLogPublic",
+				preferences.activityLogPublic
+			);
+
+		if (preferences.anonymousSongRequests !== undefined)
+			store.dispatch(
+				"user/preferences/changeAnonymousSongRequests",
+				preferences.anonymousSongRequests
+			);
+
+		if (preferences.activityWatch !== undefined)
+			store.dispatch(
+				"user/preferences/changeActivityWatch",
+				preferences.activityWatch
+			);
 	});
 
 	router.beforeEach((to, from, next) => {
@@ -239,7 +248,7 @@ lofig.folder = "../config/default.json";
 			window.stationInterval = 0;
 		}
 
-		if (ws.socket) {
+		if (ws.socket && to.fullPath !== from.fullPath) {
 			ws.clearCallbacks();
 			ws.destroyListeners();
 		}
@@ -275,34 +284,5 @@ lofig.folder = "../config/default.json";
 		} else next();
 	});
 
-	Vue.directive("click-outside", {
-		bind(element, binding) {
-			window.handleOutsideClick = event => {
-				if (
-					!(
-						element === event.target ||
-						element.contains(event.target)
-					)
-				) {
-					binding.value();
-				}
-			};
-
-			document.body.addEventListener("click", window.handleOutsideClick);
-		},
-		unbind() {
-			document.body.removeEventListener(
-				"click",
-				window.handleOutsideClick
-			);
-		}
-	});
-
-	// eslint-disable-next-line no-new
-	new Vue({
-		router,
-		store,
-		el: "#root",
-		render: wrapper => wrapper(App)
-	});
+	app.mount("#root");
 })();

+ 3 - 5
frontend/src/mixins/ScrollAndFetchHandler.vue

@@ -1,5 +1,8 @@
 <script>
 export default {
+	setup() {
+		window.addEventListener("scroll", this.handleScroll);
+	},
 	data() {
 		return {
 			position: 1,
@@ -19,11 +22,6 @@ export default {
 	},
 	unmounted() {
 		clearInterval(this.interval);
-	},
-	created() {
-		window.addEventListener("scroll", this.handleScroll);
-	},
-	destroyed() {
 		window.removeEventListener("scroll", this.handleScroll);
 	},
 	methods: {

+ 20 - 20
frontend/src/mixins/SortablePlaylists.vue

@@ -1,5 +1,5 @@
 <script>
-import { mapState } from "vuex";
+import { mapState, mapActions } from "vuex";
 import Toast from "toasters";
 import draggable from "vuedraggable";
 
@@ -12,18 +12,18 @@ export default {
 		};
 	},
 	computed: {
-		...mapState({
-			station: state => state.station.station,
-			myUserId: state => state.user.auth.userId
-		}),
 		playlists: {
 			get() {
 				return this.$store.state.user.playlists.playlists;
 			},
 			set(playlists) {
-				this.$store.commit("user/playlists/setPlaylists", playlists);
+				this.$store.commit("user/playlists/updatePlaylists", playlists);
 			}
 		},
+		...mapState({
+			station: state => state.station.station,
+			myUserId: state => state.user.auth.userId
+		}),
 		dragOptions() {
 			return {
 				animation: 200,
@@ -36,19 +36,13 @@ export default {
 	mounted() {
 		this.socket.on(
 			"event:playlist.created",
-			res => this.playlists.push(res.data.playlist),
+			res => this.addPlaylist(res.data.playlist),
 			{ replaceable: true }
 		);
 
 		this.socket.on(
 			"event:playlist.deleted",
-			res => {
-				this.playlists.forEach((playlist, index) => {
-					if (playlist._id === res.data.playlistId) {
-						this.playlists.splice(index, 1);
-					}
-				});
-			},
+			res => this.removePlaylist(res.data.playlistId),
 			{ replaceable: true }
 		);
 
@@ -67,13 +61,18 @@ export default {
 		this.socket.on(
 			"event:playlist.song.removed",
 			res => {
-				this.playlists.forEach((playlist, index) => {
+				this.playlists.forEach((playlist, playlistIndex) => {
 					if (playlist._id === res.data.playlistId) {
-						this.playlists[index].songs.forEach((song, index2) => {
-							if (song.youtubeId === res.data.youtubeId) {
-								this.playlists[index].songs.splice(index2, 1);
+						this.playlists[playlistIndex].songs.forEach(
+							(song, songIndex) => {
+								if (song.youtubeId === res.data.youtubeId) {
+									this.playlists[playlistIndex].songs.splice(
+										songIndex,
+										1
+									);
+								}
 							}
-						});
+						);
 					}
 				});
 			},
@@ -150,7 +149,8 @@ export default {
 					return new Toast(res.message);
 				}
 			);
-		}
+		},
+		...mapActions("user/playlists", ["addPlaylist", "removePlaylist"])
 	}
 };
 </script>

+ 20 - 11
frontend/src/pages/Admin/index.vue

@@ -133,22 +133,31 @@
 
 <script>
 import { mapGetters } from "vuex";
+import { defineAsyncComponent } from "vue";
 
 import MainHeader from "@/components/layout/MainHeader.vue";
 
 export default {
 	components: {
 		MainHeader,
-		UnverifiedSongs: () => import("./tabs/UnverifiedSongs.vue"),
-		VerifiedSongs: () => import("./tabs/VerifiedSongs.vue"),
-		HiddenSongs: () => import("./tabs/HiddenSongs.vue"),
-		Stations: () => import("./tabs/Stations.vue"),
-		Playlists: () => import("./tabs/Playlists.vue"),
-		Reports: () => import("./tabs/Reports.vue"),
-		News: () => import("./tabs/News.vue"),
-		Users: () => import("./tabs/Users.vue"),
-		Statistics: () => import("./tabs/Statistics.vue"),
-		Punishments: () => import("./tabs/Punishments.vue")
+		UnverifiedSongs: defineAsyncComponent(() =>
+			import("./tabs/UnverifiedSongs.vue")
+		),
+		VerifiedSongs: defineAsyncComponent(() =>
+			import("./tabs/VerifiedSongs.vue")
+		),
+		HiddenSongs: defineAsyncComponent(() =>
+			import("./tabs/HiddenSongs.vue")
+		),
+		Stations: defineAsyncComponent(() => import("./tabs/Stations.vue")),
+		Playlists: defineAsyncComponent(() => import("./tabs/Playlists.vue")),
+		Reports: defineAsyncComponent(() => import("./tabs/Reports.vue")),
+		News: defineAsyncComponent(() => import("./tabs/News.vue")),
+		Users: defineAsyncComponent(() => import("./tabs/Users.vue")),
+		Statistics: defineAsyncComponent(() => import("./tabs/Statistics.vue")),
+		Punishments: defineAsyncComponent(() =>
+			import("./tabs/Punishments.vue")
+		)
 	},
 	data() {
 		return {
@@ -166,7 +175,7 @@ export default {
 	mounted() {
 		this.changeTab(this.$route.path);
 	},
-	beforeDestroy() {
+	beforeUnmount() {
 		this.socket.dispatch("apis.leaveRooms", () => {});
 	},
 	methods: {

+ 44 - 27
frontend/src/pages/Admin/tabs/HiddenSongs.vue

@@ -5,7 +5,7 @@
 			<p>
 				<span>Sets loaded: {{ setsLoaded }} / {{ maxSets }}</span>
 				<br />
-				<span>Loaded songs: {{ this.songs.length }}</span>
+				<span>Loaded songs: {{ songs.length }}</span>
 			</p>
 			<input
 				v-model="searchQuery"
@@ -27,6 +27,9 @@
 			>
 				Keyboard shortcuts helper
 			</button>
+			<button class="button is-primary" @click="openModal('requestSong')">
+				Request song
+			</button>
 			<br />
 			<br />
 			<table class="table is-striped">
@@ -86,28 +89,32 @@
 							/>
 						</td>
 						<td class="optionsColumn">
-							<button
-								class="button is-primary"
-								@click="edit(song, index)"
-								content="Edit Song"
-								v-tippy
-							>
-								<i class="material-icons">edit</i>
-							</button>
-							<button
-								class="button is-success"
-								@click="unhide(song._id)"
-								content="Unhide Song"
-								v-tippy
-							>
-								<i class="material-icons">visibility</i>
-							</button>
+							<div>
+								<button
+									class="button is-primary"
+									@click="edit(song, index)"
+									content="Edit Song"
+									v-tippy
+								>
+									<i class="material-icons">edit</i>
+								</button>
+								<button
+									class="button is-success"
+									@click="unhide(song._id)"
+									content="Unhide Song"
+									v-tippy
+								>
+									<i class="material-icons">visibility</i>
+								</button>
+							</div>
 						</td>
 					</tr>
 				</tbody>
 			</table>
 		</div>
+		<import-album v-if="modals.importAlbum" />
 		<edit-song v-if="modals.editSong" />
+		<request-song v-if="modals.requestSong" />
 		<floating-box
 			id="keyboardShortcutsHelper"
 			ref="keyboardShortcutsHelper"
@@ -172,6 +179,7 @@
 
 <script>
 import { mapState, mapActions, mapGetters } from "vuex";
+import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
 
@@ -184,7 +192,15 @@ import ws from "@/ws";
 
 export default {
 	components: {
-		EditSong: () => import("@/components/modals/EditSong"),
+		EditSong: defineAsyncComponent(() =>
+			import("@/components/modals/EditSong")
+		),
+		ImportAlbum: defineAsyncComponent(() =>
+			import("@/components/modals/ImportAlbum.vue")
+		),
+		RequestSong: defineAsyncComponent(() =>
+			import("@/components/modals/RequestSong.vue")
+		),
 		UserIdToUsername,
 		FloatingBox
 	},
@@ -213,12 +229,6 @@ export default {
 			socket: "websockets/getSocket"
 		})
 	},
-	watch: {
-		// eslint-disable-next-line func-names
-		"modals.editSong": function(value) {
-			if (value === false) this.stopVideo();
-		}
-	},
 	mounted() {
 		this.socket.on("event:admin.hiddenSong.created", res => {
 			this.addSong(res.data.song);
@@ -305,7 +315,7 @@ export default {
 			"removeSong",
 			"updateSong"
 		]),
-		...mapActions("modals/editSong", ["editSong", "stopVideo"]),
+		...mapActions("modals/editSong", ["editSong"]),
 		...mapActions("modalVisibility", ["openModal"])
 	}
 };
@@ -340,8 +350,15 @@ export default {
 
 .optionsColumn {
 	width: 140px;
-	button {
-		width: 35px;
+
+	div {
+		button {
+			width: 35px;
+
+			&:not(:last-child) {
+				margin-right: 5px;
+			}
+		}
 	}
 }
 

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

@@ -56,6 +56,7 @@
 
 <script>
 import { mapActions, mapState, mapGetters } from "vuex";
+import { defineAsyncComponent } from "vue";
 import Toast from "toasters";
 
 import ws from "@/ws";
@@ -67,7 +68,9 @@ export default {
 	components: {
 		Confirm,
 		UserIdToUsername,
-		EditNews: () => import("@/components/modals/EditNews.vue")
+		EditNews: defineAsyncComponent(() =>
+			import("@/components/modals/EditNews.vue")
+		)
 	},
 	data() {
 		return {

+ 10 - 3
frontend/src/pages/Admin/tabs/Playlists.vue

@@ -91,6 +91,7 @@
 
 <script>
 import { mapState, mapActions, mapGetters } from "vuex";
+import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
 
@@ -101,10 +102,16 @@ import utils from "../../../../js/utils";
 
 export default {
 	components: {
-		EditPlaylist: () => import("@/components/modals/EditPlaylist"),
+		EditPlaylist: defineAsyncComponent(() =>
+			import("@/components/modals/EditPlaylist")
+		),
 		UserIdToUsername,
-		Report: () => import("@/components/modals/Report.vue"),
-		EditSong: () => import("@/components/modals/EditSong")
+		Report: defineAsyncComponent(() =>
+			import("@/components/modals/Report.vue")
+		),
+		EditSong: defineAsyncComponent(() =>
+			import("@/components/modals/EditSong")
+		)
 	},
 	data() {
 		return {

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

@@ -96,12 +96,15 @@
 <script>
 import { mapState, mapGetters, mapActions } from "vuex";
 import Toast from "toasters";
+import { defineAsyncComponent } from "vue";
 
 import ws from "@/ws";
 
 export default {
 	components: {
-		ViewPunishment: () => import("@/components/modals/ViewPunishment.vue")
+		ViewPunishment: defineAsyncComponent(() =>
+			import("@/components/modals/ViewPunishment.vue")
+		)
 	},
 	data() {
 		return {

+ 97 - 60
frontend/src/pages/Admin/tabs/Reports.vue

@@ -5,87 +5,105 @@
 			<table class="table is-striped">
 				<thead>
 					<tr>
-						<td>Song ID</td>
-						<td>Author</td>
-						<td>Time of report</td>
-						<td>Description</td>
+						<td>Summary</td>
+						<td>YouTube / Song ID</td>
+						<td>Categories Included</td>
 						<td>Options</td>
 					</tr>
 				</thead>
 				<tbody>
 					<tr v-for="report in reports" :key="report._id">
+						<td>
+							<report-info-item
+								:created-at="report.createdAt"
+								:created-by="report.createdBy"
+							/>
+						</td>
 						<td>
 							<span>
-								{{ report.song.youtubeId }}
+								<a
+									:href="
+										'https://www.youtube.com/watch?v=' +
+											`${report.song.youtubeId}`
+									"
+									target="_blank"
+								>
+									{{ report.song.youtubeId }}</a
+								>
 								<br />
 								{{ report.song._id }}
 							</span>
 						</td>
-						<td>
-							<user-id-to-username
-								:user-id="report.createdBy"
-								:link="true"
-							/>
-						</td>
-						<td>
-							<span
-								:content="report.createdAt"
-								v-tippy="{ theme: 'info' }"
-								>{{
-									formatDistance(
-										new Date(report.createdAt),
-										new Date(),
-										{ addSuffix: true }
-									)
-								}}</span
-							>
-						</td>
-						<td>
-							<span>{{ report.description }}</span>
+
+						<td id="categories-column">
+							<ul>
+								<li
+									v-for="category in getCategories(
+										report.issues
+									)"
+									:key="category"
+								>
+									{{ category }}
+								</li>
+							</ul>
 						</td>
-						<td>
+						<td id="options-column">
 							<a
-								class="button is-warning"
+								class="button is-primary"
 								href="#"
-								@click="view(report)"
-								>View</a
+								@click="view(report._id)"
+								content="Expand"
+								v-tippy
 							>
+								<i class="material-icons icon-with-button">
+									open_in_full
+								</i>
+								Expand
+							</a>
 							<a
-								class="button is-primary"
+								class="button is-success "
 								href="#"
 								@click="resolve(report._id)"
-								>Resolve</a
+								content="Resolve"
+								v-tippy
 							>
+								<i class="material-icons icon-with-button">
+									done_all
+								</i>
+								Resolve
+							</a>
 						</td>
 					</tr>
 				</tbody>
 			</table>
 		</div>
 
-		<view-report
-			v-if="modals.viewReport"
-			:report-id="viewingReportId"
-			sector="admin"
-		/>
+		<view-report v-if="modals.viewReport" sector="admin" />
+
+		<edit-song v-if="modals.editSong" song-type="songs" />
 	</div>
 </template>
 
 <script>
+import ReportInfoItem from "@/components/ReportInfoItem.vue";
 import { mapState, mapActions, mapGetters } from "vuex";
-import { formatDistance } from "date-fns";
+import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
 import ws from "@/ws";
 
 export default {
 	components: {
-		ViewReport: () => import("@/components/modals/ViewReport.vue"),
-		UserIdToUsername
+		ViewReport: defineAsyncComponent(() =>
+			import("@/components/modals/ViewReport.vue")
+		),
+		EditSong: defineAsyncComponent(() =>
+			import("@/components/modals/EditSong/index.vue")
+		),
+		ReportInfoItem
 	},
 	data() {
 		return {
-			viewingReportId: "",
 			reports: []
 		};
 	},
@@ -112,28 +130,36 @@ export default {
 		});
 
 		this.socket.on("event:admin.report.created", res =>
-			this.reports.push(res.data.report)
+			this.reports.unshift(res.data.report)
 		);
 
-		if (this.$route.query.id) {
-			this.socket.dispatch(
-				"reports.findOne",
-				this.$route.query.id,
-				res => {
-					if (res.status === "success") this.view(res.data.report);
-					else new Toast("Report with that ID not found");
-				}
-			);
-		}
+		// if (this.$route.query.id) {
+		// 	this.socket.dispatch(
+		// 		"reports.findOne",
+		// 		this.$route.query.id,
+		// 		res => {
+		// 			if (res.status === "success") this.view(res.data.report);
+		// 			else new Toast("Report with that ID not found");
+		// 		}
+		// 	);
+		// }
 	},
 	methods: {
-		formatDistance,
 		init() {
 			this.socket.dispatch("apis.joinAdminRoom", "reports", () => {});
 		},
-		view(report) {
-			// this.viewReport(report);
-			this.viewingReportId = report._id;
+		getCategories(issues) {
+			const categories = [];
+
+			issues.forEach(issue => {
+				if (categories.indexOf(issue.category) === -1)
+					categories.push(issue.category);
+			});
+
+			return categories;
+		},
+		view(reportId) {
+			this.viewReport(reportId);
 			this.openModal("viewReport");
 		},
 		resolve(reportId) {
@@ -145,7 +171,8 @@ export default {
 				.catch(err => new Toast(err.message));
 		},
 		...mapActions("modalVisibility", ["openModal", "closeModal"]),
-		...mapActions("admin/reports", ["resolveReport"])
+		...mapActions("admin/reports", ["resolveReport"]),
+		...mapActions("modals/viewReport", ["viewReport"])
 	}
 };
 </script>
@@ -177,8 +204,14 @@ export default {
 	}
 }
 
-.tag:not(:last-child) {
-	margin-right: 5px;
+#options-column {
+	a:not(:last-of-type) {
+		margin-right: 5px;
+	}
+}
+
+#categories-column {
+	text-transform: capitalize;
 }
 
 td {
@@ -186,4 +219,8 @@ td {
 	max-width: 10vw;
 	vertical-align: middle;
 }
+
+li {
+	list-style: inside;
+}
 </style>

+ 22 - 16
frontend/src/pages/Admin/tabs/Stations.vue

@@ -20,7 +20,7 @@
 					</tr>
 				</thead>
 				<tbody>
-					<tr v-for="station in stations" :key="station._id">
+					<tr v-for="(station, index) in stations" :key="station._id">
 						<td>
 							<span>{{ station._id }}</span>
 						</td>
@@ -192,6 +192,7 @@
 
 <script>
 import { mapState, mapActions, mapGetters } from "vuex";
+import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
@@ -200,20 +201,30 @@ import ws from "@/ws";
 
 export default {
 	components: {
-		RequestSong: () => import("@/components/modals/RequestSong.vue"),
-		EditPlaylist: () => import("@/components/modals/EditPlaylist"),
-		CreatePlaylist: () => import("@/components/modals/CreatePlaylist.vue"),
-		ManageStation: () =>
-			import("@/components/modals/ManageStation/index.vue"),
-		Report: () => import("@/components/modals/Report.vue"),
-		EditSong: () => import("@/components/modals/EditSong"),
+		RequestSong: defineAsyncComponent(() =>
+			import("@/components/modals/RequestSong.vue")
+		),
+		EditPlaylist: defineAsyncComponent(() =>
+			import("@/components/modals/EditPlaylist")
+		),
+		CreatePlaylist: defineAsyncComponent(() =>
+			import("@/components/modals/CreatePlaylist.vue")
+		),
+		ManageStation: defineAsyncComponent(() =>
+			import("@/components/modals/ManageStation/index.vue")
+		),
+		Report: defineAsyncComponent(() =>
+			import("@/components/modals/Report.vue")
+		),
+		EditSong: defineAsyncComponent(() =>
+			import("@/components/modals/EditSong")
+		),
 		UserIdToUsername,
 		Confirm
 	},
 	data() {
 		return {
 			editingStationId: "",
-			manageStationVersion: "",
 			newStation: {
 				genres: [],
 				blacklistedGenres: []
@@ -232,10 +243,6 @@ export default {
 		})
 	},
 	mounted() {
-		lofig.get("manageStationVersion", manageStationVersion => {
-			this.manageStationVersion = manageStationVersion;
-		});
-
 		if (this.socket.readyState === 1) this.init();
 		ws.onConnect(() => this.init());
 
@@ -290,9 +297,7 @@ export default {
 			this.socket.dispatch(
 				"stations.remove",
 				this.stations[index]._id,
-				res => {
-					new Toast(res.message);
-				}
+				res => new Toast(res.message)
 			);
 		},
 		manage(station) {
@@ -341,6 +346,7 @@ export default {
 				if (res.status === "success")
 					this.loadStations(res.data.stations);
 			});
+
 			this.socket.dispatch("apis.joinAdminRoom", "stations", () => {});
 		},
 		...mapActions("modalVisibility", ["openModal"]),

+ 54 - 32
frontend/src/pages/Admin/tabs/UnverifiedSongs.vue

@@ -5,7 +5,7 @@
 			<p>
 				<span>Sets loaded: {{ setsLoaded }} / {{ maxSets }}</span>
 				<br />
-				<span>Loaded songs: {{ this.songs.length }}</span>
+				<span>Loaded songs: {{ songs.length }}</span>
 			</p>
 			<input
 				v-model="searchQuery"
@@ -27,6 +27,9 @@
 			>
 				Keyboard shortcuts helper
 			</button>
+			<button class="button is-primary" @click="openModal('requestSong')">
+				Request song
+			</button>
 			<br />
 			<br />
 			<table class="table is-striped">
@@ -86,37 +89,46 @@
 							/>
 						</td>
 						<td class="optionsColumn">
-							<button
-								class="button is-primary"
-								@click="edit(song, index)"
-								content="Edit Song"
-								v-tippy
-							>
-								<i class="material-icons">edit</i>
-							</button>
-							<button
-								class="button is-success"
-								@click="verify(song._id)"
-								content="Verify Song"
-								v-tippy
-							>
-								<i class="material-icons">check_circle</i>
-							</button>
-							<confirm placement="left" @confirm="hide(song._id)">
+							<div>
+								<button
+									class="button is-primary"
+									@click="edit(song, index)"
+									content="Edit Song"
+									v-tippy
+								>
+									<i class="material-icons">edit</i>
+								</button>
 								<button
-									class="button is-danger"
-									content="Hide Song"
+									class="button is-success"
+									@click="verify(song._id)"
+									content="Verify Song"
 									v-tippy
 								>
-									<i class="material-icons">visibility_off</i>
+									<i class="material-icons">check_circle</i>
 								</button>
-							</confirm>
+								<confirm
+									placement="left"
+									@confirm="hide(song._id)"
+								>
+									<button
+										class="button is-danger"
+										content="Hide Song"
+										v-tippy
+									>
+										<i class="material-icons"
+											>visibility_off</i
+										>
+									</button>
+								</confirm>
+							</div>
 						</td>
 					</tr>
 				</tbody>
 			</table>
 		</div>
+		<import-album v-if="modals.importAlbum" />
 		<edit-song v-if="modals.editSong" />
+		<request-song v-if="modals.requestSong" />
 		<floating-box
 			id="keyboardShortcutsHelper"
 			ref="keyboardShortcutsHelper"
@@ -183,6 +195,7 @@
 
 <script>
 import { mapState, mapActions, mapGetters } from "vuex";
+import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
 
@@ -196,7 +209,15 @@ import ws from "@/ws";
 
 export default {
 	components: {
-		EditSong: () => import("@/components/modals/EditSong"),
+		EditSong: defineAsyncComponent(() =>
+			import("@/components/modals/EditSong")
+		),
+		ImportAlbum: defineAsyncComponent(() =>
+			import("@/components/modals/ImportAlbum.vue")
+		),
+		RequestSong: defineAsyncComponent(() =>
+			import("@/components/modals/RequestSong.vue")
+		),
 		UserIdToUsername,
 		FloatingBox,
 		Confirm
@@ -226,12 +247,6 @@ export default {
 			socket: "websockets/getSocket"
 		})
 	},
-	watch: {
-		// eslint-disable-next-line func-names
-		"modals.editSong": function(value) {
-			if (value === false) this.stopVideo();
-		}
-	},
 	mounted() {
 		this.socket.on("event:admin.unverifiedSong.created", res => {
 			this.addSong(res.data.song);
@@ -326,7 +341,7 @@ export default {
 			"removeSong",
 			"updateSong"
 		]),
-		...mapActions("modals/editSong", ["editSong", "stopVideo"]),
+		...mapActions("modals/editSong", ["editSong"]),
 		...mapActions("modalVisibility", ["openModal"])
 	}
 };
@@ -361,8 +376,15 @@ export default {
 
 .optionsColumn {
 	width: 140px;
-	button {
-		width: 35px;
+
+	div {
+		button {
+			width: 35px;
+
+			&:not(:last-child) {
+				margin-right: 5px;
+			}
+		}
 	}
 }
 

+ 5 - 2
frontend/src/pages/Admin/tabs/Users.vue

@@ -34,7 +34,7 @@
 				</tbody>
 			</table>
 
-			<h2>Users</h2>
+			<h1 id="page-title">Users</h1>
 
 			<table class="table is-striped">
 				<thead>
@@ -101,6 +101,7 @@
 
 <script>
 import { mapState, mapActions, mapGetters } from "vuex";
+import { defineAsyncComponent } from "vue";
 import Toast from "toasters";
 
 import ProfilePicture from "@/components/ProfilePicture.vue";
@@ -108,7 +109,9 @@ import ws from "@/ws";
 
 export default {
 	components: {
-		EditUser: () => import("@/components/modals/EditUser.vue"),
+		EditUser: defineAsyncComponent(() =>
+			import("@/components/modals/EditUser.vue")
+		),
 		ProfilePicture
 	},
 	data() {

+ 51 - 32
frontend/src/pages/Admin/tabs/VerifiedSongs.vue

@@ -5,7 +5,7 @@
 			<p>
 				<span>Sets loaded: {{ setsLoaded }} / {{ maxSets }}</span>
 				<br />
-				<span>Loaded songs: {{ this.songs.length }}</span>
+				<span>Loaded songs: {{ songs.length }}</span>
 			</p>
 			<input
 				v-model="searchQuery"
@@ -27,6 +27,12 @@
 			>
 				Keyboard shortcuts helper
 			</button>
+			<button class="button is-primary" @click="openModal('requestSong')">
+				Request song
+			</button>
+			<button class="button is-primary" @click="openModal('importAlbum')">
+				Import album
+			</button>
 			<confirm placement="bottom" @confirm="updateAllSongs()">
 				<button
 					class="button is-danger"
@@ -124,32 +130,36 @@
 							/>
 						</td>
 						<td class="optionsColumn">
-							<button
-								class="button is-primary"
-								@click="edit(song)"
-								content="Edit Song"
-								v-tippy
-							>
-								<i class="material-icons">edit</i>
-							</button>
-							<confirm
-								placement="left"
-								@confirm="unverify(song._id)"
-							>
+							<div>
 								<button
-									class="button is-danger"
-									content="Unverify Song"
+									class="button is-primary"
+									@click="edit(song)"
+									content="Edit Song"
 									v-tippy
 								>
-									<i class="material-icons">cancel</i>
+									<i class="material-icons">edit</i>
 								</button>
-							</confirm>
+								<confirm
+									placement="left"
+									@confirm="unverify(song._id)"
+								>
+									<button
+										class="button is-danger"
+										content="Unverify Song"
+										v-tippy
+									>
+										<i class="material-icons">cancel</i>
+									</button>
+								</confirm>
+							</div>
 						</td>
 					</tr>
 				</tbody>
 			</table>
 		</div>
+		<import-album v-if="modals.importAlbum" />
 		<edit-song v-if="modals.editSong" song-type="songs" />
+		<request-song v-if="modals.requestSong" />
 		<floating-box
 			id="keyboardShortcutsHelper"
 			ref="keyboardShortcutsHelper"
@@ -240,6 +250,7 @@
 
 <script>
 import { mapState, mapActions, mapGetters } from "vuex";
+import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
 
@@ -255,7 +266,15 @@ import ws from "@/ws";
 
 export default {
 	components: {
-		EditSong: () => import("@/components/modals/EditSong"),
+		EditSong: defineAsyncComponent(() =>
+			import("@/components/modals/EditSong")
+		),
+		ImportAlbum: defineAsyncComponent(() =>
+			import("@/components/modals/ImportAlbum.vue")
+		),
+		RequestSong: defineAsyncComponent(() =>
+			import("@/components/modals/RequestSong.vue")
+		),
 		UserIdToUsername,
 		FloatingBox,
 		Confirm
@@ -347,17 +366,10 @@ export default {
 			socket: "websockets/getSocket"
 		})
 	},
-	watch: {
-		// eslint-disable-next-line func-names
-		"modals.editSong": function(val) {
-			if (!val) this.stopVideo();
-		}
-	},
 	mounted() {
-		this.socket.on("event:admin.verifiedSong.created", res => {
-			this.addSong(res.data.song);
-			console.log("created");
-		});
+		this.socket.on("event:admin.verifiedSong.created", res =>
+			this.addSong(res.data.song)
+		);
 
 		this.socket.on("event:admin.verifiedSong.deleted", res =>
 			this.removeSong(res.data.songId)
@@ -406,7 +418,7 @@ export default {
 			}
 		);
 	},
-	beforeDestroy() {
+	beforeUnmount() {
 		const shortcutNames = [
 			"verifiedSongs.toggleKeyboardShortcutsHelper",
 			"verifiedSongs.resetKeyboardShortcutsHelper"
@@ -502,7 +514,7 @@ export default {
 			"removeSong",
 			"updateSong"
 		]),
-		...mapActions("modals/editSong", ["editSong", "stopVideo"]),
+		...mapActions("modals/editSong", ["editSong"]),
 		...mapActions("modalVisibility", ["openModal", "closeModal"])
 	}
 };
@@ -541,8 +553,15 @@ body {
 
 .optionsColumn {
 	width: 100px;
-	button {
-		width: 35px;
+
+	div {
+		button {
+			width: 35px;
+
+			&:not(:last-child) {
+				margin-right: 5px;
+			}
+		}
 	}
 }
 

+ 66 - 67
frontend/src/pages/Home.vue

@@ -34,7 +34,7 @@
 					</div>
 				</div>
 			</div>
-			<div v-if="favoriteStations.length > 0" class="group">
+			<div class="group" v-show="favoriteStations.length > 0">
 				<div class="group-title">
 					<div>
 						<h2>My Favorites</h2>
@@ -42,48 +42,41 @@
 				</div>
 
 				<draggable
-					class="scrollable-list"
+					item-key="_id"
 					v-model="favoriteStations"
 					v-bind="dragOptions"
-					@start="drag = true"
-					@end="drag = false"
 					@change="changeFavoriteOrder"
 				>
-					<transition-group
-						type="transition"
-						:name="!drag ? 'draggable-list-transition' : null"
-					>
+					<template #item="{element}">
 						<router-link
-							v-for="station in favoriteStations"
-							:key="`key-${station._id}`"
 							:to="{
 								name: 'station',
-								params: { id: station.name }
+								params: { id: element.name }
 							}"
 							:class="{
 								card: true,
 								'station-card': true,
 								'item-draggable': true,
-								isPrivate: station.privacy === 'private',
-								isMine: isOwner(station)
+								isPrivate: element.privacy === 'private',
+								isMine: isOwner(element)
 							}"
 							:style="
-								'--primary-color: var(--' + station.theme + ')'
+								'--primary-color: var(--' + element.theme + ')'
 							"
 						>
 							<song-thumbnail
 								class="card-image"
-								:song="station.currentSong"
+								:song="element.currentSong"
 							/>
 							<div class="card-content">
 								<div class="media">
 									<div class="media-left displayName">
 										<i
 											v-if="
-												loggedIn && !station.isFavorited
+												loggedIn && !element.isFavorited
 											"
 											@click.prevent="
-												favoriteStation(station)
+												favoriteStation(element._id)
 											"
 											class="favorite material-icons"
 											content="Favorite Station"
@@ -92,19 +85,19 @@
 										>
 										<i
 											v-if="
-												loggedIn && station.isFavorited
+												loggedIn && element.isFavorited
 											"
 											@click.prevent="
-												unfavoriteStation(station)
+												unfavoriteStation(element._id)
 											"
 											class="favorite material-icons"
 											content="Unfavorite Station"
 											v-tippy
 											>star</i
 										>
-										<h5>{{ station.displayName }}</h5>
+										<h5>{{ element.displayName }}</h5>
 										<i
-											v-if="station.type === 'official'"
+											v-if="element.type === 'official'"
 											class="material-icons verified-station"
 											content="Verified Station"
 											v-tippy="{
@@ -117,7 +110,7 @@
 								</div>
 
 								<div class="content">
-									{{ station.description }}
+									{{ element.description }}
 								</div>
 								<div class="under-content">
 									<p class="hostedBy">
@@ -125,14 +118,14 @@
 										<span class="host">
 											<span
 												v-if="
-													station.type === 'official'
+													element.type === 'official'
 												"
 												title="Musare"
 												>Musare</span
 											>
 											<user-id-to-username
 												v-else
-												:user-id="station.owner"
+												:user-id="element.owner"
 												:link="true"
 											/>
 										</span>
@@ -140,8 +133,8 @@
 									<div class="icons">
 										<i
 											v-if="
-												station.type === 'community' &&
-													isOwner(station)
+												element.type === 'community' &&
+													isOwner(element)
 											"
 											class="homeIcon material-icons"
 											content="This is your station."
@@ -149,7 +142,7 @@
 											>home</i
 										>
 										<i
-											v-if="station.privacy === 'private'"
+											v-if="element.privacy === 'private'"
 											class="privateIcon material-icons"
 											content="This station is not visible to other users."
 											v-tippy="{ theme: 'info' }"
@@ -157,7 +150,7 @@
 										>
 										<i
 											v-if="
-												station.privacy === 'unlisted'
+												element.privacy === 'unlisted'
 											"
 											class="unlistedIcon material-icons"
 											content="Unlisted Station"
@@ -170,8 +163,8 @@
 							<div class="bottomBar">
 								<i
 									v-if="
-										station.paused &&
-											station.currentSong.title
+										element.paused &&
+											element.currentSong.title
 									"
 									class="material-icons"
 									content="Station Paused"
@@ -179,31 +172,31 @@
 									>pause</i
 								>
 								<i
-									v-else-if="station.currentSong.title"
+									v-else-if="element.currentSong.title"
 									class="material-icons"
 									>music_note</i
 								>
 								<i v-else class="material-icons">music_off</i>
 								<span
-									v-if="station.currentSong.title"
+									v-if="element.currentSong.title"
 									class="songTitle"
 									:title="
-										station.currentSong.artists.length > 0
+										element.currentSong.artists.length > 0
 											? 'Now Playing: ' +
-											  station.currentSong.title +
+											  element.currentSong.title +
 											  ' by ' +
-											  station.currentSong.artists.join(
-													','
+											  element.currentSong.artists.join(
+													', '
 											  )
 											: 'Now Playing: ' +
-											  station.currentSong.title
+											  element.currentSong.title
 									"
-									>{{ station.currentSong.title }}
+									>{{ element.currentSong.title }}
 									{{
-										station.currentSong.artists.length > 0
+										element.currentSong.artists.length > 0
 											? " by " +
-											  station.currentSong.artists.join(
-													","
+											  element.currentSong.artists.join(
+													", "
 											  )
 											: ""
 									}}</span
@@ -214,20 +207,20 @@
 								<i
 									class="material-icons stationMode"
 									:content="
-										station.partyMode
+										element.partyMode
 											? 'Station in Party mode'
 											: 'Station in Playlist mode'
 									"
 									v-tippy="{ theme: 'info' }"
 									>{{
-										station.partyMode
+										element.partyMode
 											? "emoji_people"
 											: "playlist_play"
 									}}</i
 								>
 							</div>
 						</router-link>
-					</transition-group>
+					</template>
 				</draggable>
 			</div>
 			<div class="group bottom">
@@ -304,7 +297,9 @@
 							<div class="media-left displayName">
 								<i
 									v-if="loggedIn && !station.isFavorited"
-									@click.prevent="favoriteStation(station)"
+									@click.prevent="
+										favoriteStation(station._id)
+									"
 									class="favorite material-icons"
 									content="Favorite Station"
 									v-tippy
@@ -312,7 +307,9 @@
 								>
 								<i
 									v-if="loggedIn && station.isFavorited"
-									@click.prevent="unfavoriteStation(station)"
+									@click.prevent="
+										unfavoriteStation(station._id)
+									"
 									class="favorite material-icons"
 									content="Unfavorite Station"
 									v-tippy
@@ -399,7 +396,7 @@
 									? 'Now Playing: ' +
 									  station.currentSong.title +
 									  ' by ' +
-									  station.currentSong.artists.join(',')
+									  station.currentSong.artists.join(', ')
 									: 'Now Playing: ' +
 									  station.currentSong.title
 							"
@@ -407,7 +404,7 @@
 							{{
 								station.currentSong.artists.length > 0
 									? " by " +
-									  station.currentSong.artists.join(",")
+									  station.currentSong.artists.join(", ")
 									: ""
 							}}</span
 						>
@@ -440,6 +437,7 @@
 
 <script>
 import { mapState, mapGetters, mapActions } from "vuex";
+import { defineAsyncComponent } from "vue";
 import draggable from "vuedraggable";
 import Toast from "toasters";
 
@@ -455,8 +453,9 @@ export default {
 		MainHeader,
 		MainFooter,
 		SongThumbnail,
-		CreateCommunityStation: () =>
-			import("@/components/modals/CreateCommunityStation.vue"),
+		CreateCommunityStation: defineAsyncComponent(() =>
+			import("@/components/modals/CreateCommunityStation.vue")
+		),
 		UserIdToUsername,
 		draggable
 	},
@@ -467,8 +466,7 @@ export default {
 			favoriteStations: [],
 			searchQuery: "",
 			sitename: "Musare",
-			orderOfFavoriteStations: [],
-			drag: false
+			orderOfFavoriteStations: []
 		};
 	},
 	computed: {
@@ -509,8 +507,11 @@ export default {
 		}
 	},
 	watch: {
-		orderOfFavoriteStations() {
-			this.calculateFavoriteStations();
+		orderOfFavoriteStations: {
+			deep: true,
+			handler() {
+				this.calculateFavoriteStations();
+			}
 		}
 	},
 	async mounted() {
@@ -681,7 +682,7 @@ export default {
 			this.orderOfFavoriteStations = res.data.order;
 		});
 	},
-	beforeDestroy() {
+	beforeUnmount() {
 		this.socket.dispatch("apis.leaveRoom", "home", () => {});
 	},
 	methods: {
@@ -719,21 +720,17 @@ export default {
 		isPlaying(station) {
 			return typeof station.currentSong.title !== "undefined";
 		},
-		favoriteStation(station) {
-			this.socket.dispatch(
-				"stations.favoriteStation",
-				station._id,
-				res => {
-					if (res.status === "success") {
-						new Toast("Successfully favorited station.");
-					} else new Toast(res.message);
-				}
-			);
+		favoriteStation(stationId) {
+			this.socket.dispatch("stations.favoriteStation", stationId, res => {
+				if (res.status === "success") {
+					new Toast("Successfully favorited station.");
+				} else new Toast(res.message);
+			});
 		},
-		unfavoriteStation(station) {
+		unfavoriteStation(stationId) {
 			this.socket.dispatch(
 				"stations.unfavoriteStation",
-				station._id,
+				stationId,
 				res => {
 					if (res.status === "success") {
 						new Toast("Successfully unfavorited station.");
@@ -854,6 +851,7 @@ html {
 		filter: blur(1px);
 		border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
 		overflow: hidden;
+		user-select: none;
 	}
 	.overlay {
 		background: linear-gradient(
@@ -890,6 +888,7 @@ html {
 				font-size: 40px;
 				color: var(--white);
 				font-family: Pacifico, cursive;
+				user-select: none;
 			}
 			.buttons {
 				display: flex;

+ 38 - 39
frontend/src/pages/Profile/Tabs/Playlists.vue

@@ -21,55 +21,51 @@
 			<hr class="section-horizontal-rule" />
 
 			<draggable
-				class="menu-list scrollable-list"
+				tag="transition-group"
+				:component-data="{
+					name: !drag ? 'draggable-list-transition' : null
+				}"
 				v-if="playlists.length > 0"
 				v-model="playlists"
+				item-key="_id"
 				v-bind="dragOptions"
 				@start="drag = true"
 				@end="drag = false"
 				@change="savePlaylistOrder"
 			>
-				<transition-group
-					type="transition"
-					:name="!drag ? 'draggable-list-transition' : null"
-				>
-					<div
+				<template #item="{element}">
+					<playlist-item
+						v-if="
+							element.privacy === 'public' ||
+								(element.privacy === 'private' &&
+									element.createdBy === userId)
+						"
+						:playlist="element"
 						:class="{
 							item: true,
 							'item-draggable': myUserId === userId
 						}"
-						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"
-									content="Edit Playlist"
-									v-tippy
-									>edit</i
-								>
-								<i
-									v-else
-									@click="showPlaylist(playlist._id)"
-									class="material-icons view-icon"
-									content="View Playlist"
-									v-tippy
-									>visibility</i
-								>
-							</div>
-						</playlist-item>
-					</div>
-				</transition-group>
+						<template #actions>
+							<i
+								v-if="myUserId === userId"
+								@click="showPlaylist(element._id)"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-else
+								@click="showPlaylist(element._id)"
+								class="material-icons view-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</template>
+					</playlist-item>
+				</template>
 			</draggable>
 
 			<button
@@ -89,6 +85,7 @@
 
 <script>
 import { mapActions, mapState, mapGetters } from "vuex";
+import { defineAsyncComponent } from "vue";
 
 import PlaylistItem from "@/components/PlaylistItem.vue";
 import SortablePlaylists from "@/mixins/SortablePlaylists.vue";
@@ -97,7 +94,9 @@ import ws from "@/ws";
 export default {
 	components: {
 		PlaylistItem,
-		CreatePlaylist: () => import("@/components/modals/CreatePlaylist.vue")
+		CreatePlaylist: defineAsyncComponent(() =>
+			import("@/components/modals/CreatePlaylist.vue")
+		)
 	},
 	mixins: [SortablePlaylists],
 	props: {
@@ -142,7 +141,7 @@ export default {
 			this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database
 		});
 	},
-	beforeDestroy() {
+	beforeUnmount() {
 		this.socket.dispatch(
 			"apis.leaveRoom",
 			`profile.${this.userId}.playlists`,

+ 3 - 3
frontend/src/pages/Profile/Tabs/RecentActivity.vue

@@ -18,7 +18,7 @@
 					:key="activity._id"
 					:activity="activity"
 				>
-					<div slot="actions">
+					<template #actions>
 						<confirm
 							v-if="userId === myUserId"
 							@confirm="hideActivity(activity._id)"
@@ -29,7 +29,7 @@
 								>
 							</a>
 						</confirm>
-					</div>
+					</template>
 				</activity-item>
 			</div>
 		</div>
@@ -123,7 +123,7 @@ export default {
 			this.offsettedFromNextSet = 0;
 		});
 	},
-	beforeDestroy() {
+	beforeUnmount() {
 		this.socket.dispatch(
 			"apis.leaveRoom",
 			`profile.${this.userId}.activities`,

+ 17 - 6
frontend/src/pages/Profile/index.vue

@@ -2,6 +2,7 @@
 	<div v-if="isUser">
 		<edit-playlist v-if="modals.editPlaylist" />
 		<report v-if="modals.report" />
+		<view-report v-if="modals.viewReport" />
 		<edit-song v-if="modals.editSong" song-type="songs" />
 
 		<metadata :title="`Profile | ${user.username}`" />
@@ -107,13 +108,14 @@
 <script>
 import { mapState, mapGetters } from "vuex";
 import { format, parseISO } from "date-fns";
+import { defineAsyncComponent } from "vue";
+
+import TabQueryHandler from "@/mixins/TabQueryHandler.vue";
 
 import ProfilePicture from "@/components/ProfilePicture";
 import MainHeader from "@/components/layout/MainHeader";
 import MainFooter from "@/components/layout/MainFooter.vue";
 
-import TabQueryHandler from "@/mixins/TabQueryHandler.vue";
-
 import RecentActivity from "./Tabs/RecentActivity.vue";
 import Playlists from "./Tabs/Playlists.vue";
 
@@ -124,9 +126,18 @@ export default {
 		ProfilePicture,
 		RecentActivity,
 		Playlists,
-		EditPlaylist: () => import("@/components/modals/EditPlaylist"),
-		Report: () => import("@/components/modals/Report.vue"),
-		EditSong: () => import("@/components/modals/EditSong")
+		EditPlaylist: defineAsyncComponent(() =>
+			import("@/components/modals/EditPlaylist")
+		),
+		Report: defineAsyncComponent(() =>
+			import("@/components/modals/Report.vue")
+		),
+		ViewReport: defineAsyncComponent(() =>
+			import("@/components/modals/ViewReport.vue")
+		),
+		EditSong: defineAsyncComponent(() =>
+			import("@/components/modals/EditSong")
+		)
 	},
 	mixins: [TabQueryHandler],
 	data() {
@@ -407,7 +418,7 @@ export default {
 		}
 
 		.item {
-			overflow: hidden;
+			overflow: initial;
 
 			&:not(:last-of-type) {
 				margin-bottom: 10px;

+ 9 - 3
frontend/src/pages/ResetPassword.vue

@@ -18,7 +18,7 @@
 					<p class="step" :class="{ selected: step === 3 }">3</p>
 				</div>
 
-				<transition name="steps-fade" mode="out-in">
+				<transition-group name="steps-fade" mode="out-in">
 					<!-- Step 1 -- Enter email address -->
 					<div class="content-box" v-if="step === 1" :key="step">
 						<h2 class="content-box-title">
@@ -78,7 +78,7 @@
 						</h2>
 						<p
 							class="content-box-description"
-							v-if="!this.email.hasBeenSentAlready"
+							v-if="!email.hasBeenSentAlready"
 						>
 							A code has been sent to
 							<strong>{{ email.value }}.</strong>
@@ -257,7 +257,7 @@
 							>Return to Settings</router-link
 						>
 					</div>
-				</transition>
+				</transition-group>
 			</div>
 		</div>
 		<main-footer />
@@ -321,6 +321,8 @@ export default {
 	},
 	watch: {
 		"email.value": function watchEmail(value) {
+			if (!value) return;
+
 			if (
 				value.indexOf("@") !== value.lastIndexOf("@") ||
 				!validation.regex.emailSimple.test(value)
@@ -333,6 +335,8 @@ export default {
 			}
 		},
 		"password.value": function watchPassword(value) {
+			if (!value) return;
+
 			this.checkPasswordMatch(value, this.passwordAgain.value);
 
 			if (!validation.isLength(value, 6, 200)) {
@@ -349,6 +353,8 @@ export default {
 			}
 		},
 		"passwordAgain.value": function watchPasswordAgain(value) {
+			if (!value) return;
+
 			this.checkPasswordMatch(this.password.value, value);
 		}
 	},

+ 15 - 69
frontend/src/pages/Settings/Tabs/Preferences.vue

@@ -135,11 +135,21 @@ export default {
 		this.socket.on("keep.event:user.preferences.updated", res => {
 			const { preferences } = res.data;
 
-			this.localNightmode = preferences.nightmode;
-			this.localAutoSkipDisliked = preferences.autoSkipDisliked;
-			this.localActivityLogPublic = preferences.activityLogPublic;
-			this.localAnonymousSongRequests = preferences.anonymousSongRequests;
-			this.localActivityWatch = preferences.activityWatch;
+			if (preferences.nightmode !== undefined)
+				this.localNightmode = preferences.nightmode;
+
+			if (preferences.autoSkipDisliked !== undefined)
+				this.localAutoSkipDisliked = preferences.autoSkipDisliked;
+
+			if (preferences.activityLogPublic !== undefined)
+				this.localActivityLogPublic = preferences.activityLogPublic;
+
+			if (preferences.anonymousSongRequests !== undefined)
+				this.localAnonymousSongRequests =
+					preferences.anonymousSongRequests;
+
+			if (preferences.activityWatch !== undefined)
+				this.localActivityWatch = preferences.activityWatch;
 		});
 	},
 	methods: {
@@ -171,12 +181,10 @@ export default {
 				res => {
 					if (res.status !== "success") {
 						new Toast(res.message);
-
 						return this.$refs.saveButton.handleFailedSave();
 					}
 
 					new Toast("Successfully updated preferences");
-
 					return this.$refs.saveButton.handleSuccessfulSave();
 				}
 			);
@@ -191,65 +199,3 @@ export default {
 	}
 };
 </script>
-
-<style lang="scss" scoped>
-.checkbox-control {
-	display: flex;
-	flex-direction: row;
-	align-items: center;
-
-	p {
-		margin-left: 10px;
-	}
-
-	.switch {
-		position: relative;
-		display: inline-block;
-		flex-shrink: 0;
-		width: 40px;
-		height: 24px;
-	}
-
-	.switch input {
-		opacity: 0;
-		width: 0;
-		height: 0;
-	}
-
-	.slider {
-		position: absolute;
-		cursor: pointer;
-		top: 0;
-		left: 0;
-		right: 0;
-		bottom: 0;
-		background-color: #ccc;
-		transition: 0.2s;
-		border-radius: 34px;
-	}
-
-	.slider:before {
-		position: absolute;
-		content: "";
-		height: 16px;
-		width: 16px;
-		left: 4px;
-		bottom: 4px;
-		background-color: white;
-		transition: 0.2s;
-		border-radius: 50%;
-	}
-
-	input:checked + .slider {
-		background-color: var(--primary-color);
-	}
-
-	input:focus + .slider {
-		box-shadow: 0 0 1px var(--primary-color);
-	}
-
-	input:checked + .slider:before {
-		transform: translateX(16px);
-	}
-}
-</style>

+ 28 - 20
frontend/src/pages/Settings/Tabs/Profile.vue

@@ -27,6 +27,18 @@
 						<option value="initials">Based on initials</option>
 					</select>
 				</div>
+				<div
+					class="select"
+					v-if="modifiedUser.avatar.type === 'initials'"
+				>
+					<select v-model="modifiedUser.avatar.color">
+						<option value="blue">Blue</option>
+						<option value="orange">Orange</option>
+						<option value="green">Green</option>
+						<option value="purple">Purple</option>
+						<option value="teal">Teal</option>
+					</select>
+				</div>
 			</div>
 		</div>
 		<p class="control is-expanded margin-top-zero">
@@ -96,21 +108,6 @@ export default {
 			socket: "websockets/getSocket"
 		})
 	},
-	watch: {
-		"modifiedUser.avatar.type": function watchAvatarType(newType, oldType) {
-			if (
-				oldType &&
-				this.modifiedUser.avatar.type !==
-					this.originalUser.avatar.type &&
-				newType === "initials"
-			) {
-				const colors = ["blue", "orange", "green", "purple", "teal"];
-				const color = colors[Math.floor(Math.random() * colors.length)];
-
-				this.modifiedUser.avatar.color = color;
-			}
-		}
-	},
 	methods: {
 		saveChanges() {
 			const nameChanged =
@@ -119,12 +116,15 @@ export default {
 				this.modifiedUser.location !== this.originalUser.location;
 			const bioChanged = this.modifiedUser.bio !== this.originalUser.bio;
 			const avatarChanged =
-				this.modifiedUser.avatar.type !== this.originalUser.avatar.type;
+				this.modifiedUser.avatar.type !==
+					this.originalUser.avatar.type ||
+				this.modifiedUser.avatar.color !==
+					this.originalUser.avatar.color;
 
 			if (nameChanged) this.changeName();
 			if (locationChanged) this.changeLocation();
 			if (bioChanged) this.changeBio();
-			if (avatarChanged) this.changeAvatarType();
+			if (avatarChanged) this.changeAvatar();
 
 			if (
 				!avatarChanged &&
@@ -238,13 +238,13 @@ export default {
 				}
 			);
 		},
-		changeAvatarType() {
+		changeAvatar() {
 			const { avatar } = this.modifiedUser;
 
 			this.$refs.saveButton.status = "disabled";
 
 			return this.socket.dispatch(
-				"users.updateAvatarType",
+				"users.updateAvatar",
 				this.userId,
 				avatar,
 				res => {
@@ -252,7 +252,7 @@ export default {
 						new Toast(res.message);
 						this.$refs.saveButton.handleFailedSave();
 					} else {
-						new Toast("Successfully updated avatar type");
+						new Toast("Successfully updated avatar");
 
 						this.updateOriginalUser({
 							property: "avatar",
@@ -292,6 +292,14 @@ export default {
 		align-items: center;
 		margin-top: 5px;
 
+		.select {
+			margin-right: 8px;
+
+			&:last-child {
+				margin-right: 0;
+			}
+		}
+
 		.profile-picture {
 			margin-right: 10px;
 			width: 50px;

+ 19 - 18
frontend/src/pages/Settings/index.vue

@@ -49,21 +49,34 @@
 
 <script>
 import { mapActions, mapGetters, mapState } from "vuex";
+import { defineAsyncComponent } from "vue";
+
 import Toast from "toasters";
 
+import TabQueryHandler from "@/mixins/TabQueryHandler.vue";
+
 import MainHeader from "@/components/layout/MainHeader.vue";
 import MainFooter from "@/components/layout/MainFooter.vue";
-import TabQueryHandler from "@/mixins/TabQueryHandler.vue";
 
 export default {
 	components: {
 		MainHeader,
 		MainFooter,
-		SecuritySettings: () => import("./Tabs/Security.vue"),
-		AccountSettings: () => import("./Tabs/Account.vue"),
-		ProfileSettings: () => import("./Tabs/Profile.vue"),
-		PreferencesSettings: () => import("./Tabs/Preferences.vue"),
-		RemoveAccount: () => import("@/components/modals/RemoveAccount.vue")
+		SecuritySettings: defineAsyncComponent(() =>
+			import("./Tabs/Security.vue")
+		),
+		AccountSettings: defineAsyncComponent(() =>
+			import("./Tabs/Account.vue")
+		),
+		ProfileSettings: defineAsyncComponent(() =>
+			import("./Tabs/Profile.vue")
+		),
+		PreferencesSettings: defineAsyncComponent(() =>
+			import("./Tabs/Preferences.vue")
+		),
+		RemoveAccount: defineAsyncComponent(() =>
+			import("@/components/modals/RemoveAccount.vue")
+		)
 	},
 	mixins: [TabQueryHandler],
 	data() {
@@ -151,28 +164,16 @@ export default {
 		border-radius: 3px;
 	}
 
-	#page-title {
-		margin-top: 0;
-		font-size: 35px;
-		text-align: center;
-	}
-
 	#sidebar-with-content {
 		display: flex;
 		flex-direction: column;
 	}
 
 	@media only screen and (min-width: 700px) {
-		#page-title {
-			margin: 0;
-			font-size: 40px;
-		}
-
 		#sidebar-with-content {
 			width: 962px;
 			max-width: 100%;
 			margin: 0 auto;
-			margin-top: 30px;
 			flex-direction: row;
 
 			.nav-links {

+ 77 - 78
frontend/src/pages/Station/Sidebar/Playlists.vue

@@ -1,90 +1,92 @@
 <template>
 	<div id="my-playlists">
-		<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="menu-list scrollable-list" v-if="playlists.length > 0">
+			<draggable
+				tag="transition-group"
+				:component-data="{
+					name: !drag ? 'draggable-list-transition' : null
+				}"
+				v-model="playlists"
+				item-key="_id"
+				v-bind="dragOptions"
+				@start="drag = true"
+				@end="drag = false"
+				@change="savePlaylistOrder"
 			>
-				<playlist-item
-					:playlist="playlist"
-					v-for="playlist in playlists"
-					:key="`key-${playlist._id}`"
-					class="item-draggable"
-				>
-					<div class="icons-group" slot="actions">
-						<i
-							v-if="isExcluded(playlist._id)"
-							class="material-icons stop-icon"
-							content="This playlist is blacklisted in this station"
-							v-tippy="{ theme: 'info' }"
-							>play_disabled</i
-						>
-						<i
-							v-if="
-								station.type === 'community' &&
-									(isOwnerOrAdmin() || station.partyMode) &&
-									!isSelected(playlist._id) &&
-									!isExcluded(playlist._id)
-							"
-							@click="selectPlaylist(playlist)"
-							class="material-icons play-icon"
-							:content="
-								station.partyMode
-									? 'Request songs from this playlist'
-									: 'Play songs from this playlist'
-							"
-							v-tippy
-							>play_arrow</i
-						>
-						<confirm
-							v-if="
-								station.type === 'community' &&
-									(isOwnerOrAdmin() || station.partyMode) &&
-									isSelected(playlist._id)
-							"
-							@confirm="deselectPlaylist(playlist._id)"
-						>
+				<template #item="{element}">
+					<playlist-item :playlist="element" class="item-draggable">
+						<template #actions>
 							<i
+								v-if="isExcluded(element._id)"
 								class="material-icons stop-icon"
+								content="This playlist is blacklisted in this station"
+								v-tippy="{ theme: 'info' }"
+								>play_disabled</i
+							>
+							<i
+								v-if="
+									station.type === 'community' &&
+										(isOwnerOrAdmin() ||
+											station.partyMode) &&
+										!isSelected(element._id) &&
+										!isExcluded(element._id)
+								"
+								@click="selectPlaylist(element)"
+								class="material-icons play-icon"
 								:content="
 									station.partyMode
-										? 'Stop requesting songs from this playlist'
-										: 'Stop playing songs from this playlist'
+										? 'Request songs from this playlist'
+										: 'Play songs from this playlist'
 								"
 								v-tippy
-								>stop</i
+								>play_arrow</i
+							>
+							<confirm
+								v-if="
+									station.type === 'community' &&
+										(isOwnerOrAdmin() ||
+											station.partyMode) &&
+										isSelected(element._id)
+								"
+								@confirm="deselectPlaylist(element._id)"
 							>
-						</confirm>
-						<confirm
-							v-if="isOwnerOrAdmin() && !isExcluded(playlist._id)"
-							@confirm="blacklistPlaylist(playlist._id)"
-						>
+								<i
+									class="material-icons stop-icon"
+									:content="
+										station.partyMode
+											? 'Stop requesting songs from this playlist'
+											: 'Stop playing songs from this playlist'
+									"
+									v-tippy
+									>stop</i
+								>
+							</confirm>
+							<confirm
+								v-if="
+									isOwnerOrAdmin() && !isExcluded(element._id)
+								"
+								@confirm="blacklistPlaylist(element._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Blacklist Playlist"
+									v-tippy
+									>block</i
+								>
+							</confirm>
 							<i
-								class="material-icons stop-icon"
-								content="Blacklist Playlist"
+								@click="edit(element._id)"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
 								v-tippy
-								>block</i
+								>edit</i
 							>
-						</confirm>
-						<i
-							@click="edit(playlist._id)"
-							class="material-icons edit-icon"
-							content="Edit Playlist"
-							v-tippy
-							>edit</i
-						>
-					</div>
-				</playlist-item>
-			</transition-group>
-		</draggable>
+						</template>
+					</playlist-item>
+				</template>
+			</draggable>
+		</div>
+
 		<p v-else class="nothing-here-text scrollable-list">
 			No Playlists found
 		</p>
@@ -94,7 +96,7 @@
 			@click="openModal('createPlaylist')"
 		>
 			<i class="material-icons icon-with-button">create</i>
-			<span class="optional-desktop-only-text"> Create Playlist </span>
+			<span> Create Playlist </span>
 		</a>
 	</div>
 </template>
@@ -338,9 +340,6 @@ export default {
 }
 
 .icons-group {
-	display: flex;
-	align-items: center;
-
 	.edit-icon {
 		color: var(--primary-color);
 	}

+ 1 - 1
frontend/src/pages/Station/Sidebar/Users.vue

@@ -58,7 +58,7 @@
 			@click="copyToClipboard()"
 		>
 			<i class="material-icons icon-with-button">share</i>
-			<span class="optional-desktop-only-text">
+			<span>
 				Share (copy to clipboard)
 			</span>
 		</button>

+ 1 - 2
frontend/src/pages/Station/Sidebar/index.vue

@@ -41,9 +41,8 @@
 <script>
 import { mapActions, mapState } from "vuex";
 
-import TabQueryHandler from "@/mixins/TabQueryHandler.vue";
-
 import Queue from "@/components/Queue.vue";
+import TabQueryHandler from "@/mixins/TabQueryHandler.vue";
 import Users from "./Users.vue";
 import Playlists from "./Playlists.vue";
 

+ 190 - 74
frontend/src/pages/Station/index.vue

@@ -119,7 +119,7 @@
 									<i class="material-icons icon-with-button"
 										>play_arrow</i
 									>
-									<span class="optional-desktop-only-text">
+									<span>
 										Resume Station
 									</span>
 								</button>
@@ -131,7 +131,7 @@
 									<i class="material-icons icon-with-button"
 										>pause</i
 									>
-									<span class="optional-desktop-only-text">
+									<span>
 										Pause Station
 									</span>
 								</button>
@@ -144,7 +144,7 @@
 									<i class="material-icons icon-with-button"
 										>skip_next</i
 									>
-									<span class="optional-desktop-only-text">
+									<span>
 										Force Skip
 									</span>
 								</button>
@@ -157,7 +157,7 @@
 									<i class="material-icons icon-with-button"
 										>settings</i
 									>
-									<span class="optional-desktop-only-text">
+									<span>
 										Manage Station
 									</span>
 								</button>
@@ -319,7 +319,18 @@
 
 									<!-- Vote to Skip Button -->
 									<button
-										v-if="loggedIn"
+										v-if="!skipVotesLoaded"
+										class="button is-primary disabled"
+										content="Skip votes have not been loaded yet"
+										v-tippy
+									>
+										<i
+											class="material-icons icon-with-button"
+											>skip_next</i
+										>
+									</button>
+									<button
+										v-else-if="loggedIn"
 										class="button is-primary"
 										@click="voteSkipStation()"
 										content="Vote to Skip Song"
@@ -392,6 +403,7 @@
 									<!-- Ratings (Like/Dislike) Buttons -->
 									<div
 										id="ratings"
+										v-if="ratingsLoaded"
 										:class="{
 											liked: liked,
 											disliked: disliked
@@ -429,45 +441,77 @@
 											>{{ currentSong.dislikes }}
 										</button>
 									</div>
+									<div id="ratings" class="disabled" v-else>
+										<!-- Like Song Button -->
+										<button
+											class="button is-success like-song disabled"
+											id="like-song"
+											content="Ratings have not been loaded yet"
+											v-tippy
+										>
+											<i
+												class="material-icons icon-with-button"
+												>thumb_up_alt</i
+											>
+										</button>
+
+										<!-- Dislike Song Button -->
+										<button
+											class="button is-danger dislike-song disabled"
+											id="dislike-song"
+											content="Ratings have not been loaded yet"
+											v-tippy
+										>
+											<i
+												class="material-icons icon-with-button"
+												>thumb_down_alt</i
+											>
+										</button>
+									</div>
 
 									<!-- Add Song To Playlist Button & Dropdown -->
 									<add-to-playlist-dropdown
 										:song="currentSong"
 										placement="top-end"
 									>
-										<div
-											slot="button"
-											id="add-song-to-playlist"
-											content="Add Song to Playlist"
-											v-tippy
-										>
-											<div class="control has-addons">
-												<button
-													class="button is-primary"
-												>
-													<i class="material-icons"
-														>playlist_add</i
+										<template #button>
+											<div
+												id="add-song-to-playlist"
+												content="Add Song to Playlist"
+												v-tippy
+											>
+												<div class="control has-addons">
+													<button
+														class="button is-primary"
 													>
-												</button>
-												<button
-													class="button"
-													id="dropdown-toggle"
-												>
-													<i class="material-icons">
-														{{
-															showPlaylistDropdown
-																? "expand_more"
-																: "expand_less"
-														}}
-													</i>
-												</button>
+														<i
+															class="material-icons"
+														>
+															playlist_add
+														</i>
+													</button>
+													<button
+														class="button"
+														id="dropdown-toggle"
+													>
+														<i
+															class="material-icons"
+														>
+															{{
+																showPlaylistDropdown
+																	? "expand_more"
+																	: "expand_less"
+															}}
+														</i>
+													</button>
+												</div>
 											</div>
-										</div>
+										</template>
 									</add-to-playlist-dropdown>
 								</div>
 								<div id="right-buttons" v-else>
 									<!-- Disabled Ratings (Like/Dislike) Buttons -->
-									<div id="ratings">
+									<div id="ratings" v-if="ratingsLoaded">
 										<!-- Disabled Like Song Button -->
 										<button
 											class="button is-success disabled"
@@ -494,6 +538,33 @@
 											>{{ currentSong.dislikes }}
 										</button>
 									</div>
+									<div id="ratings" v-else>
+										<!-- Disabled Like Song Button -->
+										<button
+											class="button is-success disabled"
+											id="like-song"
+											content="Ratings have not been loaded yet"
+											v-tippy="{ theme: 'info' }"
+										>
+											<i
+												class="material-icons icon-with-button"
+												>thumb_up_alt</i
+											>
+										</button>
+
+										<!-- Disabled Dislike Song Button -->
+										<button
+											class="button is-danger disabled"
+											id="dislike-song"
+											content="Ratings have not been loaded yet"
+											v-tippy="{ theme: 'info' }"
+										>
+											<i
+												class="material-icons icon-with-button"
+												>thumb_down_alt</i
+											>
+										</button>
+									</div>
 									<!-- Disabled Add Song To Playlist Button & Dropdown -->
 									<div id="add-song-to-playlist">
 										<div class="control has-addons">
@@ -612,6 +683,7 @@
 
 <script>
 import { mapState, mapActions, mapGetters } from "vuex";
+import { defineAsyncComponent } from "vue";
 import Toast from "toasters";
 import { ContentLoader } from "vue-content-loader";
 
@@ -636,24 +708,34 @@ export default {
 		ContentLoader,
 		MainHeader,
 		MainFooter,
-		RequestSong: () => import("@/components/modals/RequestSong.vue"),
-		EditPlaylist: () => import("@/components/modals/EditPlaylist"),
-		CreatePlaylist: () => import("@/components/modals/CreatePlaylist.vue"),
-		ManageStation: () =>
-			import("@/components/modals/ManageStation/index.vue"),
-		Report: () => import("@/components/modals/Report.vue"),
+		RequestSong: defineAsyncComponent(() =>
+			import("@/components/modals/RequestSong.vue")
+		),
+		EditPlaylist: defineAsyncComponent(() =>
+			import("@/components/modals/EditPlaylist")
+		),
+		CreatePlaylist: defineAsyncComponent(() =>
+			import("@/components/modals/CreatePlaylist.vue")
+		),
+		ManageStation: defineAsyncComponent(() =>
+			import("@/components/modals/ManageStation/index.vue")
+		),
+		Report: defineAsyncComponent(() =>
+			import("@/components/modals/Report.vue")
+		),
 		Z404,
 		FloatingBox,
 		StationSidebar,
 		AddToPlaylistDropdown,
-		EditSong: () => import("@/components/modals/EditSong"),
+		EditSong: defineAsyncComponent(() =>
+			import("@/components/modals/EditSong")
+		),
 		SongItem
 	},
 	data() {
 		return {
 			utils,
 			isIOS: navigator.platform.match(/iPhone|iPod|iPad/),
-			manageStationVersion: "",
 			title: "Station",
 			loading: true,
 			exists: true,
@@ -665,7 +747,6 @@ export default {
 			liked: false,
 			disliked: false,
 			timeBeforePause: 0,
-			skipVotes: 0,
 			systemDifference: 0,
 			attemptsToPlayVideo: 0,
 			canAutoplay: true,
@@ -687,6 +768,22 @@ export default {
 		};
 	},
 	computed: {
+		skipVotesLoaded() {
+			return (
+				!this.noSong &&
+				Number.isInteger(this.currentSong.skipVotes) &&
+				this.currentSong.skipVotes >= 0
+			);
+		},
+		ratingsLoaded() {
+			return (
+				!this.noSong &&
+				Number.isInteger(this.currentSong.likes) &&
+				Number.isInteger(this.currentSong.dislikes) &&
+				this.currentSong.likes >= 0 &&
+				this.currentSong.dislikes >= 0
+			);
+		},
 		...mapState("modalVisibility", {
 			modals: state => state.modals
 		}),
@@ -717,10 +814,6 @@ export default {
 		})
 	},
 	async mounted() {
-		lofig.get("manageStationVersion", manageStationVersion => {
-			this.manageStationVersion = manageStationVersion;
-		});
-
 		this.editSongModalWatcher = this.$store.watch(
 			state => state.modals.editSong.video.paused,
 			paused => {
@@ -779,6 +872,7 @@ export default {
 				timePaused,
 				natural
 			} = res.data;
+
 			if (this.noSong || !natural) {
 				this.setCurrentSong({
 					currentSong,
@@ -840,8 +934,7 @@ export default {
 		this.socket.on("event:song.liked", res => {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
-					this.currentSong.dislikes = res.data.dislikes;
-					this.currentSong.likes = res.data.likes;
+					this.updateCurrentSongRatings(res.data);
 				}
 			}
 		});
@@ -849,8 +942,7 @@ export default {
 		this.socket.on("event:song.disliked", res => {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
-					this.currentSong.dislikes = res.data.dislikes;
-					this.currentSong.likes = res.data.likes;
+					this.updateCurrentSongRatings(res.data);
 				}
 			}
 		});
@@ -858,8 +950,7 @@ export default {
 		this.socket.on("event:song.unliked", res => {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
-					this.currentSong.dislikes = res.data.dislikes;
-					this.currentSong.likes = res.data.likes;
+					this.updateCurrentSongRatings(res.data);
 				}
 			}
 		});
@@ -867,8 +958,7 @@ export default {
 		this.socket.on("event:song.undisliked", res => {
 			if (!this.noSong) {
 				if (res.data.youtubeId === this.currentSong.youtubeId) {
-					this.currentSong.dislikes = res.data.dislikes;
-					this.currentSong.likes = res.data.likes;
+					this.updateCurrentSongRatings(res.data);
 				}
 			}
 		});
@@ -932,7 +1022,8 @@ export default {
 		});
 
 		this.socket.on("event:station.voteSkipSong", () => {
-			if (this.currentSong) this.currentSong.skipVotes += 1;
+			if (this.currentSong)
+				this.updateCurrentSongSkipVotes(this.currentSong.skipVotes + 1);
 		});
 
 		this.socket.on("event:privatePlaylist.selected", res => {
@@ -956,12 +1047,10 @@ export default {
 			document.body.style.cssText = `--primary-color: var(--${theme})`;
 		});
 
-		this.socket.on("event:station.name.updated", res => {
+		this.socket.on("event:station.name.updated", async res => {
 			this.station.name = res.data.name;
-			// eslint-disable-next-line no-restricted-globals
-			history.pushState(
-				{},
-				null,
+
+			await this.$router.push(
 				`${res.data.name}?${Object.keys(this.$route.query)
 					.map(key => {
 						return `${encodeURIComponent(key)}=${encodeURIComponent(
@@ -970,6 +1059,9 @@ export default {
 					})
 					.join("&")}`
 			);
+
+			// eslint-disable-next-line no-restricted-globals
+			history.replaceState({ ...history.state, ...{} }, null);
 		});
 
 		this.socket.on("event:station.displayName.updated", res => {
@@ -1021,7 +1113,7 @@ export default {
 			this.volumeSliderValue = volume * 100;
 		}
 	},
-	beforeDestroy() {
+	beforeUnmount() {
 		document.body.style.cssText = "";
 
 		/** Reset Songslist */
@@ -1074,6 +1166,7 @@ export default {
 		},
 		setNextCurrentSong(nextCurrentSong, skipSkipCheck = false) {
 			this.nextCurrentSong = nextCurrentSong;
+			// If skipSkipCheck is true, it won't try to skip the song
 			if (this.getTimeRemaining() <= 0 && !skipSkipCheck) {
 				this.skipSong();
 			}
@@ -1109,10 +1202,12 @@ export default {
 				pausedAt
 			} = data;
 
-			if (!currentSong.skipDuration || currentSong.skipDuration < 0)
-				currentSong.skipDuration = 0;
-			if (!currentSong.duration || currentSong.duration < 0)
-				currentSong.duration = 0;
+			if (currentSong) {
+				if (!currentSong.skipDuration || currentSong.skipDuration < 0)
+					currentSong.skipDuration = 0;
+				if (!currentSong.duration || currentSong.duration < 0)
+					currentSong.duration = 0;
+			}
 
 			this.updateCurrentSong(currentSong || {});
 
@@ -1121,6 +1216,7 @@ export default {
 				nextSong = this.songsList[0].youtubeId
 					? this.songsList[0]
 					: null;
+
 			this.updateNextSong(nextSong);
 			this.setNextCurrentSong(
 				{
@@ -1173,6 +1269,17 @@ export default {
 					}, this.getTimeRemaining());
 				}
 
+				this.socket.dispatch(
+					"songs.getSongRatings",
+					currentSong._id,
+					res => {
+						if (currentSong._id === this.currentSong._id) {
+							const { likes, dislikes } = res.data;
+							this.updateCurrentSongRatings({ likes, dislikes });
+						}
+					}
+				);
+
 				this.socket.dispatch(
 					"songs.getOwnSongRatings",
 					currentSong.youtubeId,
@@ -1201,8 +1308,6 @@ export default {
 				this.updateNoSong(true);
 			}
 
-			console.log(666);
-
 			this.calculateTimeElapsed();
 			this.resizeSeekerbar();
 		},
@@ -1757,6 +1862,18 @@ export default {
 						this.updateUserCount(res.data.userCount);
 						this.updateUsers(res.data.users);
 
+						this.socket.dispatch(
+							"stations.getStationIncludedPlaylistsById",
+							this.station._id,
+							res => {
+								if (res.status === "success") {
+									this.setIncludedPlaylists(
+										res.data.playlists
+									);
+								}
+							}
+						);
+
 						this.socket.dispatch(
 							"stations.getStationExcludedPlaylistsById",
 							this.station._id,
@@ -1771,13 +1888,10 @@ export default {
 
 						this.socket.dispatch("stations.getQueue", _id, res => {
 							if (res.status === "success") {
-								this.updateSongsList(res.data.queue);
-								let nextSong = null;
-								if (this.songsList[0]) {
-									nextSong = this.songsList[0].youtubeId
-										? this.songsList[0]
-										: null;
-								}
+								const { queue } = res.data;
+								this.updateSongsList(queue);
+								const [nextSong] = queue;
+
 								this.updateNextSong(nextSong);
 								this.setNextCurrentSong({
 									currentSong: nextSong,
@@ -2001,7 +2115,9 @@ export default {
 			"updateNoSong",
 			"updateIfStationIsFavorited",
 			"setIncludedPlaylists",
-			"setExcludedPlaylists"
+			"setExcludedPlaylists",
+			"updateCurrentSongRatings",
+			"updateCurrentSongSkipVotes"
 		]),
 		...mapActions("modals/editSong", ["stopVideo"])
 	}

+ 6 - 7
frontend/src/store/index.js

@@ -1,6 +1,5 @@
 /* eslint-disable import/no-cycle */
-import Vue from "vue";
-import Vuex from "vuex";
+import { createStore } from "vuex";
 
 import websockets from "./modules/websockets";
 
@@ -11,6 +10,7 @@ import station from "./modules/station";
 import admin from "./modules/admin";
 
 import editSongModal from "./modules/modals/editSong";
+import importAlbumModal from "./modules/modals/importAlbum";
 import editPlaylistModal from "./modules/modals/editPlaylist";
 import manageStationModal from "./modules/modals/manageStation";
 import editUserModal from "./modules/modals/editUser";
@@ -18,9 +18,7 @@ import viewPunishmentModal from "./modules/modals/viewPunishment";
 import viewReportModal from "./modules/modals/viewReport";
 import reportModal from "./modules/modals/report";
 
-Vue.use(Vuex);
-
-export default new Vuex.Store({
+export default createStore({
 	modules: {
 		websockets,
 		user,
@@ -32,12 +30,13 @@ export default new Vuex.Store({
 			namespaced: true,
 			modules: {
 				editSong: editSongModal,
+				importAlbum: importAlbumModal,
 				editPlaylist: editPlaylistModal,
 				manageStation: manageStationModal,
 				editUser: editUserModal,
 				viewPunishment: viewPunishmentModal,
-				viewReport: viewReportModal,
-				report: reportModal
+				report: reportModal,
+				viewReport: viewReportModal
 			}
 		}
 	},

+ 9 - 2
frontend/src/store/modules/admin.js

@@ -1,9 +1,11 @@
 /* eslint no-param-reassign: 0 */
 /* eslint-disable import/no-cycle */
 
-import Vue from "vue";
+// import Vue from "vue";
 import admin from "@/api/admin/index";
 
+const Vue = {};
+
 const state = {};
 const getters = {};
 const actions = {};
@@ -139,7 +141,9 @@ const modules = {
 	},
 	reports: {
 		namespaced: true,
-		state: {},
+		state: {
+			reports: []
+		},
 		getters: {},
 		actions: {
 			/* eslint-disable-next-line no-unused-vars */
@@ -150,6 +154,9 @@ const modules = {
 						.then(res => resolve(res))
 						.catch(err => reject(new Error(err.message)));
 				});
+			},
+			indexReports({ commit }, reports) {
+				commit("indexReports", reports);
 			}
 		},
 		mutations: {}

+ 2 - 1
frontend/src/store/modules/modalVisibility.js

@@ -16,6 +16,7 @@ const state = {
 		editNews: false,
 		editUser: false,
 		editSong: false,
+		importAlbum: false,
 		viewReport: false,
 		viewPunishment: false
 	},
@@ -32,7 +33,6 @@ const actions = {
 			});
 
 		commit("closeModal", modal);
-		commit("closeCurrentModal");
 	},
 	openModal: ({ commit }, modal) => {
 		commit("openModal", modal);
@@ -45,6 +45,7 @@ const actions = {
 const mutations = {
 	closeModal(state, modal) {
 		state.modals[modal] = false;
+		if (state.currentlyActive[0] === modal) state.currentlyActive.shift();
 	},
 	openModal(state, modal) {
 		state.modals[modal] = true;

+ 0 - 3
frontend/src/store/modules/modals/editPlaylist.js

@@ -1,8 +1,5 @@
 /* eslint no-param-reassign: 0 */
 
-// import Vue from "vue";
-// import admin from "@/api/admin/index";
-
 export default {
 	namespaced: true,
 	state: {

+ 15 - 4
frontend/src/store/modules/modals/editSong.js

@@ -1,8 +1,5 @@
 /* eslint no-param-reassign: 0 */
 
-// import Vue from "vue";
-// import admin from "@/api/admin/index";
-
 export default {
 	namespaced: true,
 	state: {
@@ -35,7 +32,13 @@ export default {
 		updateSongField: ({ commit }, data) => commit("updateSongField", data),
 		selectDiscogsInfo: ({ commit }, discogsInfo) =>
 			commit("selectDiscogsInfo", discogsInfo),
-		updateReports: ({ commit }, reports) => commit("updateReports", reports)
+		updateReports: ({ commit }, reports) =>
+			commit("updateReports", reports),
+		resolveReport: ({ commit }, reportId) =>
+			commit("resolveReport", reportId),
+		updateYoutubeId: ({ commit }, youtubeId) => {
+			commit("updateYoutubeId", youtubeId);
+		}
 	},
 	mutations: {
 		showTab(state, tab) {
@@ -81,6 +84,14 @@ export default {
 		},
 		updateReports(state, reports) {
 			state.reports = reports;
+		},
+		resolveReport(state, reportId) {
+			state.reports = state.reports.filter(
+				report => report._id !== reportId
+			);
+		},
+		updateYoutubeId(state, youtubeId) {
+			state.song.youtubeId = youtubeId;
 		}
 	}
 };

+ 66 - 0
frontend/src/store/modules/modals/importAlbum.js

@@ -0,0 +1,66 @@
+/* eslint no-param-reassign: 0 */
+
+// import Vue from "vue";
+// import admin from "@/api/admin/index";
+
+export default {
+	namespaced: true,
+	state: {
+		discogsAlbum: {
+			// album: {
+			// 	genres: [],
+			// 	artists: [],
+			// 	artistIds: []
+			// },
+			// tracks: []
+		},
+		originalPlaylistSongs: [],
+		playlistSongs: [],
+		editingSongs: false
+	},
+	getters: {},
+	actions: {
+		selectDiscogsAlbum: ({ commit }, discogsAlbum) =>
+			commit("selectDiscogsAlbum", discogsAlbum),
+		toggleDiscogsAlbum: ({ commit }) => {
+			commit("toggleDiscogsAlbum");
+		},
+		setPlaylistSongs: ({ commit }, playlistSongs) =>
+			commit("setPlaylistSongs", playlistSongs),
+		updatePlaylistSongs: ({ commit }, playlistSongs) =>
+			commit("updatePlaylistSongs", playlistSongs),
+		updateEditingSongs: ({ commit }, editingSongs) =>
+			commit("updateEditingSongs", editingSongs),
+		resetPlaylistSongs: ({ commit }) => commit("resetPlaylistSongs")
+	},
+	mutations: {
+		selectDiscogsAlbum(state, discogsAlbum) {
+			state.discogsAlbum = JSON.parse(JSON.stringify(discogsAlbum));
+			if (state.discogsAlbum && state.discogsAlbum.tracks) {
+				state.tracks = state.discogsAlbum.tracks.map(track => {
+					return { ...track, songs: [] };
+				});
+			}
+		},
+		toggleDiscogsAlbum(state) {
+			state.discogsAlbum.expanded = !state.discogsAlbum.expanded;
+		},
+		setPlaylistSongs(state, playlistSongs) {
+			state.originalPlaylistSongs = JSON.parse(
+				JSON.stringify(playlistSongs)
+			);
+			state.playlistSongs = JSON.parse(JSON.stringify(playlistSongs));
+		},
+		updatePlaylistSongs(state, playlistSongs) {
+			state.playlistSongs = JSON.parse(JSON.stringify(playlistSongs));
+		},
+		updateEditingSongs(state, editingSongs) {
+			state.editingSongs = editingSongs;
+		},
+		resetPlaylistSongs(state) {
+			state.playlistSongs = JSON.parse(
+				JSON.stringify(state.originalPlaylistSongs)
+			);
+		}
+	}
+};

+ 8 - 2
frontend/src/store/modules/modals/manageStation.js

@@ -52,8 +52,14 @@ export default {
 			);
 		},
 		clearStation(state) {
-			state.originalStation = null;
-			state.station = null;
+			state.originalStation = {};
+			state.station = {};
+			state.stationPlaylist = { songs: [] };
+			state.includedPlaylists = [];
+			state.excludedPlaylists = [];
+			state.songsList = [];
+			state.stationPaused = true;
+			state.currentSong = {};
 		},
 		updateSongsList(state, songsList) {
 			state.songsList = songsList;

+ 4 - 21
frontend/src/store/modules/modals/viewReport.js

@@ -1,33 +1,16 @@
 /* eslint no-param-reassign: 0 */
-/* eslint-disable import/no-cycle */
-
-import admin from "@/api/admin/index";
 
 export default {
 	namespaced: true,
 	state: {
-		report: {}
+		viewingReportId: ""
 	},
-	getters: {},
 	actions: {
-		viewReport: ({ commit }, report) => commit("viewReport", report),
-		/* eslint-disable-next-line no-unused-vars */
-		resolveReport: ({ commit }, reportId) => {
-			return new Promise((resolve, reject) => {
-				return admin.reports
-					.resolve(reportId)
-					.then(res => {
-						return resolve(res);
-					})
-					.catch(err => {
-						return reject(new Error(err.message));
-					});
-			});
-		}
+		viewReport: ({ commit }, reportId) => commit("viewReport", reportId)
 	},
 	mutations: {
-		viewReport(state, report) {
-			state.report = report;
+		viewReport(state, reportId) {
+			state.viewingReportId = reportId;
 		}
 	}
 };

+ 13 - 0
frontend/src/store/modules/station.js

@@ -69,6 +69,12 @@ const actions = {
 	},
 	setExcludedPlaylists: ({ commit }, excludedPlaylists) => {
 		commit("setExcludedPlaylists", excludedPlaylists);
+	},
+	updateCurrentSongRatings: ({ commit }, songRatings) => {
+		commit("updateCurrentSongRatings", songRatings);
+	},
+	updateCurrentSongSkipVotes: ({ commit }, skipVotes) => {
+		commit("updateCurrentSongSkipVotes", skipVotes);
 	}
 };
 
@@ -149,6 +155,13 @@ const mutations = {
 	},
 	setExcludedPlaylists(state, excludedPlaylists) {
 		state.excludedPlaylists = JSON.parse(JSON.stringify(excludedPlaylists));
+	},
+	updateCurrentSongRatings(state, songRatings) {
+		state.currentSong.likes = songRatings.likes;
+		state.currentSong.dislikes = songRatings.dislikes;
+	},
+	updateCurrentSongSkipVotes(state, skipVotes) {
+		state.currentSong.skipVotes = skipVotes;
 	}
 };
 

+ 19 - 1
frontend/src/store/modules/user.js

@@ -209,7 +209,13 @@ const modules = {
 		actions: {
 			editPlaylist: ({ commit }, id) => commit("editPlaylist", id),
 			setPlaylists: ({ commit }, playlists) =>
-				commit("setPlaylists", playlists)
+				commit("setPlaylists", playlists),
+			updatePlaylists: ({ commit }, playlists) =>
+				commit("updatePlaylists", playlists),
+			addPlaylist: ({ commit }, playlist) =>
+				commit("addPlaylist", playlist),
+			removePlaylist: ({ commit }, playlistId) =>
+				commit("removePlaylist", playlistId)
 		},
 		mutations: {
 			editPlaylist(state, id) {
@@ -218,6 +224,18 @@ const modules = {
 			setPlaylists(state, playlists) {
 				state.fetchedPlaylists = true;
 				state.playlists = playlists;
+			},
+			updatePlaylists(state, playlists) {
+				state.playlists = playlists;
+			},
+			addPlaylist(state, playlist) {
+				state.playlists.push(playlist);
+			},
+			removePlaylist(state, playlistId) {
+				state.playlists.forEach((playlist, index) => {
+					if (playlist._id === playlistId)
+						state.playlists.splice(index, 1);
+				});
 			}
 		}
 	},

+ 1 - 0
frontend/src/ws.js

@@ -112,6 +112,7 @@ export default {
 				if (!(event.type in this.listeners)) return true; // event type doesn't exist
 
 				const stack = this.listeners[event.type].slice();
+
 				stack.forEach(element => element.cb.call(this, event));
 
 				return !event.defaultPrevented;

+ 1 - 1
frontend/webpack.common.js

@@ -55,7 +55,7 @@ module.exports = {
 					{
 						loader: "css-loader",
 						options: {
-							url: false,
+							url: false
 						}
 					},
 					"sass-loader"

+ 2 - 2
frontend/webpack.dev.js

@@ -12,8 +12,8 @@ module.exports = merge(common, {
 	},
 	resolve: {
 		alias: {
-			vue: "vue/dist/vue.js",
-			styles: "src/styles"
+			styles: "src/styles",
+			vue: "vue/dist/vue.esm-bundler.js"
 		}
 	},
 	devServer: {

+ 1 - 1
tools/docker/setup-mongo.sh

@@ -1,7 +1,7 @@
 #!/bin/bash
 
 mongo musare \
-        --port ${MONGO_PORT} \
+        --port 27017 \
         -u "admin" \
         --authenticationDatabase "admin" \
         -p ${MONGO_ROOT_PASSWORD} \

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно