瀏覽代碼

Merge tag 'v3.3.0' into staging

Owen Diffey 3 年之前
父節點
當前提交
04883b37f2
共有 77 個文件被更改,包括 7128 次插入2684 次删除
  1. 1 2
      .wiki/Configuration.md
  2. 4 0
      .wiki/Value_Formats.md
  3. 4 3
      backend/config/template.json
  4. 1 1
      backend/index.js
  5. 4 4
      backend/logic/actions/apis.js
  6. 40 13
      backend/logic/actions/dataRequests.js
  7. 101 1
      backend/logic/actions/news.js
  8. 221 24
      backend/logic/actions/playlists.js
  9. 151 16
      backend/logic/actions/punishments.js
  10. 83 42
      backend/logic/actions/reports.js
  11. 804 137
      backend/logic/actions/songs.js
  12. 169 3
      backend/logic/actions/stations.js
  13. 145 34
      backend/logic/actions/users.js
  14. 5 0
      backend/logic/app.js
  15. 219 3
      backend/logic/db/index.js
  16. 1 1
      backend/logic/db/schemas/playlist.js
  17. 3 2
      backend/logic/db/schemas/song.js
  18. 3 3
      backend/logic/db/schemas/station.js
  19. 1 1
      backend/logic/mail/schemas/dataRequest.js
  20. 2 1
      backend/logic/mail/schemas/passwordRequest.js
  21. 2 1
      backend/logic/mail/schemas/resetPasswordRequest.js
  22. 1 1
      backend/logic/mail/schemas/verifyEmail.js
  23. 42 0
      backend/logic/migration/migrations/migration17.js
  24. 185 0
      backend/logic/migration/migrations/migration18.js
  25. 21 26
      backend/logic/playlists.js
  26. 361 185
      backend/logic/songs.js
  27. 3 3
      backend/logic/stations.js
  28. 2 2
      backend/logic/tasks.js
  29. 5 5
      backend/logic/youtube.js
  30. 1 1
      backend/package-lock.json
  31. 1 1
      backend/package.json
  32. 1 1
      frontend/dist/index.tpl.html
  33. 10 10
      frontend/package-lock.json
  34. 2 2
      frontend/package.json
  35. 44 3
      frontend/src/App.vue
  36. 640 62
      frontend/src/components/AdvancedTable.vue
  37. 177 0
      frontend/src/components/AutoSuggest.vue
  38. 1 4
      frontend/src/components/FloatingBox.vue
  39. 32 8
      frontend/src/components/Modal.vue
  40. 1 0
      frontend/src/components/Queue.vue
  41. 5 0
      frontend/src/components/SaveButton.vue
  42. 12 4
      frontend/src/components/SongItem.vue
  43. 1 1
      frontend/src/components/layout/MainFooter.vue
  44. 175 0
      frontend/src/components/modals/BulkActions.vue
  45. 53 0
      frontend/src/components/modals/Confirm.vue
  46. 4 0
      frontend/src/components/modals/CreatePlaylist.vue
  47. 2 2
      frontend/src/components/modals/EditNews.vue
  48. 1 1
      frontend/src/components/modals/EditPlaylist/index.vue
  49. 4 0
      frontend/src/components/modals/EditSong/Tabs/Discogs.vue
  50. 433 347
      frontend/src/components/modals/EditSong/index.vue
  51. 633 0
      frontend/src/components/modals/EditSongs.vue
  52. 30 44
      frontend/src/components/modals/ImportAlbum.vue
  53. 6 1
      frontend/src/components/modals/Login.vue
  54. 3 3
      frontend/src/components/modals/ManageStation/index.vue
  55. 1 0
      frontend/src/components/modals/Register.vue
  56. 1 1
      frontend/src/components/modals/Report.vue
  57. 1 1
      frontend/src/components/modals/ViewReport.vue
  58. 1 1
      frontend/src/components/modals/WhatIsNew.vue
  59. 4 4
      frontend/src/main.js
  60. 130 15
      frontend/src/pages/Admin/index.vue
  61. 159 159
      frontend/src/pages/Admin/tabs/News.vue
  62. 270 162
      frontend/src/pages/Admin/tabs/Playlists.vue
  63. 221 107
      frontend/src/pages/Admin/tabs/Punishments.vue
  64. 223 158
      frontend/src/pages/Admin/tabs/Reports.vue
  65. 441 251
      frontend/src/pages/Admin/tabs/Songs.vue
  66. 312 141
      frontend/src/pages/Admin/tabs/Stations.vue
  67. 0 358
      frontend/src/pages/Admin/tabs/Test.vue
  68. 397 158
      frontend/src/pages/Admin/tabs/Users.vue
  69. 6 3
      frontend/src/pages/News.vue
  70. 2 16
      frontend/src/pages/Team.vue
  71. 5 1
      frontend/src/store/index.js
  72. 11 128
      frontend/src/store/modules/admin.js
  73. 8 2
      frontend/src/store/modules/modalVisibility.js
  74. 18 0
      frontend/src/store/modules/modals/confirm.js
  75. 18 0
      frontend/src/store/modules/modals/editSong.js
  76. 29 0
      frontend/src/store/modules/modals/editSongs.js
  77. 14 9
      frontend/src/ws.js

+ 1 - 2
.wiki/Configuration.md

@@ -12,8 +12,6 @@ Location: `backend/config/default.json`
 | `serverDomain` | Should be the url where the backend will be accessible from, usually `http://localhost/backend` for docker or `http://localhost:8080` for non-Docker. |
 | `serverPort` | Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker. |
 | `registrationDisabled` | If set to true, users can't register accounts. |
-| `hideAutomaticallyRequestedSongs` | If `true` any automatically requested songs will be hidden. |
-| `hideAnonymousSongs` | If `true` any anonymously requested songs will be hidden. |
 | `sendDataRequestEmails` | If `true` all admin users will be sent an email if a data request is received. |
 | `apis.youtube.key` | YouTube Data API v3 key, obtained from [here](https://developers.google.com/youtube/v3/getting-started). |
 | `apis.youtube.rateLimit` | Minimum interval between YouTube API requests in milliseconds. |
@@ -34,6 +32,7 @@ Location: `backend/config/default.json`
 | `smtp.auth.pass` | SMTP Password |
 | `smtp.secure` | Whether SMTP is secured. |
 | `smtp.enabled` | Whether SMTP and sending emails is enabled. |
+| `mail.from` | The from field for mails sent from backend. |
 | `redis.url` | Should be left as default for Docker installations, else changed to `redis://localhost:6379/0`. |
 | `redis.password` | Redis password. |
 | `mongo.url` | For Docker replace temporary MongoDB musare user password with one specified in `.env`, and for non-Docker replace `@musare:27017` with `@localhost:27017`. |

+ 4 - 0
.wiki/Value_Formats.md

@@ -54,6 +54,10 @@ Every input needs validation, below is the required formatting of each value.
         - Length: From 1 to 32 characters.
         - Quantity: Min 1, max 16.
         - Regex: ```/^[\x00-\x7F]+$/```
+    - Tags
+        - Description: Any letter, numbers and underscores. Can be with out without data in square brackets. The base tag and data between brackets follow the same styling. If there's no data in between square brackets, there are no square brackets.
+        - Length: From 1 to 64 characters for the base part, 1 to 64 characters for data in square brackets.
+        - Regex: ```/^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/```
     - Thumbnail
         - Description: Valid url. If site is secure only https prepended urls are valid.
         - Length: From 1 to 256 characters.

+ 4 - 3
backend/config/template.json

@@ -7,8 +7,6 @@
 	"serverDomain": "http://localhost/backend",
 	"serverPort": 8080,
 	"registrationDisabled": true,
-	"hideAutomaticallyRequestedSongs": false,
-	"hideAnonymousSongs": false,
 	"sendDataRequestEmails": true,
 	"apis": {
 		"youtube": {
@@ -47,6 +45,9 @@
 		"secure": false,
 		"enabled": false
 	},
+	"mail": {
+		"from": "Musare <noreply@localhost>"
+	},
 	"redis": {
 		"url": "redis://redis:6379/0",
 		"password": "PASSWORD"
@@ -95,5 +96,5 @@
 			]
 		}
 	},
-	"configVersion": 8
+	"configVersion": 9
 }

+ 1 - 1
backend/index.js

@@ -6,7 +6,7 @@ import fs from "fs";
 
 import package_json from "./package.json";
 
-const REQUIRED_CONFIG_VERSION = 8;
+const REQUIRED_CONFIG_VERSION = 9;
 
 // eslint-disable-next-line
 Array.prototype.remove = function (item) {

+ 4 - 4
backend/logic/actions/apis.js

@@ -131,7 +131,8 @@ export default {
 			room.startsWith("edit-song.") ||
 			room.startsWith("view-report.") ||
 			room.startsWith("edit-user.") ||
-			room === "import-album"
+			room === "import-album" ||
+			room === "edit-songs"
 		) {
 			WSModule.runJob("SOCKET_JOIN_ROOM", {
 				socketId: session.socketId,
@@ -160,7 +161,8 @@ export default {
 			room.startsWith("manage-station.") ||
 			room.startsWith("edit-song.") ||
 			room.startsWith("view-report.") ||
-			room === "import-album"
+			room === "import-album" ||
+			room === "edit-songs"
 		) {
 			WSModule.runJob("SOCKET_LEAVE_ROOM", {
 				socketId: session.socketId,
@@ -184,9 +186,7 @@ export default {
 	 */
 	joinAdminRoom: isAdminRequired((session, page, cb) => {
 		if (
-			page === "unverifiedSongs" ||
 			page === "songs" ||
-			page === "hiddenSongs" ||
 			page === "stations" ||
 			page === "reports" ||
 			page === "news" ||

+ 40 - 13
backend/logic/actions/dataRequests.js

@@ -21,30 +21,57 @@ CacheModule.runJob("SUB", {
 
 export default {
 	/**
-	 * Gets all unresolved data requests
+	 * Gets data requests, used in the admin users page by the AdvancedTable component
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {Function} cb - gets called with the result
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each data request
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
 	 */
-	index: isAdminRequired(async function index(session, cb) {
-		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
-
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
 		async.waterfall(
 			[
 				next => {
-					dataRequestModel.find({ resolved: false }).sort({ createdAt: "desc" }).exec(next);
+					DBModule.runJob(
+						"GET_DATA",
+						{
+							page,
+							pageSize,
+							properties,
+							sort,
+							queries,
+							operator,
+							modelName: "dataRequest",
+							blacklistedProperties: [],
+							specialProperties: {},
+							specialQueries: {}
+						},
+						this
+					)
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
 				}
 			],
-			async (err, requests) => {
-				if (err) {
+			async (err, response) => {
+				if (err && err !== true) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "DATA_REQUESTS_INDEX", `Indexing data requests failed. "${err}"`);
+					this.log("ERROR", "DATA_REQUESTS_GET_DATA", `Failed to get data from data requests. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
-
-				this.log("SUCCESS", "DATA_REQUESTS_INDEX", `Indexing data requests successful.`, false);
-
-				return cb({ status: "success", data: { requests } });
+				this.log("SUCCESS", "DATA_REQUESTS_GET_DATA", `Got data from data requests successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully got data from data requests.",
+					data: response
+				});
 			}
 		);
 	}),

+ 101 - 1
backend/logic/actions/news.js

@@ -56,13 +56,113 @@ CacheModule.runJob("SUB", {
 });
 
 export default {
+	/**
+	 * Gets news items, used in the admin news page by the AdvancedTable component
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each news item
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
+	 */
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+		async.waterfall(
+			[
+				next => {
+					DBModule.runJob(
+						"GET_DATA",
+						{
+							page,
+							pageSize,
+							properties,
+							sort,
+							queries,
+							operator,
+							modelName: "news",
+							blacklistedProperties: [],
+							specialProperties: {
+								createdBy: [
+									{
+										$addFields: {
+											createdByOID: {
+												$convert: {
+													input: "$createdBy",
+													to: "objectId",
+													onError: "unknown",
+													onNull: "unknown"
+												}
+											}
+										}
+									},
+									{
+										$lookup: {
+											from: "users",
+											localField: "createdByOID",
+											foreignField: "_id",
+											as: "createdByUser"
+										}
+									},
+									{
+										$unwind: {
+											path: "$createdByUser",
+											preserveNullAndEmptyArrays: true
+										}
+									},
+									{
+										$addFields: {
+											createdByUsername: {
+												$ifNull: ["$createdByUser.username", "unknown"]
+											}
+										}
+									},
+									{
+										$project: {
+											createdByOID: 0,
+											createdByUser: 0
+										}
+									}
+								]
+							},
+							specialQueries: {
+								createdBy: newQuery => ({ $or: [newQuery, { createdByUsername: newQuery.createdBy }] })
+							}
+						},
+						this
+					)
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async (err, response) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "NEWS_GET_DATA", `Failed to get data from news. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "NEWS_GET_DATA", `Got data from news successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully got data from news.",
+					data: response
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Gets all news items that are published
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param {Function} cb - gets called with the result
 	 */
-	async index(session, cb) {
+	async getPublished(session, cb) {
 		const newsModel = await DBModule.runJob("GET_MODEL", { modelName: "news" }, this);
 		async.waterfall(
 			[

+ 221 - 24
backend/logic/actions/playlists.js

@@ -230,30 +230,145 @@ CacheModule.runJob("SUB", {
 	}
 });
 
+CacheModule.runJob("SUB", {
+	channel: "playlist.updated",
+	cb: async data => {
+		const playlistModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "playlist"
+		});
+
+		playlistModel.findOne(
+			{ _id: data.playlistId },
+			["_id", "displayName", "type", "privacy", "songs", "createdBy", "createdAt", "createdFor"],
+			(err, playlist) => {
+				const newPlaylist = {
+					...playlist._doc,
+					songsCount: playlist.songs.length,
+					songsLength: playlist.songs.reduce((previous, current) => ({
+						duration: previous.duration + current.duration
+					})).duration
+				};
+				delete newPlaylist.songs;
+				WSModule.runJob("EMIT_TO_ROOMS", {
+					rooms: ["admin.playlists"],
+					args: ["event:admin.playlist.updated", { data: { playlist: newPlaylist } }]
+				});
+			}
+		);
+	}
+});
+
 export default {
 	/**
-	 * Gets all playlists
+	 * Gets playlists, used in the admin playlists page by the AdvancedTable component
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {Function} cb - gets called with the result
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each playlist
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
 	 */
-	index: isAdminRequired(async function index(session, cb) {
-		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
-
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
 		async.waterfall(
 			[
 				next => {
-					playlistModel.find({}).sort({ createdAt: "desc" }).exec(next);
+					DBModule.runJob(
+						"GET_DATA",
+						{
+							page,
+							pageSize,
+							properties,
+							sort,
+							queries,
+							operator,
+							modelName: "playlist",
+							blacklistedProperties: [],
+							specialProperties: {
+								totalLength: [
+									{
+										$addFields: {
+											totalLength: { $sum: "$songs.duration" }
+										}
+									}
+								],
+								songsCount: [
+									{
+										$addFields: {
+											songsCount: { $size: "$songs" }
+										}
+									}
+								],
+								createdBy: [
+									{
+										$addFields: {
+											createdByOID: {
+												$convert: {
+													input: "$createdBy",
+													to: "objectId",
+													onError: "unknown",
+													onNull: "unknown"
+												}
+											}
+										}
+									},
+									{
+										$lookup: {
+											from: "users",
+											localField: "createdByOID",
+											foreignField: "_id",
+											as: "createdByUser"
+										}
+									},
+									{
+										$unwind: {
+											path: "$createdByUser",
+											preserveNullAndEmptyArrays: true
+										}
+									},
+									{
+										$addFields: {
+											createdByUsername: {
+												$cond: [
+													{ $eq: ["$createdBy", "Musare"] },
+													"Musare",
+													{ $ifNull: ["$createdByUser.username", "unknown"] }
+												]
+											}
+										}
+									},
+									{
+										$project: {
+											createdByOID: 0,
+											createdByUser: 0
+										}
+									}
+								]
+							},
+							specialQueries: {
+								createdBy: newQuery => ({ $or: [newQuery, { createdByUsername: newQuery.createdBy }] })
+							}
+						},
+						this
+					)
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
 				}
 			],
-			async (err, playlists) => {
+			async (err, response) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "PLAYLISTS_INDEX", `Indexing playlists failed. "${err}"`);
+					this.log("ERROR", "PLAYLISTS_GET_DATA", `Failed to get data from playlists. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
-				this.log("SUCCESS", "PLAYLISTS_INDEX", "Indexing playlists successful.");
-				return cb({ status: "success", data: { playlists } });
+				this.log("SUCCESS", "PLAYLISTS_GET_DATA", `Got data from playlists successfully.`);
+				return cb({ status: "success", message: "Successfully got data from playlists.", data: response });
 			}
 		);
 	}),
@@ -798,7 +913,18 @@ export default {
 			[
 				next => {
 					if (!playlistId) return next("No playlist id.");
-					return playlistModel.findById(playlistId, next);
+					return next();
+				},
+
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => {
+							if (!playlist || playlist.createdBy !== session.userId)
+								return next("Something went wrong when trying to get the playlist");
+
+							return next(null, playlist);
+						})
+						.catch(next);
 				},
 
 				(playlist, next) => {
@@ -867,6 +993,22 @@ export default {
 					return next();
 				},
 
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => {
+							if (!playlist || playlist.createdBy !== session.userId) {
+								return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+									userModel.findOne({ _id: session.userId }, (err, user) => {
+										if (user && user.role === "admin") return next();
+										return next("Something went wrong when trying to get the playlist");
+									});
+								});
+							}
+							return next();
+						})
+						.catch(next);
+				},
+
 				// remove song from playlist
 				next => {
 					playlistModel.updateOne(
@@ -945,22 +1087,29 @@ export default {
 				next => {
 					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
 						.then(playlist => {
-							if (!playlist || playlist.createdBy !== session.userId)
-								return next("Something went wrong when trying to get the playlist");
-
-							return async.each(
-								playlist.songs,
-								(song, nextSong) => {
-									if (song.youtubeId === youtubeId)
-										return next("That song is already in the playlist");
-									return nextSong();
-								},
-								err => next(err, playlist)
-							);
+							if (!playlist || playlist.createdBy !== session.userId) {
+								DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+									userModel.findOne({ _id: session.userId }, (err, user) => {
+										if (user && user.role === "admin") return next(null, playlist);
+										return next("Something went wrong when trying to get the playlist");
+									});
+								});
+							} else next(null, playlist);
 						})
 						.catch(next);
 				},
 
+				(playlist, next) => {
+					async.each(
+						playlist.songs,
+						(song, nextSong) => {
+							if (song.youtubeId === youtubeId) return next("That song is already in the playlist");
+							return nextSong();
+						},
+						err => next(err, playlist)
+					);
+				},
+
 				(playlist, next) => {
 					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
 						const oppositeType = playlist.type === "user-liked" ? "user-disliked" : "user-liked";
@@ -1095,6 +1244,11 @@ export default {
 					}
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updated",
+					value: { playlistId }
+				});
+
 				if (ratings && (playlist.type === "user-liked" || playlist.type === "user-disliked")) {
 					const { _id, youtubeId, title, artists, thumbnail } = newSong;
 					const { likes, dislikes } = ratings;
@@ -1255,7 +1409,14 @@ export default {
 				},
 
 				(playlist, next) => {
-					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found.");
+					if (!playlist || playlist.createdBy !== session.userId) {
+						return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+							userModel.findOne({ _id: session.userId }, (err, user) => {
+								if (user && user.role === "admin") return next(null, playlist);
+								return next("Something went wrong when trying to get the playlist");
+							});
+						});
+					}
 
 					return next(null, playlist);
 				}
@@ -1323,6 +1484,22 @@ export default {
 					return next();
 				},
 
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => {
+							if (!playlist || playlist.createdBy !== session.userId) {
+								return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+									userModel.findOne({ _id: session.userId }, (err, user) => {
+										if (user && user.role === "admin") return next();
+										return next("Something went wrong when trying to get the playlist");
+									});
+								});
+							}
+							return next();
+						})
+						.catch(next);
+				},
+
 				// remove song from playlist
 				next => playlistModel.updateOne({ _id: playlistId }, { $pull: { songs: { youtubeId } } }, next),
 
@@ -1475,6 +1652,11 @@ export default {
 					}
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updated",
+					value: { playlistId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Song has been successfully removed from playlist",
@@ -1549,6 +1731,11 @@ export default {
 					}
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updated",
+					value: { playlistId }
+				});
+
 				ActivitiesModule.runJob("ADD_ACTIVITY", {
 					userId: session.userId,
 					type: "playlist__edit_display_name",
@@ -1778,6 +1965,11 @@ export default {
 					}
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updated",
+					value: { playlistId }
+				});
+
 				ActivitiesModule.runJob("ADD_ACTIVITY", {
 					userId: session.userId,
 					type: "playlist__edit_privacy",
@@ -1851,6 +2043,11 @@ export default {
 					});
 				}
 
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updated",
+					value: { playlistId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Playlist has been successfully updated"

+ 151 - 16
backend/logic/actions/punishments.js

@@ -28,33 +28,168 @@ CacheModule.runJob("SUB", {
 
 export default {
 	/**
-	 * Gets all punishments
+	 * Gets punishments, used in the admin punishments page by the AdvancedTable component
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {Function} cb - gets called with the result
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each punishment
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
 	 */
-	index: isAdminRequired(async function index(session, cb) {
-		const punishmentModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "punishment"
-			},
-			this
-		);
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
 		async.waterfall(
 			[
 				next => {
-					punishmentModel.find({}, next);
+					DBModule.runJob(
+						"GET_DATA",
+						{
+							page,
+							pageSize,
+							properties,
+							sort,
+							queries,
+							operator,
+							modelName: "punishment",
+							blacklistedProperties: [],
+							specialProperties: {
+								status: [
+									{
+										$addFields: {
+											status: {
+												$cond: [
+													{ $eq: ["$active", true] },
+													{
+														$cond: [
+															{ $gt: [new Date(), "$expiresAt"] },
+															"Inactive",
+															"Active"
+														]
+													},
+													"Inactive"
+												]
+											}
+										}
+									}
+								],
+								value: [
+									{
+										$addFields: {
+											valueOID: {
+												$convert: {
+													input: "$value",
+													to: "objectId",
+													onError: "unknown",
+													onNull: "unknown"
+												}
+											}
+										}
+									},
+									{
+										$lookup: {
+											from: "users",
+											localField: "valueOID",
+											foreignField: "_id",
+											as: "valueUser"
+										}
+									},
+									{
+										$unwind: {
+											path: "$valueUser",
+											preserveNullAndEmptyArrays: true
+										}
+									},
+									{
+										$addFields: {
+											valueUsername: {
+												$cond: [
+													{ $eq: ["$type", "banUserId"] },
+													{ $ifNull: ["$valueUser.username", "unknown"] },
+													null
+												]
+											}
+										}
+									},
+									{
+										$project: {
+											valueOID: 0,
+											valueUser: 0
+										}
+									}
+								],
+								punishedBy: [
+									{
+										$addFields: {
+											punishedByOID: {
+												$convert: {
+													input: "$punishedBy",
+													to: "objectId",
+													onError: "unknown",
+													onNull: "unknown"
+												}
+											}
+										}
+									},
+									{
+										$lookup: {
+											from: "users",
+											localField: "punishedByOID",
+											foreignField: "_id",
+											as: "punishedByUser"
+										}
+									},
+									{
+										$unwind: {
+											path: "$punishedByUser",
+											preserveNullAndEmptyArrays: true
+										}
+									},
+									{
+										$addFields: {
+											punishedByUsername: {
+												$ifNull: ["$punishedByUser.username", "unknown"]
+											}
+										}
+									},
+									{
+										$project: {
+											punishedByOID: 0,
+											punishedByUser: 0
+										}
+									}
+								]
+							},
+							specialQueries: {
+								value: newQuery => ({ $or: [newQuery, { valueUsername: newQuery.value }] }),
+								punishedBy: newQuery => ({
+									$or: [newQuery, { punishedByUsername: newQuery.punishedBy }]
+								})
+							}
+						},
+						this
+					)
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
 				}
 			],
-			async (err, punishments) => {
-				if (err) {
+			async (err, response) => {
+				if (err && err !== true) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "PUNISHMENTS_INDEX", `Indexing punishments failed. "${err}"`);
+					this.log("ERROR", "PUNISHMENTS_GET_DATA", `Failed to get data from punishments. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
-				this.log("SUCCESS", "PUNISHMENTS_INDEX", "Indexing punishments successful.");
-				return cb({ status: "success", data: { punishments } });
+				this.log("SUCCESS", "PUNISHMENTS_GET_DATA", `Got data from punishments successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully got data from punishments.",
+					data: response
+				});
 			}
 		);
 	}),

+ 83 - 42
backend/logic/actions/reports.js

@@ -60,60 +60,101 @@ CacheModule.runJob("SUB", {
 
 export default {
 	/**
-	 * Gets all reports that haven't been yet resolved
+	 * Gets reports, used in the admin reports page by the AdvancedTable component
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {Function} cb - gets called with the result
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each user
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
 	 */
-	index: isAdminRequired(async function index(session, cb) {
-		const reportModel = await DBModule.runJob("GET_MODEL", { modelName: "report" }, this);
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
 		async.waterfall(
 			[
-				next => reportModel.find({ 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
+				next => {
+					DBModule.runJob(
+						"GET_DATA",
+						{
+							page,
+							pageSize,
+							properties,
+							sort,
+							queries,
+							operator,
+							modelName: "report",
+							blacklistedProperties: [],
+							specialProperties: {
+								createdBy: [
+									{
+										$addFields: {
+											createdByOID: {
+												$convert: {
+													input: "$createdBy",
+													to: "objectId",
+													onError: "unknown",
+													onNull: "unknown"
+												}
 											}
-										});
-
-									return cb(err);
-								});
+										}
+									},
+									{
+										$lookup: {
+											from: "users",
+											localField: "createdByOID",
+											foreignField: "_id",
+											as: "createdByUser"
+										}
+									},
+									{
+										$unwind: {
+											path: "$createdByUser",
+											preserveNullAndEmptyArrays: true
+										}
+									},
+									{
+										$addFields: {
+											createdByUsername: {
+												$ifNull: ["$createdByUser.username", "unknown"]
+											}
+										}
+									},
+									{
+										$project: {
+											createdByOID: 0,
+											createdByUser: 0
+										}
+									}
+								]
+							},
+							specialQueries: {
+								createdBy: newQuery => ({ $or: [newQuery, { createdByUsername: newQuery.createdBy }] })
+							}
 						},
-						err => next(err, reports)
-					);
+						this
+					)
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
 				}
 			],
-			async (err, reports) => {
-				if (err) {
+			async (err, response) => {
+				if (err && err !== true) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "REPORTS_INDEX", `Indexing reports failed. "${err}"`);
+					this.log("ERROR", "REPORTS_GET_DATA", `Failed to get data from reports. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
-
-				this.log("SUCCESS", "REPORTS_INDEX", "Indexing reports successful.");
-				return cb({ status: "success", data: { reports } });
+				this.log("SUCCESS", "REPORTS_GET_DATA", `Got data from reports successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully got data from reports.",
+					data: response
+				});
 			}
 		);
 	}),

+ 804 - 137
backend/logic/actions/songs.js

@@ -12,6 +12,7 @@ const SongsModule = moduleManager.modules.songs;
 const ActivitiesModule = moduleManager.modules.activities;
 const YouTubeModule = moduleManager.modules.youtube;
 const PlaylistsModule = moduleManager.modules.playlists;
+const StationsModule = moduleManager.modules.stations;
 
 CacheModule.runJob("SUB", {
 	channel: "song.updated",
@@ -22,19 +23,23 @@ CacheModule.runJob("SUB", {
 
 		songModel.findOne({ _id: data.songId }, (err, song) => {
 			WSModule.runJob("EMIT_TO_ROOMS", {
-				rooms: [
-					"import-album",
-					"admin.songs",
-					"admin.unverifiedSongs",
-					"admin.hiddenSongs",
-					`edit-song.${data.songId}`
-				],
+				rooms: ["import-album", "admin.songs", `edit-song.${data.songId}`, "edit-songs"],
 				args: ["event:admin.song.updated", { data: { song, oldStatus: data.oldStatus } }]
 			});
 		});
 	}
 });
 
+CacheModule.runJob("SUB", {
+	channel: "song.removed",
+	cb: async data => {
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: ["import-album", "admin.songs", `edit-song.${data.songId}`, "edit-songs"],
+			args: ["event:admin.song.removed", { data }]
+		});
+	}
+});
+
 CacheModule.runJob("SUB", {
 	channel: "song.like",
 	cb: data => {
@@ -186,7 +191,7 @@ export default {
 		async.waterfall(
 			[
 				next => {
-					SongsModule.runJob(
+					DBModule.runJob(
 						"GET_DATA",
 						{
 							page,
@@ -194,7 +199,95 @@ export default {
 							properties,
 							sort,
 							queries,
-							operator
+							operator,
+							modelName: "song",
+							blacklistedProperties: [],
+							specialProperties: {
+								requestedBy: [
+									{
+										$addFields: {
+											requestedByOID: {
+												$convert: {
+													input: "$requestedBy",
+													to: "objectId",
+													onError: "unknown",
+													onNull: "unknown"
+												}
+											}
+										}
+									},
+									{
+										$lookup: {
+											from: "users",
+											localField: "requestedByOID",
+											foreignField: "_id",
+											as: "requestedByUser"
+										}
+									},
+									{
+										$addFields: {
+											requestedByUsername: {
+												$ifNull: ["$requestedByUser.username", "unknown"]
+											}
+										}
+									},
+									{
+										$project: {
+											requestedByOID: 0,
+											requestedByUser: 0
+										}
+									}
+								],
+								verifiedBy: [
+									{
+										$addFields: {
+											verifiedByOID: {
+												$convert: {
+													input: "$verifiedBy",
+													to: "objectId",
+													onError: "unknown",
+													onNull: "unknown"
+												}
+											}
+										}
+									},
+									{
+										$lookup: {
+											from: "users",
+											localField: "verifiedByOID",
+											foreignField: "_id",
+											as: "verifiedByUser"
+										}
+									},
+									{
+										$unwind: {
+											path: "$verifiedByUser",
+											preserveNullAndEmptyArrays: true
+										}
+									},
+									{
+										$addFields: {
+											verifiedByUsername: {
+												$ifNull: ["$verifiedByUser.username", "unknown"]
+											}
+										}
+									},
+									{
+										$project: {
+											verifiedByOID: 0,
+											verifiedByUser: 0
+										}
+									}
+								]
+							},
+							specialQueries: {
+								requestedBy: newQuery => ({
+									$or: [newQuery, { requestedByUsername: newQuery.requestedBy }]
+								}),
+								verifiedBy: newQuery => ({
+									$or: [newQuery, { verifiedByUsername: newQuery.verifiedBy }]
+								})
+							}
 						},
 						this
 					)
@@ -312,6 +405,42 @@ export default {
 		);
 	}),
 
+	/**
+	 * Gets multiple songs from the Musare song ids
+	 * At this time only used in EditSongs
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {Array} songIds - the song ids
+	 * @param {Function} cb
+	 */
+	getSongsFromSongIds: isAdminRequired(function getSongFromSongId(session, songIds, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob(
+						"GET_SONGS",
+						{
+							songIds,
+							properties: ["youtubeId", "title", "artists", "thumbnail", "duration", "verified", "_id"]
+						},
+						this
+					)
+						.then(response => next(null, response.songs))
+						.catch(err => next(err));
+				}
+			],
+			async (err, songs) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "SONGS_GET_SONGS_FROM_MUSARE_IDS", `Failed to get songs. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "SONGS_GET_SONGS_FROM_MUSARE_IDS", `Got songs successfully.`);
+				return cb({ status: "success", data: { songs } });
+			}
+		);
+	}),
+
 	/**
 	 * Updates a song
 	 *
@@ -380,79 +509,237 @@ export default {
 		);
 	}),
 
-	// /**
-	//  * Removes a song
-	//  *
-	//  * @param session
-	//  * @param songId - the song id
-	//  * @param cb
-	//  */
-	// remove: isAdminRequired(async function remove(session, songId, cb) {
-	// 	const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
-	// 	let song = null;
-	// 	async.waterfall(
-	// 		[
-	// 			next => {
-	// 				songModel.findOne({ _id: songId }, next);
-	// 			},
-
-	// 			(_song, next) => {
-	// 				song = _song;
-	// 				songModel.deleteOne({ _id: songId }, next);
-	// 			},
-
-	// 			(res, next) => {
-	// 				CacheModule.runJob("HDEL", { table: "songs", key: songId }, this)
-	// 					.then(() => {
-	// 						next();
-	// 					})
-	// 					.catch(next)
-	// 					.finally(() => {
-	// 						song.genres.forEach(genre => {
-	// 							PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre })
-	// 								.then(() => {})
-	// 								.catch(() => {});
-	// 						});
-	// 					});
-	// 			}
-	// 		],
-	// 		async err => {
-	// 			if (err) {
-	// 				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-
-	// 				this.log("ERROR", "SONGS_REMOVE", `Failed to remove song "${songId}". "${err}"`);
-
-	// 				return cb({ status: "error", message: err });
-	// 			}
-
-	// 			this.log("SUCCESS", "SONGS_REMOVE", `Successfully removed song "${songId}".`);
-
-	// 			if (song.status === "verified") {
-	// 				CacheModule.runJob("PUB", {
-	// 					channel: "song.removedVerifiedSong",
-	// 					value: songId
-	// 				});
-	// 			}
-	// 			if (song.status === "unverified") {
-	// 				CacheModule.runJob("PUB", {
-	// 					channel: "song.removedUnverifiedSong",
-	// 					value: songId
-	// 				});
-	// 			}
-	// 			if (song.status === "hidden") {
-	// 				CacheModule.runJob("PUB", {
-	// 					channel: "song.removedHiddenSong",
-	// 					value: songId
-	// 				});
-	// 			}
-
-	// 			return cb({
-	// 				status: "success",
-	// 				message: "Song has been successfully removed"
-	// 			});
-	// 		}
-	// 	);
-	// }),
+	/**
+	 * Removes a song
+	 *
+	 * @param session
+	 * @param songId - the song id
+	 * @param cb
+	 */
+	remove: isAdminRequired(async function remove(session, songId, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					songModel.findOne({ _id: songId }, next);
+				},
+
+				(song, next) => {
+					PlaylistsModule.runJob("GET_PLAYLISTS_WITH_SONG", { songId }, this)
+						.then(res => {
+							async.eachLimit(
+								res.playlists,
+								1,
+								(playlist, next) => {
+									WSModule.runJob(
+										"RUN_ACTION2",
+										{
+											session,
+											namespace: "playlists",
+											action: "removeSongFromPlaylist",
+											args: [song.youtubeId, playlist._id]
+										},
+										this
+									)
+										.then(res => {
+											if (res.status === "error") next(res.message);
+											else next();
+										})
+										.catch(err => {
+											next(err);
+										});
+								},
+								err => {
+									if (err) next(err);
+									else next(null, song);
+								}
+							);
+						})
+						.catch(err => next(err));
+				},
+
+				(song, next) => {
+					stationModel.find({ "queue._id": songId }, (err, stations) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								stations,
+								1,
+								(station, next) => {
+									WSModule.runJob(
+										"RUN_ACTION2",
+										{
+											session,
+											namespace: "stations",
+											action: "removeFromQueue",
+											args: [station._id, song.youtubeId]
+										},
+										this
+									)
+										.then(res => {
+											if (
+												res.status === "error" &&
+												res.message !== "Station not found" &&
+												res.message !== "Song is not currently in the queue."
+											)
+												next(res.message);
+											else next();
+										})
+										.catch(err => {
+											next(err);
+										});
+								},
+								err => {
+									if (err) next(err);
+									else next();
+								}
+							);
+						}
+					});
+				},
+
+				next => {
+					stationModel.find({ "currentSong._id": songId }, (err, stations) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								stations,
+								1,
+								(station, next) => {
+									StationsModule.runJob(
+										"SKIP_STATION",
+										{ stationId: station._id, natural: false },
+										this
+									)
+										.then(() => {
+											next();
+										})
+										.catch(err => {
+											if (err.message === "Station not found.") next();
+											else next(err);
+										});
+								},
+								err => {
+									if (err) next(err);
+									else next();
+								}
+							);
+						}
+					});
+				},
+
+				next => {
+					songModel.deleteOne({ _id: songId }, err => {
+						if (err) next(err);
+						else next();
+					});
+				},
+
+				next => {
+					CacheModule.runJob("HDEL", { table: "songs", key: songId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log("ERROR", "SONGS_REMOVE", `Failed to remove song "${songId}". "${err}"`);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "SONGS_REMOVE", `Successfully removed song "${songId}".`);
+
+				CacheModule.runJob("PUB", {
+					channel: "song.removed",
+					value: { songId }
+				});
+
+				return cb({
+					status: "success",
+					message: "Song has been successfully removed"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes many songs
+	 *
+	 * @param session
+	 * @param songIds - array of song ids
+	 * @param cb
+	 */
+	removeMany: isAdminRequired(async function remove(session, songIds, cb) {
+		const successful = [];
+		const failed = [];
+
+		async.waterfall(
+			[
+				next => {
+					async.eachLimit(
+						songIds,
+						1,
+						(songId, next) => {
+							WSModule.runJob(
+								"RUN_ACTION2",
+								{
+									session,
+									namespace: "songs",
+									action: "remove",
+									args: [songId]
+								},
+								this
+							)
+								.then(res => {
+									if (res.status === "error") failed.push(songId);
+									else successful.push(songId);
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) next(err);
+							else next();
+						}
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log("ERROR", "SONGS_REMOVE_MANY", `Failed to remove songs "${failed.join(", ")}". "${err}"`);
+
+					return cb({ status: "error", message: err });
+				}
+
+				let message = "";
+				if (successful.length === 1) message += `1 song has been successfully removed`;
+				else message += `${successful.length} songs have been successfully removed`;
+				if (failed.length > 0) {
+					this.log("ERROR", "SONGS_REMOVE_MANY", `Failed to remove songs "${failed.join(", ")}". "${err}"`);
+					if (failed.length === 1) message += `, failed to remove 1 song`;
+					else message += `, failed to remove ${failed.length} songs`;
+				}
+
+				this.log("SUCCESS", "SONGS_REMOVE_MANY", `${message} "${successful.join(", ")}"`);
+
+				return cb({
+					status: "success",
+					message
+				});
+			}
+		);
+	}),
 
 	/**
 	 * Searches through official songs
@@ -530,56 +817,6 @@ export default {
 			});
 	}),
 
-	/**
-	 * Hides a song
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} songId - the song id of the song that gets hidden
-	 * @param {Function} cb - gets called with the result
-	 */
-	hide: isLoginRequired(async function add(session, songId, cb) {
-		SongsModule.runJob("HIDE_SONG", { songId }, this)
-			.then(() => {
-				this.log("SUCCESS", "SONGS_HIDE", `User "${session.userId}" successfully hid song "${songId}".`);
-				return cb({
-					status: "success",
-					message: "Successfully hid that song"
-				});
-			})
-			.catch(async err => {
-				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-				this.log("ERROR", "SONGS_HIDE", `Hiding song "${songId}" failed for user ${session.userId}. "${err}"`);
-				return cb({ status: "error", message: err });
-			});
-	}),
-
-	/**
-	 * Unhides a song
-	 *
-	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {string} songId - the song id of the song that gets hidden
-	 * @param {Function} cb - gets called with the result
-	 */
-	unhide: isLoginRequired(async function add(session, songId, cb) {
-		SongsModule.runJob("UNHIDE_SONG", { songId }, this)
-			.then(() => {
-				this.log("SUCCESS", "SONGS_UNHIDE", `User "${session.userId}" successfully unhid song "${songId}".`);
-				return cb({
-					status: "success",
-					message: "Successfully unhid that song"
-				});
-			})
-			.catch(async err => {
-				err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-				this.log(
-					"ERROR",
-					"SONGS_UNHIDE",
-					`Unhiding song "${songId}" failed for user ${session.userId}. "${err}"`
-				);
-				return cb({ status: "error", message: err });
-			});
-	}),
-
 	/**
 	 * Verifies a song
 	 *
@@ -601,11 +838,11 @@ export default {
 				},
 
 				(song, next) => {
-					const oldStatus = song.status;
+					const oldStatus = false;
 
 					song.verifiedBy = session.userId;
 					song.verifiedAt = Date.now();
-					song.status = "verified";
+					song.verified = true;
 
 					song.save(err => next(err, song, oldStatus));
 				},
@@ -645,6 +882,78 @@ export default {
 		// TODO Check if video is in queue and Add the song to the appropriate stations
 	}),
 
+	/**
+	 * Verify many songs
+	 *
+	 * @param session
+	 * @param songIds - array of song ids
+	 * @param cb
+	 */
+	verifyMany: isAdminRequired(async function verifyMany(session, songIds, cb) {
+		const successful = [];
+		const failed = [];
+
+		async.waterfall(
+			[
+				next => {
+					async.eachLimit(
+						songIds,
+						1,
+						(songId, next) => {
+							WSModule.runJob(
+								"RUN_ACTION2",
+								{
+									session,
+									namespace: "songs",
+									action: "verify",
+									args: [songId]
+								},
+								this
+							)
+								.then(res => {
+									if (res.status === "error") failed.push(songId);
+									else successful.push(songId);
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) next(err);
+							else next();
+						}
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log("ERROR", "SONGS_VERIFY_MANY", `Failed to verify songs "${failed.join(", ")}". "${err}"`);
+
+					return cb({ status: "error", message: err });
+				}
+
+				let message = "";
+				if (successful.length === 1) message += `1 song has been successfully verified`;
+				else message += `${successful.length} songs have been successfully verified`;
+				if (failed.length > 0) {
+					this.log("ERROR", "SONGS_VERIFY_MANY", `Failed to verify songs "${failed.join(", ")}". "${err}"`);
+					if (failed.length === 1) message += `, failed to verify 1 song`;
+					else message += `, failed to verify ${failed.length} songs`;
+				}
+
+				this.log("SUCCESS", "SONGS_VERIFY_MANY", `${message} "${successful.join(", ")}"`);
+
+				return cb({
+					status: "success",
+					message
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Un-verifies a song
 	 *
@@ -666,7 +975,7 @@ export default {
 				},
 
 				(song, next) => {
-					song.status = "unverified";
+					song.verified = false;
 					song.save(err => {
 						next(err, song);
 					});
@@ -685,7 +994,7 @@ export default {
 							.catch(() => {});
 					});
 
-					SongsModule.runJob("UPDATE_SONG", { songId, oldStatus: "verified" });
+					SongsModule.runJob("UPDATE_SONG", { songId, oldStatus: true });
 
 					next(null);
 				}
@@ -714,6 +1023,86 @@ export default {
 		// TODO Check if video is in queue and Add the song to the appropriate stations
 	}),
 
+	/**
+	 * Unverify many songs
+	 *
+	 * @param session
+	 * @param songIds - array of song ids
+	 * @param cb
+	 */
+	unverifyMany: isAdminRequired(async function unverifyMany(session, songIds, cb) {
+		const successful = [];
+		const failed = [];
+
+		async.waterfall(
+			[
+				next => {
+					async.eachLimit(
+						songIds,
+						1,
+						(songId, next) => {
+							WSModule.runJob(
+								"RUN_ACTION2",
+								{
+									session,
+									namespace: "songs",
+									action: "unverify",
+									args: [songId]
+								},
+								this
+							)
+								.then(res => {
+									if (res.status === "error") failed.push(songId);
+									else successful.push(songId);
+									next();
+								})
+								.catch(err => {
+									next(err);
+								});
+						},
+						err => {
+							if (err) next(err);
+							else next();
+						}
+					);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"SONGS_UNVERIFY_MANY",
+						`Failed to unverify songs "${failed.join(", ")}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				let message = "";
+				if (successful.length === 1) message += `1 song has been successfully unverified`;
+				else message += `${successful.length} songs have been successfully unverified`;
+				if (failed.length > 0) {
+					this.log(
+						"ERROR",
+						"SONGS_UNVERIFY_MANY",
+						`Failed to unverify songs "${failed.join(", ")}". "${err}"`
+					);
+					if (failed.length === 1) message += `, failed to unverify 1 song`;
+					else message += `, failed to unverify ${failed.length} songs`;
+				}
+
+				this.log("SUCCESS", "SONGS_UNVERIFY_MANY", `${message} "${successful.join(", ")}"`);
+
+				return cb({
+					status: "success",
+					message
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Requests a set of songs
 	 *
@@ -1333,7 +1722,6 @@ export default {
 	 * @param youtubeId - the youtube id
 	 * @param cb
 	 */
-
 	getOwnSongRatings: isLoginRequired(async function getOwnSongRatings(session, youtubeId, cb) {
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
@@ -1405,5 +1793,284 @@ export default {
 				});
 			}
 		);
+	}),
+
+	/**
+	 * Gets a list of all genres
+	 *
+	 * @param session
+	 * @param cb
+	 */
+	getGenres: isAdminRequired(function getGenres(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("GET_GENRES", this)
+						.then(res => {
+							next(null, res.genres);
+						})
+						.catch(next);
+				}
+			],
+			async (err, genres) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_GENRES", `User ${session.userId} failed to get genres. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "GET_GENRES", `User ${session.userId} has successfully got the genres.`);
+					cb({
+						status: "success",
+						message: "Successfully got genres.",
+						data: {
+							items: genres
+						}
+					});
+				}
+			}
+		);
+	}),
+
+	/**
+	 * Bulk update genres for selected songs
+	 *
+	 * @param session
+	 * @param method Whether to add, remove or replace genres
+	 * @param genres Array of genres to apply
+	 * @param songIds Array of songIds to apply genres to
+	 * @param cb
+	 */
+	editGenres: isAdminRequired(async function editGenres(session, method, genres, songIds, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel.find({ _id: { $in: songIds } }, next);
+				},
+
+				(songs, next) => {
+					const songsFound = songs.map(song => song._id);
+					if (songsFound.length > 0) next(null, songsFound);
+					else next("None of the specified songs were found.");
+				},
+
+				(songsFound, next) => {
+					const query = {};
+					if (method === "add") {
+						query.$push = { genres: { $each: genres } };
+					} else if (method === "remove") {
+						query.$pullAll = { genres };
+					} else if (method === "replace") {
+						query.$set = { genres };
+					} else {
+						next("Invalid method.");
+					}
+
+					songModel.updateMany({ _id: { $in: songsFound } }, query, { runValidators: true }, err => {
+						if (err) next(err);
+						SongsModule.runJob("UPDATE_SONGS", { songIds: songsFound });
+					});
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "EDIT_GENRES", `User ${session.userId} failed to edit genres. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "EDIT_GENRES", `User ${session.userId} has successfully edited genres.`);
+					cb({
+						status: "success",
+						message: "Successfully edited genres."
+					});
+				}
+			}
+		);
+	}),
+
+	/**
+	 * Gets a list of all artists
+	 *
+	 * @param session
+	 * @param cb
+	 */
+	getArtists: isAdminRequired(function getArtists(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("GET_ARTISTS", this)
+						.then(res => {
+							next(null, res.artists);
+						})
+						.catch(next);
+				}
+			],
+			async (err, artists) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_ARTISTS", `User ${session.userId} failed to get artists. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "GET_ARTISTS", `User ${session.userId} has successfully got the artists.`);
+					cb({
+						status: "success",
+						message: "Successfully got artists.",
+						data: {
+							items: artists
+						}
+					});
+				}
+			}
+		);
+	}),
+
+	/**
+	 * Bulk update artists for selected songs
+	 *
+	 * @param session
+	 * @param method Whether to add, remove or replace artists
+	 * @param artists Array of artists to apply
+	 * @param songIds Array of songIds to apply artists to
+	 * @param cb
+	 */
+	editArtists: isAdminRequired(async function editArtists(session, method, artists, songIds, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel.find({ _id: { $in: songIds } }, next);
+				},
+
+				(songs, next) => {
+					const songsFound = songs.map(song => song._id);
+					if (songsFound.length > 0) next(null, songsFound);
+					else next("None of the specified songs were found.");
+				},
+
+				(songsFound, next) => {
+					const query = {};
+					if (method === "add") {
+						query.$push = { artists: { $each: artists } };
+					} else if (method === "remove") {
+						query.$pullAll = { artists };
+					} else if (method === "replace") {
+						query.$set = { artists };
+					} else {
+						next("Invalid method.");
+					}
+
+					songModel.updateMany({ _id: { $in: songsFound } }, query, { runValidators: true }, err => {
+						if (err) next(err);
+						SongsModule.runJob("UPDATE_SONGS", { songIds: songsFound });
+					});
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "EDIT_ARTISTS", `User ${session.userId} failed to edit artists. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "EDIT_ARTISTS", `User ${session.userId} has successfully edited artists.`);
+					cb({
+						status: "success",
+						message: "Successfully edited artists."
+					});
+				}
+			}
+		);
+	}),
+
+	/**
+	 * Gets a list of all tags
+	 *
+	 * @param session
+	 * @param cb
+	 */
+	getTags: isAdminRequired(function getTags(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("GET_TAGS", this)
+						.then(res => {
+							next(null, res.tags);
+						})
+						.catch(next);
+				}
+			],
+			async (err, tags) => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "GET_TAGS", `User ${session.userId} failed to get tags. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "GET_TAGS", `User ${session.userId} has successfully got the tags.`);
+					cb({
+						status: "success",
+						message: "Successfully got tags.",
+						data: {
+							items: tags
+						}
+					});
+				}
+			}
+		);
+	}),
+
+	/**
+	 * Bulk update tags for selected songs
+	 *
+	 * @param session
+	 * @param method Whether to add, remove or replace tags
+	 * @param tags Array of tags to apply
+	 * @param songIds Array of songIds to apply tags to
+	 * @param cb
+	 */
+	editTags: isAdminRequired(async function editTags(session, method, tags, songIds, cb) {
+		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+		async.waterfall(
+			[
+				next => {
+					songModel.find({ _id: { $in: songIds } }, next);
+				},
+
+				(songs, next) => {
+					const songsFound = songs.map(song => song._id);
+					if (songsFound.length > 0) next(null, songsFound);
+					else next("None of the specified songs were found.");
+				},
+
+				(songsFound, next) => {
+					const query = {};
+					if (method === "add") {
+						query.$push = { tags: { $each: tags } };
+					} else if (method === "remove") {
+						query.$pullAll = { tags };
+					} else if (method === "replace") {
+						query.$set = { tags };
+					} else {
+						next("Invalid method.");
+					}
+
+					songModel.updateMany({ _id: { $in: songsFound } }, query, { runValidators: true }, err => {
+						if (err) next(err);
+						SongsModule.runJob("UPDATE_SONGS", { songIds: songsFound });
+					});
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "EDIT_TAGS", `User ${session.userId} failed to edit tags. '${err}'`);
+					cb({ status: "error", message: err });
+				} else {
+					this.log("SUCCESS", "EDIT_TAGS", `User ${session.userId} has successfully edited tags.`);
+					cb({
+						status: "success",
+						message: "Successfully edited tags."
+					});
+				}
+			}
+		);
 	})
 };

+ 169 - 3
backend/logic/actions/stations.js

@@ -423,7 +423,7 @@ CacheModule.runJob("SUB", {
 
 			WSModule.runJob("EMIT_TO_ROOM", {
 				room: `manage-station.${stationId}`,
-				args: ["event:station.queue.updated", { data: { stationId, queue: station.queue } }]
+				args: ["event:manageStation.queue.updated", { data: { stationId, queue: station.queue } }]
 			});
 		});
 	}
@@ -432,10 +432,18 @@ CacheModule.runJob("SUB", {
 CacheModule.runJob("SUB", {
 	channel: "station.repositionSongInQueue",
 	cb: res => {
-		WSModule.runJob("EMIT_TO_ROOMS", {
-			rooms: [`station.${res.stationId}`, `manage-station.${res.stationId}`],
+		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}`,
+			args: [
+				"event:manageStation.queue.song.repositioned",
+				{ data: { stationId: res.stationId, song: res.song } }
+			]
+		});
 	}
 });
 
@@ -524,6 +532,26 @@ CacheModule.runJob("SUB", {
 	}
 });
 
+CacheModule.runJob("SUB", {
+	channel: "station.updated",
+	cb: async data => {
+		const stationModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "station"
+		});
+
+		stationModel.findOne(
+			{ _id: data.stationId },
+			["_id", "name", "displayName", "description", "type", "privacy", "owner", "partyMode", "playMode", "theme"],
+			(err, station) => {
+				WSModule.runJob("EMIT_TO_ROOMS", {
+					rooms: ["admin.stations"],
+					args: ["event:admin.station.updated", { data: { station } }]
+				});
+			}
+		);
+	}
+});
+
 export default {
 	/**
 	 * Get a list of all the stations
@@ -610,6 +638,106 @@ export default {
 		);
 	},
 
+	/**
+	 * Gets stations, used in the admin stations page by the AdvancedTable component
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each station
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
+	 */
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
+		async.waterfall(
+			[
+				next => {
+					DBModule.runJob(
+						"GET_DATA",
+						{
+							page,
+							pageSize,
+							properties,
+							sort,
+							queries,
+							operator,
+							modelName: "station",
+							blacklistedProperties: [],
+							specialProperties: {
+								owner: [
+									{
+										$addFields: {
+											ownerOID: {
+												$convert: {
+													input: "$owner",
+													to: "objectId",
+													onError: "unknown",
+													onNull: "unknown"
+												}
+											}
+										}
+									},
+									{
+										$lookup: {
+											from: "users",
+											localField: "ownerOID",
+											foreignField: "_id",
+											as: "ownerUser"
+										}
+									},
+									{
+										$unwind: {
+											path: "$ownerUser",
+											preserveNullAndEmptyArrays: true
+										}
+									},
+									{
+										$addFields: {
+											ownerUsername: {
+												$cond: [
+													{ $eq: [{ $type: "$owner" }, "string"] },
+													{ $ifNull: ["$ownerUser.username", "unknown"] },
+													"none"
+												]
+											}
+										}
+									},
+									{
+										$project: {
+											ownerOID: 0,
+											ownerUser: 0
+										}
+									}
+								]
+							},
+							specialQueries: {
+								owner: newQuery => ({ $or: [newQuery, { ownerUsername: newQuery.owner }] })
+							}
+						},
+						this
+					)
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async (err, response) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "STATIONS_GET_DATA", `Failed to get data from stations. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "STATIONS_GET_DATA", `Got data from stations successfully.`);
+				return cb({ status: "success", message: "Successfully got data from stations.", data: response });
+			}
+		);
+	}),
+
 	/**
 	 * Obtains basic metadata of a station in order to format an activity
 	 *
@@ -1224,6 +1352,10 @@ export default {
 						locked: station.locked
 					}
 				});
+				CacheModule.runJob("PUB", {
+					channel: "station.updated",
+					value: { stationId }
+				});
 				return cb({ status: "success", data: { locked: station.locked } });
 			}
 		);
@@ -1484,6 +1616,11 @@ export default {
 					value: { stationId, name: newName }
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "station.updated",
+					value: { stationId }
+				});
+
 				ActivitiesModule.runJob("ADD_ACTIVITY", {
 					userId: session.userId,
 					type: "station__edit_name",
@@ -1562,6 +1699,11 @@ export default {
 					value: { stationId, displayName: newDisplayName }
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "station.updated",
+					value: { stationId }
+				});
+
 				ActivitiesModule.runJob("ADD_ACTIVITY", {
 					userId: session.userId,
 					type: "station__edit_display_name",
@@ -1638,6 +1780,11 @@ export default {
 					value: { stationId, description: newDescription }
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "station.updated",
+					value: { stationId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Successfully updated the description."
@@ -1709,6 +1856,11 @@ export default {
 					value: { stationId, previousPrivacy }
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "station.updated",
+					value: { stationId }
+				});
+
 				ActivitiesModule.runJob("ADD_ACTIVITY", {
 					userId: session.userId,
 					type: "station__edit_privacy",
@@ -2153,6 +2305,11 @@ export default {
 					}
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "station.updated",
+					value: { stationId }
+				});
+
 				StationsModule.runJob("SKIP_STATION", { stationId, natural: false });
 
 				return cb({
@@ -2231,6 +2388,10 @@ export default {
 						playMode: newPlayMode
 					}
 				});
+				CacheModule.runJob("PUB", {
+					channel: "station.updated",
+					value: { stationId }
+				});
 				StationsModule.runJob("SKIP_STATION", { stationId, natural: false });
 				return cb({
 					status: "success",
@@ -2300,6 +2461,11 @@ export default {
 					value: { stationId }
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "station.updated",
+					value: { stationId }
+				});
+
 				ActivitiesModule.runJob("ADD_ACTIVITY", {
 					userId: session.userId,
 					type: "station__edit_theme",

+ 145 - 34
backend/logic/actions/users.js

@@ -64,7 +64,7 @@ CacheModule.runJob("SUB", {
 	cb: user => {
 		WSModule.runJob("SOCKETS_FROM_USER", { userId: user._id }).then(sockets => {
 			sockets.forEach(socket => {
-				socket.dispatch("event:user.username.updated", { data: { username: user.username } });
+				socket.dispatch("keep.event:user.username.updated", { data: { username: user.username } });
 			});
 		});
 	}
@@ -167,53 +167,114 @@ CacheModule.runJob("SUB", {
 	}
 });
 
+CacheModule.runJob("SUB", {
+	channel: "user.updated",
+	cb: async data => {
+		const userModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "user"
+		});
+
+		userModel.findOne(
+			{ _id: data.userId },
+			[
+				"_id",
+				"name",
+				"username",
+				"avatar",
+				"services.github.id",
+				"role",
+				"email.address",
+				"email.verified",
+				"statistics.songsRequested",
+				"services.password.password"
+			],
+			(err, user) => {
+				const newUser = { ...user._doc, hasPassword: !!user.services.password.password };
+				delete newUser.services.password;
+				WSModule.runJob("EMIT_TO_ROOMS", {
+					rooms: ["admin.users", `edit-user.${data.userId}`],
+					args: ["event:admin.user.updated", { data: { user: newUser } }]
+				});
+			}
+		);
+	}
+});
+
 export default {
 	/**
-	 * Lists all Users
+	 * Gets users, used in the admin users page by the AdvancedTable component
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {Function} cb - gets called with the result
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each user
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
+	 * @param cb
 	 */
-	index: isAdminRequired(async function index(session, cb) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
-
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
 		async.waterfall(
 			[
 				next => {
-					userModel.find({}).exec(next);
+					DBModule.runJob(
+						"GET_DATA",
+						{
+							page,
+							pageSize,
+							properties,
+							sort,
+							queries,
+							operator,
+							modelName: "user",
+							blacklistedProperties: [
+								"services.password.password",
+								"services.password.reset.code",
+								"services.password.reset.expires",
+								"services.password.set.code",
+								"services.password.set.expires",
+								"services.github.access_token",
+								"email.verificationToken"
+							],
+							specialProperties: {
+								hasPassword: [
+									{
+										$addFields: {
+											hasPassword: {
+												$cond: [
+													{ $eq: [{ $type: "$services.password.password" }, "string"] },
+													true,
+													false
+												]
+											}
+										}
+									}
+								]
+							},
+							specialQueries: {}
+						},
+						this
+					)
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
 				}
 			],
-			async (err, users) => {
-				if (err) {
+			async (err, response) => {
+				if (err && err !== true) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log("ERROR", "USER_INDEX", `Indexing users failed. "${err}"`);
+					this.log("ERROR", "USERS_GET_DATA", `Failed to get data from users. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
-				this.log("SUCCESS", "USER_INDEX", `Indexing users successful.`);
-				const filteredUsers = [];
-				users.forEach(user => {
-					filteredUsers.push({
-						_id: user._id,
-						name: user.name,
-						username: user.username,
-						role: user.role,
-						liked: user.liked,
-						disliked: user.disliked,
-						songsRequested: user.statistics.songsRequested,
-						email: {
-							address: user.email.address,
-							verified: user.email.verified
-						},
-						avatar: {
-							type: user.avatar.type,
-							url: user.avatar.url,
-							color: user.avatar.color
-						},
-						hasPassword: !!user.services.password,
-						services: { github: user.services.github }
-					});
+				this.log("SUCCESS", "USERS_GET_DATA", `Got data from users successfully.`);
+				return cb({
+					status: "success",
+					message: "Successfully got data from users.",
+					data: response
 				});
-				return cb({ status: "success", data: { users: filteredUsers } });
 			}
 		);
 	}),
@@ -1655,6 +1716,11 @@ export default {
 					}
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "user.updated",
+					value: { userId: updatingUserId }
+				});
+
 				this.log(
 					"SUCCESS",
 					"UPDATE_USERNAME",
@@ -1765,6 +1831,11 @@ export default {
 					`Updated email for user "${updatingUserId}" to email "${newEmail}".`
 				);
 
+				CacheModule.runJob("PUB", {
+					channel: "user.updated",
+					value: { userId: updatingUserId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Email updated successfully."
@@ -1831,6 +1902,11 @@ export default {
 
 				this.log("SUCCESS", "UPDATE_NAME", `Updated name for user "${updatingUserId}" to name "${newName}".`);
 
+				CacheModule.runJob("PUB", {
+					channel: "user.updated",
+					value: { userId: updatingUserId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Name updated successfully"
@@ -1903,6 +1979,11 @@ export default {
 					`Updated location for user "${updatingUserId}" to location "${newLocation}".`
 				);
 
+				CacheModule.runJob("PUB", {
+					channel: "user.updated",
+					value: { userId: updatingUserId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Location updated successfully"
@@ -1963,6 +2044,11 @@ export default {
 
 				this.log("SUCCESS", "UPDATE_BIO", `Updated bio for user "${updatingUserId}" to bio "${newBio}".`);
 
+				CacheModule.runJob("PUB", {
+					channel: "user.updated",
+					value: { userId: updatingUserId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Bio updated successfully"
@@ -2027,6 +2113,11 @@ export default {
 					`Updated avatar for user "${updatingUserId}" to type "${newAvatar.type} and color ${newAvatar.color}".`
 				);
 
+				CacheModule.runJob("PUB", {
+					channel: "user.updated",
+					value: { userId: updatingUserId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Avatar updated successfully"
@@ -2086,6 +2177,11 @@ export default {
 					`User "${session.userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`
 				);
 
+				CacheModule.runJob("PUB", {
+					channel: "user.updated",
+					value: { userId: updatingUserId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Role successfully updated."
@@ -2365,6 +2461,11 @@ export default {
 					value: session.userId
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "user.updated",
+					value: { userId: session.userId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Successfully added password."
@@ -2412,6 +2513,11 @@ export default {
 					value: session.userId
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "user.updated",
+					value: { userId: session.userId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Successfully unlinked password."
@@ -2459,6 +2565,11 @@ export default {
 					value: session.userId
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "user.updated",
+					value: { userId: session.userId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Successfully unlinked GitHub."

+ 5 - 0
backend/logic/app.js

@@ -216,6 +216,11 @@ class _AppModule extends CoreClass {
 												value: user._id
 											});
 
+											CacheModule.runJob("PUB", {
+												channel: "user.updated",
+												value: { userId: user._id }
+											});
+
 											res.redirect(`${config.get("domain")}/settings?tab=security`);
 										}
 									],

+ 219 - 3
backend/logic/db/index.js

@@ -8,12 +8,12 @@ import CoreClass from "../../core";
 const REQUIRED_DOCUMENT_VERSIONS = {
 	activity: 2,
 	news: 2,
-	playlist: 5,
+	playlist: 6,
 	punishment: 1,
 	queueSong: 1,
 	report: 5,
-	song: 5,
-	station: 6,
+	song: 7,
+	station: 7,
 	user: 3
 };
 
@@ -199,6 +199,12 @@ class _DBModule extends CoreClass {
 					};
 					this.schemas.song.path("genres").validate(songGenres, "Invalid genres.");
 
+					const songTags = tags =>
+						tags.filter(tag =>
+							new RegExp(/^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/).test(tag)
+						).length === tags.length;
+					this.schemas.song.path("tags").validate(songTags, "Invalid tags.");
+
 					const songThumbnail = thumbnail => {
 						if (!isLength(thumbnail, 1, 256)) return false;
 						if (config.get("cookie.secure") === true) return thumbnail.startsWith("https://");
@@ -299,6 +305,216 @@ class _DBModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Gets data
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.page - the page
+	 * @param {string} payload.pageSize - the page size
+	 * @param {string} payload.properties - the properties to return for each song
+	 * @param {string} payload.sort - the sort object
+	 * @param {string} payload.queries - the queries array
+	 * @param {string} payload.operator - the operator for queries
+	 * @param {string} payload.modelName - the db collection modal name
+	 * @param {string} payload.blacklistedProperties - the properties that are not allowed to be returned, filtered by or sorted by
+	 * @param {string} payload.specialProperties - the special properties
+	 * @param {string} payload.specialQueries - the special queries
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_DATA(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					// Creates pipeline array
+					next => next(null, []),
+
+					// If a query filter property or sort property is blacklisted, throw error
+					(pipeline, next) => {
+						const { sort, queries, blacklistedProperties } = payload;
+						if (
+							queries.some(query =>
+								blacklistedProperties.some(blacklistedProperty =>
+									blacklistedProperty.startsWith(query.filter.property)
+								)
+							)
+						)
+							return next("Unable to filter by blacklisted property.");
+						if (
+							Object.keys(sort).some(property =>
+								blacklistedProperties.some(blacklistedProperty =>
+									blacklistedProperty.startsWith(property)
+								)
+							)
+						)
+							return next("Unable to sort by blacklisted property.");
+
+						return next(null, pipeline);
+					},
+
+					// If a filter or property exists for a special property, add some custom pipeline steps
+					(pipeline, next) => {
+						const { properties, queries, specialProperties } = payload;
+
+						async.eachLimit(
+							Object.entries(specialProperties),
+							1,
+							([specialProperty, pipelineSteps], next) => {
+								// Check if a filter with the special property exists
+								const filterExists =
+									queries.map(query => query.filter.property).indexOf(specialProperty) !== -1;
+								// Check if a property with the special property exists
+								const propertyExists = properties.indexOf(specialProperty) !== -1;
+								// If no such filter or property exists, skip this function
+								if (!filterExists && !propertyExists) return next();
+								// Add the specified pipeline steps into the pipeline
+								pipeline.push(...pipelineSteps);
+								return next();
+							},
+							err => {
+								next(err, pipeline);
+							}
+						);
+					},
+
+					// Adds the match stage to aggregation pipeline, which is responsible for filtering
+					(pipeline, next) => {
+						const { queries, operator, specialQueries } = payload;
+
+						let queryError;
+						const newQueries = queries.flatMap(query => {
+							const { data, filter, filterType } = query;
+							const newQuery = {};
+							if (filterType === "regex") {
+								newQuery[filter.property] = new RegExp(`${data.slice(1, data.length - 1)}`, "i");
+							} else if (filterType === "contains") {
+								newQuery[filter.property] = new RegExp(
+									`${data.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
+									"i"
+								);
+							} else if (filterType === "exact") {
+								newQuery[filter.property] = data.toString();
+							} else if (filterType === "datetimeBefore") {
+								newQuery[filter.property] = { $lte: new Date(data) };
+							} else if (filterType === "datetimeAfter") {
+								newQuery[filter.property] = { $gte: new Date(data) };
+							} else if (filterType === "numberLesserEqual") {
+								newQuery[filter.property] = { $lte: Number(data) };
+							} else if (filterType === "numberLesser") {
+								newQuery[filter.property] = { $lt: Number(data) };
+							} else if (filterType === "numberGreater") {
+								newQuery[filter.property] = { $gt: Number(data) };
+							} else if (filterType === "numberGreaterEqual") {
+								newQuery[filter.property] = { $gte: Number(data) };
+							} else if (filterType === "numberEquals") {
+								newQuery[filter.property] = { $eq: Number(data) };
+							} else if (filterType === "boolean") {
+								newQuery[filter.property] = { $eq: !!data };
+							}
+
+							if (specialQueries[filter.property]) {
+								return specialQueries[filter.property](newQuery);
+							}
+
+							return newQuery;
+						});
+						if (queryError) next(queryError);
+
+						const queryObject = {};
+						if (newQueries.length > 0) {
+							if (operator === "and") queryObject.$and = newQueries;
+							else if (operator === "or") queryObject.$or = newQueries;
+							else if (operator === "nor") queryObject.$nor = newQueries;
+						}
+
+						pipeline.push({ $match: queryObject });
+
+						next(null, pipeline);
+					},
+
+					// Adds sort stage to aggregation pipeline if there is at least one column being sorted, responsible for sorting data
+					(pipeline, next) => {
+						const { sort } = payload;
+						const newSort = Object.fromEntries(
+							Object.entries(sort).map(([property, direction]) => [
+								property,
+								direction === "ascending" ? 1 : -1
+							])
+						);
+						if (Object.keys(newSort).length > 0) pipeline.push({ $sort: newSort });
+						next(null, pipeline);
+					},
+
+					// Adds first project stage to aggregation pipeline, responsible for including only the requested properties
+					(pipeline, next) => {
+						const { properties } = payload;
+
+						pipeline.push({ $project: Object.fromEntries(properties.map(property => [property, 1])) });
+
+						next(null, pipeline);
+					},
+
+					// Adds second project stage to aggregation pipeline, responsible for excluding some specific properties
+					(pipeline, next) => {
+						const { blacklistedProperties } = payload;
+						if (blacklistedProperties.length > 0)
+							pipeline.push({
+								$project: Object.fromEntries(blacklistedProperties.map(property => [property, 0]))
+							});
+
+						next(null, pipeline);
+					},
+
+					// Adds the facet stage to aggregation pipeline, responsible for returning a total document count, skipping and limitting the documents that will be returned
+					(pipeline, next) => {
+						const { page, pageSize } = payload;
+
+						pipeline.push({
+							$facet: {
+								count: [{ $count: "count" }],
+								documents: [{ $skip: pageSize * (page - 1) }, { $limit: pageSize }]
+							}
+						});
+
+						// console.dir(pipeline, { depth: 6 });
+
+						next(null, pipeline);
+					},
+
+					(pipeline, next) => {
+						const { modelName } = payload;
+
+						DBModule.runJob("GET_MODEL", { modelName }, this)
+							.then(model => {
+								if (!model) return next("Invalid model.");
+								return next(null, pipeline, model);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					// Executes the aggregation pipeline
+					(pipeline, model, next) => {
+						model.aggregate(pipeline).exec((err, result) => {
+							// console.dir(err);
+							// console.dir(result, { depth: 6 });
+							if (err) return next(err);
+							if (result[0].count.length === 0) return next(null, 0, []);
+							const { count } = result[0].count[0];
+							const { documents } = result[0];
+							// console.log(111, err, result, count, documents[0]);
+							return next(null, count, documents);
+						});
+					}
+				],
+				(err, count, documents) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ data: documents, count });
+				}
+			);
+		});
+	}
+
 	/**
 	 * Checks if a password to be stored in the database has a valid length
 	 *

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

@@ -18,5 +18,5 @@ export default {
 	createdFor: { type: String },
 	privacy: { type: String, enum: ["public", "private"], default: "private" },
 	type: { type: String, enum: ["user", "user-liked", "user-disliked", "genre", "artist", "station"], required: true },
-	documentVersion: { type: Number, default: 5, required: true }
+	documentVersion: { type: Number, default: 6, required: true }
 };

+ 3 - 2
backend/logic/db/schemas/song.js

@@ -3,6 +3,7 @@ export default {
 	title: { type: String, required: true },
 	artists: [{ type: String, default: [] }],
 	genres: [{ type: String, default: [] }],
+	tags: [{ type: String, default: [] }],
 	duration: { type: Number, min: 1, required: true },
 	skipDuration: { type: Number, required: true, default: 0 },
 	thumbnail: { type: String },
@@ -11,9 +12,9 @@ export default {
 	explicit: { type: Boolean },
 	requestedBy: { type: String },
 	requestedAt: { type: Date },
+	verified: { type: Boolean, default: false },
 	verifiedBy: { type: String },
 	verifiedAt: { type: Date },
 	discogs: { type: Object },
-	status: { type: String, required: true, default: "hidden", enum: ["hidden", "unverified", "verified"] },
-	documentVersion: { type: Number, default: 5, required: true }
+	documentVersion: { type: Number, default: 7, required: true }
 };

+ 3 - 3
backend/logic/db/schemas/station.js

@@ -17,7 +17,7 @@ export default {
 		skipVotes: [{ type: String }],
 		requestedBy: { type: String },
 		requestedAt: { type: Date },
-		status: { type: String }
+		verified: { type: Boolean }
 	},
 	currentSongIndex: { type: Number, default: 0, required: true },
 	timePaused: { type: Number, default: 0, required: true },
@@ -36,7 +36,7 @@ export default {
 			thumbnail: { type: String },
 			requestedBy: { type: String },
 			requestedAt: { type: Date },
-			status: { type: String }
+			verified: { type: Boolean }
 		}
 	],
 	owner: { type: String },
@@ -45,5 +45,5 @@ export default {
 	theme: { type: String, enum: ["blue", "purple", "teal", "orange", "red"], default: "blue" },
 	includedPlaylists: [{ type: String }],
 	excludedPlaylists: [{ type: String }],
-	documentVersion: { type: Number, default: 6, required: true }
+	documentVersion: { type: Number, default: 7, required: true }
 };

+ 1 - 1
backend/logic/mail/schemas/dataRequest.js

@@ -12,7 +12,7 @@ import mail from "../index";
  */
 export default (to, userId, type, cb) => {
 	const data = {
-		from: "Musare <noreply@musare.com>",
+		from: config.get("mail.from"),
 		to,
 		subject: `Data Request - ${type}`,
 		html: `

+ 2 - 1
backend/logic/mail/schemas/passwordRequest.js

@@ -1,3 +1,4 @@
+import config from "config";
 import mail from "../index";
 
 /**
@@ -10,7 +11,7 @@ import mail from "../index";
  */
 export default (to, username, code, cb) => {
 	const data = {
-		from: "Musare <noreply@musare.com>",
+		from: config.get("mail.from"),
 		to,
 		subject: "Password request",
 		html: `

+ 2 - 1
backend/logic/mail/schemas/resetPasswordRequest.js

@@ -1,3 +1,4 @@
+import config from "config";
 import mail from "../index";
 
 /**
@@ -10,7 +11,7 @@ import mail from "../index";
  */
 export default (to, username, code, cb) => {
 	const data = {
-		from: "Musare <noreply@musare.com>",
+		from: config.get("mail.from"),
 		to,
 		subject: "Password reset request",
 		html: `

+ 1 - 1
backend/logic/mail/schemas/verifyEmail.js

@@ -11,7 +11,7 @@ import mail from "../index";
  */
 export default (to, username, code, cb) => {
 	const data = {
-		from: "Musare <noreply@musare.com>",
+		from: config.get("mail.from"),
 		to,
 		subject: "Please verify your email",
 		html: `

+ 42 - 0
backend/logic/migration/migrations/migration17.js

@@ -0,0 +1,42 @@
+import async from "async";
+
+/**
+ * Migration 17
+ *
+ * Migration for songs to add tags property
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 17. Finding songs with document version 5.`);
+					songModel.updateMany(
+						{ documentVersion: 5 },
+						{ $set: { documentVersion: 6, tags: [] } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								console.log(res);
+								this.log(
+									"INFO",
+									`Migration 17. Matched: ${res.matchedCount}, modified: ${res.modifiedCount}.`
+								);
+								next();
+							}
+						}
+					);
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 185 - 0
backend/logic/migration/migrations/migration18.js

@@ -0,0 +1,185 @@
+import async from "async";
+
+/**
+ * Migration 18
+ *
+ * Migration for song status property.
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const songModel = await MigrationModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+	const stationModel = await MigrationModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 18. Finding hidden songs with document version 6.`);
+					songModel.updateMany(
+						{ documentVersion: 6, status: { $in: ["hidden"] } },
+						{
+							$push: { tags: "hidden" },
+							$set: { documentVersion: 7, verified: false },
+							$unset: { status: "" }
+						},
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 18 (hidden songs). Matched: ${res.matchedCount}, modified: ${res.modifiedCount}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 18. Finding unverified songs with document version 6.`);
+					songModel.updateMany(
+						{ documentVersion: 6, status: { $in: ["unverified"] } },
+						{ $set: { documentVersion: 7, verified: false }, $unset: { status: "" } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 18 (unverified songs). Matched: ${res.matchedCount}, modified: ${res.modifiedCount}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 18. Finding verified songs with document version 6.`);
+					songModel.updateMany(
+						{ documentVersion: 6, status: "verified" },
+						{ $set: { documentVersion: 7, verified: true }, $unset: { status: "" } },
+						(err, res) => {
+							if (err) next(err);
+							else {
+								this.log(
+									"INFO",
+									`Migration 18 (verified songs). Matched: ${res.matchedCount}, modified: ${res.modifiedCount}, ok: ${res.ok}.`
+								);
+
+								next();
+							}
+						}
+					);
+				},
+
+				next => {
+					this.log("INFO", `Migration 18. Updating playlist songs and queue songs.`);
+					songModel.find({ documentVersion: 6 }, (err, songs) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								songs.map(song => song._doc),
+								1,
+								(song, next) => {
+									const {
+										_id,
+										youtubeId,
+										title,
+										artists,
+										thumbnail,
+										duration,
+										skipDuration,
+										verified
+									} = song;
+									const trimmedSong = {
+										_id,
+										youtubeId,
+										title,
+										artists,
+										thumbnail,
+										duration,
+										skipDuration,
+										verified
+									};
+									async.waterfall(
+										[
+											next => {
+												playlistModel.updateMany(
+													{ "songs._id": song._id, documentVersion: 5 },
+													{ $set: { "songs.$": trimmedSong } },
+													next
+												);
+											},
+
+											(res, next) => {
+												stationModel.updateMany(
+													{ "queue._id": song._id, documentVersion: 6 },
+													{ $set: { "queue.$": trimmedSong } },
+													next
+												);
+											},
+
+											(res, next) => {
+												stationModel.updateMany(
+													{ "currentSong._id": song._id, documentVersion: 6 },
+													{ $set: { currentSong: null } },
+													next
+												);
+											}
+										],
+										err => {
+											next(err);
+										}
+									);
+								},
+								err => {
+									next(err);
+								}
+							);
+						}
+					});
+				},
+
+				next => {
+					playlistModel.updateMany({ documentVersion: 5 }, { $set: { documentVersion: 6 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 18 (playlist). Matched: ${res.matchedCount}, modified: ${res.modifiedCount}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				},
+
+				next => {
+					stationModel.updateMany({ documentVersion: 6 }, { $set: { documentVersion: 7 } }, (err, res) => {
+						if (err) next(err);
+						else {
+							this.log(
+								"INFO",
+								`Migration 18 (station). Matched: ${res.matchedCount}, modified: ${res.modifiedCount}, ok: ${res.ok}.`
+							);
+
+							next();
+						}
+					});
+				}
+			],
+			err => {
+				if (err) {
+					reject(new Error(err));
+				} else {
+					resolve();
+				}
+			}
+		);
+	});
+}

+ 21 - 26
backend/logic/playlists.js

@@ -110,28 +110,23 @@ class _PlaylistsModule extends CoreClass {
 		);
 	}
 
-	// /**
-	//  * Returns a list of playlists that include a specific song
-	//  *
-	//  * @param {object} payload - object that contains the payload
-	//  * @param {string} payload.songId - the song id
-	//  * @param {string} payload.includeSongs - include the songs
-	//  * @returns {Promise} - returns promise (reject, resolve)
-	//  */
-	// GET_PLAYLISTS_WITH_SONG(payload) {
-	// 	return new Promise((resolve, reject) => {
-	// 		async.waterfall([
-	// 			next => {
-	// 				const includeObject = payload.includeSongs ? null : { songs: false };
-	// 				PlaylistsModule.playlistModel.find({ "songs._id": payload.songId }, includeObject, next);
-	// 			},
-
-	// 			(playlists, next) => {
-	// 				console.log(playlists);
-	// 			}
-	// 		]);
-	// 	});
-	// }
+	/**
+	 * Returns a list of playlists that include a specific song
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.songId - the song id
+	 * @param {string} payload.includeSongs - include the songs
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_PLAYLISTS_WITH_SONG(payload) {
+		return new Promise((resolve, reject) => {
+			const includeObject = payload.includeSongs ? null : { songs: false };
+			PlaylistsModule.playlistModel.find({ "songs._id": payload.songId }, includeObject, (err, playlists) => {
+				if (err) reject(err);
+				else resolve({ playlists });
+			});
+		});
+	}
 
 	/**
 	 * Creates a playlist owned by a user
@@ -513,7 +508,7 @@ class _PlaylistsModule extends CoreClass {
 	 */
 	ADD_SONG_TO_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
-			const { _id, youtubeId, title, artists, thumbnail, duration, status } = payload.song;
+			const { _id, youtubeId, title, artists, thumbnail, duration, verified } = payload.song;
 			const trimmedSong = {
 				_id,
 				youtubeId,
@@ -521,7 +516,7 @@ class _PlaylistsModule extends CoreClass {
 				artists,
 				thumbnail,
 				duration,
-				status
+				verified
 			};
 
 			PlaylistsModule.playlistModel.updateOne(
@@ -615,7 +610,7 @@ class _PlaylistsModule extends CoreClass {
 
 					(playlistId, _songs, next) => {
 						const songs = _songs.map(song => {
-							const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+							const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
 							return {
 								_id,
 								youtubeId,
@@ -623,7 +618,7 @@ class _PlaylistsModule extends CoreClass {
 								artists,
 								thumbnail,
 								duration,
-								status
+								verified
 							};
 						});
 

+ 361 - 185
backend/logic/songs.js

@@ -1,5 +1,4 @@
 import async from "async";
-import config from "config";
 import mongoose from "mongoose";
 import CoreClass from "../core";
 
@@ -206,71 +205,6 @@ class _SongsModule extends CoreClass {
 		);
 	}
 
-	/**
-	 * Gets songs data
-	 *
-	 * @param {object} payload - object containing the payload
-	 * @param {string} payload.page - the page
-	 * @param {string} payload.pageSize - the page size
-	 * @param {string} payload.properties - the properties to return for each song
-	 * @param {string} payload.sort - the sort object
-	 * @param {string} payload.queries - the queries array
-	 * @param {string} payload.operator - the operator for queries
-	 * @returns {Promise} - returns a promise (resolve, reject)
-	 */
-	GET_DATA(payload) {
-		return new Promise((resolve, reject) => {
-			const { page, pageSize, properties, sort, queries, operator } = payload;
-
-			console.log("GET_DATA", payload);
-
-			const newQueries = queries.map(query => {
-				const { data, filter, filterType } = query;
-				const newQuery = {};
-				if (filterType === "regex") {
-					newQuery[filter.property] = new RegExp(`${data.slice(1, data.length - 1)}`, "i");
-				} else if (filterType === "contains") {
-					newQuery[filter.property] = new RegExp(`${data.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
-				} else if (filterType === "exact") {
-					newQuery[filter.property] = data.toString();
-				}
-				return newQuery;
-			});
-
-			const queryObject = {};
-			if (newQueries.length > 0) {
-				if (operator === "and") queryObject.$and = newQueries;
-				else if (operator === "or") queryObject.$or = newQueries;
-				else if (operator === "nor") queryObject.$nor = newQueries;
-			}
-
-			async.waterfall(
-				[
-					next => {
-						SongsModule.SongModel.find(queryObject).count((err, count) => {
-							next(err, count);
-						});
-					},
-
-					(count, next) => {
-						SongsModule.SongModel.find(queryObject)
-							.sort(sort)
-							.skip(pageSize * (page - 1))
-							.limit(pageSize)
-							.select(properties.join(" "))
-							.exec((err, songs) => {
-								next(err, count, songs);
-							});
-					}
-				],
-				(err, count, songs) => {
-					if (err && err !== true) return reject(new Error(err));
-					return resolve({ data: songs, count });
-				}
-			);
-		});
-	}
-
 	/**
 	 * Makes sure that if a song is not currently in the songs db, to add it
 	 *
@@ -307,15 +241,8 @@ class _SongsModule extends CoreClass {
 								return next(null, song);
 							});
 						} else {
-							const status =
-								(!payload.userId && config.get("hideAnonymousSongs")) ||
-								(payload.automaticallyRequested && config.get("hideAutomaticallyRequestedSongs"))
-									? "hidden"
-									: "unverified";
-
 							const song = new SongsModule.SongModel({
 								...youtubeSong,
-								status,
 								requestedBy: payload.userId,
 								requestedAt: Date.now()
 							});
@@ -398,7 +325,7 @@ class _SongsModule extends CoreClass {
 					},
 
 					(song, next) => {
-						const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+						const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
 						const trimmedSong = {
 							_id,
 							youtubeId,
@@ -406,7 +333,7 @@ class _SongsModule extends CoreClass {
 							artists,
 							thumbnail,
 							duration,
-							status
+							verified
 						};
 						this.log("INFO", `Going to update playlists now for song ${_id}`);
 						DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this)
@@ -454,7 +381,7 @@ class _SongsModule extends CoreClass {
 					},
 
 					(song, next) => {
-						const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+						const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
 						this.log("INFO", `Going to update stations now for song ${_id}`);
 						DBModule.runJob("GET_MODEL", { modelName: "station" }, this)
 							.then(stationModel => {
@@ -467,7 +394,7 @@ class _SongsModule extends CoreClass {
 											"queue.$.artists": artists,
 											"queue.$.thumbnail": thumbnail,
 											"queue.$.duration": duration,
-											"queue.$.status": status
+											"queue.$.verified": verified
 										}
 									},
 									err => {
@@ -554,6 +481,283 @@ class _SongsModule extends CoreClass {
 		);
 	}
 
+	/**
+	 * Gets multiple songs from id from Mongo and updates the cache with it
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {Array} payload.songIds - the ids of the songs we are trying to update
+	 * @param {string} payload.oldStatus - old status of song being updated (optional)
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	async UPDATE_SONGS(payload) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					// Get songs from Mongo
+					next => {
+						const { songIds } = payload;
+
+						SongsModule.SongModel.find({ _id: songIds }, next);
+					},
+
+					// Any songs that were not in Mongo, remove from cache, if they're in the cache
+					(songs, next) => {
+						const { songIds } = payload;
+
+						async.eachLimit(
+							songIds,
+							1,
+							(songId, next) => {
+								if (songs.findIndex(song => song._id.toString() === songId) === -1) {
+									// NOTE: could be made lower priority
+									CacheModule.runJob("HDEL", {
+										table: "songs",
+										key: songId
+									});
+									next();
+								} else next();
+							},
+							() => {
+								next(null, songs);
+							}
+						);
+					},
+
+					// Adds/updates all songs in the cache
+					(songs, next) => {
+						async.eachLimit(
+							songs,
+							1,
+							(song, next) => {
+								CacheModule.runJob(
+									"HSET",
+									{
+										table: "songs",
+										key: song._id,
+										value: song
+									},
+									this
+								)
+									.then(() => {
+										next();
+									})
+									.catch(next);
+							},
+							() => {
+								next(null, songs);
+							}
+						);
+					},
+
+					// Updates all playlists that the songs are in by setting the new trimmed song
+					(songs, next) => {
+						const trimmedSongs = songs.map(song => {
+							const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
+							return {
+								_id,
+								youtubeId,
+								title,
+								artists,
+								thumbnail,
+								duration,
+								verified
+							};
+						});
+
+						const playlistsToUpdate = new Set();
+
+						async.eachLimit(
+							trimmedSongs,
+							1,
+							(trimmedSong, next) => {
+								async.waterfall(
+									[
+										next => {
+											playlistModel.updateMany(
+												{ "songs._id": trimmedSong._id },
+												{ $set: { "songs.$": trimmedSong } },
+												next
+											);
+										},
+
+										(res, next) => {
+											playlistModel.find({ "songs._id": trimmedSong._id }, next);
+										},
+
+										(playlists, next) => {
+											playlists.forEach(playlist => {
+												playlistsToUpdate.add(playlist._id.toString());
+											});
+
+											next();
+										}
+									],
+									next
+								);
+							},
+							err => {
+								next(err, songs, playlistsToUpdate);
+							}
+						);
+					},
+
+					// Updates all playlists that the songs are in
+					(songs, playlistsToUpdate, next) => {
+						async.eachLimit(
+							playlistsToUpdate,
+							1,
+							(playlistId, next) => {
+								PlaylistsModule.runJob(
+									"UPDATE_PLAYLIST",
+									{
+										playlistId
+									},
+									this
+								)
+									.then(() => {
+										next();
+									})
+									.catch(err => {
+										next(err);
+									});
+							},
+							err => {
+								next(err, songs);
+							}
+						);
+					},
+
+					// Updates all station queues that the songs are in by setting the new trimmed song
+					(songs, next) => {
+						const stationsToUpdate = new Set();
+
+						async.eachLimit(
+							songs,
+							1,
+							(song, next) => {
+								async.waterfall(
+									[
+										next => {
+											const { youtubeId, title, artists, thumbnail, duration, verified } = song;
+											stationModel.updateMany(
+												{ "queue._id": song._id },
+												{
+													$set: {
+														"queue.$.youtubeId": youtubeId,
+														"queue.$.title": title,
+														"queue.$.artists": artists,
+														"queue.$.thumbnail": thumbnail,
+														"queue.$.duration": duration,
+														"queue.$.verified": verified
+													}
+												},
+												next
+											);
+										},
+
+										(res, next) => {
+											stationModel.find({ "queue._id": song._id }, next);
+										},
+
+										(stations, next) => {
+											stations.forEach(station => {
+												stationsToUpdate.add(station._id.toString());
+											});
+
+											next();
+										}
+									],
+									next
+								);
+							},
+							err => {
+								next(err, songs, stationsToUpdate);
+							}
+						);
+					},
+
+					// Updates all playlists that the songs are in
+					(songs, stationsToUpdate, next) => {
+						async.eachLimit(
+							stationsToUpdate,
+							1,
+							(stationId, next) => {
+								StationsModule.runJob(
+									"UPDATE_STATION",
+									{
+										stationId
+									},
+									this
+								)
+									.then(() => {
+										next();
+									})
+									.catch(err => {
+										next(err);
+									});
+							},
+							err => {
+								next(err, songs);
+							}
+						);
+					},
+
+					// Autofill the genre playlists of all genres of all songs
+					(songs, next) => {
+						const genresToAutofill = new Set();
+
+						songs.forEach(song => {
+							song.genres.forEach(genre => {
+								genresToAutofill.add(genre);
+							});
+						});
+
+						async.eachLimit(
+							genresToAutofill,
+							1,
+							(genre, next) => {
+								PlaylistsModule.runJob("AUTOFILL_GENRE_PLAYLIST", { genre }, this)
+									.then(() => {
+										next();
+									})
+									.catch(err => next(err));
+							},
+							err => {
+								next(err, songs);
+							}
+						);
+					},
+
+					// Send event that the song was updated
+					(songs, next) => {
+						async.eachLimit(
+							songs,
+							1,
+							(song, next) => {
+								CacheModule.runJob("PUB", {
+									channel: "song.updated",
+									value: { songId: song._id, oldStatus: null }
+								});
+								next();
+							},
+							() => {
+								next();
+							}
+						);
+					}
+				],
+				err => {
+					if (err && err !== true) return reject(new Error(err));
+
+					return resolve();
+				}
+			)
+		);
+	}
+
 	/**
 	 * Updates all songs
 	 *
@@ -683,7 +887,6 @@ class _SongsModule extends CoreClass {
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.query - the query
-	 * @param {string} payload.includeHidden - include hidden songs
 	 * @param {string} payload.includeUnverified - include unverified songs
 	 * @param {string} payload.includeVerified - include verified songs
 	 * @param {string} payload.trimmed - include trimmed songs
@@ -695,11 +898,10 @@ class _SongsModule extends CoreClass {
 			async.waterfall(
 				[
 					next => {
-						const statuses = [];
-						if (payload.includeHidden) statuses.push("hidden");
-						if (payload.includeUnverified) statuses.push("unverified");
-						if (payload.includeVerified) statuses.push("verified");
-						if (statuses.length === 0) return next("No statuses have been included.");
+						const isVerified = [];
+						if (payload.includeUnverified) isVerified.push(false);
+						if (payload.includeVerified) isVerified.push(true);
+						if (isVerified.length === 0) return next("No verified status has been included.");
 
 						let { query } = payload;
 
@@ -711,11 +913,11 @@ class _SongsModule extends CoreClass {
 						const filterArray = [
 							{
 								title: new RegExp(`${query}`, "i"),
-								status: { $in: statuses }
+								verified: { $in: isVerified }
 							},
 							{
 								artists: new RegExp(`${query}`, "i"),
-								status: { $in: statuses }
+								verified: { $in: isVerified }
 							}
 						];
 
@@ -754,7 +956,7 @@ class _SongsModule extends CoreClass {
 						else if (payload.trimmed) {
 							next(null, {
 								songs: data.songs.map(song => {
-									const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+									const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
 									return {
 										_id,
 										youtubeId,
@@ -762,7 +964,7 @@ class _SongsModule extends CoreClass {
 										artists,
 										thumbnail,
 										duration,
-										status
+										verified
 									};
 								}),
 								...data
@@ -794,7 +996,7 @@ class _SongsModule extends CoreClass {
 				[
 					next => {
 						playlistModel.countDocuments(
-							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, type: "user-liked" },
+							{ songs: { $elemMatch: { _id: payload.songId } }, type: "user-liked" },
 							(err, likes) => {
 								if (err) return next(err);
 								return next(null, likes);
@@ -804,7 +1006,7 @@ class _SongsModule extends CoreClass {
 
 					(likes, next) => {
 						playlistModel.countDocuments(
-							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, type: "user-disliked" },
+							{ songs: { $elemMatch: { _id: payload.songId } }, type: "user-disliked" },
 							(err, dislikes) => {
 								if (err) return next(err);
 								return next(err, { likes, dislikes });
@@ -883,7 +1085,7 @@ class _SongsModule extends CoreClass {
 			async.waterfall(
 				[
 					next => {
-						SongsModule.SongModel.find({ status: "verified" }, { genres: 1, _id: false }, next);
+						SongsModule.SongModel.find({ verified: true }, { genres: 1, _id: false }, next);
 					},
 
 					(songs, next) => {
@@ -957,7 +1159,7 @@ class _SongsModule extends CoreClass {
 					next => {
 						SongsModule.SongModel.find(
 							{
-								status: "verified",
+								verified: true,
 								genres: { $regex: new RegExp(`^${payload.genre.toLowerCase()}$`, "i") }
 							},
 							next
@@ -1077,9 +1279,6 @@ class _SongsModule extends CoreClass {
 						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;
-						const status = !requestedBy && config.get("hideAnonymousSongs") ? "hidden" : "unverified";
-
 						return YouTubeModule.runJob("GET_SONG", { youtubeId }, this)
 							.then(response => {
 								const { song } = response;
@@ -1089,7 +1288,7 @@ class _SongsModule extends CoreClass {
 								song.explicit = false;
 								song.requestedBy = user.preferences.anonymousSongRequests ? null : userId;
 								song.requestedAt = requestedAt;
-								song.status = status;
+								song.verified = false;
 								next(null, song);
 							})
 							.catch(next);
@@ -1122,7 +1321,7 @@ class _SongsModule extends CoreClass {
 				async (err, song) => {
 					if (err && err !== "This song is already in the database.") return reject(err);
 
-					const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+					const { _id, youtubeId, title, artists, thumbnail, duration, verified } = song;
 					const trimmedSong = {
 						_id,
 						youtubeId,
@@ -1130,7 +1329,7 @@ class _SongsModule extends CoreClass {
 						artists,
 						thumbnail,
 						duration,
-						status
+						verified
 					};
 
 					if (err && err === "This song is already in the database.")
@@ -1144,92 +1343,6 @@ class _SongsModule extends CoreClass {
 		});
 	}
 
-	/**
-	 * Hides a song
-	 *
-	 * @param {object} payload - The payload
-	 * @param {string} payload.songId - The song id of the song
-	 * @returns {Promise} - returns promise (reject, resolve)
-	 */
-	HIDE_SONG(payload) {
-		return new Promise((resolve, reject) => {
-			const { songId } = payload;
-
-			async.waterfall(
-				[
-					next => {
-						SongsModule.SongModel.findOne({ _id: songId }, next);
-					},
-
-					// Get YouTube data from id
-					(song, next) => {
-						if (!song) return next("This song does not exist.");
-						if (song.status === "hidden") return next("This song is already hidden.");
-						// TODO Add err object as first param of callback
-						return next(null, song.status);
-					},
-
-					(oldStatus, next) => {
-						SongsModule.SongModel.updateOne({ _id: songId }, { status: "hidden" }, res =>
-							next(null, res, oldStatus)
-						);
-					},
-
-					(res, oldStatus, next) => {
-						SongsModule.runJob("UPDATE_SONG", { songId, oldStatus });
-						next();
-					}
-				],
-				async err => {
-					if (err) reject(err);
-					resolve();
-				}
-			);
-		});
-	}
-
-	/**
-	 * Unhides a song
-	 *
-	 * @param {object} payload - The payload
-	 * @param {string} payload.songId - The song id of the song
-	 * @returns {Promise} - returns promise (reject, resolve)
-	 */
-	UNHIDE_SONG(payload) {
-		return new Promise((resolve, reject) => {
-			const { songId } = payload;
-
-			async.waterfall(
-				[
-					next => {
-						SongsModule.SongModel.findOne({ _id: songId }, next);
-					},
-
-					// Get YouTube data from id
-					(song, next) => {
-						if (!song) return next("This song does not exist.");
-						if (song.status !== "hidden") return next("This song is not hidden.");
-						// TODO Add err object as first param of callback
-						return next();
-					},
-
-					next => {
-						SongsModule.SongModel.updateOne({ _id: songId }, { status: "unverified" }, next);
-					},
-
-					(res, next) => {
-						SongsModule.runJob("UPDATE_SONG", { songId, oldStatus: "hidden" });
-						next();
-					}
-				],
-				async err => {
-					if (err) reject(err);
-					resolve();
-				}
-			);
-		});
-	}
-
 	// runjob songs REQUEST_ORPHANED_PLAYLIST_SONGS {}
 
 	/**
@@ -1276,7 +1389,7 @@ class _SongsModule extends CoreClass {
 										},
 
 										(song, next) => {
-											const { _id, title, artists, thumbnail, duration, status } = song;
+											const { _id, title, artists, thumbnail, duration, verified } = song;
 											const trimmedSong = {
 												_id,
 												youtubeId,
@@ -1284,7 +1397,7 @@ class _SongsModule extends CoreClass {
 												artists,
 												thumbnail,
 												duration,
-												status
+												verified
 											};
 											playlistModel.updateMany(
 												{ "songs.youtubeId": song.youtubeId },
@@ -1342,6 +1455,69 @@ class _SongsModule extends CoreClass {
 				.catch(reject);
 		});
 	}
+
+	/**
+	 * Gets a list of all genres
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_GENRES() {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.distinct("genres", next);
+					}
+				],
+				(err, genres) => {
+					if (err) reject(err);
+					resolve({ genres });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets a list of all artists
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_ARTISTS() {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.distinct("artists", next);
+					}
+				],
+				(err, artists) => {
+					if (err) reject(err);
+					resolve({ artists });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Gets a list of all tags
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	GET_TAGS() {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.distinct("tags", next);
+					}
+				],
+				(err, tags) => {
+					if (err) reject(err);
+					resolve({ tags });
+				}
+			);
+		});
+	}
 }
 
 export default new _SongsModule();

+ 3 - 3
backend/logic/stations.js

@@ -538,7 +538,7 @@ class _StationsModule extends CoreClass {
 								"skipDuration",
 								"artists",
 								"thumbnail",
-								"status"
+								"verified"
 							]
 						})
 							.then(response => {
@@ -637,7 +637,7 @@ class _StationsModule extends CoreClass {
 											requestedBy: queueSong.requestedBy,
 											likes: song.likes,
 											dislikes: song.dislikes,
-											status: song.status
+											verified: song.verified
 										};
 
 										return next(null, newSong);
@@ -837,7 +837,7 @@ class _StationsModule extends CoreClass {
 								thumbnail: song.thumbnail,
 								requestedAt: song.requestedAt,
 								requestedBy: song.requestedBy,
-								status: song.status
+								verified: song.verified
 							};
 						}
 

+ 2 - 2
backend/logic/tasks.js

@@ -243,8 +243,8 @@ class _TasksModule extends CoreClass {
 								if (Date.now() - session.refreshDate > 60 * 60 * 24 * 30 * 1000) {
 									return WSModule.runJob("SOCKETS_FROM_SESSION_ID", {
 										sessionId: session.sessionId
-									}).then(response => {
-										if (response.sockets.length > 0) {
+									}).then(sockets => {
+										if (sockets.length > 0) {
 											session.refreshDate = Date.now();
 											CacheModule.runJob("HSET", {
 												table: "sessions",

+ 5 - 5
backend/logic/youtube.js

@@ -283,7 +283,7 @@ class _YouTubeModule extends CoreClass {
 						YouTubeModule.log("ERROR", "GET_PLAYLIST", "Some error has occurred.", err.message);
 						reject(new Error(err.message));
 					} else {
-						resolve({ songs: response.filteredSongs ? response.filteredSongs.youtubeIds : response.songs });
+						resolve({ songs: response.filteredSongs ? response.filteredSongs.videoIds : response.songs });
 					}
 				}
 			);
@@ -400,14 +400,14 @@ class _YouTubeModule extends CoreClass {
 							return reject(new Error("An error has occured. Please try again later."));
 						}
 
-						const youtubeIds = [];
+						const videoIds = [];
 
 						res.data.items.forEach(item => {
-							const youtubeId = item.id;
+							const videoId = item.id;
 
 							if (!item.topicDetails) return;
 							if (item.topicDetails.topicCategories.indexOf("https://en.wikipedia.org/wiki/Music") !== -1)
-								youtubeIds.push(youtubeId);
+								videoIds.push(videoId);
 						});
 
 						return YouTubeModule.runJob(
@@ -415,7 +415,7 @@ class _YouTubeModule extends CoreClass {
 							{ videoIds: payload.videoIds, page: page + 1 },
 							this
 						)
-							.then(result => resolve({ youtubeIds: youtubeIds.concat(result.youtubeIds) }))
+							.then(result => resolve({ videoIds: videoIds.concat(result.videoIds) }))
 							.catch(err => reject(err));
 					})
 					.catch(err => {

+ 1 - 1
backend/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "musare-backend",
-  "version": "3.3.0-dev",
+  "version": "3.3.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {

+ 1 - 1
backend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "musare-backend",
   "private": true,
-  "version": "3.3.0-dev",
+  "version": "3.3.0",
   "type": "module",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "index.js",

+ 1 - 1
frontend/dist/index.tpl.html

@@ -9,7 +9,7 @@
 	<meta name='viewport' content='width=device-width, initial-scale=1, user-scalable=no'>
 	<meta name='keywords' content='music, <%= htmlWebpackPlugin.options.title %>, listen, station, station, radio, edm, chill, community, official, rooms, room, party, good, mus, pop'>
 	<meta name='description' content='On <%= htmlWebpackPlugin.options.title %> you can listen to lots of different songs, playing 24/7 in our official stations and in user-made community stations!'>
-	<meta name='copyright' content='© Copyright <%= htmlWebpackPlugin.options.title %> 2015-2021 All Right Reserved'>
+	<meta name='copyright' content='© Copyright <%= htmlWebpackPlugin.options.title %> 2015-2022 All Right Reserved'>
 
 	<link rel='apple-touch-icon' sizes='57x57' href='/assets/favicon/apple-touch-icon-57x57.png?v=06042016'>
 	<link rel='apple-touch-icon' sizes='60x60' href='/assets/favicon/apple-touch-icon-60x60.png?v=06042016'>

+ 10 - 10
frontend/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "musare-frontend",
-  "version": "3.3.0-dev",
+  "version": "3.3.0",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -3994,9 +3994,9 @@
       "dev": true
     },
     "follow-redirects": {
-      "version": "1.14.4",
-      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz",
-      "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==",
+      "version": "1.14.7",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.7.tgz",
+      "integrity": "sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==",
       "dev": true
     },
     "forever-agent": {
@@ -5058,9 +5058,9 @@
       "dev": true
     },
     "marked": {
-      "version": "3.0.7",
-      "resolved": "https://registry.npmjs.org/marked/-/marked-3.0.7.tgz",
-      "integrity": "sha512-ctKqbnLuNbsHbI26cfMyOlKgXGfl1orOv1AvWWDX7AkgfMOwCWvmuYc+mVLeWhQ9W6hdWVBynOs96VkcscKo0Q=="
+      "version": "4.0.10",
+      "resolved": "https://registry.npmjs.org/marked/-/marked-4.0.10.tgz",
+      "integrity": "sha512-+QvuFj0nGgO970fySghXGmuw+Fd0gD2x3+MqCWLIPf5oxdv1Ka6b2q+z9RP01P/IaKPMEramy+7cNy/Lw8c3hw=="
     },
     "media-typer": {
       "version": "0.3.0",
@@ -5379,9 +5379,9 @@
       "dev": true
     },
     "nanoid": {
-      "version": "3.1.29",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.29.tgz",
-      "integrity": "sha512-dW2pUSGZ8ZnCFIlBIA31SV8huOGCHb6OwzVCc7A69rb/a+SgPBwfmLvK5TKQ3INPbRkcI8a/Owo0XbiTNH19wg=="
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.2.0.tgz",
+      "integrity": "sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA=="
     },
     "natural-compare": {
       "version": "1.4.0",

+ 2 - 2
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
   ],
   "private": true,
-  "version": "3.3.0-dev",
+  "version": "3.3.0",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "main.js",
   "author": "Musare Team",
@@ -49,7 +49,7 @@
     "eslint-config-airbnb-base": "^14.2.1",
     "html-webpack-plugin": "^5.3.2",
     "lofig": "^1.3.4",
-    "marked": "^3.0.7",
+    "marked": "^4.0.10",
     "normalize.css": "^8.0.1",
     "toasters": "^2.3.1",
     "vue": "^3.2.20",

+ 44 - 3
frontend/src/App.vue

@@ -159,7 +159,11 @@ export default {
 			shift: false,
 			ctrl: false,
 			handler: () => {
-				if (Object.keys(this.currentlyActive).length !== 0)
+				if (
+					Object.keys(this.currentlyActive).length !== 0 &&
+					this.currentlyActive[0] !== "editSong" &&
+					this.currentlyActive[0] !== "editSongs"
+				)
 					this.closeCurrentModal();
 			}
 		});
@@ -396,6 +400,11 @@ export default {
 			background-color: var(--white);
 		}
 	}
+
+	.pill {
+		background-color: var(--dark-grey);
+		color: var(--primary-color);
+	}
 }
 
 .christmas-mode {
@@ -1214,7 +1223,7 @@ img {
 	background-color: var(--white);
 	color: var(--dark-grey);
 	width: 100% !important;
-	max-width: 500px !important;
+	max-width: 600px !important;
 	max-height: calc(100vh - 300px);
 	overflow-y: auto;
 
@@ -2002,6 +2011,16 @@ html {
 		::-webkit-scrollbar-thumb {
 			background-color: var(--light-grey);
 		}
+
+		::-webkit-scrollbar-track {
+			background-color: var(--dark-grey-3);
+		}
+	}
+
+	div {
+		::-webkit-scrollbar-track {
+			background-color: transparent !important;
+		}
 	}
 }
 
@@ -2011,7 +2030,7 @@ html {
 }
 
 ::-webkit-scrollbar-track {
-	background-color: transparent;
+	background-color: var(--light-grey-2);
 }
 
 ::-webkit-scrollbar-thumb {
@@ -2026,4 +2045,26 @@ html {
 .disabled {
 	cursor: not-allowed;
 }
+
+.pill {
+	background-color: var(--light-grey);
+	color: var(--primary-color);
+	padding: 5px 10px;
+	border-radius: 5px;
+	font-size: 14px;
+	font-weight: 600;
+	white-space: nowrap;
+	margin-top: 5px;
+	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+	transition: all 0.2s ease-in-out;
+
+	&:hover,
+	&:focus {
+		filter: brightness(95%);
+	}
+
+	&:not(:last-of-type) {
+		margin-right: 5px;
+	}
+}
 </style>

文件差異過大導致無法顯示
+ 640 - 62
frontend/src/components/AdvancedTable.vue


+ 177 - 0
frontend/src/components/AutoSuggest.vue

@@ -0,0 +1,177 @@
+<template>
+	<div>
+		<input
+			v-model="value"
+			class="input"
+			type="text"
+			:placeholder="placeholder"
+			:disabled="disabled"
+			@blur="blurInput($event)"
+			@focus="focusInput()"
+			@keydown.enter="$emit('submitted')"
+			@keydown="keydownInput()"
+		/>
+		<div
+			class="autosuggest-container"
+			v-if="
+				(inputFocussed || containerFocussed || itemFocussed) &&
+				items.length > 0
+			"
+			@mouseover="focusAutosuggestContainer()"
+			@mouseleave="blurAutosuggestContainer()"
+		>
+			<span
+				class="autosuggest-item"
+				tabindex="0"
+				@click="selectAutosuggestItem(item)"
+				@keyup.enter="selectAutosuggestItem(item)"
+				@focus="focusAutosuggestItem()"
+				@blur="blurAutosuggestItem($event)"
+				v-for="item in items"
+				:key="item"
+			>
+				{{ item }}
+			</span>
+		</div>
+	</div>
+</template>
+
+<script>
+export default {
+	props: {
+		modelValue: {
+			type: String,
+			default: ""
+		},
+		placeholder: {
+			type: String,
+			default: "Search value"
+		},
+		disabled: {
+			type: Boolean,
+			default: false
+		},
+		allItems: {
+			type: Array,
+			default: () => []
+		}
+	},
+	emits: ["update:modelValue"],
+	data() {
+		return {
+			inputFocussed: false,
+			containerFocussed: false,
+			itemFocussed: false,
+			keydownInputTimeout: null,
+			items: []
+		};
+	},
+	computed: {
+		value: {
+			get() {
+				return this.modelValue;
+			},
+			set(value) {
+				this.$emit("update:modelValue", value);
+			}
+		}
+	},
+	methods: {
+		blurInput(event) {
+			if (
+				event.relatedTarget &&
+				event.relatedTarget.classList.contains("autosuggest-item")
+			)
+				this.itemFocussed = true;
+			this.inputFocussed = false;
+		},
+		focusInput() {
+			this.inputFocussed = true;
+		},
+		keydownInput() {
+			clearTimeout(this.keydownInputTimeout);
+			this.keydownInputTimeout = setTimeout(() => {
+				if (this.value && this.value.length > 1) {
+					this.items = this.allItems.filter(item =>
+						item.toLowerCase().startsWith(this.value.toLowerCase())
+					);
+				} else this.items = [];
+			}, 1000);
+		},
+		focusAutosuggestContainer() {
+			this.containerFocussed = true;
+		},
+		blurAutosuggestContainer() {
+			this.containerFocussed = false;
+		},
+		selectAutosuggestItem(item) {
+			this.value = item;
+			this.items = [];
+		},
+		focusAutosuggestItem() {
+			this.itemFocussed = true;
+		},
+		blurAutosuggestItem(event) {
+			if (
+				!event.relatedTarget ||
+				!event.relatedTarget.classList.contains("autosuggest-item")
+			)
+				this.itemFocussed = false;
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.night-mode .autosuggest-container {
+	background-color: var(--dark-grey) !important;
+
+	.autosuggest-item {
+		background-color: var(--dark-grey) !important;
+		color: var(--white) !important;
+		border-color: var(--dark-grey) !important;
+	}
+
+	.autosuggest-item:hover,
+	.autosuggest-item:focus {
+		background-color: var(--dark-grey-2) !important;
+	}
+}
+
+.autosuggest-container {
+	position: absolute;
+	background: var(--white);
+	width: calc(100% + 1px);
+	top: 35px;
+	z-index: 200;
+	overflow: auto;
+	max-height: 98px;
+	clear: both;
+
+	.autosuggest-item {
+		padding: 8px;
+		display: block;
+		border: 1px solid var(--light-grey-2);
+		margin-top: -1px;
+		line-height: 16px;
+		cursor: pointer;
+		-webkit-user-select: none;
+		-ms-user-select: none;
+		-moz-user-select: none;
+		user-select: none;
+	}
+
+	.autosuggest-item:hover,
+	.autosuggest-item:focus {
+		background-color: var(--light-grey);
+	}
+
+	.autosuggest-item:first-child {
+		border-top: none;
+	}
+
+	.autosuggest-item:last-child {
+		border-radius: 0 0 3px 3px;
+	}
+}
+</style>

+ 1 - 4
frontend/src/components/FloatingBox.vue

@@ -146,6 +146,7 @@ export default {
 
 .floating-box {
 	display: flex;
+	flex-direction: column;
 	background-color: var(--white);
 	color: var(--black);
 	position: fixed;
@@ -158,10 +159,6 @@ export default {
 	min-width: 50px !important;
 	padding: 0;
 
-	&.column {
-		flex-direction: column;
-	}
-
 	.box-header {
 		z-index: 100000001;
 		background-color: var(--primary-color);

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

@@ -1,18 +1,23 @@
 <template>
 	<div class="modal is-active">
-		<div class="modal-background" @click="closeCurrentModal()" />
+		<div class="modal-background" @click="closeCurrentModalClick()" />
+		<slot name="sidebar" />
 		<div
 			:class="{
 				'modal-card': true,
-				'modal-wide': wide,
+				'modal-slim': size === 'slim',
+				'modal-wide': size === 'wide',
 				'modal-split': split
 			}"
 		>
 			<header class="modal-card-head">
+				<slot name="toggleMobileSidebar" />
 				<h2 class="modal-card-title is-marginless">
 					{{ title }}
 				</h2>
-				<span class="delete material-icons" @click="closeCurrentModal()"
+				<span
+					class="delete material-icons"
+					@click="closeCurrentModalClick()"
 					>highlight_off</span
 				>
 				<christmas-lights v-if="christmas" small :lights="5" />
@@ -20,7 +25,12 @@
 			<section class="modal-card-body">
 				<slot name="body" />
 			</section>
-			<footer class="modal-card-foot" v-if="$slots['footer'] != null">
+			<footer
+				:class="{
+					'modal-card-foot': true,
+					blank: $slots['footer'] == null
+				}"
+			>
 				<slot name="footer" />
 			</footer>
 		</div>
@@ -39,9 +49,11 @@ export default {
 	},
 	props: {
 		title: { type: String, default: "Modal" },
-		wide: { type: Boolean, default: false },
-		split: { type: Boolean, default: false }
+		size: { type: String, default: null },
+		split: { type: Boolean, default: false },
+		interceptClose: { type: Boolean, default: false }
 	},
+	emits: ["close"],
 	data() {
 		return {
 			christmas: false
@@ -57,6 +69,10 @@ export default {
 		this.christmas = await lofig.get("siteSettings.christmas");
 	},
 	methods: {
+		closeCurrentModalClick() {
+			if (this.interceptClose) this.$emit("close");
+			else this.closeCurrentModal();
+		},
 		toCamelCase: str =>
 			str
 				.toLowerCase()
@@ -130,10 +146,14 @@ export default {
 		width: 800px;
 		max-width: calc(100% - 40px);
 		max-height: calc(100vh - 40px);
-		overflow: auto;
+		overflow: visible;
 		margin: 0;
 		font-size: 16px;
 
+		&.modal-slim {
+			width: 640px;
+		}
+
 		&.modal-wide {
 			width: 1300px;
 		}
@@ -209,7 +229,7 @@ export default {
 		.modal-card-foot {
 			border-top: 1px solid var(--light-grey-2);
 			border-radius: 0 0 5px 5px;
-			overflow: initial;
+			overflow-x: auto;
 			column-gap: 16px;
 
 			& > div {
@@ -225,6 +245,10 @@ export default {
 				justify-content: flex-end;
 				column-gap: 16px;
 			}
+
+			&.blank {
+				padding: 10px;
+			}
 		}
 
 		.modal-card-body {

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

@@ -305,6 +305,7 @@ export default {
 #queue {
 	background-color: var(--white);
 	border-radius: 0 0 5px 5px;
+	user-select: none;
 
 	.actionable-button-hidden {
 		max-height: 100%;

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

@@ -31,7 +31,10 @@ export default {
 				case "save-failure":
 					return `<i class="material-icons icon-with-button">error_outline</i>Failed to save`;
 				case "disabled":
+				case "saving":
 					return "Saving...";
+				case "verifying":
+					return "Verifying...";
 				default:
 					return this.defaultMessage
 						? this.defaultMessage
@@ -44,6 +47,8 @@ export default {
 					return "is-success";
 				case "save-failure":
 					return `is-danger`;
+				case "saving":
+				case "verifying":
 				case "disabled":
 					return "is-default";
 				default:

+ 12 - 4
frontend/src/components/SongItem.vue

@@ -5,7 +5,8 @@
 		v-if="song"
 	>
 		<div class="thumbnail-and-info">
-			<song-thumbnail :song="song" />
+			<slot v-if="$slots.leftIcon" name="leftIcon" />
+			<song-thumbnail :song="song" v-if="thumbnail" />
 			<div class="song-info">
 				<h6 v-if="header">{{ header }}</h6>
 				<div class="song-title">
@@ -21,7 +22,7 @@
 						{{ song.title }}
 					</h4>
 					<i
-						v-if="song.status === 'verified'"
+						v-if="song.verified"
 						class="material-icons verified-song"
 						content="Verified Song"
 						v-tippy="{ theme: 'info' }"
@@ -184,6 +185,10 @@ export default {
 			type: Boolean,
 			default: true
 		},
+		thumbnail: {
+			type: Boolean,
+			default: true
+		},
 		disabledActions: {
 			type: Array,
 			default: () => []
@@ -265,7 +270,7 @@ export default {
 		},
 		edit(song) {
 			this.hideTippyElements();
-			this.editSong(song);
+			this.editSong({ songId: song._id });
 			this.openModal("editSong");
 		},
 		...mapActions("modals/editSong", ["editSong"]),
@@ -299,6 +304,8 @@ export default {
 }
 
 .song-item {
+	min-height: 65px;
+
 	&:not(:last-of-type) {
 		margin-bottom: 10px;
 	}
@@ -326,13 +333,14 @@ export default {
 		width: 65px;
 		height: 65px;
 		margin: -7.5px;
+		margin-right: calc(20px - 7.5px);
 	}
 
 	.song-info {
 		display: flex;
 		flex-direction: column;
 		justify-content: center;
-		margin-left: 20px;
+		// margin-left: 20px;
 		min-width: 0;
 
 		*:not(i) {

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

@@ -3,7 +3,7 @@
 		<div class="container">
 			<div class="footer-content">
 				<div id="footer-copyright">
-					<p>© Copyright {{ siteSettings.sitename }} 2015 - 2021</p>
+					<p>© Copyright {{ siteSettings.sitename }} 2015 - 2022</p>
 				</div>
 				<router-link id="footer-logo" to="/">
 					<img

+ 175 - 0
frontend/src/components/modals/BulkActions.vue

@@ -0,0 +1,175 @@
+<template>
+	<div>
+		<modal title="Bulk Actions" class="bulk-actions-modal">
+			<template #body>
+				<label class="label">Method</label>
+				<div class="control is-expanded select">
+					<select v-model="method">
+						<option value="add">Add</option>
+						<option value="remove">Remove</option>
+						<option value="replace">Replace</option>
+					</select>
+				</div>
+
+				<label class="label">{{ type.name.slice(0, -1) }}</label>
+				<div class="control is-grouped input-with-button">
+					<auto-suggest
+						v-model="itemInput"
+						:placeholder="`Enter ${type.name} to ${method}`"
+						:all-items="allItems"
+						@submitted="addItem()"
+					/>
+					<p class="control">
+						<button
+							class="button is-primary material-icons"
+							@click="addItem()"
+						>
+							add
+						</button>
+					</p>
+				</div>
+
+				<label class="label"
+					>{{ type.name }} to be
+					{{ method === "add" ? `added` : `${method}d` }}</label
+				>
+				<div v-if="items.length > 0">
+					<div
+						v-for="(item, index) in items"
+						:key="`item-${item}`"
+						class="pill"
+					>
+						{{ item }}
+						<span
+							class="material-icons remove-item"
+							@click="removeItem(index)"
+							content="Remove item"
+							v-tippy
+							>highlight_off</span
+						>
+					</div>
+				</div>
+				<p v-else>No {{ type.name }} specified</p>
+			</template>
+			<template #footer>
+				<button
+					class="button is-primary"
+					:disabled="items.length === 0"
+					@click="applyChanges()"
+				>
+					Apply Changes
+				</button>
+			</template>
+		</modal>
+	</div>
+</template>
+
+<script>
+import { mapGetters, mapActions } from "vuex";
+
+import Toast from "toasters";
+
+import Modal from "../Modal.vue";
+import AutoSuggest from "@/components/AutoSuggest.vue";
+
+import ws from "@/ws";
+
+export default {
+	components: { Modal, AutoSuggest },
+	props: {
+		type: {
+			type: Object,
+			default: () => {}
+		}
+	},
+	data() {
+		return {
+			method: "add",
+			items: [],
+			itemInput: null,
+			allItems: []
+		};
+	},
+	computed: {
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	beforeUnmount() {
+		this.itemInput = null;
+		this.items = [];
+	},
+	mounted() {
+		ws.onConnect(this.init);
+	},
+	methods: {
+		init() {
+			if (this.type.autosuggest && this.type.autosuggestDataAction)
+				this.socket.dispatch(this.type.autosuggestDataAction, res => {
+					if (res.status === "success") {
+						const { items } = res.data;
+						this.allItems = items;
+					} else {
+						new Toast(res.message);
+					}
+				});
+		},
+		addItem() {
+			if (!this.itemInput) return;
+			if (this.type.regex && !this.type.regex.test(this.itemInput)) {
+				new Toast(`Invalid ${this.type.name} format.`);
+			} else if (this.items.includes(this.itemInput)) {
+				new Toast(`Duplicate ${this.type.name} specified.`);
+			} else {
+				this.items.push(this.itemInput);
+				this.itemInput = null;
+			}
+		},
+		removeItem(index) {
+			this.items.splice(index, 1);
+		},
+		applyChanges() {
+			this.socket.dispatch(
+				this.type.action,
+				this.method,
+				this.items,
+				this.type.items,
+				res => {
+					new Toast(res.message);
+					this.closeModal("bulkActions");
+				}
+			);
+		},
+		...mapActions("modalVisibility", ["closeModal"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.label {
+	text-transform: capitalize;
+}
+
+.select.is-expanded select {
+	width: 100%;
+}
+
+.control.input-with-button > div {
+	flex: 1;
+}
+
+.pill {
+	display: inline-flex;
+
+	.remove-item {
+		font-size: 16px;
+		margin: auto 2px auto 5px;
+		cursor: pointer;
+	}
+}
+
+/deep/ .autosuggest-container {
+	width: calc(100% - 40px);
+	top: unset;
+}
+</style>

+ 53 - 0
frontend/src/components/modals/Confirm.vue

@@ -0,0 +1,53 @@
+<template>
+	<div>
+		<modal class="confirm-modal" title="Confirm Action" :size="'slim'">
+			<template #body>
+				<div class="confirm-modal-inner-container">
+					{{ message }}
+				</div>
+			</template>
+			<template #footer>
+				<button class="button is-danger" @click="confirmAction()">
+					<i class="material-icons icon-with-button">warning</i>
+					Confirm
+				</button>
+			</template>
+		</modal>
+	</div>
+</template>
+
+<script>
+import { mapState, mapActions } from "vuex";
+import Modal from "../Modal.vue";
+
+export default {
+	components: { Modal },
+	emits: ["confirmed"],
+	data() {
+		return {
+			modalName: ""
+		};
+	},
+	computed: {
+		...mapState("modalVisibility", {
+			currentlyActive: state => state.currentlyActive
+		}),
+		...mapState("modals/confirm", {
+			message: state => state.message
+		})
+	},
+	mounted() {
+		// eslint-disable-next-line
+		this.modalName = this.currentlyActive[0];
+	},
+	methods: {
+		confirmAction() {
+			this.updateConfirmMessage("");
+			this.$emit("confirmed");
+			this.closeModal(this.modalName);
+		},
+		...mapActions("modals/confirm", ["updateConfirmMessage"]),
+		...mapActions("modalVisibility", ["closeModal"])
+	}
+};
+</script>

+ 4 - 0
frontend/src/components/modals/CreatePlaylist.vue

@@ -130,6 +130,10 @@ li a {
 	}
 }
 
+.control.select {
+	width: min-content;
+}
+
 .label {
 	font-size: 1rem;
 }

+ 2 - 2
frontend/src/components/modals/EditNews.vue

@@ -2,7 +2,7 @@
 	<modal
 		class="edit-news-modal"
 		:title="newsId ? 'Edit News' : 'Create News'"
-		:wide="true"
+		:size="'wide'"
 		:split="true"
 	>
 		<template #body>
@@ -62,7 +62,7 @@
 
 <script>
 import { mapActions, mapGetters, mapState } from "vuex";
-import marked from "marked";
+import { marked } from "marked";
 import { sanitize } from "dompurify";
 import Toast from "toasters";
 import { formatDistance } from "date-fns";

+ 1 - 1
frontend/src/components/modals/EditPlaylist/index.vue

@@ -7,7 +7,7 @@
 			'edit-playlist-modal': true,
 			'view-only': !isEditable()
 		}"
-		:wide="isEditable()"
+		:size="isEditable() ? 'wide' : null"
 		:split="true"
 	>
 		<template #body>

+ 4 - 0
frontend/src/components/modals/EditSong/Tabs/Discogs.vue

@@ -115,6 +115,7 @@
 					</p>
 					<button
 						class="button is-primary"
+						:disabled="bulk"
 						@click="importAlbum(result)"
 					>
 						Import album
@@ -157,6 +158,9 @@ import Toast from "toasters";
 import keyboardShortcuts from "@/keyboardShortcuts";
 
 export default {
+	props: {
+		bulk: { type: Boolean, default: false }
+	},
 	data() {
 		return {
 			discogs: {

文件差異過大導致無法顯示
+ 433 - 347
frontend/src/components/modals/EditSong/index.vue


+ 633 - 0
frontend/src/components/modals/EditSongs.vue

@@ -0,0 +1,633 @@
+<template>
+	<div>
+		<edit-song
+			:bulk="true"
+			:flagged="currentSongFlagged"
+			v-if="currentSong"
+			@savedSuccess="onSavedSuccess"
+			@savedError="onSavedError"
+			@saving="onSaving"
+			@toggleFlag="toggleFlag"
+			@nextSong="editNextSong"
+			@close="onClose"
+		>
+			<template #toggleMobileSidebar>
+				<i
+					class="material-icons toggle-sidebar-icon"
+					:content="`${
+						sidebarMobileActive ? 'Close' : 'Open'
+					} Edit Queue`"
+					v-tippy
+					@click="toggleMobileSidebar()"
+					>expand_circle_down</i
+				>
+			</template>
+			<template #sidebar>
+				<div class="sidebar" :class="{ active: sidebarMobileActive }">
+					<header class="sidebar-head">
+						<h2 class="sidebar-title is-marginless">Edit Queue</h2>
+						<i
+							class="material-icons toggle-sidebar-icon"
+							:content="`${
+								sidebarMobileActive ? 'Close' : 'Open'
+							} Edit Queue`"
+							v-tippy
+							@click="toggleMobileSidebar()"
+							>expand_circle_down</i
+						>
+					</header>
+					<section class="sidebar-body">
+						<div
+							class="item"
+							v-for="(
+								{ status, flagged, song }, index
+							) in filteredItems"
+							:key="song._id"
+						>
+							<song-item
+								:song="song"
+								:thumbnail="false"
+								:duration="false"
+								:disabled-actions="
+									song.removed ? ['all'] : ['report', 'edit']
+								"
+								:class="{
+									updated: song.updated,
+									removed: song.removed
+								}"
+							>
+								<template #leftIcon>
+									<i
+										v-if="currentSong._id === song._id"
+										class="
+											material-icons
+											item-icon
+											editing-icon
+										"
+										content="Currently editing song"
+										v-tippy="{ theme: 'info' }"
+										@click="toggleDone(index)"
+										>edit</i
+									>
+									<i
+										v-else-if="song.removed"
+										class="
+											material-icons
+											item-icon
+											removed-icon
+										"
+										content="Song removed"
+										v-tippy="{ theme: 'info' }"
+										>delete_forever</i
+									>
+									<i
+										v-else-if="status === 'error'"
+										class="
+											material-icons
+											item-icon
+											error-icon
+										"
+										content="Error saving song"
+										v-tippy="{ theme: 'info' }"
+										@click="toggleDone(index)"
+										>error</i
+									>
+									<i
+										v-else-if="status === 'saving'"
+										class="
+											material-icons
+											item-icon
+											saving-icon
+										"
+										content="Currently saving song"
+										v-tippy="{ theme: 'info' }"
+										>pending</i
+									>
+									<i
+										v-else-if="flagged"
+										class="
+											material-icons
+											item-icon
+											flag-icon
+										"
+										content="Song flagged"
+										v-tippy="{ theme: 'info' }"
+										@click="toggleDone(index)"
+										>flag_circle</i
+									>
+									<i
+										v-else-if="status === 'done'"
+										class="
+											material-icons
+											item-icon
+											done-icon
+										"
+										content="Song marked complete"
+										v-tippy="{ theme: 'info' }"
+										@click="toggleDone(index)"
+										>check_circle</i
+									>
+									<i
+										v-else-if="status === 'todo'"
+										class="
+											material-icons
+											item-icon
+											todo-icon
+										"
+										content="Song marked todo"
+										v-tippy="{ theme: 'info' }"
+										@click="toggleDone(index)"
+										>cancel</i
+									>
+								</template>
+								<template v-if="!song.removed" #actions>
+									<i
+										class="material-icons edit-icon"
+										content="Edit Song"
+										v-tippy
+										@click="pickSong(song)"
+									>
+										edit
+									</i>
+								</template>
+								<template #tippyActions>
+									<i
+										class="material-icons flag-icon"
+										:class="{ flagged }"
+										content="Toggle Flag"
+										v-tippy
+										@click="toggleFlag(index)"
+									>
+										flag_circle
+									</i>
+								</template>
+							</song-item>
+						</div>
+						<p v-if="filteredItems.length === 0" class="no-items">
+							{{
+								flagFilter
+									? "No flagged songs queued"
+									: "No songs queued"
+							}}
+						</p>
+					</section>
+					<footer class="sidebar-foot">
+						<button
+							@click="toggleFlagFilter()"
+							class="button is-primary"
+						>
+							{{
+								flagFilter
+									? "Show All Songs"
+									: "Show Only Flagged Songs"
+							}}
+						</button>
+					</footer>
+				</div>
+				<div
+					v-if="sidebarMobileActive"
+					class="sidebar-overlay"
+					@click="toggleMobileSidebar()"
+				></div>
+			</template>
+		</edit-song>
+		<confirm
+			v-if="modals.editSongsConfirm"
+			@confirmed="handleConfirmed()"
+		/>
+	</div>
+</template>
+
+<script>
+import { mapState, mapActions, mapGetters } from "vuex";
+import { defineAsyncComponent } from "vue";
+
+import Toast from "toasters";
+
+import SongItem from "@/components/SongItem.vue";
+
+export default {
+	components: {
+		EditSong: defineAsyncComponent(() =>
+			import("@/components/modals/EditSong")
+		),
+		Confirm: defineAsyncComponent(() =>
+			import("@/components/modals/Confirm.vue")
+		),
+		SongItem
+	},
+	props: {},
+	data() {
+		return {
+			items: [],
+			currentSong: {},
+			flagFilter: false,
+			sidebarMobileActive: false,
+			confirm: {
+				message: "",
+				action: "",
+				params: null
+			}
+		};
+	},
+	computed: {
+		editingItemIndex() {
+			return this.items.findIndex(
+				item => item.song._id === this.currentSong._id
+			);
+		},
+		filteredEditingItemIndex() {
+			return this.filteredItems.findIndex(
+				item => item.song._id === this.currentSong._id
+			);
+		},
+		filteredItems: {
+			get() {
+				return this.items.filter(item =>
+					this.flagFilter ? item.flagged : true
+				);
+			},
+			set(newItem) {
+				const index = this.items.findIndex(
+					item => item.song._id === newItem._id
+				);
+				this.item[index] = newItem;
+			}
+		},
+		currentSongFlagged() {
+			return this.items.find(
+				item => item.song._id === this.currentSong._id
+			)?.flagged;
+		},
+		...mapState("modals/editSongs", {
+			songIds: state => state.songIds,
+			songPrefillData: state => state.songPrefillData
+		}),
+		...mapState("modalVisibility", {
+			modals: state => state.modals
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	async mounted() {
+		this.socket.dispatch("apis.joinRoom", "edit-songs");
+
+		this.socket.dispatch("songs.getSongsFromSongIds", this.songIds, res => {
+			res.data.songs.forEach(song => {
+				this.items.push({
+					status: "todo",
+					flagged: false,
+					song
+				});
+			});
+
+			if (this.items.length === 0) {
+				this.closeThisModal();
+				new Toast("You can't edit 0 songs.");
+			} else this.editNextSong();
+		});
+
+		this.socket.on(`event:admin.song.updated`, res => {
+			const index = this.items
+				.map(item => item.song._id)
+				.indexOf(res.data.song._id);
+			this.items[index].song = {
+				...this.items[index].song,
+				...res.data.song,
+				updated: true
+			};
+		});
+
+		this.socket.on(`event:admin.song.removed`, res => {
+			const index = this.items
+				.map(item => item.song._id)
+				.indexOf(res.songId);
+			this.items[index].song.removed = true;
+		});
+	},
+	beforeUnmount() {
+		this.socket.dispatch("apis.leaveRoom", "edit-songs");
+		this.resetSongs();
+	},
+	methods: {
+		pickSong(song) {
+			this.editSong({
+				songId: song._id,
+				prefill: this.songPrefillData[song._id]
+			});
+			this.currentSong = song;
+		},
+		editNextSong() {
+			const currentlyEditingSongIndex = this.filteredEditingItemIndex;
+			let newEditingSongIndex = -1;
+			const index =
+				currentlyEditingSongIndex + 1 === this.filteredItems.length
+					? 0
+					: currentlyEditingSongIndex + 1;
+			for (let i = index; i < this.filteredItems.length; i += 1) {
+				if (!this.flagFilter || this.filteredItems[i].flagged) {
+					newEditingSongIndex = i;
+					break;
+				}
+			}
+
+			if (newEditingSongIndex > -1)
+				this.pickSong(this.filteredItems[newEditingSongIndex].song);
+		},
+		toggleFlag(songIndex = null) {
+			if (songIndex && songIndex > -1) {
+				this.filteredItems[songIndex].flagged =
+					!this.filteredItems[songIndex].flagged;
+				new Toast(
+					`Successfully ${
+						this.filteredItems[songIndex].flagged
+							? "flagged"
+							: "unflagged"
+					} song.`
+				);
+			} else if (!songIndex && this.editingItemIndex > -1) {
+				this.items[this.editingItemIndex].flagged =
+					!this.items[this.editingItemIndex].flagged;
+				new Toast(
+					`Successfully ${
+						this.items[this.editingItemIndex].flagged
+							? "flagged"
+							: "unflagged"
+					} song.`
+				);
+			}
+		},
+		onSavedSuccess(songId) {
+			const itemIndex = this.items.findIndex(
+				item => item.song._id === songId
+			);
+			if (itemIndex > -1) {
+				this.items[itemIndex].status = "done";
+				this.items[itemIndex].flagged = false;
+			}
+		},
+		onSavedError(songId) {
+			const itemIndex = this.items.findIndex(
+				item => item.song._id === songId
+			);
+			if (itemIndex > -1) this.items[itemIndex].status = "error";
+		},
+		onSaving(songId) {
+			const itemIndex = this.items.findIndex(
+				item => item.song._id === songId
+			);
+			if (itemIndex > -1) this.items[itemIndex].status = "saving";
+		},
+		toggleDone(index, overwrite = null) {
+			const { status } = this.filteredItems[index];
+
+			if (status === "done" && overwrite !== "done")
+				this.filteredItems[index].status = "todo";
+			else {
+				this.filteredItems[index].status = "done";
+				this.filteredItems[index].flagged = false;
+			}
+		},
+		toggleFlagFilter() {
+			this.flagFilter = !this.flagFilter;
+		},
+		toggleMobileSidebar() {
+			this.sidebarMobileActive = !this.sidebarMobileActive;
+		},
+		confirmAction(confirm) {
+			this.confirm = confirm;
+			this.updateConfirmMessage(confirm.message);
+			this.openModal("editSongsConfirm");
+		},
+		handleConfirmed() {
+			const { action, params } = this.confirm;
+			if (typeof this[action] === "function") {
+				if (params) this[action](params);
+				else this[action]();
+			}
+			this.confirm = {
+				message: "",
+				action: "",
+				params: null
+			};
+		},
+		onClose() {
+			const doneItems = this.items.filter(
+				item => item.status === "done"
+			).length;
+			const flaggedItems = this.items.filter(item => item.flagged).length;
+			const notDoneItems = this.items.length - doneItems;
+
+			if (doneItems > 0 && notDoneItems > 0)
+				this.confirmAction({
+					message:
+						"You have songs which are not done yet. Are you sure you want to stop editing songs?",
+					action: "closeThisModal",
+					params: null
+				});
+			else if (flaggedItems > 0)
+				this.confirmAction({
+					message:
+						"You have songs which are flagged. Are you sure you want to stop editing songs?",
+					action: "closeThisModal",
+					params: null
+				});
+			else this.closeThisModal();
+		},
+		closeThisModal() {
+			this.closeModal("editSongs");
+		},
+		...mapActions("modals/confirm", ["updateConfirmMessage"]),
+		...mapActions("modalVisibility", ["openModal", "closeModal"]),
+		...mapActions("modals/editSong", ["editSong"]),
+		...mapActions("modals/editSongs", ["resetSongs"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.night-mode .sidebar {
+	.sidebar-head,
+	.sidebar-foot {
+		background-color: var(--dark-grey-3);
+		border: none;
+	}
+
+	.sidebar-body {
+		background-color: var(--dark-grey-4) !important;
+	}
+
+	.sidebar-head .toggle-sidebar-icon.material-icons,
+	.sidebar-title {
+		color: var(--white);
+	}
+
+	p,
+	label,
+	td,
+	th {
+		color: var(--light-grey-2) !important;
+	}
+
+	h1,
+	h2,
+	h3,
+	h4,
+	h5,
+	h6 {
+		color: var(--white) !important;
+	}
+}
+
+.toggle-sidebar-icon {
+	display: none;
+}
+
+.sidebar {
+	width: 100%;
+	max-width: 350px;
+	z-index: 2000;
+	display: flex;
+	flex-direction: column;
+	position: relative;
+	height: 100%;
+	max-height: calc(100vh - 40px);
+	overflow: auto;
+	margin-right: 8px;
+	border-radius: 5px;
+
+	.sidebar-head,
+	.sidebar-foot {
+		display: flex;
+		flex-shrink: 0;
+		position: relative;
+		justify-content: flex-start;
+		align-items: center;
+		padding: 20px;
+		background-color: var(--light-grey);
+	}
+
+	.sidebar-head {
+		border-bottom: 1px solid var(--light-grey-2);
+		border-radius: 5px 5px 0 0;
+
+		.sidebar-title {
+			display: flex;
+			flex: 1;
+			margin: 0;
+			font-size: 26px;
+			font-weight: 600;
+		}
+	}
+
+	.sidebar-body {
+		background-color: var(--white);
+		display: flex;
+		flex-direction: column;
+		row-gap: 8px;
+		flex: 1;
+		overflow: auto;
+		padding: 10px;
+
+		.item {
+			display: flex;
+			flex-direction: row;
+			align-items: center;
+			column-gap: 8px;
+
+			/deep/ .song-item {
+				.item-icon {
+					margin-right: 10px;
+					cursor: pointer;
+				}
+
+				.removed-icon,
+				.error-icon {
+					color: var(--red);
+				}
+
+				.saving-icon,
+				.todo-icon,
+				.editing-icon {
+					color: var(--primary-color);
+				}
+
+				.done-icon {
+					color: var(--green);
+				}
+
+				.flag-icon {
+					color: var(--orange);
+
+					&.flagged {
+						color: var(--grey);
+					}
+				}
+
+				&.removed {
+					filter: grayscale(100%);
+					cursor: not-allowed;
+					user-select: none;
+				}
+			}
+		}
+
+		.no-items {
+			text-align: center;
+			font-size: 18px;
+		}
+	}
+
+	.sidebar-foot {
+		border-top: 1px solid var(--light-grey-2);
+		border-radius: 0 0 5px 5px;
+
+		.button {
+			flex: 1;
+		}
+	}
+
+	.sidebar-overlay {
+		display: none;
+	}
+}
+
+@media only screen and (max-width: 1580px) {
+	.toggle-sidebar-icon {
+		display: flex;
+		margin-right: 5px;
+		transform: rotate(90deg);
+		cursor: pointer;
+	}
+
+	.sidebar {
+		display: none;
+
+		&.active {
+			display: flex;
+			position: absolute;
+			z-index: 2010;
+			top: 20px;
+			left: 20px;
+
+			.sidebar-head .toggle-sidebar-icon {
+				display: flex;
+				margin-left: 5px;
+				transform: rotate(-90deg);
+			}
+		}
+	}
+
+	.sidebar-overlay {
+		display: flex;
+		position: absolute;
+		z-index: 2009;
+		top: 0;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		background-color: rgba(10, 10, 10, 0.85);
+	}
+}
+</style>

+ 30 - 44
frontend/src/components/modals/ImportAlbum.vue

@@ -305,7 +305,7 @@
 				<button class="button is-primary" @click="tryToAutoMove()">
 					Try to auto move
 				</button>
-				<button class="button is-primary" @click="editSongs()">
+				<button class="button is-primary" @click="startEditingSongs()">
 					Edit songs
 				</button>
 				<p class="is-expanded checkbox-control">
@@ -348,7 +348,7 @@ export default {
 			isImportingPlaylist: false,
 			trackSongs: [],
 			songsToEdit: [],
-			currentEditSongIndex: 0,
+			// currentEditSongIndex: 0,
 			search: {
 				playlist: {
 					query: ""
@@ -399,13 +399,6 @@ export default {
 			socket: "websockets/getSocket"
 		})
 	},
-	watch: {
-		/* eslint-disable */
-		"modals.editSong": function (value) {
-			if (!value) this.editNextSong();
-		}
-		/* eslint-enable */
-	},
 	mounted() {
 		ws.onConnect(this.init);
 
@@ -423,8 +416,7 @@ export default {
 		init() {
 			this.socket.dispatch("apis.joinRoom", "import-album");
 		},
-		editSongs() {
-			this.updateEditingSongs(true);
+		startEditingSongs() {
 			this.songsToEdit = [];
 			this.trackSongs.forEach((songs, index) => {
 				songs.forEach(song => {
@@ -436,38 +428,34 @@ export default {
 					delete discogsAlbum.expanded;
 					delete discogsAlbum.gotMoreInfo;
 
-					this.songsToEdit.push({
+					const songToEdit = {
 						songId: song._id,
-						discogs: discogsAlbum
-					});
+						prefill: {
+							discogs: discogsAlbum
+						}
+					};
+
+					if (this.prefillDiscogs) {
+						songToEdit.prefill.title = discogsAlbum.track.title;
+						songToEdit.prefill.thumbnail =
+							discogsAlbum.album.albumArt;
+						songToEdit.prefill.genres = JSON.parse(
+							JSON.stringify(discogsAlbum.album.genres)
+						);
+						songToEdit.prefill.artists = JSON.parse(
+							JSON.stringify(discogsAlbum.album.artists)
+						);
+					}
+
+					this.songsToEdit.push(songToEdit);
 				});
 			});
-			this.editNextSong();
-		},
-		editNextSong() {
-			if (this.editingSongs) {
-				setTimeout(() => {
-					const song = {
-						_id: this.songsToEdit[this.currentEditSongIndex].songId,
-						discogs:
-							this.songsToEdit[this.currentEditSongIndex].discogs
-					};
-					if (song.discogs && this.prefillDiscogs)
-						song.prefill = {
-							title: song.discogs.track.title,
-							thumbnail: song.discogs.album.albumArt,
-							genres: JSON.parse(
-								JSON.stringify(song.discogs.album.genres)
-							),
-							artists: JSON.parse(
-								JSON.stringify(song.discogs.album.artists)
-							)
-						};
-					console.log(song);
-					this.editSong(song);
-					this.currentEditSongIndex += 1;
-					this.openModal("editSong");
-				}, 500);
+
+			if (this.songsToEdit.length === 0)
+				new Toast("You can't edit 0 songs.");
+			else {
+				this.editSongs(this.songsToEdit);
+				this.openModal("editSongs");
 			}
 		},
 		log(evt) {
@@ -508,9 +496,7 @@ export default {
 				true,
 				res => {
 					this.isImportingPlaylist = false;
-					const songs = res.songs.filter(
-						song => song.status !== "verified"
-					);
+					const songs = res.songs.filter(song => !song.verified);
 					const songsAlreadyVerified =
 						res.songs.length - songs.length;
 					this.setPlaylistSongs(songs);
@@ -684,7 +670,7 @@ export default {
 			"togglePrefillDiscogs",
 			"updatePlaylistSong"
 		]),
-		...mapActions("modals/editSong", ["editSong"]),
+		...mapActions("modals/editSongs", ["editSongs"]),
 		...mapActions("modalVisibility", ["closeModal", "openModal"])
 	}
 };

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

@@ -1,6 +1,11 @@
 <template>
 	<div>
-		<modal title="Login" class="login-modal" @closed="closeLoginModal()">
+		<modal
+			title="Login"
+			class="login-modal"
+			:size="'slim'"
+			@closed="closeLoginModal()"
+		>
 			<template #body>
 				<form>
 					<!-- email address -->

+ 3 - 3
frontend/src/components/modals/ManageStation/index.vue

@@ -10,7 +10,7 @@
 		"
 		:style="`--primary-color: var(--${station.theme})`"
 		class="manage-station-modal"
-		:wide="isOwnerOrAdmin() || sector !== 'home'"
+		:size="isOwnerOrAdmin() || sector !== 'home' ? 'wide' : null"
 		:split="isOwnerOrAdmin() || sector !== 'home'"
 	>
 		<template #body v-if="station && station._id">
@@ -247,7 +247,7 @@ export default {
 		ws.onConnect(this.init);
 
 		this.socket.on(
-			"event:station.queue.updated",
+			"event:manageStation.queue.updated",
 			res => {
 				if (res.data.stationId === this.station._id)
 					this.updateSongsList(res.data.queue);
@@ -256,7 +256,7 @@ export default {
 		);
 
 		this.socket.on(
-			"event:station.queue.song.repositioned",
+			"event:manageStation.queue.song.repositioned",
 			res => {
 				if (res.data.stationId === this.station._id)
 					this.repositionSongInList(res.data.song);

+ 1 - 0
frontend/src/components/modals/Register.vue

@@ -3,6 +3,7 @@
 		<modal
 			title="Register"
 			class="register-modal"
+			:size="'slim'"
 			@closed="closeRegisterModal()"
 		>
 			<template #body>

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

@@ -3,7 +3,7 @@
 		<modal
 			class="report-modal"
 			title="Report"
-			:wide="existingReports.length > 0"
+			:size="existingReports.length > 0 ? 'wide' : null"
 		>
 			<template #body>
 				<div class="report-modal-inner-container">

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

@@ -217,7 +217,7 @@ export default {
 			);
 		},
 		openSong() {
-			this.editSong({ _id: this.report.song._id });
+			this.editSong({ songId: this.report.song._id });
 			this.openModal("editSong");
 		},
 		...mapActions("admin/reports", ["indexReports", "resolveReport"]),

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

@@ -29,7 +29,7 @@
 
 <script>
 import { formatDistance } from "date-fns";
-import marked from "marked";
+import { marked } from "marked";
 import { sanitize } from "dompurify";
 import { mapGetters, mapActions } from "vuex";
 import ws from "@/ws";

+ 4 - 4
frontend/src/main.js

@@ -184,14 +184,14 @@ router.beforeEach((to, from, next) => {
 	if (to.meta.loginRequired || to.meta.adminRequired || to.meta.guestsOnly) {
 		const gotData = () => {
 			if (to.meta.loginRequired && !store.state.user.auth.loggedIn)
-				next({ path: "/login" });
+				next({ path: "/login", query: "" });
 			else if (
 				to.meta.adminRequired &&
 				store.state.user.auth.role !== "admin"
 			)
-				next({ path: "/" });
+				next({ path: "/", query: "" });
 			else if (to.meta.guestsOnly && store.state.user.auth.loggedIn)
-				next({ path: "/" });
+				next({ path: "/", query: "" });
 			else next();
 		};
 
@@ -246,7 +246,7 @@ app.use(router);
 		store.dispatch("user/auth/banUser", res.data.ban)
 	);
 
-	ws.socket.on("event:user.username.updated", res =>
+	ws.socket.on("keep.event:user.username.updated", res =>
 		store.dispatch("user/auth/updateUsername", res.data.username)
 	);
 

+ 130 - 15
frontend/src/pages/Admin/index.vue

@@ -3,16 +3,6 @@
 		<main-header />
 		<div class="tabs is-centered">
 			<ul>
-				<li
-					:class="{ 'is-active': currentTab == 'test' }"
-					ref="test-tab"
-					@click="showTab('test')"
-				>
-					<router-link class="tab test" to="/admin/test">
-						<i class="material-icons">music_note</i>
-						<span>&nbsp;Test</span>
-					</router-link>
-				</li>
 				<li
 					:class="{ 'is-active': currentTab == 'songs' }"
 					ref="songs-tab"
@@ -100,7 +90,6 @@
 		</div>
 
 		<div class="admin-container">
-			<test v-if="currentTab == 'test'" />
 			<songs v-if="currentTab == 'songs'" />
 			<stations v-if="currentTab == 'stations'" />
 			<playlists v-if="currentTab == 'playlists'" />
@@ -111,6 +100,76 @@
 			<punishments v-if="currentTab == 'punishments'" />
 		</div>
 
+		<floating-box
+			id="keyboardShortcutsHelper"
+			ref="keyboardShortcutsHelper"
+		>
+			<template #body>
+				<div>
+					<div>
+						<span class="biggest"
+							><b>Keyboard shortcuts helper</b></span
+						>
+						<span
+							><b>Ctrl + /</b> - Toggles this keyboard shortcuts
+							helper</span
+						>
+						<span
+							><b>Ctrl + Shift + /</b> - Resets the position of
+							this keyboard shortcuts helper</span
+						>
+						<hr />
+					</div>
+					<div>
+						<span class="biggest"><b>Table</b></span>
+						<span class="bigger"><b>Navigation</b></span>
+						<span
+							><b>Up / Down arrow keys</b> - Move between
+							rows</span
+						>
+						<hr />
+					</div>
+					<div>
+						<span class="bigger"><b>Page navigation</b></span>
+						<span
+							><b>Ctrl + Left/Right arrow keys</b> - Previous/next
+							page</span
+						>
+						<span
+							><b>Ctrl + Shift + Left/Right arrow keys</b> -
+							First/last page</span
+						>
+						<hr />
+					</div>
+					<div>
+						<span class="bigger"><b>Reset localStorage</b></span>
+						<span><b>Ctrl + F5</b> - Resets localStorage</span>
+						<hr />
+					</div>
+					<div>
+						<span class="bigger"><b>Selecting</b></span>
+						<span><b>Space</b> - Selects/unselects a row</span>
+						<span><b>Ctrl + A</b> - Selects all rows</span>
+						<span
+							><b>Shift + Up/Down arrow keys</b> - Selects all
+							rows in between</span
+						>
+						<span
+							><b>Ctrl + Up/Down arrow keys</b> - Unselects all
+							rows in between</span
+						>
+						<hr />
+					</div>
+					<div>
+						<span class="bigger"><b>Popup actions</b></span>
+						<span><b>Ctrl + 1-9</b> - Execute action 1-9</span>
+						<span><b>Ctrl + 0</b> - Select action 1</span>
+						<hr />
+					</div>
+				</div>
+			</template>
+		</floating-box>
+
 		<main-footer />
 	</div>
 </template>
@@ -119,14 +178,17 @@
 import { mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 
+import keyboardShortcuts from "@/keyboardShortcuts";
+
 import MainHeader from "@/components/layout/MainHeader.vue";
 import MainFooter from "@/components/layout/MainFooter.vue";
+import FloatingBox from "@/components/FloatingBox.vue";
 
 export default {
 	components: {
 		MainHeader,
 		MainFooter,
-		Test: defineAsyncComponent(() => import("./tabs/Test.vue")),
+		FloatingBox,
 		Songs: defineAsyncComponent(() => import("./tabs/Songs.vue")),
 		Stations: defineAsyncComponent(() => import("./tabs/Stations.vue")),
 		Playlists: defineAsyncComponent(() => import("./tabs/Playlists.vue")),
@@ -153,16 +215,47 @@ export default {
 	},
 	mounted() {
 		this.changeTab(this.$route.path);
+
+		keyboardShortcuts.registerShortcut(
+			"admin.toggleKeyboardShortcutsHelper",
+			{
+				keyCode: 191, // '/' key
+				ctrl: true,
+				preventDefault: true,
+				handler: () => {
+					this.toggleKeyboardShortcutsHelper();
+				}
+			}
+		);
+
+		keyboardShortcuts.registerShortcut(
+			"admin.resetKeyboardShortcutsHelper",
+			{
+				keyCode: 191, // '/' key
+				ctrl: true,
+				shift: true,
+				preventDefault: true,
+				handler: () => {
+					this.resetKeyboardShortcutsHelper();
+				}
+			}
+		);
 	},
 	beforeUnmount() {
 		this.socket.dispatch("apis.leaveRooms");
+
+		const shortcutNames = [
+			"admin.toggleKeyboardShortcutsHelper",
+			"admin.resetKeyboardShortcutsHelper"
+		];
+
+		shortcutNames.forEach(shortcutName => {
+			keyboardShortcuts.unregisterShortcut(shortcutName);
+		});
 	},
 	methods: {
 		changeTab(path) {
 			switch (path) {
-				case "/admin/test":
-					this.showTab("test");
-					break;
 				case "/admin/songs":
 					this.showTab("songs");
 					break;
@@ -209,6 +302,12 @@ export default {
 				});
 			this.currentTab = tab;
 			localStorage.setItem("lastAdminPage", tab);
+		},
+		toggleKeyboardShortcutsHelper() {
+			this.$refs.keyboardShortcutsHelper.toggleBox();
+		},
+		resetKeyboardShortcutsHelper() {
+			this.$refs.keyboardShortcutsHelper.resetBox();
 		}
 	}
 };
@@ -351,6 +450,22 @@ export default {
 	}
 }
 
+#keyboardShortcutsHelper {
+	.box-body {
+		.biggest {
+			font-size: 18px;
+		}
+
+		.bigger {
+			font-size: 16px;
+		}
+
+		span {
+			display: block;
+		}
+	}
+}
+
 @media screen and (min-width: 980px) {
 	/deep/ .container {
 		margin: 0 auto;

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

@@ -7,48 +7,72 @@
 					Create News Item
 				</button>
 			</div>
-			<table class="table">
-				<thead>
-					<tr>
-						<td>Status</td>
-						<td>Title</td>
-						<td>Author</td>
-						<td>Markdown</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for="news in news" :key="news._id">
-						<td class="news-item-status">{{ news.status }}</td>
-						<td>
-							<strong>{{ news.title }}</strong>
-						</td>
-						<td>
-							<user-id-to-username
-								:user-id="news.createdBy"
-								:alt="news.createdBy"
-								:link="true"
-							/>
-						</td>
-						<td class="news-item-markdown">{{ news.markdown }}</td>
-						<td id="options-column">
-							<div>
-								<button
-									class="button is-primary"
-									@click="edit(news._id)"
-								>
-									Edit
-								</button>
-								<quick-confirm @confirm="remove(news._id)">
-									<button class="button is-danger">
-										Remove
-									</button>
-								</quick-confirm>
-							</div>
-						</td>
-					</tr>
-				</tbody>
-			</table>
+			<advanced-table
+				:column-default="columnDefault"
+				:columns="columns"
+				:filters="filters"
+				data-action="news.getData"
+				name="admin-news"
+				max-width="1200"
+				:events="events"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="
+								button
+								is-primary
+								icon-with-button
+								material-icons
+							"
+							@click="edit(slotProps.item._id)"
+							content="Edit News"
+							v-tippy
+						>
+							edit
+						</button>
+						<quick-confirm
+							@confirm="remove(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+						>
+							<button
+								class="
+									button
+									is-danger
+									icon-with-button
+									material-icons
+								"
+								content="Remove News"
+								v-tippy
+							>
+								delete_forever
+							</button>
+						</quick-confirm>
+					</div>
+				</template>
+				<template #column-status="slotProps">
+					<span :title="slotProps.item.status">{{
+						slotProps.item.status
+					}}</span>
+				</template>
+				<template #column-title="slotProps">
+					<span :title="slotProps.item.title">{{
+						slotProps.item.title
+					}}</span>
+				</template>
+				<template #column-createdBy="slotProps">
+					<user-id-to-username
+						:user-id="slotProps.item.createdBy"
+						:alt="slotProps.item.createdBy"
+						:link="true"
+					/>
+				</template>
+				<template #column-markdown="slotProps">
+					<span :title="slotProps.item.markdown">{{
+						slotProps.item.markdown
+					}}</span>
+				</template>
+			</advanced-table>
 		</div>
 
 		<edit-news
@@ -64,13 +88,13 @@ import { mapActions, mapState, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 import Toast from "toasters";
 
-import ws from "@/ws";
-
+import AdvancedTable from "@/components/AdvancedTable.vue";
 import QuickConfirm from "@/components/QuickConfirm.vue";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 export default {
 	components: {
+		AdvancedTable,
 		QuickConfirm,
 		UserIdToUsername,
 		EditNews: defineAsyncComponent(() =>
@@ -79,35 +103,106 @@ export default {
 	},
 	data() {
 		return {
-			editingNewsId: ""
+			editingNewsId: "",
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 150,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 85,
+					defaultWidth: 85
+				},
+				{
+					name: "status",
+					displayName: "Status",
+					properties: ["status"],
+					sortProperty: "status",
+					defaultWidth: 150
+				},
+				{
+					name: "title",
+					displayName: "Title",
+					properties: ["title"],
+					sortProperty: "title"
+				},
+				{
+					name: "createdBy",
+					displayName: "Created By",
+					properties: ["createdBy"],
+					sortProperty: "createdBy",
+					defaultWidth: 150
+				},
+				{
+					name: "markdown",
+					displayName: "Markdown",
+					properties: ["markdown"],
+					sortProperty: "markdown"
+				}
+			],
+			filters: [
+				{
+					name: "status",
+					displayName: "Status",
+					property: "status",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "title",
+					displayName: "Title",
+					property: "title",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "createdBy",
+					displayName: "Created By",
+					property: "createdBy",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "markdown",
+					displayName: "Markdown",
+					property: "markdown",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				}
+			],
+			events: {
+				adminRoom: "news",
+				updated: {
+					event: "admin.news.updated",
+					id: "news._id",
+					item: "news"
+				},
+				removed: {
+					event: "admin.news.deleted",
+					id: "newsId"
+				}
+			}
 		};
 	},
 	computed: {
 		...mapState("modalVisibility", {
 			modals: state => state.modals
 		}),
-		...mapState("admin/news", {
-			news: state => state.news
-		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
 		})
 	},
-	mounted() {
-		this.socket.on("event:admin.news.created", res =>
-			this.addNews(res.data.news)
-		);
-
-		this.socket.on("event:admin.news.updated", res =>
-			this.updateNews(res.data.news)
-		);
-
-		this.socket.on("event:admin.news.deleted", res =>
-			this.removeNews(res.data.newsId)
-		);
-
-		ws.onConnect(this.init);
-	},
 	methods: {
 		edit(id) {
 			if (id) this.editingNewsId = id;
@@ -121,102 +216,7 @@ export default {
 				res => new Toast(res.message)
 			);
 		},
-		init() {
-			this.socket.dispatch("news.index", res => {
-				if (res.status === "success") this.setNews(res.data.news);
-			});
-
-			this.socket.dispatch("apis.joinAdminRoom", "news");
-		},
-		...mapActions("modalVisibility", ["openModal", "closeModal"]),
-		...mapActions("admin/news", [
-			"editNews",
-			"addNews",
-			"setNews",
-			"removeNews",
-			"updateNews"
-		])
+		...mapActions("modalVisibility", ["openModal"])
 	}
 };
 </script>
-
-<style lang="scss" scoped>
-.night-mode {
-	.table {
-		color: var(--light-grey-2);
-		background-color: var(--dark-grey-3);
-
-		thead tr {
-			background: var(--dark-grey-3);
-			td {
-				color: var(--white);
-			}
-		}
-
-		tbody tr:hover {
-			background-color: var(--dark-grey-4) !important;
-		}
-
-		tbody tr:nth-child(even) {
-			background-color: var(--dark-grey-2);
-		}
-
-		strong {
-			color: var(--light-grey-2);
-		}
-	}
-
-	.card {
-		background: var(--dark-grey-3);
-
-		.card-header {
-			box-shadow: 0 1px 2px rgba(10, 10, 10, 0.8);
-		}
-
-		p,
-		.label {
-			color: var(--light-grey-2);
-		}
-	}
-}
-
-.tag:not(:last-child) {
-	margin-right: 5px;
-}
-
-td {
-	vertical-align: middle;
-
-	& > div {
-		display: inline-flex;
-	}
-}
-
-.is-info:focus {
-	background-color: var(--primary-color);
-}
-
-.card-footer-item {
-	color: var(--primary-color);
-}
-
-.news-item-status {
-	text-transform: capitalize;
-}
-
-.news-item-markdown {
-	text-overflow: ellipsis;
-	white-space: nowrap;
-	overflow: hidden;
-	max-width: 400px;
-}
-
-#options-column {
-	> div {
-		display: flex;
-		button {
-			margin-right: 5px;
-		}
-	}
-}
-</style>

+ 270 - 162
frontend/src/pages/Admin/tabs/Playlists.vue

@@ -1,55 +1,87 @@
 <template>
 	<div>
 		<page-metadata title="Admin | Playlists" />
-		<div class="container">
+		<div class="admin-tab">
 			<div class="button-row">
 				<run-job-dropdown :jobs="jobs" />
 			</div>
-			<table class="table">
-				<thead>
-					<tr>
-						<td>Display name</td>
-						<td>Type</td>
-						<td>Privacy</td>
-						<td>Songs #</td>
-						<td>Playlist length</td>
-						<td>Created by</td>
-						<td>Created at</td>
-						<td>Created for</td>
-						<td>Playlist id</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for="playlist in playlists" :key="playlist._id">
-						<td>{{ playlist.displayName }}</td>
-						<td>{{ playlist.type }}</td>
-						<td>{{ playlist.privacy }}</td>
-						<td>{{ playlist.songs.length }}</td>
-						<td>{{ totalLengthForPlaylist(playlist.songs) }}</td>
-						<td v-if="playlist.createdBy === 'Musare'">Musare</td>
-						<td v-else>
-							<user-id-to-username
-								:user-id="playlist.createdBy"
-								:link="true"
-							/>
-						</td>
-						<td :title="new Date(playlist.createdAt)">
-							{{ getDateFormatted(playlist.createdAt) }}
-						</td>
-						<td>{{ playlist.createdFor }}</td>
-						<td>{{ playlist._id }}</td>
-						<td>
-							<button
-								class="button is-primary"
-								@click="edit(playlist._id)"
-							>
-								View
-							</button>
-						</td>
-					</tr>
-				</tbody>
-			</table>
+			<advanced-table
+				:column-default="columnDefault"
+				:columns="columns"
+				:filters="filters"
+				data-action="playlists.getData"
+				name="admin-playlists"
+				:events="events"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="
+								button
+								is-primary
+								icon-with-button
+								material-icons
+							"
+							@click="edit(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+							content="Edit Playlist"
+							v-tippy
+						>
+							edit
+						</button>
+					</div>
+				</template>
+				<template #column-displayName="slotProps">
+					<span :title="slotProps.item.displayName">{{
+						slotProps.item.displayName
+					}}</span>
+				</template>
+				<template #column-type="slotProps">
+					<span :title="slotProps.item.type">{{
+						slotProps.item.type
+					}}</span>
+				</template>
+				<template #column-privacy="slotProps">
+					<span :title="slotProps.item.privacy">{{
+						slotProps.item.privacy
+					}}</span>
+				</template>
+				<template #column-songsCount="slotProps">
+					<span :title="slotProps.item.songsCount">{{
+						slotProps.item.songsCount
+					}}</span>
+				</template>
+				<template #column-totalLength="slotProps">
+					<span :title="formatTimeLong(slotProps.item.totalLength)">{{
+						formatTimeLong(slotProps.item.totalLength)
+					}}</span>
+				</template>
+				<template #column-createdBy="slotProps">
+					<span v-if="slotProps.item.createdBy === 'Musare'"
+						>Musare</span
+					>
+					<user-id-to-username
+						v-else
+						:user-id="slotProps.item.createdBy"
+						:link="true"
+					/>
+				</template>
+				<template #column-createdAt="slotProps">
+					<span :title="new Date(slotProps.item.createdAt)">{{
+						getDateFormatted(slotProps.item.createdAt)
+					}}</span>
+				</template>
+				<template #column-createdFor="slotProps">
+					<span :title="slotProps.item.createdFor">{{
+						slotProps.item.createdFor
+					}}</span>
+				</template>
+				<template #column-_id="slotProps">
+					<span :title="slotProps.item._id">{{
+						slotProps.item._id
+					}}</span>
+				</template>
+			</advanced-table>
 		</div>
 
 		<edit-playlist v-if="modals.editPlaylist" sector="admin" />
@@ -59,14 +91,13 @@
 </template>
 
 <script>
-import { mapState, mapActions, mapGetters } from "vuex";
+import { mapState, mapActions } from "vuex";
 import { defineAsyncComponent } from "vue";
 
+import AdvancedTable from "@/components/AdvancedTable.vue";
 import RunJobDropdown from "@/components/RunJobDropdown.vue";
-
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
-import ws from "@/ws";
 import utils from "../../../../js/utils";
 
 export default {
@@ -74,18 +105,204 @@ export default {
 		EditPlaylist: defineAsyncComponent(() =>
 			import("@/components/modals/EditPlaylist")
 		),
-		UserIdToUsername,
 		Report: defineAsyncComponent(() =>
 			import("@/components/modals/Report.vue")
 		),
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong")
 		),
-		RunJobDropdown
+		AdvancedTable,
+		RunJobDropdown,
+		UserIdToUsername
 	},
 	data() {
 		return {
 			utils,
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 150,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 76,
+					defaultWidth: 76
+				},
+				{
+					name: "displayName",
+					displayName: "Display Name",
+					properties: ["displayName"],
+					sortProperty: "displayName"
+				},
+				{
+					name: "type",
+					displayName: "Type",
+					properties: ["type"],
+					sortProperty: "type"
+				},
+				{
+					name: "privacy",
+					displayName: "Privacy",
+					properties: ["privacy"],
+					sortProperty: "privacy"
+				},
+				{
+					name: "songsCount",
+					displayName: "Songs #",
+					properties: ["songsCount"],
+					sortProperty: "songsCount",
+					minWidth: 100,
+					defaultWidth: 100
+				},
+				{
+					name: "totalLength",
+					displayName: "Total Length",
+					properties: ["totalLength"],
+					sortProperty: "totalLength",
+					minWidth: 250,
+					defaultWidth: 250
+				},
+				{
+					name: "createdBy",
+					displayName: "Created By",
+					properties: ["createdBy"],
+					sortProperty: "createdBy",
+					defaultWidth: 150
+				},
+				{
+					name: "createdAt",
+					displayName: "Created At",
+					properties: ["createdAt"],
+					sortProperty: "createdAt",
+					defaultWidth: 150
+				},
+				{
+					name: "createdFor",
+					displayName: "Created For",
+					properties: ["createdFor"],
+					sortProperty: "createdFor",
+					minWidth: 230,
+					defaultWidth: 230
+				},
+				{
+					name: "_id",
+					displayName: "Playlist ID",
+					properties: ["_id"],
+					sortProperty: "_id",
+					minWidth: 230,
+					defaultWidth: 230
+				}
+			],
+			filters: [
+				{
+					name: "_id",
+					displayName: "Playlist ID",
+					property: "_id",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "displayName",
+					displayName: "Display Name",
+					property: "displayName",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "type",
+					displayName: "Type",
+					property: "type",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
+					dropdown: [
+						["genre", "Genre"],
+						["station", "Station"],
+						["user", "User"],
+						["user-disliked", "User Disliked"],
+						["user-liked", "User Liked"]
+					]
+				},
+				{
+					name: "privacy",
+					displayName: "Privacy",
+					property: "privacy",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
+					dropdown: [
+						["public", "Public"],
+						["private", "Private"]
+					]
+				},
+				{
+					name: "songsCount",
+					displayName: "Songs Count",
+					property: "songsCount",
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
+				},
+				{
+					name: "totalLength",
+					displayName: "Total Length",
+					property: "totalLength",
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
+				},
+				{
+					name: "createdBy",
+					displayName: "Created By",
+					property: "createdBy",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "createdAt",
+					displayName: "Created At",
+					property: "createdAt",
+					filterTypes: ["datetimeBefore", "datetimeAfter"],
+					defaultFilterType: "datetimeBefore"
+				},
+				{
+					name: "createdFor",
+					displayName: "Created For",
+					property: "createdFor",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				}
+			],
+			events: {
+				adminRoom: "playlists",
+				updated: {
+					event: "admin.playlist.updated",
+					id: "playlist._id",
+					item: "playlist"
+				},
+				removed: {
+					event: "admin.playlist.deleted",
+					id: "playlistId"
+				}
+			},
 			jobs: [
 				{
 					name: "Delete orphaned station playlists",
@@ -129,73 +346,13 @@ export default {
 	computed: {
 		...mapState("modalVisibility", {
 			modals: state => state.modals
-		}),
-		...mapState("admin/playlists", {
-			playlists: state => state.playlists
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
 		})
 	},
-	mounted() {
-		this.socket.on("event:admin.playlist.created", res =>
-			this.addPlaylist(res.data.playlist)
-		);
-
-		this.socket.on("event:admin.playlist.deleted", res =>
-			this.removePlaylist(res.data.playlistId)
-		);
-
-		this.socket.on("event:admin.playlist.song.added", res =>
-			this.addPlaylistSong({
-				playlistId: res.data.playlistId,
-				song: res.data.song
-			})
-		);
-
-		this.socket.on("event:admin.playlist.song.removed", res =>
-			this.removePlaylistSong({
-				playlistId: res.data.playlistId,
-				youtubeId: res.data.youtubeId
-			})
-		);
-
-		this.socket.on("event:admin.playlist.displayName.updated", res =>
-			this.updatePlaylistDisplayName({
-				playlistId: res.data.playlistId,
-				displayName: res.data.displayName
-			})
-		);
-
-		this.socket.on("event:admin.playlist.privacy.updated", res =>
-			this.updatePlaylistPrivacy({
-				playlistId: res.data.playlistId,
-				privacy: res.data.privacy
-			})
-		);
-
-		ws.onConnect(this.init);
-	},
 	methods: {
 		edit(playlistId) {
 			this.editPlaylist(playlistId);
 			this.openModal("editPlaylist");
 		},
-		init() {
-			this.socket.dispatch("playlists.index", res => {
-				if (res.status === "success") {
-					this.setPlaylists(res.data.playlists);
-					if (this.$route.query.playlistId) {
-						const playlist = this.playlists.find(
-							playlist =>
-								playlist._id === this.$route.query.playlistId
-						);
-						if (playlist) this.edit(playlist._id);
-					}
-				}
-			});
-			this.socket.dispatch("apis.joinAdminRoom", "playlists", () => {});
-		},
 		getDateFormatted(createdAt) {
 			const date = new Date(createdAt);
 			const year = date.getFullYear();
@@ -205,60 +362,11 @@ export default {
 			const minute = `${date.getMinutes()}`.padStart(2, 0);
 			return `${year}-${month}-${day} ${hour}:${minute}`;
 		},
-		totalLengthForPlaylist(songs) {
-			let length = 0;
-			songs.forEach(song => {
-				length += song.duration;
-			});
+		formatTimeLong(length) {
 			return this.utils.formatTimeLong(length);
 		},
 		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("user/playlists", ["editPlaylist"]),
-		...mapActions("admin/playlists", [
-			"addPlaylist",
-			"setPlaylists",
-			"removePlaylist",
-			"addPlaylistSong",
-			"removePlaylistSong",
-			"updatePlaylistDisplayName",
-			"updatePlaylistPrivacy"
-		])
+		...mapActions("user/playlists", ["editPlaylist"])
 	}
 };
 </script>
-
-<style lang="scss" scoped>
-.night-mode {
-	.table {
-		color: var(--light-grey-2);
-		background-color: var(--dark-grey-3);
-
-		thead tr {
-			background: var(--dark-grey-3);
-			td {
-				color: var(--white);
-			}
-		}
-
-		tbody tr:hover {
-			background-color: var(--dark-grey-4) !important;
-		}
-
-		tbody tr:nth-child(even) {
-			background-color: var(--dark-grey-2);
-		}
-
-		strong {
-			color: var(--light-grey-2);
-		}
-	}
-}
-
-td {
-	vertical-align: middle;
-}
-
-.is-primary:focus {
-	background-color: var(--primary-color) !important;
-}
-</style>

+ 221 - 107
frontend/src/pages/Admin/tabs/Punishments.vue

@@ -2,61 +2,82 @@
 	<div>
 		<page-metadata title="Admin | Punishments" />
 		<div class="container">
-			<table class="table">
-				<thead>
-					<tr>
-						<td>Status</td>
-						<td>Type</td>
-						<td>Value</td>
-						<td>Reason</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr
-						v-for="punishment in sortedPunishments"
-						:key="punishment._id"
+			<advanced-table
+				:column-default="columnDefault"
+				:columns="columns"
+				:filters="filters"
+				data-action="punishments.getData"
+				name="admin-punishments"
+				max-width="1200"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="
+								button
+								is-primary
+								icon-with-button
+								material-icons
+							"
+							@click="view(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+							content="View Punishment"
+							v-tippy
+						>
+							open_in_full
+						</button>
+					</div>
+				</template>
+				<template #column-status="slotProps">
+					<span>{{ slotProps.item.status }}</span>
+				</template>
+				<template #column-type="slotProps">
+					<span
+						:title="
+							slotProps.item.type === 'banUserId'
+								? 'User ID'
+								: 'IP Address'
+						"
+						>{{
+							slotProps.item.type === "banUserId"
+								? "User ID"
+								: "IP Address"
+						}}</span
 					>
-						<td>
-							{{
-								punishment.active &&
-								new Date(punishment.expiresAt).getTime() >
-									Date.now()
-									? "Active"
-									: "Inactive"
-							}}
-						</td>
-						<td v-if="punishment.type === 'banUserId'">User ID</td>
-						<td v-else>IP Address</td>
-						<td v-if="punishment.type === 'banUserId'">
-							<user-id-to-username
-								:user-id="punishment.value"
-								:alt="punishment.value"
-								:link="true"
-							/>
-							({{ punishment.value }})
-						</td>
-						<td v-else>
-							{{ punishment.value }}
-						</td>
-						<td>{{ punishment.reason }}</td>
-
-						<td>
-							<a
-								class="button is-primary"
-								@click="view(punishment)"
-								content="Expand"
-								v-tippy
-							>
-								<i class="material-icons icon-with-button">
-									open_in_full
-								</i>
-								Expand
-							</a>
-						</td>
-					</tr>
-				</tbody>
-			</table>
+				</template>
+				<template #column-value="slotProps">
+					<user-id-to-username
+						v-if="slotProps.item.type === 'banUserId'"
+						:user-id="slotProps.item.value"
+						:alt="slotProps.item.value"
+						:link="true"
+					/>
+					<span v-else :title="slotProps.item.value">{{
+						slotProps.item.value
+					}}</span>
+				</template>
+				<template #column-reason="slotProps">
+					<span :title="slotProps.item.reason">{{
+						slotProps.item.reason
+					}}</span>
+				</template>
+				<template #column-punishedBy="slotProps">
+					<user-id-to-username
+						:user-id="slotProps.item.punishedBy"
+						:link="true"
+					/>
+				</template>
+				<template #column-punishedAt="slotProps">
+					<span :title="new Date(slotProps.item.punishedAt)">{{
+						getDateFormatted(slotProps.item.punishedAt)
+					}}</span>
+				</template>
+				<template #column-expiresAt="slotProps">
+					<span :title="new Date(slotProps.item.expiresAt)">{{
+						getDateFormatted(slotProps.item.expiresAt)
+					}}</span>
+				</template>
+			</advanced-table>
 			<div class="card">
 				<header class="card-header">
 					<p>Ban an IP</p>
@@ -110,8 +131,7 @@ import { mapState, mapGetters, mapActions } from "vuex";
 import Toast from "toasters";
 import { defineAsyncComponent } from "vue";
 
-import ws from "@/ws";
-
+import AdvancedTable from "@/components/AdvancedTable.vue";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 export default {
@@ -119,21 +139,148 @@ export default {
 		ViewPunishment: defineAsyncComponent(() =>
 			import("@/components/modals/ViewPunishment.vue")
 		),
+		AdvancedTable,
 		UserIdToUsername
 	},
 	data() {
 		return {
 			viewingPunishmentId: "",
-			punishments: [],
 			ipBan: {
 				expiresAt: "1h"
-			}
+			},
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 150,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 76,
+					defaultWidth: 76
+				},
+				{
+					name: "status",
+					displayName: "Status",
+					properties: ["status"],
+					sortable: false,
+					defaultWidth: 150
+				},
+				{
+					name: "type",
+					displayName: "Type",
+					properties: ["type"],
+					sortProperty: "type"
+				},
+				{
+					name: "value",
+					displayName: "Value",
+					properties: ["value"],
+					sortProperty: "value",
+					defaultWidth: 150
+				},
+				{
+					name: "reason",
+					displayName: "Reason",
+					properties: ["reason"],
+					sortProperty: "reason"
+				},
+				{
+					name: "punishedBy",
+					displayName: "Punished By",
+					properties: ["punishedBy"],
+					sortProperty: "punishedBy",
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "punishedAt",
+					displayName: "Punished At",
+					properties: ["punishedAt"],
+					sortProperty: "punishedAt",
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "expiresAt",
+					displayName: "Expires At",
+					properties: ["expiresAt"],
+					sortProperty: "verifiedAt",
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
+				}
+			],
+			filters: [
+				{
+					name: "status",
+					displayName: "Status",
+					property: "status",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
+					dropdown: [
+						["Active", "Active"],
+						["Inactive", "Inactive"]
+					]
+				},
+				{
+					name: "type",
+					displayName: "Type",
+					property: "type",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
+					dropdown: [
+						["banUserId", "User ID"],
+						["banUserIp", "IP Address"]
+					]
+				},
+				{
+					name: "value",
+					displayName: "Value",
+					property: "value",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "reason",
+					displayName: "Reason",
+					property: "reason",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "punishedBy",
+					displayName: "Punished By",
+					property: "punishedBy",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "punishedAt",
+					displayName: "Punished At",
+					property: "punishedAt",
+					filterTypes: ["datetimeBefore", "datetimeAfter"],
+					defaultFilterType: "datetimeBefore"
+				},
+				{
+					name: "expiresAt",
+					displayName: "Expires At",
+					property: "expiresAt",
+					filterTypes: ["datetimeBefore", "datetimeAfter"],
+					defaultFilterType: "datetimeBefore"
+				}
+			]
 		};
 	},
 	computed: {
-		sortedPunishments() {
-			return this.punishments;
-		},
 		...mapState("modalVisibility", {
 			modals: state => state.modals
 		}),
@@ -141,16 +288,9 @@ export default {
 			socket: "websockets/getSocket"
 		})
 	},
-	mounted() {
-		ws.onConnect(this.init);
-
-		this.socket.on("event:admin.punishment.created", res =>
-			this.punishments.push(res.data.punishment)
-		);
-	},
 	methods: {
-		view(punishment) {
-			this.viewingPunishmentId = punishment._id;
+		view(punishmentId) {
+			this.viewingPunishmentId = punishmentId;
 			this.openModal("viewPunishment");
 		},
 		banIP() {
@@ -164,13 +304,14 @@ export default {
 				}
 			);
 		},
-		init() {
-			this.socket.dispatch("punishments.index", res => {
-				if (res.status === "success")
-					this.punishments = res.data.punishments;
-			});
-
-			this.socket.dispatch("apis.joinAdminRoom", "punishments", () => {});
+		getDateFormatted(createdAt) {
+			const date = new Date(createdAt);
+			const year = date.getFullYear();
+			const month = `${date.getMonth() + 1}`.padStart(2, 0);
+			const day = `${date.getDate()}`.padStart(2, 0);
+			const hour = `${date.getHours()}`.padStart(2, 0);
+			const minute = `${date.getMinutes()}`.padStart(2, 0);
+			return `${year}-${month}-${day} ${hour}:${minute}`;
 		},
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("admin/punishments", ["viewPunishment"])
@@ -180,30 +321,6 @@ export default {
 
 <style lang="scss" scoped>
 .night-mode {
-	.table {
-		color: var(--light-grey-2);
-		background-color: var(--dark-grey-3);
-
-		thead tr {
-			background: var(--dark-grey-3);
-			td {
-				color: var(--white);
-			}
-		}
-
-		tbody tr:hover {
-			background-color: var(--dark-grey-4) !important;
-		}
-
-		tbody tr:nth-child(even) {
-			background-color: var(--dark-grey-2);
-		}
-
-		strong {
-			color: var(--light-grey-2);
-		}
-	}
-
 	.card {
 		background: var(--dark-grey-3);
 
@@ -233,12 +350,9 @@ export default {
 	.button.is-primary {
 		width: 100%;
 	}
-}
 
-td {
-	vertical-align: middle;
-}
-select {
-	margin-bottom: 10px;
+	select {
+		margin-bottom: 10px;
+	}
 }
 </style>

+ 223 - 158
frontend/src/pages/Admin/tabs/Reports.vue

@@ -2,78 +2,98 @@
 	<div>
 		<page-metadata title="Admin | Reports" />
 		<div class="container">
-			<table class="table">
-				<thead>
-					<tr>
-						<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>
-								<a
-									:href="
-										'https://www.youtube.com/watch?v=' +
-										`${report.song.youtubeId}`
-									"
-									target="_blank"
-								>
-									{{ report.song.youtubeId }}</a
-								>
-								<br />
-								{{ report.song._id }}
-							</span>
-						</td>
-
-						<td id="categories-column">
-							<ul>
-								<li
-									v-for="category in getCategories(
-										report.issues
-									)"
-									:key="category"
-								>
-									{{ category }}
-								</li>
-							</ul>
-						</td>
-						<td id="options-column">
-							<button
-								class="button is-primary"
-								@click="view(report._id)"
-								content="Expand"
-								v-tippy
-							>
-								<i class="material-icons icon-with-button">
-									open_in_full
-								</i>
-								Expand
-							</button>
-							<button
-								class="button is-success"
-								@click="resolve(report._id)"
-								content="Resolve"
-								v-tippy
-							>
-								<i class="material-icons icon-with-button">
-									done_all
-								</i>
-								Resolve
-							</button>
-						</td>
-					</tr>
-				</tbody>
-			</table>
+			<advanced-table
+				:column-default="columnDefault"
+				:columns="columns"
+				:filters="filters"
+				data-action="reports.getData"
+				name="admin-reports"
+				max-width="1200"
+				:events="events"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="
+								button
+								is-primary
+								icon-with-button
+								material-icons
+							"
+							@click="view(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+							content="View Report"
+							v-tippy
+						>
+							open_in_full
+						</button>
+						<button
+							class="
+								button
+								is-success
+								icon-with-button
+								material-icons
+							"
+							@click="resolve(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+							content="Resolve Report"
+							v-tippy
+						>
+							done_all
+						</button>
+					</div>
+				</template>
+				<template #column-_id="slotProps">
+					<span :title="slotProps.item._id">{{
+						slotProps.item._id
+					}}</span>
+				</template>
+				<template #column-songId="slotProps">
+					<span :title="slotProps.item.song._id">{{
+						slotProps.item.song._id
+					}}</span>
+				</template>
+				<template #column-songYoutubeId="slotProps">
+					<a
+						:href="
+							'https://www.youtube.com/watch?v=' +
+							`${slotProps.item.song.youtubeId}`
+						"
+						target="_blank"
+					>
+						{{ slotProps.item.song.youtubeId }}
+					</a>
+				</template>
+				<template #column-categories="slotProps">
+					<span
+						:title="
+							slotProps.item.issues
+								.map(issue => issue.category)
+								.join(', ')
+						"
+						>{{
+							slotProps.item.issues
+								.map(issue => issue.category)
+								.join(", ")
+						}}</span
+					>
+				</template>
+				<template #column-createdBy="slotProps">
+					<span v-if="slotProps.item.createdBy === 'Musare'"
+						>Musare</span
+					>
+					<user-id-to-username
+						v-else
+						:user-id="slotProps.item.createdBy"
+						:link="true"
+					/>
+				</template>
+				<template #column-createdAt="slotProps">
+					<span :title="new Date(slotProps.item.createdAt)">{{
+						getDateFormatted(slotProps.item.createdAt)
+					}}</span>
+				</template>
+			</advanced-table>
 		</div>
 
 		<view-report v-if="modals.viewReport" sector="admin" />
@@ -83,12 +103,13 @@
 </template>
 
 <script>
-import { mapState, mapActions, mapGetters } from "vuex";
+import { mapState, mapActions } from "vuex";
 import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
-import ReportInfoItem from "@/components/ReportInfoItem.vue";
-import ws from "@/ws";
+
+import AdvancedTable from "@/components/AdvancedTable.vue";
+import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 export default {
 	components: {
@@ -101,52 +122,135 @@ export default {
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong/index.vue")
 		),
-		ReportInfoItem
+		AdvancedTable,
+		UserIdToUsername
 	},
 	data() {
 		return {
-			reports: []
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 150,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 85,
+					defaultWidth: 85
+				},
+				{
+					name: "_id",
+					displayName: "Report ID",
+					properties: ["_id"],
+					sortProperty: "_id",
+					minWidth: 215,
+					defaultWidth: 215
+				},
+				{
+					name: "songId",
+					displayName: "Song ID",
+					properties: ["song"],
+					sortProperty: "song._id",
+					minWidth: 215,
+					defaultWidth: 215
+				},
+				{
+					name: "songYoutubeId",
+					displayName: "Song YouTube ID",
+					properties: ["song"],
+					sortProperty: "song.youtubeId",
+					minWidth: 165,
+					defaultWidth: 165
+				},
+				{
+					name: "categories",
+					displayName: "Categories",
+					properties: ["issues"],
+					sortable: false
+				},
+				{
+					name: "createdBy",
+					displayName: "Created By",
+					properties: ["createdBy"],
+					sortProperty: "createdBy",
+					defaultWidth: 150
+				},
+				{
+					name: "createdAt",
+					displayName: "Created At",
+					properties: ["createdAt"],
+					sortProperty: "createdAt",
+					defaultWidth: 150
+				}
+			],
+			filters: [
+				{
+					name: "_id",
+					displayName: "Report ID",
+					property: "_id",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "songId",
+					displayName: "Song ID",
+					property: "song._id",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "songYoutubeId",
+					displayName: "Song YouTube ID",
+					property: "song.youtubeId",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "categories",
+					displayName: "Categories",
+					property: "issues.category",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "createdBy",
+					displayName: "Created By",
+					property: "createdBy",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "createdAt",
+					displayName: "Created At",
+					property: "createdAt",
+					filterTypes: ["datetimeBefore", "datetimeAfter"],
+					defaultFilterType: "datetimeBefore"
+				}
+			],
+			events: {
+				adminRoom: "reports",
+				removed: {
+					event: "admin.report.resolved",
+					id: "reportId"
+				}
+			}
 		};
 	},
 	computed: {
 		...mapState("modalVisibility", {
 			modals: state => state.modals
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
 		})
 	},
-	mounted() {
-		ws.onConnect(this.init);
-
-		this.socket.on("event:admin.report.resolved", res => {
-			this.reports = this.reports.filter(
-				report => report._id !== res.data.reportId
-			);
-		});
-
-		this.socket.on("event:admin.report.created", res =>
-			this.reports.unshift(res.data.report)
-		);
-	},
 	methods: {
-		init() {
-			this.socket.dispatch("reports.index", res => {
-				if (res.status === "success") this.reports = res.data.reports;
-			});
-
-			this.socket.dispatch("apis.joinAdminRoom", "reports", () => {});
-		},
-		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");
@@ -159,57 +263,18 @@ export default {
 				})
 				.catch(err => new Toast(err.message));
 		},
+		getDateFormatted(createdAt) {
+			const date = new Date(createdAt);
+			const year = date.getFullYear();
+			const month = `${date.getMonth() + 1}`.padStart(2, 0);
+			const day = `${date.getDate()}`.padStart(2, 0);
+			const hour = `${date.getHours()}`.padStart(2, 0);
+			const minute = `${date.getMinutes()}`.padStart(2, 0);
+			return `${year}-${month}-${day} ${hour}:${minute}`;
+		},
 		...mapActions("modalVisibility", ["openModal", "closeModal"]),
 		...mapActions("admin/reports", ["resolveReport"]),
 		...mapActions("modals/viewReport", ["viewReport"])
 	}
 };
 </script>
-
-<style lang="scss" scoped>
-.night-mode {
-	.table {
-		color: var(--light-grey-2);
-		background-color: var(--dark-grey-3);
-
-		thead tr {
-			background: var(--dark-grey-3);
-			td {
-				color: var(--white);
-			}
-		}
-
-		tbody tr:hover {
-			background-color: var(--dark-grey-4) !important;
-		}
-
-		tbody tr:nth-child(even) {
-			background-color: var(--dark-grey-2);
-		}
-
-		strong {
-			color: var(--light-grey-2);
-		}
-	}
-}
-
-#options-column {
-	button:not(:last-of-type) {
-		margin-right: 5px;
-	}
-}
-
-#categories-column {
-	text-transform: capitalize;
-}
-
-td {
-	word-wrap: break-word;
-	max-width: 10vw;
-	vertical-align: middle;
-}
-
-li {
-	list-style: inside;
-}
-</style>

+ 441 - 251
frontend/src/pages/Admin/tabs/Songs.vue

@@ -3,13 +3,6 @@
 		<page-metadata title="Admin | Songs" />
 		<div class="admin-tab">
 			<div class="button-row">
-				<button
-					class="button is-primary"
-					@click="toggleKeyboardShortcutsHelper"
-					@dblclick="resetKeyboardShortcutsHelper"
-				>
-					Keyboard shortcuts helper
-				</button>
 				<button
 					class="button is-primary"
 					@click="openModal('requestSong')"
@@ -30,7 +23,80 @@
 				:filters="filters"
 				data-action="songs.getData"
 				name="admin-songs"
+				:events="events"
 			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="
+								button
+								is-primary
+								icon-with-button
+								material-icons
+							"
+							@click="editOne(slotProps.item)"
+							:disabled="slotProps.item.removed"
+							content="Edit Song"
+							v-tippy
+						>
+							edit
+						</button>
+						<quick-confirm
+							v-if="slotProps.item.verified"
+							@confirm="unverifyOne(slotProps.item._id)"
+						>
+							<button
+								class="
+									button
+									is-danger
+									icon-with-button
+									material-icons
+								"
+								:disabled="slotProps.item.removed"
+								content="Unverify Song"
+								v-tippy
+							>
+								cancel
+							</button>
+						</quick-confirm>
+						<button
+							v-else
+							class="
+								button
+								is-success
+								icon-with-button
+								material-icons
+							"
+							@click="verifyOne(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+							content="Verify Song"
+							v-tippy
+						>
+							check_circle
+						</button>
+						<button
+							class="
+								button
+								is-danger
+								icon-with-button
+								material-icons
+							"
+							@click.prevent="
+								confirmAction({
+									message:
+										'Removing this song will remove it from all playlists and cause a ratings recalculation.',
+									action: 'deleteOne',
+									params: slotProps.item._id
+								})
+							"
+							:disabled="slotProps.item.removed"
+							content="Delete Song"
+							v-tippy
+						>
+							delete_forever
+						</button>
+					</div>
+				</template>
 				<template #column-thumbnailImage="slotProps">
 					<img
 						class="song-thumbnail"
@@ -59,6 +125,11 @@
 						slotProps.item.genres.join(", ")
 					}}</span>
 				</template>
+				<template #column-tags="slotProps">
+					<span :title="slotProps.item.tags.join(', ')">{{
+						slotProps.item.tags.join(", ")
+					}}</span>
+				</template>
 				<template #column-likes="slotProps">
 					<span :title="slotProps.item.likes">{{
 						slotProps.item.likes
@@ -85,9 +156,19 @@
 						{{ slotProps.item.youtubeId }}
 					</a>
 				</template>
-				<template #column-status="slotProps">
-					<span :title="slotProps.item.status">{{
-						slotProps.item.status
+				<template #column-verified="slotProps">
+					<span :title="slotProps.item.verified">{{
+						slotProps.item.verified
+					}}</span>
+				</template>
+				<template #column-duration="slotProps">
+					<span :title="slotProps.item.duration">{{
+						slotProps.item.duration
+					}}</span>
+				</template>
+				<template #column-skipDuration="slotProps">
+					<span :title="slotProps.item.skipDuration">{{
+						slotProps.item.skipDuration
 					}}</span>
 				</template>
 				<template #column-requestedBy="slotProps">
@@ -96,13 +177,30 @@
 						:link="true"
 					/>
 				</template>
+				<template #column-requestedAt="slotProps">
+					<span :title="new Date(slotProps.item.requestedAt)">{{
+						getDateFormatted(slotProps.item.requestedAt)
+					}}</span>
+				</template>
+				<template #column-verifiedBy="slotProps">
+					<user-id-to-username
+						:user-id="slotProps.item.verifiedBy"
+						:link="true"
+					/>
+				</template>
+				<template #column-verifiedAt="slotProps">
+					<span :title="new Date(slotProps.item.verifiedAt)">{{
+						getDateFormatted(slotProps.item.verifiedAt)
+					}}</span>
+				</template>
 				<template #bulk-actions="slotProps">
-					<div class="song-bulk-actions">
+					<div class="bulk-actions">
 						<i
 							class="material-icons edit-songs-icon"
 							@click.prevent="editMany(slotProps.item)"
 							content="Edit Songs"
 							v-tippy
+							tabindex="0"
 						>
 							edit
 						</i>
@@ -111,22 +209,29 @@
 							@click.prevent="verifyMany(slotProps.item)"
 							content="Verify Songs"
 							v-tippy
+							tabindex="0"
 						>
 							check_circle
 						</i>
-						<i
-							class="material-icons unverify-songs-icon"
-							@click.prevent="unverifyMany(slotProps.item)"
-							content="Unverify Songs"
-							v-tippy
+						<quick-confirm
+							placement="left"
+							@confirm="unverifyMany(slotProps.item)"
+							tabindex="0"
 						>
-							cancel
-						</i>
+							<i
+								class="material-icons unverify-songs-icon"
+								content="Unverify Songs"
+								v-tippy
+							>
+								cancel
+							</i>
+						</quick-confirm>
 						<i
 							class="material-icons tag-songs-icon"
-							@click.prevent="tagMany(slotProps.item)"
-							content="Tag Songs"
+							@click.prevent="setTags(slotProps.item)"
+							content="Set Tags"
 							v-tippy
+							tabindex="0"
 						>
 							local_offer
 						</i>
@@ -135,6 +240,7 @@
 							@click.prevent="setArtists(slotProps.item)"
 							content="Set Artists"
 							v-tippy
+							tabindex="0"
 						>
 							group
 						</i>
@@ -143,103 +249,37 @@
 							@click.prevent="setGenres(slotProps.item)"
 							content="Set Genres"
 							v-tippy
+							tabindex="0"
 						>
 							theater_comedy
 						</i>
-						<quick-confirm
-							placement="left"
-							@confirm="deleteMany(slotProps.item)"
+						<i
+							class="material-icons delete-icon"
+							@click.prevent="
+								confirmAction({
+									message:
+										'Removing these songs will remove them from all playlists and cause a ratings recalculation.',
+									action: 'deleteMany',
+									params: slotProps.item
+								})
+							"
+							content="Delete Songs"
+							v-tippy
+							tabindex="0"
 						>
-							<i
-								class="material-icons delete-songs-icon"
-								content="Delete Songs"
-								v-tippy
-							>
-								delete_forever
-							</i>
-						</quick-confirm>
+							delete_forever
+						</i>
 					</div>
 				</template>
 			</advanced-table>
 		</div>
 		<import-album v-if="modals.importAlbum" />
-		<edit-song v-if="modals.editSong" song-type="songs" :key="song._id" />
+		<edit-song v-if="modals.editSong" song-type="songs" />
+		<edit-songs v-if="modals.editSongs" />
 		<report v-if="modals.report" />
 		<request-song v-if="modals.requestSong" />
-		<floating-box
-			id="keyboardShortcutsHelper"
-			ref="keyboardShortcutsHelper"
-		>
-			<template #body>
-				<div>
-					<div>
-						<span class="biggest"
-							><b>Keyboard shortcuts helper</b></span
-						>
-						<span
-							><b>Ctrl + /</b> - Toggles this keyboard shortcuts
-							helper</span
-						>
-						<span
-							><b>Ctrl + Shift + /</b> - Resets the position of
-							this keyboard shortcuts helper</span
-						>
-						<hr />
-					</div>
-					<div>
-						<span class="biggest"><b>Edit song modal</b></span>
-						<span class="bigger"><b>Navigation</b></span>
-						<span><b>Home</b> - Edit</span>
-						<span><b>End</b> - Edit</span>
-						<hr />
-					</div>
-					<div>
-						<span class="bigger"><b>Player controls</b></span>
-						<span class="bigger"
-							><i>Don't forget to turn off numlock!</i></span
-						>
-						<span><b>Numpad up/down</b> - Volume up/down 10%</span>
-						<span
-							><b>Ctrl + Numpad up/down</b> - Volume up/down
-							1%</span
-						>
-						<span><b>Numpad center</b> - Pause/resume</span>
-						<span><b>Ctrl + Numpad center</b> - Stop</span>
-						<span
-							><b>Numpad Right</b> - Skip to last 10 seconds</span
-						>
-						<hr />
-					</div>
-					<div>
-						<span class="bigger"><b>Form control</b></span>
-						<span
-							><b>Enter</b> - Executes blue button in that
-							input</span
-						>
-						<span
-							><b>Shift + Enter</b> - Executes purple/red button
-							in that input</span
-						>
-						<span
-							><b>Ctrl + Alt + D</b> - Fill in all Discogs
-							fields</span
-						>
-						<hr />
-					</div>
-					<div>
-						<span class="bigger"><b>Modal control</b></span>
-						<span><b>Ctrl + S</b> - Save</span>
-						<span><b>Ctrl + Alt + S</b> - Save and close</span>
-						<span
-							><b>Ctrl + Alt + V</b> - Save, verify and
-							close</span
-						>
-						<span><b>F4</b> - Close without saving</span>
-						<hr />
-					</div>
-				</div>
-			</template>
-		</floating-box>
+		<bulk-actions v-if="modals.bulkActions" :type="bulkActionsType" />
+		<confirm v-if="modals.confirm" @confirmed="handleConfirmed()" />
 	</div>
 </template>
 
@@ -249,11 +289,8 @@ import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
 
-import keyboardShortcuts from "@/keyboardShortcuts";
-
 import AdvancedTable from "@/components/AdvancedTable.vue";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
-import FloatingBox from "@/components/FloatingBox.vue";
 import QuickConfirm from "@/components/QuickConfirm.vue";
 import RunJobDropdown from "@/components/RunJobDropdown.vue";
 
@@ -262,6 +299,9 @@ export default {
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong")
 		),
+		EditSongs: defineAsyncComponent(() =>
+			import("@/components/modals/EditSongs.vue")
+		),
 		Report: defineAsyncComponent(() =>
 			import("@/components/modals/Report.vue")
 		),
@@ -271,9 +311,14 @@ export default {
 		RequestSong: defineAsyncComponent(() =>
 			import("@/components/modals/RequestSong.vue")
 		),
+		BulkActions: defineAsyncComponent(() =>
+			import("@/components/modals/BulkActions.vue")
+		),
+		Confirm: defineAsyncComponent(() =>
+			import("@/components/modals/Confirm.vue")
+		),
 		AdvancedTable,
 		UserIdToUsername,
-		FloatingBox,
 		QuickConfirm,
 		RunJobDropdown
 	},
@@ -289,6 +334,16 @@ export default {
 				maxWidth: 600
 			},
 			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id", "verified"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 129,
+					defaultWidth: 129
+				},
 				{
 					name: "thumbnailImage",
 					displayName: "Thumb",
@@ -317,6 +372,12 @@ export default {
 					properties: ["genres"],
 					sortable: false
 				},
+				{
+					name: "tags",
+					displayName: "Tags",
+					properties: ["tags"],
+					sortable: false
+				},
 				{
 					name: "likes",
 					displayName: "Likes",
@@ -337,7 +398,7 @@ export default {
 				},
 				{
 					name: "_id",
-					displayName: "Musare ID",
+					displayName: "Song ID",
 					properties: ["_id"],
 					sortProperty: "_id",
 					minWidth: 215,
@@ -352,11 +413,10 @@ export default {
 					defaultWidth: 120
 				},
 				{
-					name: "status",
-					displayName: "Status",
-					properties: ["status"],
-					sortProperty: "status",
-					defaultVisibility: "hidden",
+					name: "verified",
+					displayName: "Verified",
+					properties: ["verified"],
+					sortProperty: "verified",
 					minWidth: 120,
 					defaultWidth: 120
 				},
@@ -367,18 +427,59 @@ export default {
 					sortProperty: "thumbnail",
 					defaultVisibility: "hidden"
 				},
+				{
+					name: "duration",
+					displayName: "Duration",
+					properties: ["duration"],
+					sortProperty: "duration",
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "skipDuration",
+					displayName: "Skip Duration",
+					properties: ["skipDuration"],
+					sortProperty: "skipDuration",
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
+				},
 				{
 					name: "requestedBy",
 					displayName: "Requested By",
 					properties: ["requestedBy"],
 					sortProperty: "requestedBy",
-					defaultWidth: 200
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "requestedAt",
+					displayName: "Requested At",
+					properties: ["requestedAt"],
+					sortProperty: "requestedAt",
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "verifiedBy",
+					displayName: "Verified By",
+					properties: ["verifiedBy"],
+					sortProperty: "verifiedBy",
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "verifiedAt",
+					displayName: "Verified At",
+					properties: ["verifiedAt"],
+					sortProperty: "verifiedAt",
+					defaultWidth: 200,
+					defaultVisibility: "hidden"
 				}
 			],
 			filters: [
 				{
 					name: "_id",
-					displayName: "Musare ID",
+					displayName: "Song ID",
 					property: "_id",
 					filterTypes: ["exact"],
 					defaultFilterType: "exact"
@@ -402,14 +503,27 @@ export default {
 					displayName: "Artists",
 					property: "artists",
 					filterTypes: ["contains", "exact", "regex"],
-					defaultFilterType: "contains"
+					defaultFilterType: "contains",
+					autosuggest: true,
+					autosuggestDataAction: "songs.getArtists"
 				},
 				{
 					name: "genres",
 					displayName: "Genres",
 					property: "genres",
 					filterTypes: ["contains", "exact", "regex"],
-					defaultFilterType: "contains"
+					defaultFilterType: "contains",
+					autosuggest: true,
+					autosuggestDataAction: "songs.getGenres"
+				},
+				{
+					name: "tags",
+					displayName: "Tags",
+					property: "tags",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains",
+					autosuggest: true,
+					autosuggestDataAction: "songs.getTags"
 				},
 				{
 					name: "thumbnail",
@@ -426,27 +540,98 @@ export default {
 					defaultFilterType: "contains"
 				},
 				{
-					name: "status",
-					displayName: "Status",
-					property: "status",
+					name: "requestedAt",
+					displayName: "Requested At",
+					property: "requestedAt",
+					filterTypes: ["datetimeBefore", "datetimeAfter"],
+					defaultFilterType: "datetimeBefore"
+				},
+				{
+					name: "verifiedBy",
+					displayName: "Verified By",
+					property: "verifiedBy",
 					filterTypes: ["contains", "exact", "regex"],
-					defaultFilterType: "exact"
+					defaultFilterType: "contains"
+				},
+				{
+					name: "verifiedAt",
+					displayName: "Verified At",
+					property: "verifiedAt",
+					filterTypes: ["datetimeBefore", "datetimeAfter"],
+					defaultFilterType: "datetimeBefore"
+				},
+				{
+					name: "verified",
+					displayName: "Verified",
+					property: "verified",
+					filterTypes: ["boolean"],
+					defaultFilterType: "boolean"
 				},
 				{
 					name: "likes",
 					displayName: "Likes",
 					property: "likes",
-					filterTypes: ["contains", "exact", "regex"],
-					defaultFilterType: "exact"
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
 				},
 				{
 					name: "dislikes",
 					displayName: "Dislikes",
 					property: "dislikes",
-					filterTypes: ["contains", "exact", "regex"],
-					defaultFilterType: "exact"
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
+				},
+				{
+					name: "duration",
+					displayName: "Duration",
+					property: "duration",
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
+				},
+				{
+					name: "skipDuration",
+					displayName: "Skip Duration",
+					property: "skipDuration",
+					filterTypes: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
 				}
 			],
+			events: {
+				adminRoom: "songs",
+				updated: {
+					event: "admin.song.updated",
+					id: "song._id",
+					item: "song"
+				},
+				removed: {
+					event: "admin.song.removed",
+					id: "songId"
+				}
+			},
 			jobs: [
 				{
 					name: "Update all songs",
@@ -456,7 +641,13 @@ export default {
 					name: "Recalculate all song ratings",
 					socket: "songs.recalculateAllRatings"
 				}
-			]
+			],
+			confirm: {
+				message: "",
+				action: "",
+				params: null
+			},
+			bulkActionsType: null
 		};
 	},
 	computed: {
@@ -471,14 +662,6 @@ export default {
 		})
 	},
 	mounted() {
-		// TODO: Implement song update events in advanced table
-		// this.socket.on("event:admin.song.updated", res => {
-		// 	const { song } = res.data;
-		// 	if (this.songs.filter(s => s._id === song._id).length === 0)
-		// 		this.addSong(song);
-		// 	else this.updateSong(song);
-		// });
-
 		if (this.$route.query.songId) {
 			this.socket.dispatch(
 				"songs.getSongFromSongId",
@@ -490,150 +673,157 @@ export default {
 				}
 			);
 		}
-
-		keyboardShortcuts.registerShortcut(
-			"songs.toggleKeyboardShortcutsHelper",
-			{
-				keyCode: 191, // '/' key
-				ctrl: true,
-				preventDefault: true,
-				handler: () => {
-					this.toggleKeyboardShortcutsHelper();
-				}
-			}
-		);
-
-		keyboardShortcuts.registerShortcut(
-			"songs.resetKeyboardShortcutsHelper",
-			{
-				keyCode: 191, // '/' key
-				ctrl: true,
-				shift: true,
-				preventDefault: true,
-				handler: () => {
-					this.resetKeyboardShortcutsHelper();
-				}
-			}
-		);
-	},
-	beforeUnmount() {
-		const shortcutNames = [
-			"songs.toggleKeyboardShortcutsHelper",
-			"songs.resetKeyboardShortcutsHelper"
-		];
-
-		shortcutNames.forEach(shortcutName => {
-			keyboardShortcuts.unregisterShortcut(shortcutName);
-		});
 	},
 	methods: {
+		editOne(song) {
+			this.editSong({ songId: song._id });
+			this.openModal("editSong");
+		},
 		editMany(selectedRows) {
-			if (selectedRows.length === 1) {
-				this.editSong(selectedRows[0]);
-				this.openModal("editSong");
-			} else {
-				new Toast("Bulk editing not yet implemented.");
+			if (selectedRows.length === 1) this.editOne(selectedRows[0]);
+			else {
+				const songs = selectedRows.map(row => ({
+					songId: row._id
+				}));
+				this.editSongs(songs);
+				this.openModal("editSongs");
 			}
 		},
+		verifyOne(songId) {
+			this.socket.dispatch("songs.verify", songId, res => {
+				new Toast(res.message);
+			});
+		},
 		verifyMany(selectedRows) {
-			if (selectedRows.length === 1) {
-				this.socket.dispatch(
-					"songs.verify",
-					selectedRows[0]._id,
-					res => {
-						new Toast(res.message);
-					}
-				);
-			} else {
-				new Toast("Bulk verifying not yet implemented.");
-			}
+			this.socket.dispatch(
+				"songs.verifyMany",
+				selectedRows.map(row => row._id),
+				res => {
+					new Toast(res.message);
+				}
+			);
+		},
+		unverifyOne(songId) {
+			this.socket.dispatch("songs.unverify", songId, res => {
+				new Toast(res.message);
+			});
 		},
 		unverifyMany(selectedRows) {
-			if (selectedRows.length === 1) {
-				this.socket.dispatch(
-					"songs.unverify",
-					selectedRows[0]._id,
-					res => {
-						new Toast(res.message);
-					}
-				);
-			} else {
-				new Toast("Bulk unverifying not yet implemented.");
-			}
+			this.socket.dispatch(
+				"songs.unverifyMany",
+				selectedRows.map(row => row._id),
+				res => {
+					new Toast(res.message);
+				}
+			);
 		},
-		tagMany() {
-			new Toast("Bulk tagging not yet implemented.");
+		setTags(selectedRows) {
+			this.bulkActionsType = {
+				name: "tags",
+				action: "songs.editTags",
+				items: selectedRows.map(row => row._id),
+				regex: new RegExp(
+					/^[a-zA-Z0-9_]{1,64}$|^[a-zA-Z0-9_]{1,64}\[[a-zA-Z0-9_]{1,64}\]$/
+				),
+				autosuggest: true,
+				autosuggestDataAction: "songs.getTags"
+			};
+			this.openModal("bulkActions");
 		},
-		setArtists() {
-			new Toast("Bulk setting artists not yet implemented.");
+		setArtists(selectedRows) {
+			this.bulkActionsType = {
+				name: "artists",
+				action: "songs.editArtists",
+				items: selectedRows.map(row => row._id),
+				regex: new RegExp(/^(?=.{1,64}$).*$/),
+				autosuggest: true,
+				autosuggestDataAction: "songs.getArtists"
+			};
+			this.openModal("bulkActions");
 		},
-		setGenres() {
-			new Toast("Bulk setting genres not yet implemented.");
+		setGenres(selectedRows) {
+			this.bulkActionsType = {
+				name: "genres",
+				action: "songs.editGenres",
+				items: selectedRows.map(row => row._id),
+				regex: new RegExp(/^[\x00-\x7F]{1,32}$/),
+				autosuggest: true,
+				autosuggestDataAction: "songs.getGenres"
+			};
+			this.openModal("bulkActions");
 		},
-		deleteMany() {
-			new Toast("Bulk deleting not yet implemented.");
+		deleteOne(songId) {
+			this.socket.dispatch("songs.remove", songId, res => {
+				new Toast(res.message);
+			});
 		},
-		toggleKeyboardShortcutsHelper() {
-			this.$refs.keyboardShortcutsHelper.toggleBox();
+		deleteMany(selectedRows) {
+			this.socket.dispatch(
+				"songs.removeMany",
+				selectedRows.map(row => row._id),
+				res => {
+					new Toast(res.message);
+				}
+			);
+		},
+		getDateFormatted(createdAt) {
+			const date = new Date(createdAt);
+			const year = date.getFullYear();
+			const month = `${date.getMonth() + 1}`.padStart(2, 0);
+			const day = `${date.getDate()}`.padStart(2, 0);
+			const hour = `${date.getHours()}`.padStart(2, 0);
+			const minute = `${date.getMinutes()}`.padStart(2, 0);
+			return `${year}-${month}-${day} ${hour}:${minute}`;
 		},
-		resetKeyboardShortcutsHelper() {
-			this.$refs.keyboardShortcutsHelper.resetBox();
+		confirmAction(confirm) {
+			this.confirm = confirm;
+			this.updateConfirmMessage(confirm.message);
+			this.openModal("confirm");
+		},
+		handleConfirmed() {
+			const { action, params } = this.confirm;
+			if (typeof this[action] === "function") {
+				if (params) this[action](params);
+				else this[action]();
+			}
+			this.confirm = {
+				message: "",
+				action: "",
+				params: null
+			};
 		},
 		...mapActions("modals/editSong", ["editSong"]),
+		...mapActions("modals/editSongs", ["editSongs"]),
+		...mapActions("modals/confirm", ["updateConfirmMessage"]),
 		...mapActions("modalVisibility", ["openModal"])
 	}
 };
 </script>
 
 <style lang="scss" scoped>
-#keyboardShortcutsHelper {
-	.box-body {
-		.biggest {
-			font-size: 18px;
-		}
-
-		.bigger {
-			font-size: 16px;
-		}
-
-		span {
-			display: block;
-		}
-	}
-}
-
 .song-thumbnail {
 	display: block;
 	max-width: 50px;
 	margin: 0 auto;
 }
 
-.bulk-popup {
-	.song-bulk-actions {
-		display: flex;
-		flex-direction: row;
-		width: 100%;
-		justify-content: space-evenly;
-
-		.material-icons {
-			position: relative;
-			top: 6px;
-			margin-left: 5px;
-			cursor: pointer;
-			color: var(--primary-color);
-
-			&:hover,
-			&:focus {
-				filter: brightness(90%);
+/deep/ .bulk-popup .bulk-actions {
+	.verify-songs-icon {
+		color: var(--green);
+	}
+	& > span {
+		position: relative;
+		top: 6px;
+		margin-left: 5px;
+		height: 25px;
+		& > div {
+			height: 25px;
+			& > .unverify-songs-icon {
+				color: var(--dark-red);
+				top: unset;
+				margin-left: unset;
 			}
 		}
-		.verify-songs-icon {
-			color: var(--green);
-		}
-		.unverify-songs-icon,
-		.delete-songs-icon {
-			color: var(--dark-red);
-		}
 	}
 }
 </style>

+ 312 - 141
frontend/src/pages/Admin/tabs/Stations.vue

@@ -1,7 +1,7 @@
 <template>
 	<div>
 		<page-metadata title="Admin | Stations" />
-		<div class="container">
+		<div class="admin-tab">
 			<div class="button-row">
 				<button
 					class="button is-primary"
@@ -11,67 +11,110 @@
 				</button>
 				<run-job-dropdown :jobs="jobs" />
 			</div>
-			<table class="table">
-				<thead>
-					<tr>
-						<td>ID</td>
-						<td>Name</td>
-						<td>Type</td>
-						<td>Display Name</td>
-						<td>Description</td>
-						<td>Owner</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for="(station, index) in stations" :key="station._id">
-						<td>
-							<span>{{ station._id }}</span>
-						</td>
-						<td>
-							<span>
-								<router-link
-									:to="{
-										name: 'station',
-										params: { id: station.name }
-									}"
-								>
-									{{ station.name }}
-								</router-link>
-							</span>
-						</td>
-						<td>
-							<span>{{ station.type }}</span>
-						</td>
-						<td>
-							<span>{{ station.displayName }}</span>
-						</td>
-						<td>
-							<span>{{ station.description }}</span>
-						</td>
-						<td>
-							<span
-								v-if="station.type === 'official'"
-								title="Musare"
-								>Musare</span
+			<advanced-table
+				:column-default="columnDefault"
+				:columns="columns"
+				:filters="filters"
+				data-action="stations.getData"
+				name="admin-stations"
+				:events="events"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="
+								button
+								is-primary
+								icon-with-button
+								material-icons
+							"
+							@click="edit(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+							content="Manage Station"
+							v-tippy
+						>
+							settings
+						</button>
+						<quick-confirm
+							@confirm="remove(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+						>
+							<button
+								class="
+									button
+									is-danger
+									icon-with-button
+									material-icons
+								"
+								content="Remove Station"
+								v-tippy
 							>
-							<user-id-to-username
-								v-else
-								:user-id="station.owner"
-								:link="true"
-							/>
-						</td>
-						<td>
-							<a class="button is-info" @click="manage(station)"
-								>Manage</a
-							>
-							<quick-confirm @confirm="removeStation(index)">
-								<a class="button is-danger">Remove</a>
-							</quick-confirm>
-						</td>
-					</tr>
-				</tbody>
-			</table>
+								delete_forever
+							</button>
+						</quick-confirm>
+					</div>
+				</template>
+				<template #column-_id="slotProps">
+					<span :title="slotProps.item._id">{{
+						slotProps.item._id
+					}}</span>
+				</template>
+				<template #column-name="slotProps">
+					<span :title="slotProps.item.name">{{
+						slotProps.item.name
+					}}</span>
+				</template>
+				<template #column-displayName="slotProps">
+					<span :title="slotProps.item.displayName">{{
+						slotProps.item.displayName
+					}}</span>
+				</template>
+				<template #column-type="slotProps">
+					<span :title="slotProps.item.type">{{
+						slotProps.item.type
+					}}</span>
+				</template>
+				<template #column-description="slotProps">
+					<span :title="slotProps.item.description">{{
+						slotProps.item.description
+					}}</span>
+				</template>
+				<template #column-privacy="slotProps">
+					<span :title="slotProps.item.privacy">{{
+						slotProps.item.privacy
+					}}</span>
+				</template>
+				<template #column-owner="slotProps">
+					<span v-if="slotProps.item.type === 'official'"
+						>Musare</span
+					>
+					<user-id-to-username
+						v-else
+						:user-id="slotProps.item.owner"
+						:link="true"
+					/>
+				</template>
+				<template #column-stationMode="slotProps">
+					<span
+						:title="slotProps.item.partyMode ? 'Party' : 'Playlist'"
+						>{{
+							slotProps.item.partyMode ? "Party" : "Playlist"
+						}}</span
+					>
+				</template>
+				<template #column-playMode="slotProps">
+					<span :title="slotProps.item.playMode">{{
+						slotProps.item.playMode === "random"
+							? "Random"
+							: "Sequential"
+					}}</span>
+				</template>
+				<template #column-theme="slotProps">
+					<span :title="slotProps.item.theme">{{
+						slotProps.item.theme
+					}}</span>
+				</template>
+			</advanced-table>
 		</div>
 
 		<request-song v-if="modals.requestSong" />
@@ -93,10 +136,11 @@ import { mapState, mapActions, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
+
+import AdvancedTable from "@/components/AdvancedTable.vue";
 import QuickConfirm from "@/components/QuickConfirm.vue";
+import UserIdToUsername from "@/components/UserIdToUsername.vue";
 import RunJobDropdown from "@/components/RunJobDropdown.vue";
-import ws from "@/ws";
 
 export default {
 	components: {
@@ -121,13 +165,210 @@ export default {
 		CreateStation: defineAsyncComponent(() =>
 			import("@/components/modals/CreateStation.vue")
 		),
-		UserIdToUsername,
+		AdvancedTable,
 		QuickConfirm,
+		UserIdToUsername,
 		RunJobDropdown
 	},
 	data() {
 		return {
 			editingStationId: "",
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 150,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 85,
+					defaultWidth: 85
+				},
+				{
+					name: "_id",
+					displayName: "Station ID",
+					properties: ["_id"],
+					sortProperty: "_id",
+					minWidth: 230,
+					defaultWidth: 230
+				},
+				{
+					name: "name",
+					displayName: "Name",
+					properties: ["name"],
+					sortProperty: "name"
+				},
+				{
+					name: "displayName",
+					displayName: "Display Name",
+					properties: ["displayName"],
+					sortProperty: "displayName"
+				},
+				{
+					name: "description",
+					displayName: "Description",
+					properties: ["description"],
+					sortProperty: "description",
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "type",
+					displayName: "Type",
+					properties: ["type"],
+					sortProperty: "type"
+				},
+				{
+					name: "privacy",
+					displayName: "Privacy",
+					properties: ["privacy"],
+					sortProperty: "privacy"
+				},
+				{
+					name: "owner",
+					displayName: "Owner",
+					properties: ["owner", "type"],
+					sortProperty: "owner",
+					defaultWidth: 150
+				},
+				{
+					name: "stationMode",
+					displayName: "Station Mode",
+					properties: ["partyMode"],
+					sortable: false,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "playMode",
+					displayName: "Play Mode",
+					properties: ["playMode"],
+					sortable: false,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "theme",
+					displayName: "Theme",
+					properties: ["theme"],
+					sortProperty: "theme",
+					defaultVisibility: "hidden"
+				}
+			],
+			filters: [
+				{
+					name: "_id",
+					displayName: "Station ID",
+					property: "_id",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "name",
+					displayName: "Name",
+					property: "name",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "displayName",
+					displayName: "Display Name",
+					property: "displayName",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "description",
+					displayName: "Description",
+					property: "description",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "type",
+					displayName: "Type",
+					property: "type",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
+					dropdown: [
+						["official", "Official"],
+						["community", "Community"]
+					]
+				},
+				{
+					name: "privacy",
+					displayName: "Privacy",
+					property: "privacy",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
+					dropdown: [
+						["public", "Public"],
+						["unlisted", "Unlisted"],
+						["private", "Private"]
+					]
+				},
+				{
+					name: "owner",
+					displayName: "Owner",
+					property: "owner",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "stationMode",
+					displayName: "Station Mode",
+					property: "partyMode",
+					filterTypes: ["boolean"],
+					defaultFilterType: "boolean",
+					dropdown: [
+						[true, "Party"],
+						[false, "Playlist"]
+					]
+				},
+				{
+					name: "playMode",
+					displayName: "Play Mode",
+					property: "playMode",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
+					dropdown: [
+						["random", "Random"],
+						["sequential", "Sequential"]
+					]
+				},
+				{
+					name: "theme",
+					displayName: "Theme",
+					property: "theme",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact",
+					dropdown: [
+						["blue", "Blue"],
+						["purple", "Purple"],
+						["teal", "Teal"],
+						["orange", "Orange"],
+						["red", "Red"]
+					]
+				}
+			],
+			events: {
+				adminRoom: "stations",
+				updated: {
+					event: "admin.station.updated",
+					id: "station._id",
+					item: "station"
+				},
+				removed: {
+					event: "admin.station.deleted",
+					id: "stationId"
+				}
+			},
 			jobs: [
 				{
 					name: "Clear every station queue",
@@ -137,9 +378,6 @@ export default {
 		};
 	},
 	computed: {
-		...mapState("admin/stations", {
-			stations: state => state.stations
-		}),
 		...mapState("modalVisibility", {
 			modals: state => state.modals
 		}),
@@ -147,86 +385,19 @@ export default {
 			socket: "websockets/getSocket"
 		})
 	},
-	mounted() {
-		ws.onConnect(this.init);
-
-		this.socket.on("event:admin.station.created", res =>
-			this.stationAdded(res.data.station)
-		);
-
-		this.socket.on("event:admin.station.deleted", res =>
-			this.stationRemoved(res.data.stationId)
-		);
-	},
 	methods: {
-		removeStation(index) {
+		edit(stationId) {
+			this.editingStationId = stationId;
+			this.openModal("manageStation");
+		},
+		remove(stationId) {
 			this.socket.dispatch(
 				"stations.remove",
-				this.stations[index]._id,
+				stationId,
 				res => new Toast(res.message)
 			);
 		},
-		manage(station) {
-			this.editingStationId = station._id;
-			this.openModal("manageStation");
-		},
-		init() {
-			this.socket.dispatch("stations.index", res => {
-				if (res.status === "success")
-					this.loadStations(res.data.stations);
-			});
-
-			this.socket.dispatch("apis.joinAdminRoom", "stations", () => {});
-		},
-		...mapActions("modalVisibility", ["openModal"]),
-		...mapActions("admin/stations", [
-			"manageStation",
-			"loadStations",
-			"stationRemoved",
-			"stationAdded"
-		])
+		...mapActions("modalVisibility", ["openModal"])
 	}
 };
 </script>
-
-<style lang="scss" scoped>
-.night-mode {
-	.table {
-		color: var(--light-grey-2);
-		background-color: var(--dark-grey-3);
-
-		thead tr {
-			background: var(--dark-grey-3);
-			td {
-				color: var(--white);
-			}
-		}
-
-		tbody tr:hover {
-			background-color: var(--dark-grey-4) !important;
-		}
-
-		tbody tr:nth-child(even) {
-			background-color: var(--dark-grey-2);
-		}
-
-		strong {
-			color: var(--light-grey-2);
-		}
-	}
-}
-
-td {
-	word-wrap: break-word;
-	max-width: 10vw;
-	vertical-align: middle;
-
-	& > div {
-		display: inline-flex;
-	}
-}
-
-.is-info:focus {
-	background-color: var(--primary-color);
-}
-</style>

+ 0 - 358
frontend/src/pages/Admin/tabs/Test.vue

@@ -1,358 +0,0 @@
-<template>
-	<div>
-		<page-metadata title="Admin | Test" />
-		<div class="admin-tab">
-			<advanced-table
-				:column-default="columnDefault"
-				:columns="columns"
-				:filters="filters"
-				data-action="songs.getData"
-				name="admin-test"
-			>
-				<template #column-thumbnailImage="slotProps">
-					<img
-						class="song-thumbnail"
-						:src="slotProps.item.thumbnail"
-						onerror="this.src='/assets/notes-transparent.png'"
-						loading="lazy"
-					/>
-				</template>
-				<template #column-thumbnailUrl="slotProps">
-					<a :href="slotProps.item.thumbnail" target="_blank">
-						{{ slotProps.item.thumbnail }}
-					</a>
-				</template>
-				<template #column-_id="slotProps">
-					<span :title="slotProps.item._id">{{
-						slotProps.item._id
-					}}</span>
-				</template>
-				<template #column-youtubeId="slotProps">
-					<a
-						:href="
-							'https://www.youtube.com/watch?v=' +
-							`${slotProps.item.youtubeId}`
-						"
-						target="_blank"
-					>
-						{{ slotProps.item.youtubeId }}
-					</a>
-				</template>
-				<template #column-title="slotProps">
-					<span :title="slotProps.item.title">{{
-						slotProps.item.title
-					}}</span>
-				</template>
-				<template #column-artists="slotProps">
-					<span :title="slotProps.item.artists.join(', ')">{{
-						slotProps.item.artists.join(", ")
-					}}</span>
-				</template>
-				<template #column-genres="slotProps">
-					<span :title="slotProps.item.genres.join(', ')">{{
-						slotProps.item.genres.join(", ")
-					}}</span>
-				</template>
-				<template #column-requestedBy="slotProps">
-					<user-id-to-username
-						:user-id="slotProps.item.requestedBy"
-						:link="true"
-					/>
-				</template>
-				<template #bulk-actions="slotProps">
-					<div class="song-bulk-actions">
-						<i
-							class="material-icons edit-songs-icon"
-							@click.prevent="editMany(slotProps.item)"
-							content="Edit Songs"
-							v-tippy
-						>
-							edit
-						</i>
-						<i
-							class="material-icons verify-songs-icon"
-							@click.prevent="verifyMany(slotProps.item)"
-							content="Verify Songs"
-							v-tippy
-						>
-							check_circle
-						</i>
-						<i
-							class="material-icons unverify-songs-icon"
-							@click.prevent="unverifyMany(slotProps.item)"
-							content="Unverify Songs"
-							v-tippy
-						>
-							cancel
-						</i>
-						<i
-							class="material-icons tag-songs-icon"
-							@click.prevent="tagMany(slotProps.item)"
-							content="Tag Songs"
-							v-tippy
-						>
-							local_offer
-						</i>
-						<i
-							class="material-icons artists-songs-icon"
-							@click.prevent="setArtists(slotProps.item)"
-							content="Set Artists"
-							v-tippy
-						>
-							group
-						</i>
-						<i
-							class="material-icons genres-songs-icon"
-							@click.prevent="setGenres(slotProps.item)"
-							content="Set Genres"
-							v-tippy
-						>
-							theater_comedy
-						</i>
-						<quick-confirm
-							placement="left"
-							@confirm="deleteMany(slotProps.item)"
-						>
-							<i
-								class="material-icons delete-songs-icon"
-								content="Delete Songs"
-								v-tippy
-							>
-								delete_forever
-							</i>
-						</quick-confirm>
-					</div>
-				</template>
-				<!-- <template #bulk-actions-right="slotProps">
-				</template> -->
-			</advanced-table>
-		</div>
-		<edit-song v-if="modals.editSong" song-type="songs" :key="song._id" />
-		<report v-if="modals.report" />
-	</div>
-</template>
-
-<script>
-import { mapState, mapActions } from "vuex";
-import { defineAsyncComponent } from "vue";
-
-import Toast from "toasters";
-import AdvancedTable from "@/components/AdvancedTable.vue";
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
-import QuickConfirm from "@/components/QuickConfirm.vue";
-
-export default {
-	components: {
-		AdvancedTable,
-		UserIdToUsername,
-		QuickConfirm,
-		EditSong: defineAsyncComponent(() =>
-			import("@/components/modals/EditSong")
-		),
-		Report: defineAsyncComponent(() =>
-			import("@/components/modals/Report.vue")
-		)
-	},
-	data() {
-		return {
-			columnDefault: {
-				sortable: true,
-				hidable: true,
-				defaultVisibility: "shown",
-				draggable: true,
-				resizable: true,
-				minWidth: 200,
-				maxWidth: 600
-			},
-			columns: [
-				{
-					name: "thumbnailImage",
-					displayName: "Thumb",
-					properties: ["thumbnail"],
-					sortable: false,
-					minWidth: 75,
-					defaultWidth: 75,
-					maxWidth: 75,
-					resizable: false
-				},
-				{
-					name: "_id",
-					displayName: "Musare ID",
-					properties: ["_id"],
-					sortProperty: "_id",
-					minWidth: 215,
-					defaultWidth: 215
-				},
-				{
-					name: "youtubeId",
-					displayName: "YouTube ID",
-					properties: ["youtubeId"],
-					sortProperty: "youtubeId",
-					minWidth: 120,
-					defaultWidth: 120
-				},
-				{
-					name: "title",
-					displayName: "Title",
-					properties: ["title"],
-					sortProperty: "title"
-				},
-				{
-					name: "artists",
-					displayName: "Artists",
-					properties: ["artists"],
-					sortable: false
-				},
-				{
-					name: "genres",
-					displayName: "Genres",
-					properties: ["genres"],
-					sortable: false
-				},
-				{
-					name: "thumbnailUrl",
-					displayName: "Thumbnail (URL)",
-					properties: ["thumbnail"],
-					sortProperty: "thumbnail",
-					defaultVisibility: "hidden"
-				},
-				{
-					name: "requestedBy",
-					displayName: "Requested By",
-					properties: ["requestedBy"],
-					sortProperty: "requestedBy",
-					defaultWidth: 200
-				}
-			],
-			filters: [
-				{
-					name: "_id",
-					displayName: "Musare ID",
-					property: "_id",
-					filterTypes: ["exact"],
-					defaultFilterType: "exact"
-				},
-				{
-					name: "youtubeId",
-					displayName: "YouTube ID",
-					property: "youtubeId",
-					filterTypes: ["contains", "exact", "regex"],
-					defaultFilterType: "contains"
-				},
-				{
-					name: "title",
-					displayName: "Title",
-					property: "title",
-					filterTypes: ["contains", "exact", "regex"],
-					defaultFilterType: "contains"
-				},
-				{
-					name: "artists",
-					displayName: "Artists",
-					property: "artists",
-					filterTypes: ["contains", "exact", "regex"],
-					defaultFilterType: "contains"
-				},
-				{
-					name: "genres",
-					displayName: "Genres",
-					property: "genres",
-					filterTypes: ["contains", "exact", "regex"],
-					defaultFilterType: "contains"
-				},
-				{
-					name: "thumbnail",
-					displayName: "Thumbnail",
-					property: "thumbnail",
-					filterTypes: ["contains", "exact", "regex"],
-					defaultFilterType: "contains"
-				},
-				{
-					name: "requestedBy",
-					displayName: "Requested By",
-					property: "requestedBy",
-					filterTypes: ["contains", "exact", "regex"],
-					defaultFilterType: "contains"
-				}
-			]
-		};
-	},
-	computed: {
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		}),
-		...mapState("modals/editSong", {
-			song: state => state.song
-		})
-	},
-	mounted() {},
-	beforeUnmount() {},
-	methods: {
-		editMany(selectedRows) {
-			if (selectedRows.length === 1) {
-				this.editSong(selectedRows[0]);
-				this.openModal("editSong");
-			} else {
-				new Toast("Bulk editing not yet implemented.");
-			}
-		},
-		verifyMany() {
-			new Toast("Bulk verifying not yet implemented.");
-		},
-		unverifyMany() {
-			new Toast("Bulk unverifying not yet implemented.");
-		},
-		tagMany() {
-			new Toast("Bulk tagging not yet implemented.");
-		},
-		setArtists() {
-			new Toast("Bulk setting artists not yet implemented.");
-		},
-		setGenres() {
-			new Toast("Bulk setting genres not yet implemented.");
-		},
-		deleteMany() {
-			new Toast("Bulk deleting not yet implemented.");
-		},
-		...mapActions("modals/editSong", ["editSong"]),
-		...mapActions("modalVisibility", ["openModal"])
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-.song-thumbnail {
-	display: block;
-	max-width: 50px;
-	margin: 0 auto;
-}
-
-.bulk-popup {
-	.song-bulk-actions {
-		display: flex;
-		flex-direction: row;
-		width: 100%;
-		justify-content: space-evenly;
-
-		.material-icons {
-			position: relative;
-			top: 6px;
-			margin-left: 5px;
-			cursor: pointer;
-			color: var(--primary-color);
-
-			&:hover,
-			&:focus {
-				filter: brightness(90%);
-			}
-		}
-		.verify-songs-icon {
-			color: var(--green);
-		}
-		.unverify-songs-icon,
-		.delete-songs-icon {
-			color: var(--dark-red);
-		}
-	}
-}
-</style>

+ 397 - 158
frontend/src/pages/Admin/tabs/Users.vue

@@ -2,90 +2,153 @@
 	<div>
 		<page-metadata title="Admin | Users" />
 		<div class="container">
-			<h2 v-if="dataRequests.length > 0">Data Requests</h2>
+			<h2>Data Requests</h2>
 
-			<table class="table" v-if="dataRequests.length > 0">
-				<thead>
-					<tr>
-						<td>User ID</td>
-						<td>Request Type</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for="(request, index) in dataRequests" :key="index">
-						<td>{{ request.userId }}</td>
-						<td>
-							{{
-								request.type === "remove"
-									? "Remove all associated data"
-									: request.type
-							}}
-						</td>
-						<td>
+			<advanced-table
+				:column-default="dataRequests.columnDefault"
+				:columns="dataRequests.columns"
+				:filters="dataRequests.filters"
+				data-action="dataRequests.getData"
+				name="admin-data-requests"
+				max-width="1200"
+				:query="false"
+				:events="dataRequests.events"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<quick-confirm
+							placement="right"
+							@confirm="resolveDataRequest(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+						>
 							<button
-								class="button is-primary"
-								@click="resolveDataRequest(request._id)"
+								class="
+									button
+									is-success
+									icon-with-button
+									material-icons
+								"
+								content="Resolve Data Request"
+								v-tippy
 							>
-								Resolve
+								done_all
 							</button>
-						</td>
-					</tr>
-				</tbody>
-			</table>
+						</quick-confirm>
+					</div>
+				</template>
+				<template #column-type="slotProps">
+					<span
+						:title="
+							slotProps.item.type
+								? 'Remove all associated data'
+								: slotProps.item.type
+						"
+						>{{
+							slotProps.item.type
+								? "Remove all associated data"
+								: slotProps.item.type
+						}}</span
+					>
+				</template>
+				<template #column-userId="slotProps">
+					<span :title="slotProps.item.userId">{{
+						slotProps.item.userId
+					}}</span>
+				</template>
+				<template #column-_id="slotProps">
+					<span :title="slotProps.item._id">{{
+						slotProps.item._id
+					}}</span>
+				</template>
+			</advanced-table>
 
 			<h1 id="page-title">Users</h1>
 
-			<table class="table">
-				<thead>
-					<tr>
-						<td class="ppRow">Profile Picture</td>
-						<td>User ID</td>
-						<td>GitHub ID</td>
-						<td>Password</td>
-						<td>Username</td>
-						<td>Role</td>
-						<td>Email Address</td>
-						<td>Email Verified</td>
-						<td>Songs Requested</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for="user in users" :key="user._id">
-						<td>
-							<profile-picture
-								:avatar="user.avatar"
-								:name="user.name ? user.name : user.username"
-							/>
-						</td>
-						<td>{{ user._id }}</td>
-						<td v-if="user.services.github">
-							{{ user.services.github.id }}
-						</td>
-						<td v-else>Not Linked</td>
-						<td v-if="user.hasPassword">Yes</td>
-						<td v-else>Not Linked</td>
-						<td>
-							<a :href="'/u/' + user.username" target="_blank">{{
-								user.username
-							}}</a>
-						</td>
-						<td>{{ user.role }}</td>
-						<td>{{ user.email.address }}</td>
-						<td>{{ user.email.verified }}</td>
-						<td>{{ user.songsRequested }}</td>
-						<td>
-							<button
-								class="button is-primary"
-								@click="edit(user)"
-							>
-								Edit
-							</button>
-						</td>
-					</tr>
-				</tbody>
-			</table>
+			<advanced-table
+				:column-default="users.columnDefault"
+				:columns="users.columns"
+				:filters="users.filters"
+				data-action="users.getData"
+				name="admin-users"
+				max-width="1200"
+				:events="users.events"
+			>
+				<template #column-options="slotProps">
+					<div class="row-options">
+						<button
+							class="
+								button
+								is-primary
+								icon-with-button
+								material-icons
+							"
+							@click="edit(slotProps.item._id)"
+							:disabled="slotProps.item.removed"
+							content="Edit User"
+							v-tippy
+						>
+							edit
+						</button>
+					</div>
+				</template>
+				<template #column-profilePicture="slotProps">
+					<profile-picture
+						:avatar="slotProps.item.avatar"
+						:name="
+							slotProps.item.name
+								? slotProps.item.name
+								: slotProps.item.username
+						"
+					/>
+				</template>
+				<template #column-name="slotProps">
+					<span :title="slotProps.item.name">{{
+						slotProps.item.name
+					}}</span>
+				</template>
+				<template #column-username="slotProps">
+					<span :title="slotProps.item.username">{{
+						slotProps.item.username
+					}}</span>
+				</template>
+				<template #column-_id="slotProps">
+					<span :title="slotProps.item._id">{{
+						slotProps.item._id
+					}}</span>
+				</template>
+				<template #column-githubId="slotProps">
+					<span
+						v-if="slotProps.item.services.github"
+						:title="slotProps.item.services.github.id"
+						>{{ slotProps.item.services.github.id }}</span
+					>
+				</template>
+				<template #column-hasPassword="slotProps">
+					<span :title="slotProps.item.hasPassword">{{
+						slotProps.item.hasPassword
+					}}</span>
+				</template>
+				<template #column-role="slotProps">
+					<span :title="slotProps.item.role">{{
+						slotProps.item.role
+					}}</span>
+				</template>
+				<template #column-emailAddress="slotProps">
+					<span :title="slotProps.item.email.address">{{
+						slotProps.item.email.address
+					}}</span>
+				</template>
+				<template #column-emailVerified="slotProps">
+					<span :title="slotProps.item.email.verified">{{
+						slotProps.item.email.verified
+					}}</span>
+				</template>
+				<template #column-songsRequested="slotProps">
+					<span :title="slotProps.item.statistics.songsRequested">{{
+						slotProps.item.statistics.songsRequested
+					}}</span>
+				</template>
+			</advanced-table>
 		</div>
 		<edit-user
 			v-if="modals.editUser"
@@ -100,21 +163,271 @@ import { mapState, mapActions, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 import Toast from "toasters";
 
+import AdvancedTable from "@/components/AdvancedTable.vue";
 import ProfilePicture from "@/components/ProfilePicture.vue";
-import ws from "@/ws";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
 	components: {
 		EditUser: defineAsyncComponent(() =>
 			import("@/components/modals/EditUser.vue")
 		),
-		ProfilePicture
+		AdvancedTable,
+		ProfilePicture,
+		QuickConfirm
 	},
 	data() {
 		return {
 			editingUserId: "",
-			dataRequests: [],
-			users: []
+			dataRequests: {
+				columnDefault: {
+					sortable: true,
+					hidable: true,
+					defaultVisibility: "shown",
+					draggable: true,
+					resizable: true,
+					minWidth: 150,
+					maxWidth: 600
+				},
+				columns: [
+					{
+						name: "options",
+						displayName: "Options",
+						properties: ["_id"],
+						sortable: false,
+						hidable: false,
+						resizable: false,
+						minWidth: 76,
+						defaultWidth: 76
+					},
+					{
+						name: "type",
+						displayName: "Type",
+						properties: ["type"],
+						sortable: false
+					},
+					{
+						name: "userId",
+						displayName: "User ID",
+						properties: ["userId"],
+						sortProperty: "userId"
+					},
+					{
+						name: "_id",
+						displayName: "Request ID",
+						properties: ["_id"],
+						sortProperty: "_id"
+					}
+				],
+				filters: [
+					{
+						name: "_id",
+						displayName: "Request ID",
+						property: "_id",
+						filterTypes: ["exact"],
+						defaultFilterType: "exact"
+					},
+					{
+						name: "userId",
+						displayName: "User ID",
+						property: "userId",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					}
+				],
+				events: {
+					adminRoom: "users",
+					removed: {
+						event: "admin.dataRequests.resolved",
+						id: "dataRequestId"
+					}
+				}
+			},
+			users: {
+				columnDefault: {
+					sortable: true,
+					hidable: true,
+					defaultVisibility: "shown",
+					draggable: true,
+					resizable: true,
+					minWidth: 150,
+					maxWidth: 600
+				},
+				columns: [
+					{
+						name: "options",
+						displayName: "Options",
+						properties: ["_id"],
+						sortable: false,
+						hidable: false,
+						resizable: false,
+						minWidth: 76,
+						defaultWidth: 76
+					},
+					{
+						name: "profilePicture",
+						displayName: "Image",
+						properties: ["avatar", "name", "username"],
+						sortable: false,
+						resizable: false,
+						minWidth: 71,
+						defaultWidth: 71
+					},
+					{
+						name: "name",
+						displayName: "Display Name",
+						properties: ["name"],
+						sortProperty: "name"
+					},
+					{
+						name: "username",
+						displayName: "Username",
+						properties: ["username"],
+						sortProperty: "username"
+					},
+					{
+						name: "_id",
+						displayName: "User ID",
+						properties: ["_id"],
+						sortProperty: "_id",
+						minWidth: 230,
+						defaultWidth: 230
+					},
+					{
+						name: "githubId",
+						displayName: "GitHub ID",
+						properties: ["services.github.id"],
+						sortProperty: "services.github.id",
+						minWidth: 115,
+						defaultWidth: 115
+					},
+					{
+						name: "hasPassword",
+						displayName: "Has Password",
+						properties: ["hasPassword"],
+						sortProperty: "hasPassword"
+					},
+					{
+						name: "role",
+						displayName: "Role",
+						properties: ["role"],
+						sortProperty: "role",
+						minWidth: 90,
+						defaultWidth: 90
+					},
+					{
+						name: "emailAddress",
+						displayName: "Email Address",
+						properties: ["email.address"],
+						sortProperty: "email.address",
+						defaultVisibility: "hidden"
+					},
+					{
+						name: "emailVerified",
+						displayName: "Email Verified",
+						properties: ["email.verified"],
+						sortProperty: "email.verified",
+						defaultVisibility: "hidden",
+						minWidth: 140,
+						defaultWidth: 140
+					},
+					{
+						name: "songsRequested",
+						displayName: "Songs Requested",
+						properties: ["statistics.songsRequested"],
+						sortProperty: "statistics.songsRequested",
+						minWidth: 170,
+						defaultWidth: 170
+					}
+				],
+				filters: [
+					{
+						name: "_id",
+						displayName: "User ID",
+						property: "_id",
+						filterTypes: ["exact"],
+						defaultFilterType: "exact"
+					},
+					{
+						name: "name",
+						displayName: "Display Name",
+						property: "name",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					},
+					{
+						name: "username",
+						displayName: "Username",
+						property: "username",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					},
+					{
+						name: "githubId",
+						displayName: "GitHub ID",
+						property: "services.github.id",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					},
+					{
+						name: "hasPassword",
+						displayName: "Has Password",
+						property: "hasPassword",
+						filterTypes: ["boolean"],
+						defaultFilterType: "boolean"
+					},
+					{
+						name: "role",
+						displayName: "Role",
+						property: "role",
+						filterTypes: ["exact"],
+						defaultFilterType: "exact",
+						dropdown: [
+							["admin", "Admin"],
+							["default", "Default"]
+						]
+					},
+					{
+						name: "emailAddress",
+						displayName: "Email Address",
+						property: "email.address",
+						filterTypes: ["contains", "exact", "regex"],
+						defaultFilterType: "contains"
+					},
+					{
+						name: "emailVerified",
+						displayName: "Email Verified",
+						property: "email.verified",
+						filterTypes: ["boolean"],
+						defaultFilterType: "boolean"
+					},
+					{
+						name: "songsRequested",
+						displayName: "Songs Requested",
+						property: "statistics.songsRequested",
+						filterTypes: [
+							"numberLesserEqual",
+							"numberLesser",
+							"numberGreater",
+							"numberGreaterEqual",
+							"numberEquals"
+						],
+						defaultFilterType: "numberLesser"
+					}
+				],
+				events: {
+					adminRoom: "users",
+					updated: {
+						event: "admin.user.updated",
+						id: "user._id",
+						item: "user"
+					},
+					removed: {
+						event: "user.removed",
+						id: "userId"
+					}
+				}
+			}
 		};
 	},
 	computed: {
@@ -126,49 +439,13 @@ export default {
 		})
 	},
 	mounted() {
-		ws.onConnect(this.init);
-
-		this.socket.on("event:admin.dataRequests.created", res =>
-			this.dataRequests.push(res.data.request)
-		);
-
-		this.socket.on("event:admin.dataRequests.resolved", res => {
-			this.dataRequests = this.dataRequests.filter(
-				request => request._id !== res.data.dataRequestId
-			);
-		});
-
-		this.socket.on("event:user.removed", res => {
-			this.users = this.users.filter(
-				user => user._id !== res.data.userId
-			);
-		});
+		if (this.$route.query.userId) this.edit(this.$route.query.userId);
 	},
 	methods: {
-		edit(user) {
-			this.editingUserId = user._id;
+		edit(userId) {
+			this.editingUserId = userId;
 			this.openModal("editUser");
 		},
-		init() {
-			this.socket.dispatch("users.index", res => {
-				if (res.status === "success") {
-					this.users = res.data.users;
-					if (this.$route.query.userId) {
-						const user = this.users.find(
-							user => user._id === this.$route.query.userId
-						);
-						if (user) this.edit(user);
-					}
-				}
-			});
-
-			this.socket.dispatch("dataRequests.index", res => {
-				if (res.status === "success")
-					this.dataRequests = res.data.requests;
-			});
-
-			this.socket.dispatch("apis.joinAdminRoom", "users", () => {});
-		},
 		resolveDataRequest(id) {
 			this.socket.dispatch("dataRequests.resolve", id, res => {
 				if (res.status === "success") new Toast(res.message);
@@ -180,32 +457,6 @@ export default {
 </script>
 
 <style lang="scss" scoped>
-.night-mode {
-	.table {
-		color: var(--light-grey-2);
-		background-color: var(--dark-grey-3);
-
-		thead tr {
-			background: var(--dark-grey-3);
-			td {
-				color: var(--white);
-			}
-		}
-
-		tbody tr:hover {
-			background-color: var(--dark-grey-4) !important;
-		}
-
-		tbody tr:nth-child(even) {
-			background-color: var(--dark-grey-2);
-		}
-
-		strong {
-			color: var(--light-grey-2);
-		}
-	}
-}
-
 #page-title {
 	margin: 30px 0;
 }
@@ -227,16 +478,4 @@ h2 {
 /deep/ .profile-picture.using-initials span {
 	font-size: 20px; // 2/5th of .profile-picture height/width
 }
-
-td {
-	vertical-align: middle;
-
-	&.ppRow {
-		max-width: 50px;
-	}
-}
-
-.is-primary:focus {
-	background-color: var(--primary-color) !important;
-}
 </style>

+ 6 - 3
frontend/src/pages/News.vue

@@ -39,7 +39,7 @@
 <script>
 import { formatDistance } from "date-fns";
 import { mapGetters } from "vuex";
-import marked from "marked";
+import { marked } from "marked";
 import { sanitize } from "dompurify";
 
 import ws from "@/ws";
@@ -102,7 +102,7 @@ export default {
 		sanitize,
 		formatDistance,
 		init() {
-			this.socket.dispatch("news.index", res => {
+			this.socket.dispatch("news.getPublished", res => {
 				if (res.status === "success") this.news = res.data.news;
 			});
 
@@ -119,9 +119,12 @@ export default {
 	}
 }
 
+.container {
+	width: calc(100% - 32px);
+}
+
 .section {
 	border: 1px solid var(--light-grey-3);
-	width: 1000px;
 	max-width: 100%;
 	margin-top: 50px;
 

+ 2 - 16
frontend/src/pages/Team.vue

@@ -43,6 +43,7 @@
 								<a
 									v-for="project in member.projects"
 									:key="project"
+									class="pill"
 									:href="
 										'https://github.com/Musare/' +
 										project +
@@ -94,6 +95,7 @@
 								<a
 									v-for="project in member.projects"
 									:key="project"
+									class="pill"
 									:href="
 										'https://github.com/Musare/' +
 										project +
@@ -264,10 +266,6 @@ export default {
 			color: var(--light-grey-2);
 		}
 	}
-	.group .card .card-content .projects a,
-	.other-contributors div a {
-		background-color: var(--dark-grey);
-	}
 }
 
 .container {
@@ -384,18 +382,6 @@ h2 {
 				display: flex;
 				flex-wrap: wrap;
 				margin-top: auto;
-
-				a {
-					background: var(--light-grey);
-					height: 30px;
-					padding: 5px;
-					border-radius: 5px;
-					white-space: nowrap;
-					margin-top: 5px;
-					&:not(:last-of-type) {
-						margin-right: 5px;
-					}
-				}
 			}
 		}
 	}

+ 5 - 1
frontend/src/store/index.js

@@ -10,6 +10,7 @@ import station from "./modules/station";
 import admin from "./modules/admin";
 
 import editSongModal from "./modules/modals/editSong";
+import editSongsModal from "./modules/modals/editSongs";
 import importAlbumModal from "./modules/modals/importAlbum";
 import editPlaylistModal from "./modules/modals/editPlaylist";
 import manageStationModal from "./modules/modals/manageStation";
@@ -17,6 +18,7 @@ import editUserModal from "./modules/modals/editUser";
 import viewPunishmentModal from "./modules/modals/viewPunishment";
 import viewReportModal from "./modules/modals/viewReport";
 import reportModal from "./modules/modals/report";
+import confirmModal from "./modules/modals/confirm";
 
 export default createStore({
 	modules: {
@@ -30,13 +32,15 @@ export default createStore({
 			namespaced: true,
 			modules: {
 				editSong: editSongModal,
+				editSongs: editSongsModal,
 				importAlbum: importAlbumModal,
 				editPlaylist: editPlaylistModal,
 				manageStation: manageStationModal,
 				editUser: editUserModal,
 				viewPunishment: viewPunishmentModal,
 				report: reportModal,
-				viewReport: viewReportModal
+				viewReport: viewReportModal,
+				confirm: confirmModal
 			}
 		}
 	},

+ 11 - 128
frontend/src/store/modules/admin.js

@@ -18,37 +18,14 @@ const modules = {
 	},
 	stations: {
 		namespaced: true,
-		state: {
-			stations: []
-		},
+		state: {},
 		getters: {},
-		actions: {
-			loadStations: ({ commit }, stations) =>
-				commit("loadStations", stations),
-			stationRemoved: ({ commit }, stationId) =>
-				commit("stationRemoved", stationId),
-			stationAdded: ({ commit }, station) =>
-				commit("stationAdded", station)
-		},
-		mutations: {
-			loadStations(state, stations) {
-				state.stations = stations;
-			},
-			stationAdded(state, station) {
-				state.stations.push(station);
-			},
-			stationRemoved(state, stationId) {
-				state.stations = state.stations.filter(
-					station => station._id !== stationId
-				);
-			}
-		}
+		actions: {},
+		mutations: {}
 	},
 	reports: {
 		namespaced: true,
-		state: {
-			reports: []
-		},
+		state: {},
 		getters: {},
 		actions: {
 			/* eslint-disable-next-line no-unused-vars */
@@ -58,10 +35,7 @@ const modules = {
 						.resolve(reportId)
 						.then(res => resolve(res))
 						.catch(err => reject(new Error(err.message)))
-				),
-			indexReports({ commit }, reports) {
-				commit("indexReports", reports);
-			}
+				)
 		},
 		mutations: {}
 	},
@@ -74,108 +48,17 @@ const modules = {
 	},
 	news: {
 		namespaced: true,
-		state: {
-			news: []
-		},
+		state: {},
 		getters: {},
-		actions: {
-			setNews: ({ commit }, news) => commit("setNews", news),
-			addNews: ({ commit }, news) => commit("addNews", news),
-			removeNews: ({ commit }, newsId) => commit("removeNews", newsId),
-			updateNews: ({ commit }, updatedNews) =>
-				commit("updateNews", updatedNews)
-		},
-		mutations: {
-			setNews(state, news) {
-				state.news = news;
-			},
-			addNews(state, news) {
-				state.news.push(news);
-			},
-			removeNews(state, newsId) {
-				state.news = state.news.filter(news => news._id !== newsId);
-			},
-			updateNews(state, updatedNews) {
-				state.news.forEach((news, index) => {
-					if (news._id === updatedNews._id)
-						this.set(state.news, index, updatedNews);
-				});
-			}
-		}
+		actions: {},
+		mutations: {}
 	},
 	playlists: {
 		namespaced: true,
-		state: {
-			playlists: []
-		},
+		state: {},
 		getters: {},
-		actions: {
-			setPlaylists: ({ commit }, playlists) =>
-				commit("setPlaylists", playlists),
-			addPlaylist: ({ commit }, playlist) =>
-				commit("addPlaylist", playlist),
-			removePlaylist: ({ commit }, playlistId) =>
-				commit("removePlaylist", playlistId),
-			addPlaylistSong: ({ commit }, { playlistId, song }) =>
-				commit("addPlaylistSong", { playlistId, song }),
-			removePlaylistSong: ({ commit }, { playlistId, youtubeId }) =>
-				commit("removePlaylistSong", { playlistId, youtubeId }),
-			updatePlaylistDisplayName: (
-				{ commit },
-				{ playlistId, displayName }
-			) =>
-				commit("updatePlaylistDisplayName", {
-					playlistId,
-					displayName
-				}),
-			updatePlaylistPrivacy: ({ commit }, { playlistId, privacy }) =>
-				commit("updatePlaylistPrivacy", { playlistId, privacy })
-		},
-		mutations: {
-			setPlaylists(state, playlists) {
-				state.playlists = playlists;
-			},
-			addPlaylist(state, playlist) {
-				state.playlists.unshift(playlist);
-			},
-			removePlaylist(state, playlistId) {
-				state.playlists = state.playlists.filter(
-					playlist => playlist._id !== playlistId
-				);
-			},
-			addPlaylistSong(state, { playlistId, song }) {
-				state.playlists[
-					state.playlists.findIndex(
-						playlist => playlist._id === playlistId
-					)
-				].songs.push(song);
-			},
-			removePlaylistSong(state, { playlistId, youtubeId }) {
-				const playlistIndex = state.playlists.findIndex(
-					playlist => playlist._id === playlistId
-				);
-				state.playlists[playlistIndex].songs.splice(
-					state.playlists[playlistIndex].songs.findIndex(
-						song => song.youtubeId === youtubeId
-					),
-					1
-				);
-			},
-			updatePlaylistDisplayName(state, { playlistId, displayName }) {
-				state.playlists[
-					state.playlists.findIndex(
-						playlist => playlist._id === playlistId
-					)
-				].displayName = displayName;
-			},
-			updatePlaylistPrivacy(state, { playlistId, privacy }) {
-				state.playlists[
-					state.playlists.findIndex(
-						playlist => playlist._id === playlistId
-					)
-				].privacy = privacy;
-			}
-		}
+		actions: {},
+		mutations: {}
 	}
 };
 

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

@@ -16,9 +16,14 @@ const state = {
 		editNews: false,
 		editUser: false,
 		editSong: false,
+		editSongs: false,
 		importAlbum: false,
 		viewReport: false,
-		viewPunishment: false
+		viewPunishment: false,
+		confirm: false,
+		editSongConfirm: false,
+		editSongsConfirm: false,
+		bulkActions: false
 	},
 	currentlyActive: []
 };
@@ -45,7 +50,8 @@ const actions = {
 const mutations = {
 	closeModal(state, modal) {
 		state.modals[modal] = false;
-		if (state.currentlyActive[0] === modal) state.currentlyActive.shift();
+		const index = state.currentlyActive.indexOf(modal);
+		if (index > -1) state.currentlyActive.splice(index, 1);
 	},
 	openModal(state, modal) {
 		state.modals[modal] = true;

+ 18 - 0
frontend/src/store/modules/modals/confirm.js

@@ -0,0 +1,18 @@
+/* eslint no-param-reassign: 0 */
+
+export default {
+	namespaced: true,
+	state: {
+		message: ""
+	},
+	getters: {},
+	actions: {
+		updateConfirmMessage: ({ commit }, message) =>
+			commit("updateConfirmMessage", message)
+	},
+	mutations: {
+		updateConfirmMessage(state, message) {
+			state.message = message;
+		}
+	}
+};

+ 18 - 0
frontend/src/store/modules/modals/editSong.js

@@ -10,6 +10,7 @@ export default {
 			autoPlayed: false,
 			currentTime: 0
 		},
+		songId: "",
 		song: {},
 		originalSong: {},
 		reports: [],
@@ -19,6 +20,10 @@ export default {
 	actions: {
 		showTab: ({ commit }, tab) => commit("showTab", tab),
 		editSong: ({ commit }, song) => commit("editSong", song),
+		setSong: ({ commit }, song) => commit("setSong", song),
+		updateOriginalSong: ({ commit }, song) =>
+			commit("updateOriginalSong", song),
+		resetSong: ({ commit }, songId) => commit("resetSong", songId),
 		stopVideo: ({ commit }) => commit("stopVideo"),
 		loadVideoById: ({ commit }, id, skipDuration) =>
 			commit("loadVideoById", id, skipDuration),
@@ -45,10 +50,23 @@ export default {
 			state.tab = tab;
 		},
 		editSong(state, song) {
+			state.songId = song.songId;
+			state.prefillData = song.prefill ? song.prefill : {};
+		},
+		setSong(state, song) {
 			if (song.discogs === undefined) song.discogs = null;
 			state.originalSong = JSON.parse(JSON.stringify(song));
 			state.song = { ...song };
 		},
+		updateOriginalSong(state, song) {
+			state.originalSong = JSON.parse(JSON.stringify(song));
+		},
+		resetSong(state, songId) {
+			if (state.songId === songId) state.songId = "";
+			if (state.song && state.song._id === songId) state.song = {};
+			if (state.originalSong && state.originalSong._id === songId)
+				state.originalSong = {};
+		},
 		stopVideo(state) {
 			state.video.player.stopVideo();
 		},

+ 29 - 0
frontend/src/store/modules/modals/editSongs.js

@@ -0,0 +1,29 @@
+/* eslint no-param-reassign: 0 */
+
+export default {
+	namespaced: true,
+	state: {
+		songIds: [],
+		songPrefillData: {}
+	},
+	getters: {},
+	actions: {
+		editSongs: ({ commit }, songs) => commit("editSongs", songs),
+		resetSongs: ({ commit }) => commit("resetSongs")
+	},
+	mutations: {
+		editSongs(state, songs) {
+			state.songIds = songs.map(song => song.songId);
+			state.songPrefillData = Object.fromEntries(
+				songs.map(song => [
+					song.songId,
+					song.prefill ? song.prefill : {}
+				])
+			);
+		},
+		resetSongs(state) {
+			state.songIds = [];
+			state.songPrefillData = {};
+		}
+	}
+};

+ 14 - 9
frontend/src/ws.js

@@ -4,6 +4,7 @@ import ListenerHandler from "./classes/ListenerHandler.class";
 
 const onConnect = [];
 let ready = false;
+let firstInit = true;
 
 let pendingDispatches = [];
 
@@ -144,17 +145,21 @@ export default {
 			console.log("WS: SOCKET ERROR", err);
 		};
 
-		this.socket.on("ready", () => {
-			console.log("WS: SOCKET READY");
-			ready = true;
+		if (firstInit) {
+			firstInit = false;
+			this.socket.on("ready", () => {
+				console.log("WS: SOCKET READY");
 
-			setTimeout(() => {
 				onConnect.forEach(cb => cb());
 
-				// dispatches that were attempted while the server was offline
-				pendingDispatches.forEach(cb => cb());
-				pendingDispatches = [];
-			}, 150); // small delay between readyState being 1 and the server actually receiving dispatches
-		});
+				ready = true;
+
+				setTimeout(() => {
+					// dispatches that were attempted while the server was offline
+					pendingDispatches.forEach(cb => cb());
+					pendingDispatches = [];
+				}, 150); // small delay between readyState being 1 and the server actually receiving dispatches
+			});
+		}
 	}
 };

部分文件因文件數量過多而無法顯示