浏览代码

Merge branch 'staging'

Owen Diffey 3 年之前
父节点
当前提交
f7da74dd83
共有 100 个文件被更改,包括 11568 次插入4933 次删除
  1. 1 0
      .wiki/Backend_Commands.md
  2. 10 2
      .wiki/Configuration.md
  3. 4 0
      .wiki/Value_Formats.md
  4. 13 0
      SECURITY.md
  5. 4 3
      backend/config/template.json
  6. 29 1
      backend/index.js
  7. 7 4
      backend/logic/actions/apis.js
  8. 40 13
      backend/logic/actions/dataRequests.js
  9. 101 1
      backend/logic/actions/news.js
  10. 456 49
      backend/logic/actions/playlists.js
  11. 151 16
      backend/logic/actions/punishments.js
  12. 83 44
      backend/logic/actions/reports.js
  13. 589 302
      backend/logic/actions/songs.js
  14. 169 4
      backend/logic/actions/stations.js
  15. 455 40
      backend/logic/actions/users.js
  16. 9 4
      backend/logic/app.js
  17. 219 3
      backend/logic/db/index.js
  18. 2 3
      backend/logic/db/schemas/playlist.js
  19. 3 2
      backend/logic/db/schemas/song.js
  20. 3 3
      backend/logic/db/schemas/station.js
  21. 1 1
      backend/logic/mail/schemas/dataRequest.js
  22. 2 1
      backend/logic/mail/schemas/passwordRequest.js
  23. 2 1
      backend/logic/mail/schemas/resetPasswordRequest.js
  24. 1 1
      backend/logic/mail/schemas/verifyEmail.js
  25. 65 0
      backend/logic/migration/migrations/migration16.js
  26. 42 0
      backend/logic/migration/migrations/migration17.js
  27. 185 0
      backend/logic/migration/migrations/migration18.js
  28. 24 30
      backend/logic/playlists.js
  29. 410 150
      backend/logic/songs.js
  30. 3 3
      backend/logic/stations.js
  31. 2 2
      backend/logic/tasks.js
  32. 5 5
      backend/logic/youtube.js
  33. 1 1
      backend/package-lock.json
  34. 1 1
      backend/package.json
  35. 4 2
      docker-compose.yml
  36. 二进制
      frontend/dist/assets/15-seconds-of-silence.mp3
  37. 12 1
      frontend/dist/config/template.json
  38. 二进制
      frontend/dist/fonts/MaterialIcons-Regular.ttf
  39. 11 3
      frontend/dist/index.tpl.html
  40. 0 23
      frontend/dist/vendor/can-autoplay.min.js
  41. 383 94
      frontend/package-lock.json
  42. 4 2
      frontend/package.json
  43. 223 26
      frontend/src/App.vue
  44. 8 16
      frontend/src/components/AddToPlaylistDropdown.vue
  45. 2447 0
      frontend/src/components/AdvancedTable.vue
  46. 177 0
      frontend/src/components/AutoSuggest.vue
  47. 1 4
      frontend/src/components/FloatingBox.vue
  48. 34 8
      frontend/src/components/Modal.vue
  49. 5 4
      frontend/src/components/Queue.vue
  50. 2 2
      frontend/src/components/QuickConfirm.vue
  51. 112 0
      frontend/src/components/RunJobDropdown.vue
  52. 5 0
      frontend/src/components/SaveButton.vue
  53. 12 4
      frontend/src/components/SongItem.vue
  54. 1 0
      frontend/src/components/SongThumbnail.vue
  55. 5 4
      frontend/src/components/layout/MainFooter.vue
  56. 1 0
      frontend/src/components/layout/MainHeader.vue
  57. 175 0
      frontend/src/components/modals/BulkActions.vue
  58. 53 0
      frontend/src/components/modals/Confirm.vue
  59. 4 0
      frontend/src/components/modals/CreatePlaylist.vue
  60. 2 2
      frontend/src/components/modals/EditNews.vue
  61. 24 1
      frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue
  62. 13 16
      frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue
  63. 23 23
      frontend/src/components/modals/EditPlaylist/index.vue
  64. 4 0
      frontend/src/components/modals/EditSong/Tabs/Discogs.vue
  65. 17 21
      frontend/src/components/modals/EditSong/Tabs/Songs.vue
  66. 16 20
      frontend/src/components/modals/EditSong/Tabs/Youtube.vue
  67. 437 364
      frontend/src/components/modals/EditSong/index.vue
  68. 633 0
      frontend/src/components/modals/EditSongs.vue
  69. 58 4
      frontend/src/components/modals/EditUser.vue
  70. 526 360
      frontend/src/components/modals/ImportAlbum.vue
  71. 6 1
      frontend/src/components/modals/Login.vue
  72. 37 37
      frontend/src/components/modals/ManageStation/Tabs/Playlists.vue
  73. 1 1
      frontend/src/components/modals/ManageStation/Tabs/Settings.vue
  74. 80 64
      frontend/src/components/modals/ManageStation/index.vue
  75. 1 0
      frontend/src/components/modals/Register.vue
  76. 4 4
      frontend/src/components/modals/RemoveAccount.vue
  77. 1 1
      frontend/src/components/modals/Report.vue
  78. 1 1
      frontend/src/components/modals/ViewReport.vue
  79. 1 1
      frontend/src/components/modals/WhatIsNew.vue
  80. 9 5
      frontend/src/main.js
  81. 0 51
      frontend/src/mixins/ScrollAndFetchHandler.vue
  82. 2 2
      frontend/src/mixins/SearchMusare.vue
  83. 102 0
      frontend/src/ms.js
  84. 177 71
      frontend/src/pages/Admin/index.vue
  85. 0 616
      frontend/src/pages/Admin/tabs/HiddenSongs.vue
  86. 161 161
      frontend/src/pages/Admin/tabs/News.vue
  87. 299 262
      frontend/src/pages/Admin/tabs/Playlists.vue
  88. 221 107
      frontend/src/pages/Admin/tabs/Punishments.vue
  89. 222 158
      frontend/src/pages/Admin/tabs/Reports.vue
  90. 829 0
      frontend/src/pages/Admin/tabs/Songs.vue
  91. 322 153
      frontend/src/pages/Admin/tabs/Stations.vue
  92. 0 645
      frontend/src/pages/Admin/tabs/UnverifiedSongs.vue
  93. 397 152
      frontend/src/pages/Admin/tabs/Users.vue
  94. 0 713
      frontend/src/pages/Admin/tabs/VerifiedSongs.vue
  95. 139 5
      frontend/src/pages/Home.vue
  96. 6 3
      frontend/src/pages/News.vue
  97. 4 6
      frontend/src/pages/Profile/Tabs/RecentActivity.vue
  98. 4 4
      frontend/src/pages/Settings/Tabs/Account.vue
  99. 11 8
      frontend/src/pages/Settings/Tabs/Security.vue
  100. 7 7
      frontend/src/pages/Station/Sidebar/Playlists.vue

+ 1 - 0
.wiki/Backend_Commands.md

@@ -13,6 +13,7 @@ Backend commands are inputted via STDIN or if using the Utility Script by using
 | `runjob` | `module job_name json_encoded_payload` | Run a specified job in a specified module including a JSON encoded payload, and return response. |
 | `eval` | `some_javascript` | Execute JavaScript within the index.js context and return response. |
 | `lockdown` | | Lockdown backend. |
+| `version` | | Prints the Musare version and Git repository info. |
 | `stats` | `module` | Returns job statistics for a specified module. |
 
 ## Modules

+ 10 - 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`. |
@@ -73,12 +72,21 @@ Location: `frontend/dist/config/default.json`
 | `siteSettings.logo_blue` | Path to the blue logo image, by default it is `/assets/blue_wordmark.png`. |
 | `siteSettings.sitename` | Should be the name of the site. |
 | `siteSettings.github` | URL of GitHub repository, defaults to `https://github.com/Musare/MusareNode`. |
+| `siteSettings.mediasession` | Whether to enable mediasession functionality. |
 | `siteSettings.christmas` | Whether to enable christmas theming. |
 | `messages.accountRemoval` | Message to return to users on account removal. |
 | `shortcutOverrides` | Overwrite keyboard shortcuts, for example `"editSong.useAllDiscogs": { "keyCode": 68, "ctrl": true, "alt": true, "shift": false, "preventDefault": true }`. |
+| `debug.git.remote` | Allow the website/users to view the current Git repository's remote. [^1] |
+| `debug.git.remoteUrl` | Allow the website/users to view the current Git repository's remote URL. [^1] |
+| `debug.git.branch` | Allow the website/users to view the current Git repository's branch. [^1] |
+| `debug.git.latestCommit` | Allow the website/users to view the current Git repository's latest commit hash. [^1] |
+| `debug.git.latestCommitShort` | Allow the website/users to view the current Git repository's latest commit hash (short). [^1] |
+| `debug.version` | Allow the website/users to view the current package.json version. [^1] |
 | `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
 | `configVersion` | Version of the config. Every time the template changes, you should change your config accordingly and update the configVersion. |
 
+[^1]: Requires a frontend restart to update. The data will be available from the frontend console and by the frontend code.
+
 ## Docker Environment
 Location: `.env`
 

+ 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.

+ 13 - 0
SECURITY.md

@@ -0,0 +1,13 @@
+# Security Policy
+
+## Supported Versions
+
+Only the latest published production version is supported.
+
+## Reporting a Vulnerability
+
+To report a vulnerability with a supported version please get in touch with us via email at [core@musare.com](mailto:core@musare.com).
+
+We endeavour to respond to reports as soon as possible, this may however take a few days. Please refrain from reporting security issues in public forums such as GitHub issues.
+
+Reports will be disclosed via a security advisory once fixes are included in a production release.

+ 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
 }

+ 29 - 1
backend/index.js

@@ -2,8 +2,11 @@ import "./loadEnvVariables.js";
 
 import util from "util";
 import config from "config";
+import fs from "fs";
 
-const REQUIRED_CONFIG_VERSION = 8;
+import package_json from "./package.json";
+
+const REQUIRED_CONFIG_VERSION = 9;
 
 // eslint-disable-next-line
 Array.prototype.remove = function (item) {
@@ -29,6 +32,28 @@ console.log = (...args) => {
 	if (!blacklisted) oldConsole.log.apply(null, args);
 };
 
+const MUSARE_VERSION = package_json.version;
+
+const printVersion = () => {
+	console.log(`Musare version: ${MUSARE_VERSION}.`);
+
+	try {
+		const head_contents = fs.readFileSync("app/.parent_git/HEAD").toString().replaceAll("\n", "");
+		const branch = new RegExp("ref: refs/heads/([\.A-Za-z0-9_-]+)").exec(head_contents)[1];
+		const config_contents = fs.readFileSync("app/.parent_git/config").toString().replaceAll("\t", "").split("\n");
+		const remote = new RegExp("remote = (.+)").exec(config_contents[config_contents.indexOf(`[branch "${branch}"]`) + 1])[1];
+		const remote_url = new RegExp("url = (.+)").exec(config_contents[config_contents.indexOf(`[remote "${remote}"]`) + 1])[1];
+		const latest_commit = fs.readFileSync(`app/.parent_git/refs/heads/${branch}`).toString().replaceAll("\n", "");
+		const latest_commit_short = latest_commit.substr(0, 7);
+
+		console.log(`Git branch: ${remote}/${branch}. Remote url: ${remote_url}. Latest commit: ${latest_commit} (${latest_commit_short}).`);
+	} catch(e) {
+		console.log(`Could not get Git info: ${e.message}.`);
+	}
+}
+
+printVersion();
+
 if (
 	(!config.has("configVersion") || config.get("configVersion") !== REQUIRED_CONFIG_VERSION) &&
 	!(config.has("skipConfigVersionCheck") && config.get("skipConfigVersionCheck"))
@@ -274,6 +299,9 @@ function printTask(task, layer) {
 
 process.stdin.on("data", data => {
 	const command = data.toString().replace(/\r?\n|\r/g, "");
+	if (command === "version") {
+		printVersion();
+	}
 	if (command === "lockdown") {
 		console.log("Locking down.");
 		moduleManager._lockdown();

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

@@ -129,7 +129,10 @@ export default {
 			room.startsWith("profile.") ||
 			room.startsWith("manage-station.") ||
 			room.startsWith("edit-song.") ||
-			room.startsWith("view-report.")
+			room.startsWith("view-report.") ||
+			room.startsWith("edit-user.") ||
+			room === "import-album" ||
+			room === "edit-songs"
 		) {
 			WSModule.runJob("SOCKET_JOIN_ROOM", {
 				socketId: session.socketId,
@@ -157,7 +160,9 @@ export default {
 			room.startsWith("profile.") ||
 			room.startsWith("manage-station.") ||
 			room.startsWith("edit-song.") ||
-			room.startsWith("view-report.")
+			room.startsWith("view-report.") ||
+			room === "import-album" ||
+			room === "edit-songs"
 		) {
 			WSModule.runJob("SOCKET_LEAVE_ROOM", {
 				socketId: session.socketId,
@@ -181,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(
 			[

+ 456 - 49
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 });
 			}
 		);
 	}),
@@ -415,7 +530,7 @@ export default {
 
 					const match = {
 						createdBy: userId,
-						type: "user"
+						type: { $in: ["user", "user-liked", "user-disliked"] }
 					};
 
 					// if a playlist order exists
@@ -471,10 +586,9 @@ export default {
 	 * Gets all playlists for the user requesting it
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param {boolean} showNonModifiablePlaylists - whether or not to show non modifiable playlists e.g. liked songs
 	 * @param {Function} cb - gets called with the result
 	 */
-	indexMyPlaylists: isLoginRequired(async function indexMyPlaylists(session, showNonModifiablePlaylists, cb) {
+	indexMyPlaylists: isLoginRequired(async function indexMyPlaylists(session, cb) {
 		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
@@ -489,12 +603,9 @@ export default {
 
 					const match = {
 						createdBy: session.userId,
-						type: "user"
+						type: { $in: ["user", "user-liked", "user-disliked"] }
 					};
 
-					// if non modifiable playlists should be shown as well
-					if (!showNonModifiablePlaylists) match.isUserModifiable = true;
-
 					// if a playlist order exists
 					if (orderOfPlaylists > 0) match._id = { $in: orderOfPlaylists };
 
@@ -801,7 +912,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) => {
@@ -870,6 +992,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(
@@ -948,22 +1086,47 @@ 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)
-							);
+							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";
+						const oppositePlaylistName = oppositeType === "user-liked" ? "Liked Songs" : "Disliked Songs";
+						playlistModel.count(
+							{ type: oppositeType, createdBy: session.userId, "songs.youtubeId": youtubeId },
+							(err, results) => {
+								if (err) next(err);
+								else if (results > 0)
+									next(
+										`That song is already in your ${oppositePlaylistName} playlist. A song cannot be in both the Liked Songs playlist and the Disliked Songs playlist at the same time.`
+									);
+								else next();
+							}
+						);
+					} else next();
+				},
+
 				next => {
 					DBModule.runJob("GET_MODEL", { modelName: "user" }, this)
 						.then(UserModel => {
@@ -1013,9 +1176,21 @@ export default {
 								.catch(next);
 						}
 					);
+				},
+				(playlist, newSong, next) => {
+					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
+						SongsModule.runJob("RECALCULATE_SONG_RATINGS", {
+							songId: newSong._id,
+							youtubeId: newSong.youtubeId
+						})
+							.then(ratings => next(null, playlist, newSong, ratings))
+							.catch(next);
+					} else {
+						next(null, playlist, newSong, null);
+					}
 				}
 			],
-			async (err, playlist, newSong) => {
+			async (err, playlist, newSong, ratings) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log(
@@ -1032,7 +1207,7 @@ export default {
 					`Successfully added song "${youtubeId}" to private playlist "${playlistId}" for user "${session.userId}".`
 				);
 
-				if (!isSet && playlist.displayName !== "Liked Songs" && playlist.displayName !== "Disliked Songs") {
+				if (!isSet && playlist.type !== "user-liked" && playlist.type !== "user-disliked") {
 					const songName = newSong.artists
 						? `${newSong.title} by ${newSong.artists.join(", ")}`
 						: newSong.title;
@@ -1068,6 +1243,60 @@ 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;
+
+					SongsModule.runJob("UPDATE_SONG", { songId: _id });
+
+					if (playlist.type === "user-liked") {
+						CacheModule.runJob("PUB", {
+							channel: "song.like",
+							value: JSON.stringify({
+								youtubeId,
+								userId: session.userId,
+								likes,
+								dislikes
+							})
+						});
+
+						ActivitiesModule.runJob("ADD_ACTIVITY", {
+							userId: session.userId,
+							type: "song__like",
+							payload: {
+								message: `Liked song <youtubeId>${title} by ${artists.join(", ")}</youtubeId>`,
+								youtubeId,
+								thumbnail
+							}
+						});
+					} else {
+						CacheModule.runJob("PUB", {
+							channel: "song.dislike",
+							value: JSON.stringify({
+								youtubeId,
+								userId: session.userId,
+								likes,
+								dislikes
+							})
+						});
+
+						ActivitiesModule.runJob("ADD_ACTIVITY", {
+							userId: session.userId,
+							type: "song__dislike",
+							payload: {
+								message: `Disliked song <youtubeId>${title} by ${artists.join(", ")}</youtubeId>`,
+								youtubeId,
+								thumbnail
+							}
+						});
+					}
+				}
+
 				return cb({
 					status: "success",
 					message: "Song has been successfully added to the playlist",
@@ -1114,6 +1343,8 @@ export default {
 					let successful = 0;
 					let failed = 0;
 					let alreadyInPlaylist = 0;
+					let alreadyInLikedPlaylist = 0;
+					let alreadyInDislikedPlaylist = 0;
 
 					if (youtubeIds.length === 0) next();
 
@@ -1137,6 +1368,20 @@ export default {
 										addedSongs.push(youtubeId);
 									} else failed += 1;
 									if (res.message === "That song is already in the playlist") alreadyInPlaylist += 1;
+									else if (
+										res.message ===
+										"That song is already in your Liked Songs playlist. " +
+											"A song cannot be in both the Liked Songs playlist" +
+											" and the Disliked Songs playlist at the same time."
+									)
+										alreadyInLikedPlaylist += 1;
+									else if (
+										res.message ===
+										"That song is already in your Disliked Songs playlist. " +
+											"A song cannot be in both the Liked Songs playlist " +
+											"and the Disliked Songs playlist at the same time."
+									)
+										alreadyInDislikedPlaylist += 1;
 								})
 								.catch(() => {
 									failed += 1;
@@ -1144,7 +1389,13 @@ export default {
 								.finally(() => next());
 						},
 						() => {
-							addSongsStats = { successful, failed, alreadyInPlaylist };
+							addSongsStats = {
+								successful,
+								failed,
+								alreadyInPlaylist,
+								alreadyInLikedPlaylist,
+								alreadyInDislikedPlaylist
+							};
 							next(null);
 						}
 					);
@@ -1157,8 +1408,14 @@ export default {
 				},
 
 				(playlist, next) => {
-					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found.");
-					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
+					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);
 				}
@@ -1187,7 +1444,7 @@ export default {
 				this.log(
 					"SUCCESS",
 					"PLAYLIST_IMPORT",
-					`Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${session.userId}". Videos in playlist: ${videosInPlaylistTotal}, songs in playlist: ${songsInPlaylistTotal}, songs successfully added: ${addSongsStats.successful}, songs failed: ${addSongsStats.failed}, already in playlist: ${addSongsStats.alreadyInPlaylist}.`
+					`Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${session.userId}". Videos in playlist: ${videosInPlaylistTotal}, songs in playlist: ${songsInPlaylistTotal}, songs successfully added: ${addSongsStats.successful}, songs failed: ${addSongsStats.failed}, already in playlist: ${addSongsStats.alreadyInPlaylist}, already in liked ${addSongsStats.alreadyInLikedPlaylist}, already in disliked ${addSongsStats.alreadyInDislikedPlaylist}.`
 				);
 
 				return cb({
@@ -1197,7 +1454,9 @@ export default {
 						songs: playlist.songs,
 						stats: {
 							videosInPlaylistTotal,
-							songsInPlaylistTotal
+							songsInPlaylistTotal,
+							alreadyInLikedPlaylist: addSongsStats.alreadyInLikedPlaylist,
+							alreadyInDislikedPlaylist: addSongsStats.alreadyInDislikedPlaylist
 						}
 					}
 				});
@@ -1224,6 +1483,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),
 
@@ -1246,9 +1521,11 @@ export default {
 					SongsModule.runJob("GET_SONG_FROM_YOUTUBE_ID", { youtubeId }, this)
 						.then(res =>
 							next(null, playlist, {
+								_id: res.song._id,
 								title: res.song.title,
 								thumbnail: res.song.thumbnail,
-								artists: res.song.artists
+								artists: res.song.artists,
+								youtubeId: res.song.youtubeId
 							})
 						)
 						.catch(() => {
@@ -1258,14 +1535,26 @@ export default {
 						});
 				},
 
-				(playlist, youtubeSong, next) => {
-					const songName = youtubeSong.artists
-						? `${youtubeSong.title} by ${youtubeSong.artists.join(", ")}`
-						: youtubeSong.title;
+				(playlist, newSong, next) => {
+					if (playlist.type === "user-liked" || playlist.type === "user-disliked") {
+						SongsModule.runJob("RECALCULATE_SONG_RATINGS", {
+							songId: newSong._id,
+							youtubeId: newSong.youtubeId
+						})
+							.then(ratings => next(null, playlist, newSong, ratings))
+							.catch(next);
+					} else {
+						next(null, playlist, newSong, null);
+					}
+				},
+
+				(playlist, newSong, ratings, next) => {
+					const { _id, title, artists, thumbnail } = newSong;
+					const songName = artists ? `${title} by ${artists.join(", ")}` : title;
 
 					if (
-						playlist.displayName !== "Liked Songs" &&
-						playlist.displayName !== "Disliked Songs" &&
+						playlist.type !== "user-liked" &&
+						playlist.type !== "user-disliked" &&
 						playlist.privacy === "public"
 					) {
 						ActivitiesModule.runJob("ADD_ACTIVITY", {
@@ -1273,13 +1562,65 @@ export default {
 							type: "playlist__remove_song",
 							payload: {
 								message: `Removed <youtubeId>${songName}</youtubeId> from playlist <playlistId>${playlist.displayName}</playlistId>`,
-								thumbnail: youtubeSong.thumbnail,
+								thumbnail,
 								playlistId,
-								youtubeId
+								youtubeId: newSong.youtubeId
 							}
 						});
 					}
 
+					if (ratings && (playlist.type === "user-liked" || playlist.type === "user-disliked")) {
+						const { likes, dislikes } = ratings;
+
+						SongsModule.runJob("UPDATE_SONG", { songId: _id });
+
+						if (playlist.type === "user-liked") {
+							CacheModule.runJob("PUB", {
+								channel: "song.unlike",
+								value: JSON.stringify({
+									youtubeId: newSong.youtubeId,
+									userId: session.userId,
+									likes,
+									dislikes
+								})
+							});
+
+							ActivitiesModule.runJob("ADD_ACTIVITY", {
+								userId: session.userId,
+								type: "song__unlike",
+								payload: {
+									message: `Removed <youtubeId>${title} by ${artists.join(
+										", "
+									)}</youtubeId> from your Liked Songs`,
+									youtubeId: newSong.youtubeId,
+									thumbnail
+								}
+							});
+						} else {
+							CacheModule.runJob("PUB", {
+								channel: "song.undislike",
+								value: JSON.stringify({
+									youtubeId: newSong.youtubeId,
+									userId: session.userId,
+									likes,
+									dislikes
+								})
+							});
+
+							ActivitiesModule.runJob("ADD_ACTIVITY", {
+								userId: session.userId,
+								type: "song__undislike",
+								payload: {
+									message: `Removed <youtubeId>${title} by ${artists.join(
+										", "
+									)}</youtubeId> from your Disliked Songs`,
+									youtubeId: newSong.youtubeId,
+									thumbnail
+								}
+							});
+						}
+					}
+
 					return next(null, playlist);
 				}
 			],
@@ -1310,6 +1651,11 @@ export default {
 					}
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updated",
+					value: { playlistId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Song has been successfully removed from playlist",
@@ -1338,7 +1684,7 @@ export default {
 				},
 
 				(playlist, next) => {
-					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
+					if (playlist.type !== "user") return next("Playlist cannot be modified.");
 					return next(null);
 				},
 
@@ -1384,6 +1730,11 @@ export default {
 					}
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updated",
+					value: { playlistId }
+				});
+
 				ActivitiesModule.runJob("ADD_ACTIVITY", {
 					userId: session.userId,
 					type: "playlist__edit_display_name",
@@ -1421,7 +1772,7 @@ export default {
 
 				(playlist, next) => {
 					if (playlist.createdBy !== session.userId) return next("You do not own this playlist.");
-					if (!playlist.isUserModifiable) return next("Playlist cannot be removed.");
+					if (playlist.type !== "user") return next("Playlist cannot be removed.");
 					return next(null, playlist);
 				},
 
@@ -1501,7 +1852,7 @@ export default {
 				},
 
 				(playlist, next) => {
-					if (!playlist.isUserModifiable) return next("Playlist cannot be removed.");
+					if (playlist.type !== "user") return next("Playlist cannot be removed.");
 					return next(null, playlist);
 				},
 
@@ -1613,6 +1964,11 @@ export default {
 					}
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updated",
+					value: { playlistId }
+				});
+
 				ActivitiesModule.runJob("ADD_ACTIVITY", {
 					userId: session.userId,
 					type: "playlist__edit_privacy",
@@ -1686,6 +2042,11 @@ export default {
 					});
 				}
 
+				CacheModule.runJob("PUB", {
+					channel: "playlist.updated",
+					value: { playlistId }
+				});
+
 				return cb({
 					status: "success",
 					message: "Playlist has been successfully updated"
@@ -2031,5 +2392,51 @@ export default {
 				});
 			}
 		);
+	}),
+
+	/**
+	 * Create missing genre playlists
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
+	createMissingGenrePlaylists: isAdminRequired(async function index(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("CREATE_MISSING_GENRE_PLAYLISTS", this)
+						.then(() => {
+							next();
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_CREATE_MISSING_GENRE_PLAYLISTS",
+						`Creating missing genre playlists failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_CREATE_MISSING_GENRE_PLAYLISTS",
+					`Successfully created missing genre playlists for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Missing genre playlists have been successfully created"
+				});
+			}
+		);
 	})
 };

+ 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 - 44
backend/logic/actions/reports.js

@@ -60,62 +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) => {
-							console.log(typeof report.createdBy);
-
-							userModel
-								.findById(report.createdBy)
-								.select({ avatar: -1, name: -1, username: -1 })
-								.exec((err, user) => {
-									if (!user)
-										reports.push({
-											...report._doc,
-											createdBy: { _id: report.createdBy }
-										});
-									else
-										reports.push({
-											...report._doc,
-											createdBy: {
-												avatar: user.avatar,
-												name: user.name,
-												username: user.username,
-												_id: report.createdBy
+				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
+				});
 			}
 		);
 	}),

文件差异内容过多而无法显示
+ 589 - 302
backend/logic/actions/songs.js


+ 169 - 4
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",
@@ -2621,7 +2787,6 @@ export default {
 					const stationId = mongoose.Types.ObjectId();
 					playlistModel.create(
 						{
-							isUserModifiable: false,
 							displayName: `Station - ${data.name}`,
 							songs: [],
 							createdBy: data.type === "official" ? "Musare" : session.userId,

+ 455 - 40
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 } });
 			});
 		});
 	}
@@ -157,53 +157,124 @@ CacheModule.runJob("SUB", {
 	}
 });
 
+CacheModule.runJob("SUB", {
+	channel: "user.removeAccount",
+	cb: userId => {
+		WSModule.runJob("EMIT_TO_ROOMS", {
+			rooms: ["admin.users", `edit-user.${userId}`],
+			args: ["event:user.removed", { data: { userId } }]
+		});
+	}
+});
+
+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 } });
 			}
 		);
 	}),
@@ -263,7 +334,7 @@ export default {
 				},
 
 				next => {
-					playlistModel.findOne({ createdBy: session.userId, displayName: "Liked Songs" }, next);
+					playlistModel.findOne({ createdBy: session.userId, type: "user-liked" }, next);
 				},
 
 				// get all liked songs (as the global rating values for these songs will need adjusted)
@@ -278,7 +349,7 @@ export default {
 				},
 
 				next => {
-					playlistModel.findOne({ createdBy: session.userId, displayName: "Disliked Songs" }, next);
+					playlistModel.findOne({ createdBy: session.userId, type: "user-disliked" }, next);
 				},
 
 				// get all disliked songs (as the global rating values for these songs will need adjusted)
@@ -360,6 +431,175 @@ export default {
 					`Successfully removed data and account for user "${session.userId}"`
 				);
 
+				CacheModule.runJob("PUB", {
+					channel: "user.removeAccount",
+					value: session.userId
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully removed data and account."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Removes all data held on a user, including their ability to login, by userId
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the user id that is going to be banned
+	 * @param {Function} cb - gets called with the result
+	 */
+	adminRemove: isAdminRequired(async function adminRemove(session, userId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const dataRequestModel = await DBModule.runJob("GET_MODEL", { modelName: "dataRequest" }, this);
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+		const activityModel = await DBModule.runJob("GET_MODEL", { modelName: "activity" }, this);
+
+		const dataRequestEmail = await MailModule.runJob("GET_SCHEMA", { schemaName: "dataRequest" }, this);
+
+		const songsToAdjustRatings = [];
+
+		async.waterfall(
+			[
+				next => {
+					if (!userId) return next("You must provide a userId to remove.");
+					return next();
+				},
+				// activities related to the user
+				next => {
+					activityModel.deleteMany({ userId }, next);
+				},
+
+				// user's stations
+				(res, next) => {
+					stationModel.find({ owner: userId }, (err, stations) => {
+						if (err) return next(err);
+
+						return async.each(
+							stations,
+							(station, callback) => {
+								// delete the station
+								stationModel.deleteOne({ _id: station._id }, err => {
+									if (err) return callback(err);
+
+									// if applicable, delete the corresponding playlist for the station
+									if (station.playlist)
+										return PlaylistsModule.runJob("DELETE_PLAYLIST", {
+											playlistId: station.playlist
+										})
+											.then(() => callback())
+											.catch(callback);
+
+									return callback();
+								});
+							},
+							err => next(err)
+						);
+					});
+				},
+
+				next => {
+					playlistModel.findOne({ createdBy: userId, type: "user-liked" }, next);
+				},
+
+				// get all liked songs (as the global rating values for these songs will need adjusted)
+				(playlist, next) => {
+					if (!playlist) return next();
+
+					playlist.songs.forEach(song =>
+						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
+					);
+
+					return next();
+				},
+
+				next => {
+					playlistModel.findOne({ createdBy: userId, type: "user-disliked" }, next);
+				},
+
+				// get all disliked songs (as the global rating values for these songs will need adjusted)
+				(playlist, next) => {
+					if (!playlist) return next();
+
+					playlist.songs.forEach(song =>
+						songsToAdjustRatings.push({ songId: song._id, youtubeId: song.youtubeId })
+					);
+
+					return next();
+				},
+
+				// user's playlists
+				next => {
+					playlistModel.deleteMany({ createdBy: userId }, next);
+				},
+
+				(res, next) => {
+					async.each(
+						songsToAdjustRatings,
+						(song, next) => {
+							const { songId, youtubeId } = song;
+
+							SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId, youtubeId })
+								.then(() => next())
+								.catch(next);
+						},
+						err => next(err)
+					);
+				},
+
+				// user object
+				next => {
+					userModel.deleteMany({ _id: userId }, next);
+				},
+
+				// request data removal for user
+				(res, next) => {
+					dataRequestModel.create({ userId, type: "remove" }, next);
+				},
+
+				(request, next) => {
+					WSModule.runJob("EMIT_TO_ROOM", {
+						room: "admin.users",
+						args: ["event:admin.dataRequests.created", { data: { request } }]
+					});
+
+					return next();
+				},
+
+				next => userModel.find({ role: "admin" }, next),
+
+				// send email to all admins of a data removal request
+				(users, next) => {
+					if (!config.get("sendDataRequestEmails")) return next();
+					if (users.length === 0) return next();
+
+					const to = [];
+					users.forEach(user => to.push(user.email.address));
+
+					return dataRequestEmail(to, userId, "remove", err => next(err));
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"USER_ADMIN_REMOVE",
+						`Removing data and account for user "${userId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "USER_ADMIN_REMOVE", `Successfully removed data and account for user "${userId}"`);
+
+				CacheModule.runJob("PUB", {
+					channel: "user.removeAccount",
+					value: userId
+				});
+
 				return cb({
 					status: "success",
 					message: "Successfully removed data and account."
@@ -574,10 +814,10 @@ export default {
 
 				// create a liked songs playlist for the new user
 				(userId, next) => {
-					PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+					PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
 						userId,
 						displayName: "Liked Songs",
-						type: "user"
+						type: "user-liked"
 					})
 						.then(likedSongsPlaylist => {
 							next(null, likedSongsPlaylist, userId);
@@ -587,10 +827,10 @@ export default {
 
 				// create a disliked songs playlist for the new user
 				(likedSongsPlaylist, userId, next) => {
-					PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+					PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
 						userId,
 						displayName: "Disliked Songs",
-						type: "user"
+						type: "user-disliked"
 					})
 						.then(dislikedSongsPlaylist => {
 							next(null, { likedSongsPlaylist, dislikedSongsPlaylist }, userId);
@@ -1476,6 +1716,11 @@ export default {
 					}
 				});
 
+				CacheModule.runJob("PUB", {
+					channel: "user.updated",
+					value: { userId: updatingUserId }
+				});
+
 				this.log(
 					"SUCCESS",
 					"UPDATE_USERNAME",
@@ -1586,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."
@@ -1652,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"
@@ -1724,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"
@@ -1784,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"
@@ -1848,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"
@@ -1907,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."
@@ -2186,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."
@@ -2233,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."
@@ -2280,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."
@@ -2367,6 +2657,81 @@ export default {
 		);
 	},
 
+	/**
+	 * Requests a password reset for a a user as an admin
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} email - the email of the user for which the password reset is intended
+	 * @param {Function} cb - gets called with the result
+	 */
+	adminRequestPasswordReset: isAdminRequired(async function adminRequestPasswordReset(session, userId, cb) {
+		const code = await UtilsModule.runJob("GENERATE_RANDOM_STRING", { length: 8 }, this);
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+
+		const resetPasswordRequestSchema = await MailModule.runJob(
+			"GET_SCHEMA",
+			{ schemaName: "resetPasswordRequest" },
+			this
+		);
+
+		async.waterfall(
+			[
+				next => userModel.findOne({ _id: userId }, next),
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					if (!user.services.password || !user.services.password.password)
+						return next("User does not have a password set, and probably uses GitHub to log in.");
+					return next();
+				},
+
+				next => {
+					const expires = new Date();
+					expires.setDate(expires.getDate() + 1);
+					userModel.findOneAndUpdate(
+						{ _id: userId },
+						{
+							$set: {
+								"services.password.reset": {
+									code,
+									expires
+								}
+							}
+						},
+						{ runValidators: true },
+						next
+					);
+				},
+
+				(user, next) => {
+					resetPasswordRequestSchema(user.email.address, user.username, code, next);
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"ADMINREQUEST_PASSWORD_RESET",
+						`User '${userId}' failed to get a password reset. '${err}'`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"ADMIN_REQUEST_PASSWORD_RESET",
+					`User '${userId}' successfully got sent a password reset.`
+				);
+
+				return cb({
+					status: "success",
+					message: "Successfully requested password reset for user."
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Verifies a reset code
 	 *
@@ -2479,6 +2844,56 @@ export default {
 		);
 	},
 
+	/**
+	 * Resends the verify email email
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} userId - the user id of the person to resend the email to
+	 * @param {Function} cb - gets called with the result
+	 */
+	resendVerifyEmail: isAdminRequired(async function resendVerifyEmail(session, userId, cb) {
+		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+		const verifyEmailSchema = await MailModule.runJob("GET_SCHEMA", { schemaName: "verifyEmail" }, this);
+
+		async.waterfall(
+			[
+				next => userModel.findOne({ _id: userId }, next),
+
+				(user, next) => {
+					if (!user) return next("User not found.");
+					if (user.email.verified) return next("The user's email is already verified.");
+					return next(null, user);
+				},
+
+				(user, next) => {
+					verifyEmailSchema(user.email.address, user.username, user.email.verificationToken, err => {
+						next(err);
+					});
+				}
+			],
+			async err => {
+				if (err && err !== true) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"RESEND_VERIFY_EMAIL",
+						`Couldn't resend verify email for user "${userId}". '${err}'`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log("SUCCESS", "RESEND_VERIFY_EMAIL", `Resent verify email for user "${userId}".`);
+
+				return cb({
+					status: "success",
+					message: "Email resent successfully."
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Bans a user by userId
 	 *

+ 9 - 4
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`);
 										}
 									],
@@ -325,10 +330,10 @@ class _AppModule extends CoreClass {
 
 						// create a liked songs playlist for the new user
 						(userId, next) => {
-							PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+							PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
 								userId,
 								displayName: "Liked Songs",
-								type: "user"
+								type: "user-liked"
 							})
 								.then(likedSongsPlaylist => {
 									next(null, likedSongsPlaylist, userId);
@@ -338,10 +343,10 @@ class _AppModule extends CoreClass {
 
 						// create a disliked songs playlist for the new user
 						(likedSongsPlaylist, userId, next) => {
-							PlaylistsModule.runJob("CREATE_READ_ONLY_PLAYLIST", {
+							PlaylistsModule.runJob("CREATE_USER_PLAYLIST", {
 								userId,
 								displayName: "Disliked Songs",
-								type: "user"
+								type: "user-disliked"
 							})
 								.then(dislikedSongsPlaylist => {
 									next(null, { likedSongsPlaylist, dislikedSongsPlaylist }, userId);

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

@@ -8,12 +8,12 @@ import CoreClass from "../../core";
 const REQUIRED_DOCUMENT_VERSIONS = {
 	activity: 2,
 	news: 2,
-	playlist: 4,
+	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://");
@@ -302,6 +308,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
 	 *

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

@@ -2,7 +2,6 @@ import mongoose from "mongoose";
 
 export default {
 	displayName: { type: String, min: 2, max: 32, required: true },
-	isUserModifiable: { type: Boolean, default: true, required: true },
 	songs: [
 		{
 			_id: { type: mongoose.Schema.Types.ObjectId, required: false },
@@ -18,6 +17,6 @@ export default {
 	createdAt: { type: Date, default: Date.now, required: true },
 	createdFor: { type: String },
 	privacy: { type: String, enum: ["public", "private"], default: "private" },
-	type: { type: String, enum: ["user", "genre", "station"], required: true },
-	documentVersion: { type: Number, default: 4, required: true }
+	type: { type: String, enum: ["user", "user-liked", "user-disliked", "genre", "station"], 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: `

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

@@ -0,0 +1,65 @@
+import async from "async";
+
+/**
+ * Migration 16
+ *
+ * Migration for playlists to remove isUserModifiable
+ *
+ * @param {object} MigrationModule - the MigrationModule
+ * @returns {Promise} - returns promise
+ */
+export default async function migrate(MigrationModule) {
+	const playlistModel = await MigrationModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+	return new Promise((resolve, reject) => {
+		async.waterfall(
+			[
+				next => {
+					this.log("INFO", `Migration 16. Finding playlists with document version 4.`);
+					playlistModel.find({ documentVersion: 4 }, (err, playlists) => {
+						if (err) next(err);
+						else {
+							async.eachLimit(
+								playlists.map(playlisti => playlisti._doc),
+								1,
+								(playlisti, next) => {
+									// set liked/disliked playlist to new type
+									if (playlisti.type === "user" && playlisti.displayName === "Liked Songs")
+										playlisti.type = "user-liked";
+									else if (playlisti.type === "user" && playlisti.displayName === "Disliked Songs")
+										playlisti.type = "user-disliked";
+
+									// update the database
+									playlistModel.updateOne(
+										{ _id: playlisti._id },
+										{
+											$unset: {
+												isUserModifiable: ""
+											},
+											$set: {
+												type: playlisti.type,
+												documentVersion: 5
+											}
+										},
+										next
+									);
+								},
+								err => {
+									if (err) next(err);
+									else {
+										this.log("INFO", `Migration 16. Playlists found: ${playlists.length}.`);
+										next();
+									}
+								}
+							);
+						}
+					});
+				}
+			],
+			err => {
+				if (err) reject(new Error(err));
+				else resolve();
+			}
+		);
+	});
+}

+ 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();
+				}
+			}
+		);
+	});
+}

+ 24 - 30
backend/logic/playlists.js

@@ -110,42 +110,37 @@ 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 that is not generated or editable by a user e.g. liked songs playlist
+	 * Creates a playlist owned by a user
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.userId - the id of the user to create the playlist for
 	 * @param {string} payload.displayName - the display name of the playlist
+	 * @param {string} payload.type - the type of the playlist
 	 * @returns {Promise} - returns promise (reject, resolve)
 	 */
-	CREATE_READ_ONLY_PLAYLIST(payload) {
+	CREATE_USER_PLAYLIST(payload) {
 		return new Promise((resolve, reject) => {
 			PlaylistsModule.playlistModel.create(
 				{
-					isUserModifiable: false,
 					displayName: payload.displayName,
 					songs: [],
 					createdBy: payload.userId,
@@ -178,7 +173,6 @@ class _PlaylistsModule extends CoreClass {
 					if (err.message === "Playlist not found") {
 						PlaylistsModule.playlistModel.create(
 							{
-								isUserModifiable: false,
 								displayName: `Genre - ${payload.genre}`,
 								songs: [],
 								createdBy: "Musare",
@@ -362,7 +356,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,
@@ -370,7 +364,7 @@ class _PlaylistsModule extends CoreClass {
 				artists,
 				thumbnail,
 				duration,
-				status
+				verified
 			};
 
 			PlaylistsModule.playlistModel.updateOne(
@@ -464,7 +458,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,
@@ -472,7 +466,7 @@ class _PlaylistsModule extends CoreClass {
 								artists,
 								thumbnail,
 								duration,
-								status
+								verified
 							};
 						});
 

+ 410 - 150
backend/logic/songs.js

@@ -1,5 +1,4 @@
 import async from "async";
-import config from "config";
 import mongoose from "mongoose";
 import CoreClass from "../core";
 
@@ -242,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()
 							});
@@ -297,6 +289,7 @@ class _SongsModule extends CoreClass {
 	 *
 	 * @param {object} payload - an object containing the payload
 	 * @param {string} payload.songId - the id of the song we are trying to update
+	 * @param {string} payload.oldStatus - old status of song being updated (optional)
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	UPDATE_SONG(payload) {
@@ -332,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,
@@ -340,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)
@@ -388,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 => {
@@ -401,7 +394,7 @@ class _SongsModule extends CoreClass {
 											"queue.$.artists": artists,
 											"queue.$.thumbnail": thumbnail,
 											"queue.$.duration": duration,
-											"queue.$.status": status
+											"queue.$.verified": verified
 										}
 									},
 									err => {
@@ -460,12 +453,297 @@ class _SongsModule extends CoreClass {
 				],
 				(err, song) => {
 					if (err && err !== true) return reject(new Error(err));
+
+					if (!payload.oldStatus) payload.oldStatus = null;
+
+					CacheModule.runJob("PUB", {
+						channel: "song.updated",
+						value: { songId: song._id, oldStatus: payload.oldStatus }
+					});
+
 					return resolve(song);
 				}
 			)
 		);
 	}
 
+	/**
+	 * 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
 	 *
@@ -595,7 +873,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
@@ -607,11 +884,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;
 
@@ -623,11 +899,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 }
 							}
 						];
 
@@ -666,7 +942,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,
@@ -674,7 +950,7 @@ class _SongsModule extends CoreClass {
 										artists,
 										thumbnail,
 										duration,
-										status
+										verified
 									};
 								}),
 								...data
@@ -706,7 +982,7 @@ class _SongsModule extends CoreClass {
 				[
 					next => {
 						playlistModel.countDocuments(
-							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, displayName: "Liked Songs" },
+							{ songs: { $elemMatch: { _id: payload.songId } }, type: "user-liked" },
 							(err, likes) => {
 								if (err) return next(err);
 								return next(null, likes);
@@ -716,7 +992,7 @@ class _SongsModule extends CoreClass {
 
 					(likes, next) => {
 						playlistModel.countDocuments(
-							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, displayName: "Disliked Songs" },
+							{ songs: { $elemMatch: { _id: payload.songId } }, type: "user-disliked" },
 							(err, dislikes) => {
 								if (err) return next(err);
 								return next(err, { likes, dislikes });
@@ -745,6 +1021,46 @@ class _SongsModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Recalculates dislikes and likes for all songs
+	 *
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	RECALCULATE_ALL_SONG_RATINGS() {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.find({}, { _id: true }, next);
+					},
+
+					(songs, next) => {
+						async.eachLimit(
+							songs,
+							2,
+							(song, next) => {
+								SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id }, this)
+									.then(() => {
+										next();
+									})
+									.catch(err => {
+										next(err);
+									});
+							},
+							err => {
+								next(err);
+							}
+						);
+					}
+				],
+				err => {
+					if (err) return reject(new Error(err));
+					return resolve();
+				}
+			);
+		});
+	}
+
 	/**
 	 * Gets an array of all genres
 	 *
@@ -755,7 +1071,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) => {
@@ -794,7 +1110,7 @@ class _SongsModule extends CoreClass {
 					next => {
 						SongsModule.SongModel.find(
 							{
-								status: "verified",
+								verified: true,
 								genres: { $regex: new RegExp(`^${payload.genre.toLowerCase()}$`, "i") }
 							},
 							next
@@ -885,9 +1201,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;
@@ -897,7 +1210,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);
@@ -930,7 +1243,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,
@@ -938,7 +1251,7 @@ class _SongsModule extends CoreClass {
 						artists,
 						thumbnail,
 						duration,
-						status
+						verified
 					};
 
 					if (err && err === "This song is already in the database.")
@@ -946,128 +1259,12 @@ class _SongsModule extends CoreClass {
 
 					SongsModule.runJob("UPDATE_SONG", { songId: song._id });
 
-					CacheModule.runJob("PUB", {
-						channel: "song.newUnverifiedSong",
-						value: song._id
-					});
-
 					return resolve({ song: trimmedSong });
 				}
 			);
 		});
 	}
 
-	/**
-	 * 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();
-					},
-
-					next => {
-						SongsModule.SongModel.updateOne({ _id: songId }, { status: "hidden" }, next);
-					},
-
-					(res, next) => {
-						SongsModule.runJob("UPDATE_SONG", { songId });
-						next();
-					}
-				],
-				async err => {
-					if (err) reject(err);
-
-					CacheModule.runJob("PUB", {
-						channel: "song.newHiddenSong",
-						value: songId
-					});
-
-					CacheModule.runJob("PUB", {
-						channel: "song.removedUnverifiedSong",
-						value: songId
-					});
-
-					CacheModule.runJob("PUB", {
-						channel: "song.removedVerifiedSong",
-						value: songId
-					});
-
-					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 });
-						next();
-					}
-				],
-				async err => {
-					if (err) reject(err);
-
-					CacheModule.runJob("PUB", {
-						channel: "song.newUnverifiedSong",
-						value: songId
-					});
-
-					CacheModule.runJob("PUB", {
-						channel: "song.removedHiddenSong",
-						value: songId
-					});
-
-					resolve();
-				}
-			);
-		});
-	}
-
 	// runjob songs REQUEST_ORPHANED_PLAYLIST_SONGS {}
 
 	/**
@@ -1114,7 +1311,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,
@@ -1122,7 +1319,7 @@ class _SongsModule extends CoreClass {
 												artists,
 												thumbnail,
 												duration,
-												status
+												verified
 											};
 											playlistModel.updateMany(
 												{ "songs.youtubeId": song.youtubeId },
@@ -1180,6 +1377,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.2.2",
+  "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.2.2",
+  "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",

+ 4 - 2
docker-compose.yml

@@ -8,6 +8,7 @@ services:
     volumes:
       - ./backend:/opt/app
       - ./log:/opt/log
+      - ./.git:/opt/app/.parent_git:ro
     links:
       - mongo
       - redis
@@ -21,6 +22,7 @@ services:
     volumes:
       - ./frontend:/opt/app
       - /opt/app/node_modules/
+      - ./.git:/opt/app/.parent_git:ro
     environment:
       - FRONTEND_MODE=${FRONTEND_MODE}
     links:
@@ -46,7 +48,7 @@ services:
     image: redis
     ports:
       - "${REDIS_HOST}:${REDIS_PORT}:6379"
-    command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD} --appendonly yes"
+    command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD}
+      --appendonly yes"
     volumes:
       - .redis:/data
-

二进制
frontend/dist/assets/15-seconds-of-silence.mp3


+ 12 - 1
frontend/dist/config/template.json

@@ -23,6 +23,7 @@
 		"logo_blue": "/assets/blue_wordmark.png",
 		"sitename": "Musare",
 		"github": "https://github.com/Musare/Musare",
+		"mediasession": false,
 		"christmas": false
 	},
 	"messages": {
@@ -37,6 +38,16 @@
 	// 		"preventDefault": true
 	// 	}
 	// },
+	"debug": {
+		"git": {
+			"remote": false,
+			"remoteUrl": false,
+			"branch": true,
+			"latestCommit": true,
+			"latestCommitShort": true
+		},
+		"version": true
+	},
 	"skipConfigVersionCheck": false,
-	"configVersion": 8
+	"configVersion": 9
 }

二进制
frontend/dist/fonts/MaterialIcons-Regular.ttf


+ 11 - 3
frontend/dist/index.tpl.html

@@ -11,7 +11,7 @@
 		content='music, musare, listen, station, station, radio, edm, chill, community, official, rooms, room, party, good, mus, pop'>
 	<meta name='description'
 		content='On Musare 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 Musare 2015-2021 All Right Reserved'>
+	<meta name='copyright' content='© Copyright Musare 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'>
@@ -36,8 +36,16 @@
 	<meta name='google' content='nositelinkssearchbox' />
 
 	<script src='https://www.youtube.com/iframe_api'></script>
-	<script type='text/javascript' src='/vendor/can-autoplay.min.js'></script>
-	<script type='text/javascript' src='/vendor/lofig.1.3.4.min.js'></script>
+
+	<!--Musare version: <%= htmlWebpackPlugin.options.debug.version %>-->
+	<script>
+		const MUSARE_VERSION = "<%= htmlWebpackPlugin.options.debug.version %>";
+		const MUSARE_GIT_REMOTE = "<%= htmlWebpackPlugin.options.debug.git.remote %>";
+		const MUSARE_GIT_REMOTE_URL = "<%= htmlWebpackPlugin.options.debug.git.remoteUrl %>";
+		const MUSARE_GIT_BRANCH = "<%= htmlWebpackPlugin.options.debug.git.branch %>";
+		const MUSARE_GIT_LATEST_COMMIT = "<%= htmlWebpackPlugin.options.debug.git.latestCommit %>";
+		const MUSARE_GIT_LATEST_COMMIT_SHORT = "<%= htmlWebpackPlugin.options.debug.git.latestCommitShort %>";
+	</script>
 </head>
 
 <body>

+ 0 - 23
frontend/dist/vendor/can-autoplay.min.js

@@ -1,23 +0,0 @@
-var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.owns=function(a,c){return Object.prototype.hasOwnProperty.call(a,c)};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(a,c,e){a!=Array.prototype&&a!=Object.prototype&&(a[c]=e.value)};
-$jscomp.getGlobal=function(a){return"undefined"!=typeof window&&window===a?a:"undefined"!=typeof global&&null!=global?global:a};$jscomp.global=$jscomp.getGlobal(this);$jscomp.polyfill=function(a,c,e,f){if(c){e=$jscomp.global;a=a.split(".");for(f=0;f<a.length-1;f++){var b=a[f];b in e||(e[b]={});e=e[b]}a=a[a.length-1];f=e[a];c=c(f);c!=f&&null!=c&&$jscomp.defineProperty(e,a,{configurable:!0,writable:!0,value:c})}};
-$jscomp.polyfill("Object.assign",function(a){return a?a:function(a,e){for(var c=1;c<arguments.length;c++){var b=arguments[c];if(b)for(var g in b)$jscomp.owns(b,g)&&(a[g]=b[g])}return a}},"es6","es3");$jscomp.SYMBOL_PREFIX="jscomp_symbol_";$jscomp.initSymbol=function(){$jscomp.initSymbol=function(){};$jscomp.global.Symbol||($jscomp.global.Symbol=$jscomp.Symbol)};$jscomp.Symbol=function(){var a=0;return function(c){return $jscomp.SYMBOL_PREFIX+(c||"")+a++}}();
-$jscomp.initSymbolIterator=function(){$jscomp.initSymbol();var a=$jscomp.global.Symbol.iterator;a||(a=$jscomp.global.Symbol.iterator=$jscomp.global.Symbol("iterator"));"function"!=typeof Array.prototype[a]&&$jscomp.defineProperty(Array.prototype,a,{configurable:!0,writable:!0,value:function(){return $jscomp.arrayIterator(this)}});$jscomp.initSymbolIterator=function(){}};$jscomp.arrayIterator=function(a){var c=0;return $jscomp.iteratorPrototype(function(){return c<a.length?{done:!1,value:a[c++]}:{done:!0}})};
-$jscomp.iteratorPrototype=function(a){$jscomp.initSymbolIterator();a={next:a};a[$jscomp.global.Symbol.iterator]=function(){return this};return a};$jscomp.makeIterator=function(a){$jscomp.initSymbolIterator();var c=a[Symbol.iterator];return c?c.call(a):$jscomp.arrayIterator(a)};$jscomp.FORCE_POLYFILL_PROMISE=!1;
-$jscomp.polyfill("Promise",function(a){function c(){this.batch_=null}function e(d){return d instanceof b?d:new b(function(a,b){a(d)})}if(a&&!$jscomp.FORCE_POLYFILL_PROMISE)return a;c.prototype.asyncExecute=function(d){null==this.batch_&&(this.batch_=[],this.asyncExecuteBatch_());this.batch_.push(d);return this};c.prototype.asyncExecuteBatch_=function(){var d=this;this.asyncExecuteFunction(function(){d.executeBatch_()})};var f=$jscomp.global.setTimeout;c.prototype.asyncExecuteFunction=function(d){f(d,
-0)};c.prototype.executeBatch_=function(){for(;this.batch_&&this.batch_.length;){var d=this.batch_;this.batch_=[];for(var a=0;a<d.length;++a){var b=d[a];delete d[a];try{b()}catch(h){this.asyncThrow_(h)}}}this.batch_=null};c.prototype.asyncThrow_=function(d){this.asyncExecuteFunction(function(){throw d;})};var b=function(d){this.state_=0;this.result_=void 0;this.onSettledCallbacks_=[];var a=this.createResolveAndReject_();try{d(a.resolve,a.reject)}catch(l){a.reject(l)}};b.prototype.createResolveAndReject_=
-function(){function a(a){return function(d){c||(c=!0,a.call(b,d))}}var b=this,c=!1;return{resolve:a(this.resolveTo_),reject:a(this.reject_)}};b.prototype.resolveTo_=function(a){if(a===this)this.reject_(new TypeError("A Promise cannot resolve to itself"));else if(a instanceof b)this.settleSameAsPromise_(a);else{a:switch(typeof a){case "object":var d=null!=a;break a;case "function":d=!0;break a;default:d=!1}d?this.resolveToNonPromiseObj_(a):this.fulfill_(a)}};b.prototype.resolveToNonPromiseObj_=function(a){var b=
-void 0;try{b=a.then}catch(l){this.reject_(l);return}"function"==typeof b?this.settleSameAsThenable_(b,a):this.fulfill_(a)};b.prototype.reject_=function(a){this.settle_(2,a)};b.prototype.fulfill_=function(a){this.settle_(1,a)};b.prototype.settle_=function(a,b){if(0!=this.state_)throw Error("Cannot settle("+a+", "+b|"): Promise already settled in state"+this.state_);this.state_=a;this.result_=b;this.executeOnSettledCallbacks_()};b.prototype.executeOnSettledCallbacks_=function(){if(null!=this.onSettledCallbacks_){for(var a=
-this.onSettledCallbacks_,b=0;b<a.length;++b)a[b].call(),a[b]=null;this.onSettledCallbacks_=null}};var g=new c;b.prototype.settleSameAsPromise_=function(a){var b=this.createResolveAndReject_();a.callWhenSettled_(b.resolve,b.reject)};b.prototype.settleSameAsThenable_=function(a,b){var c=this.createResolveAndReject_();try{a.call(b,c.resolve,c.reject)}catch(h){c.reject(h)}};b.prototype.then=function(a,c){function d(a,b){return"function"==typeof a?function(b){try{h(a(b))}catch(m){e(m)}}:b}var h,e,g=new b(function(a,
-b){h=a;e=b});this.callWhenSettled_(d(a,h),d(c,e));return g};b.prototype.catch=function(a){return this.then(void 0,a)};b.prototype.callWhenSettled_=function(a,b){function c(){switch(d.state_){case 1:a(d.result_);break;case 2:b(d.result_);break;default:throw Error("Unexpected state: "+d.state_);}}var d=this;null==this.onSettledCallbacks_?g.asyncExecute(c):this.onSettledCallbacks_.push(function(){g.asyncExecute(c)})};b.resolve=e;b.reject=function(a){return new b(function(b,c){c(a)})};b.race=function(a){return new b(function(b,
-c){for(var d=$jscomp.makeIterator(a),g=d.next();!g.done;g=d.next())e(g.value).callWhenSettled_(b,c)})};b.all=function(a){var c=$jscomp.makeIterator(a),d=c.next();return d.done?e([]):new b(function(a,b){function g(b){return function(c){f[b]=c;h--;0==h&&a(f)}}var f=[],h=0;do f.push(void 0),h++,e(d.value).callWhenSettled_(g(f.length-1),b),d=c.next();while(!d.done)})};return b},"es6","es3");
-(function(a,c){"object"===typeof exports&&"undefined"!==typeof module?module.exports=c():"function"===typeof define&&define.amd?define(c):a.canAutoplay=c()})(this,function(){function a(a){return Object.assign({muted:!1,timeout:250,inline:!1},a)}function c(a,c){var b=a.muted,e=a.timeout;a=a.inline;c=c();var f=c.element;c=c.source;var h=void 0,g=void 0,k=void 0;f.muted=b;!0===b&&f.setAttribute("muted","muted");!0===a&&f.setAttribute("playsinline","playsinline");f.src=c;return new Promise(function(a){h=
-f.play();g=setTimeout(function(){k(!1,Error("Timeout "+e+" ms has been reached"))},e);k=function(b){var c=1<arguments.length&&void 0!==arguments[1]?arguments[1]:null;clearTimeout(g);a({result:b,error:c})};void 0!==h?h.then(function(){return k(!0)}).catch(function(a){return k(!1,a)}):k(!0)})}var e=new Blob([new Uint8Array([255,227,24,196,0,0,0,3,72,1,64,0,0,4,132,16,31,227,192,225,76,255,67,12,255,221,27,255,228,97,73,63,255,195,131,69,192,232,223,255,255,207,102,239,255,255,255,101,158,206,70,20,
-59,255,254,95,70,149,66,4,16,128,0,2,2,32,240,138,255,36,106,183,255,227,24,196,59,11,34,62,80,49,135,40,0,253,29,191,209,200,141,71,7,255,252,152,74,15,130,33,185,6,63,255,252,195,70,203,86,53,15,255,255,247,103,76,121,64,32,47,255,34,227,194,209,138,76,65,77,69,51,46,57,55,170,170,170,170,170,170,170,170,170,170,255,227,24,196,73,13,153,210,100,81,135,56,0,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,
-170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170,170])],{type:"audio/mpeg"}),f=new Blob([new Uint8Array([0,0,0,28,102,116,121,112,105,115,111,109,0,0,2,0,105,115,111,109,105,115,111,50,109,112,52,49,0,0,0,8,102,114,101,101,0,0,2,239,109,100,97,116,33,16,5,32,164,27,255,192,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
-0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,55,167,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
-0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,112,33,16,5,32,164,27,255,192,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
-0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,55,167,128,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,112,0,0,2,194,109,111,111,118,0,0,0,108,109,118,104,100,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,232,0,0,0,47,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,
-0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,1,236,116,114,97,107,0,0,0,92,116,107,104,100,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,47,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,36,101,100,116,115,0,0,0,28,101,108,115,116,0,0,0,0,0,0,0,1,0,0,0,47,0,0,0,0,0,1,0,0,0,0,1,100,109,100,105,97,0,0,0,32,109,100,104,100,0,0,0,0,0,0,0,0,0,0,0,0,0,0,172,68,0,0,8,0,85,196,0,0,0,0,0,45,104,100,108,114,0,
-0,0,0,0,0,0,0,115,111,117,110,0,0,0,0,0,0,0,0,0,0,0,0,83,111,117,110,100,72,97,110,100,108,101,114,0,0,0,1,15,109,105,110,102,0,0,0,16,115,109,104,100,0,0,0,0,0,0,0,0,0,0,0,36,100,105,110,102,0,0,0,28,100,114,101,102,0,0,0,0,0,0,0,1,0,0,0,12,117,114,108,32,0,0,0,1,0,0,0,211,115,116,98,108,0,0,0,103,115,116,115,100,0,0,0,0,0,0,0,1,0,0,0,87,109,112,52,97,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,2,0,16,0,0,0,0,172,68,0,0,0,0,0,51,101,115,100,115,0,0,0,0,3,128,128,128,34,0,2,0,4,128,128,128,20,64,21,0,0,0,0,
-1,244,0,0,1,243,249,5,128,128,128,2,18,16,6,128,128,128,1,2,0,0,0,24,115,116,116,115,0,0,0,0,0,0,0,1,0,0,0,2,0,0,4,0,0,0,0,28,115,116,115,99,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,2,0,0,0,1,0,0,0,28,115,116,115,122,0,0,0,0,0,0,0,0,0,0,0,2,0,0,1,115,0,0,1,116,0,0,0,20,115,116,99,111,0,0,0,0,0,0,0,1,0,0,0,44,0,0,0,98,117,100,116,97,0,0,0,90,109,101,116,97,0,0,0,0,0,0,0,33,104,100,108,114,0,0,0,0,0,0,0,0,109,100,105,114,97,112,112,108,0,0,0,0,0,0,0,0,0,0,0,0,45,105,108,115,116,0,0,0,37,169,116,111,111,0,0,0,
-29,100,97,116,97,0,0,0,1,0,0,0,0,76,97,118,102,53,54,46,52,48,46,49,48,49])],{type:"video/mp4"});return{audio:function(b){b=a(b);return c(b,function(){return{element:document.createElement("audio"),source:URL.createObjectURL(e)}})},video:function(b){b=a(b);return c(b,function(){return{element:document.createElement("video"),source:URL.createObjectURL(f)}})}}});

文件差异内容过多而无法显示
+ 383 - 94
frontend/package-lock.json


+ 4 - 2
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
   ],
   "private": true,
-  "version": "3.2.2",
+  "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",
@@ -42,12 +42,14 @@
   },
   "dependencies": {
     "@babel/runtime": "^7.15.4",
+    "can-autoplay": "^3.0.2",
     "config": "^3.3.6",
     "date-fns": "^2.25.0",
     "dompurify": "^2.3.3",
     "eslint-config-airbnb-base": "^14.2.1",
     "html-webpack-plugin": "^5.3.2",
-    "marked": "^3.0.7",
+    "lofig": "^1.3.4",
+    "marked": "^4.0.10",
     "normalize.css": "^8.0.1",
     "toasters": "^2.3.1",
     "vue": "^3.2.20",

+ 223 - 26
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();
 			}
 		});
@@ -258,17 +262,17 @@ export default {
 		},
 		enableNightmode: () => {
 			document
-				.getElementsByTagName("body")[0]
+				.getElementsByTagName("html")[0]
 				.classList.add("night-mode");
 		},
 		disableNightmode: () => {
 			document
-				.getElementsByTagName("body")[0]
+				.getElementsByTagName("html")[0]
 				.classList.remove("night-mode");
 		},
 		enableChristmasMode: () => {
 			document
-				.getElementsByTagName("body")[0]
+				.getElementsByTagName("html")[0]
 				.classList.add("christmas-mode");
 		},
 		...mapActions("modalVisibility", ["closeCurrentModal"]),
@@ -320,6 +324,10 @@ export default {
 }
 
 .night-mode {
+	body {
+		background-color: var(--black) !important;
+	}
+
 	div {
 		color: var(--light-grey-2);
 	}
@@ -336,6 +344,15 @@ export default {
 		}
 	}
 
+	.control.has-addons .button {
+		background-color: var(--dark-grey-2);
+		border: 0;
+
+		i {
+			color: var(--white);
+		}
+	}
+
 	h1,
 	h2,
 	h3,
@@ -373,6 +390,21 @@ export default {
 		background-color: var(--light-grey) !important;
 		color: var(--dark-grey-2) !important;
 	}
+
+	.checkbox input[type="checkbox"] {
+		background-color: var(--dark-grey);
+		border-color: transparent;
+
+		&:checked:before,
+		&:checked:after {
+			background-color: var(--white);
+		}
+	}
+
+	.pill {
+		background-color: var(--dark-grey);
+		color: var(--primary-color);
+	}
 }
 
 .christmas-mode {
@@ -506,10 +538,6 @@ code {
 	color: var(--dark-red) !important;
 }
 
-body.night-mode {
-	background-color: var(--black) !important;
-}
-
 #toasts-container {
 	z-index: 10000 !important;
 
@@ -805,7 +833,8 @@ img {
 		}
 
 		&[data-theme~="songActions"],
-		&[data-theme~="addToPlaylist"],
+		&[data-theme~="dropdown"],
+		&[data-theme~="search"],
 		&[data-theme~="stationSettings"] {
 			background-color: var(--dark-grey-2);
 			border: 0 !important;
@@ -825,7 +854,7 @@ img {
 			}
 		}
 
-		&[data-theme~="addToPlaylist"] {
+		&[data-theme~="dropdown"] {
 			background-color: var(--dark-grey-2);
 			border: 0 !important;
 
@@ -843,11 +872,21 @@ img {
 				}
 			}
 		}
+
+		&[data-theme~="search"] {
+			background-color: var(--dark-grey-2);
+			border: 0 !important;
+		}
+
+		&[data-theme~="info"] p {
+			color: var(--black) !important;
+		}
 	}
 
 	.tippy-box[data-placement^="top"] {
 		&[data-theme~="songActions"],
-		&[data-theme~="addToPlaylist"] {
+		&[data-theme~="dropdown"],
+		&[data-theme~="search"] {
 			> .tippy-arrow::before {
 				border-top-color: var(--dark-grey-2);
 			}
@@ -856,7 +895,8 @@ img {
 
 	.tippy-box[data-placement^="bottom"] {
 		&[data-theme~="songActions"],
-		&[data-theme~="addToPlaylist"],
+		&[data-theme~="dropdown"],
+		&[data-theme~="search"],
 		&[data-theme~="stationSettings"] {
 			> .tippy-arrow::before {
 				border-bottom-color: var(--dark-grey-2);
@@ -866,7 +906,8 @@ img {
 
 	.tippy-box[data-placement^="left"] {
 		&[data-theme~="songActions"],
-		&[data-theme~="addToPlaylist"] {
+		&[data-theme~="dropdown"],
+		&[data-theme~="search"] {
 			> .tippy-arrow::before {
 				border-left-color: var(--dark-grey-2);
 			}
@@ -875,7 +916,8 @@ img {
 
 	.tippy-box[data-placement^="right"] {
 		&[data-theme~="songActions"],
-		&[data-theme~="addToPlaylist"] {
+		&[data-theme~="dropdown"],
+		&[data-theme~="search"] {
 			> .tippy-arrow::before {
 				border-right-color: var(--dark-grey-2);
 			}
@@ -888,7 +930,7 @@ img {
 	letter-spacing: 1px;
 }
 
-.tippy-box[data-theme~="confirm"] {
+.tippy-box[data-theme~="quickConfirm"] {
 	background-color: var(--dark-red);
 	border: 0;
 
@@ -967,49 +1009,53 @@ img {
 
 .tippy-box[data-placement^="top"] {
 	&[data-theme~="songActions"],
-	&[data-theme~="addToPlaylist"] {
+	&[data-theme~="dropdown"],
+	&[data-theme~="search"] {
 		> .tippy-arrow::before {
 			border-top-color: var(--white);
 		}
 	}
-	&[data-theme~="confirm"] > .tippy-arrow::before {
+	&[data-theme~="quickConfirm"] > .tippy-arrow::before {
 		border-top-color: var(--dark-red);
 	}
 }
 
 .tippy-box[data-placement^="bottom"] {
 	&[data-theme~="songActions"],
-	&[data-theme~="addToPlaylist"],
-	&[data-theme~="stationSettings"] {
+	&[data-theme~="dropdown"],
+	&[data-theme~="stationSettings"],
+	&[data-theme~="search"] {
 		> .tippy-arrow::before {
 			border-bottom-color: var(--white);
 		}
 	}
-	&[data-theme~="confirm"] > .tippy-arrow::before {
+	&[data-theme~="quickConfirm"] > .tippy-arrow::before {
 		border-bottom-color: var(--dark-red);
 	}
 }
 
 .tippy-box[data-placement^="left"] {
 	&[data-theme~="songActions"],
-	&[data-theme~="addToPlaylist"] {
+	&[data-theme~="dropdown"],
+	&[data-theme~="search"] {
 		> .tippy-arrow::before {
 			border-left-color: var(--white);
 		}
 	}
-	&[data-theme~="confirm"] > .tippy-arrow::before {
+	&[data-theme~="quickConfirm"] > .tippy-arrow::before {
 		border-left-color: var(--dark-red);
 	}
 }
 
 .tippy-box[data-placement^="right"] {
 	&[data-theme~="songActions"],
-	&[data-theme~="addToPlaylist"] {
+	&[data-theme~="dropdown"],
+	&[data-theme~="search"] {
 		> .tippy-arrow::before {
 			border-right-color: var(--white);
 		}
 	}
-	&[data-theme~="confirm"] > .tippy-arrow::before {
+	&[data-theme~="quickConfirm"] > .tippy-arrow::before {
 		border-right-color: var(--dark-red);
 	}
 }
@@ -1024,7 +1070,7 @@ img {
 	}
 }
 
-.tippy-box[data-theme~="addToPlaylist"] {
+.tippy-box[data-theme~="dropdown"] {
 	font-size: 15px;
 	padding: 0;
 	border: 1px solid var(--light-grey-3);
@@ -1040,7 +1086,7 @@ img {
 	.nav-dropdown-items {
 		max-height: 220px;
 		overflow-y: auto;
-		padding: 10px 10px 0 10px;
+		padding: 10px;
 
 		.nav-item {
 			width: 100%;
@@ -1089,6 +1135,10 @@ img {
 					background-color: var(--light-grey-3);
 					transition: 0.2s;
 					border-radius: 34px;
+
+					&.disabled {
+						cursor: not-allowed;
+					}
 				}
 
 				.slider:before {
@@ -1142,6 +1192,29 @@ img {
 	}
 }
 
+.tippy-box[data-theme~="search"] {
+	font-size: 15px;
+	padding: 0;
+	border: 1px solid var(--light-grey-3);
+	box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
+	background-color: var(--white);
+	color: var(--dark-grey);
+	width: 100% !important;
+	max-width: 600px !important;
+	max-height: calc(100vh - 300px);
+	overflow-y: auto;
+
+	.tippy-content {
+		padding: 0;
+
+		& > span {
+			display: flex;
+			flex-direction: column;
+			padding: 5px;
+		}
+	}
+}
+
 .has-text-centered {
 	text-align: center;
 }
@@ -1180,6 +1253,46 @@ img {
 	}
 }
 
+.checkbox {
+	height: 25px;
+	width: 25px;
+
+	input[type="checkbox"] {
+		height: 25px;
+		width: 25px;
+		background-color: var(--white);
+		border: 1px solid var(--light-grey-2);
+		appearance: none;
+		border-radius: 3px;
+		cursor: pointer;
+		position: relative;
+
+		&:checked {
+			&:before {
+				content: "";
+				position: absolute;
+				top: 4px;
+				right: 7px;
+				background-color: var(--primary-color);
+				width: 4px;
+				height: 16px;
+				transform: rotate(45deg);
+			}
+
+			&:after {
+				content: "";
+				position: absolute;
+				top: 12px;
+				left: 2px;
+				background-color: var(--primary-color);
+				width: 10px;
+				height: 4px;
+				transform: rotate(45deg);
+			}
+		}
+	}
+}
+
 .button:focus,
 .button:active {
 	border-color: var(--light-grey-2) !important;
@@ -1292,6 +1405,16 @@ button.delete:focus {
 		border-width: 0;
 		color: var(--light-grey);
 	}
+
+	&.is-fullwidth {
+		display: flex;
+		width: 100%;
+	}
+
+	&.disabled {
+		filter: grayscale(1);
+		cursor: not-allowed;
+	}
 }
 
 .input,
@@ -1847,4 +1970,78 @@ h4.section-title {
 		transform: translateX(16px);
 	}
 }
+
+html {
+	&,
+	* {
+		scrollbar-color: var(--primary-color) transparent;
+		scrollbar-width: thin;
+	}
+
+	&.night-mode {
+		&,
+		* {
+			scrollbar-color: var(--light-grey) transparent !important;
+		}
+
+		&::-webkit-scrollbar-thumb,
+		::-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;
+		}
+	}
+}
+
+::-webkit-scrollbar {
+	height: 10px;
+	width: 10px;
+}
+
+::-webkit-scrollbar-track {
+	background-color: var(--light-grey-2);
+}
+
+::-webkit-scrollbar-thumb {
+	background-color: var(--primary-color);
+}
+
+::-webkit-scrollbar-corner {
+	background-color: transparent;
+}
+
+:disabled,
+.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>

+ 8 - 16
frontend/src/components/AddToPlaylistDropdown.vue

@@ -4,7 +4,7 @@
 		:touch="true"
 		:interactive="true"
 		:placement="placement"
-		theme="addToPlaylist"
+		theme="dropdown"
 		ref="dropdown"
 		trigger="click"
 		append-to="parent"
@@ -84,13 +84,9 @@ export default {
 			socket: "websockets/getSocket"
 		}),
 		...mapState({
+			playlists: state => state.user.playlists.playlists,
 			fetchedPlaylists: state => state.user.playlists.fetchedPlaylists
-		}),
-		playlists() {
-			return this.$store.state.user.playlists.playlists.filter(
-				playlist => playlist.isUserModifiable
-			);
-		}
+		})
 	},
 	mounted() {
 		ws.onConnect(this.init);
@@ -123,15 +119,11 @@ export default {
 	methods: {
 		init() {
 			if (!this.fetchedPlaylists)
-				this.socket.dispatch(
-					"playlists.indexMyPlaylists",
-					true,
-					res => {
-						if (res.status === "success")
-							if (!this.fetchedPlaylists)
-								this.setPlaylists(res.data.playlists);
-					}
-				);
+				this.socket.dispatch("playlists.indexMyPlaylists", res => {
+					if (res.status === "success")
+						if (!this.fetchedPlaylists)
+							this.setPlaylists(res.data.playlists);
+				});
 		},
 		toggleSongInPlaylist(playlistIndex) {
 			const playlist = this.playlists[playlistIndex];

+ 2447 - 0
frontend/src/components/AdvancedTable.vue

@@ -0,0 +1,2447 @@
+<template>
+	<div>
+		<div
+			class="table-outer-container"
+			@mousemove="columnResizing($event)"
+			@touchmove="columnResizing($event)"
+		>
+			<div class="table-header">
+				<div>
+					<tippy
+						v-if="filters.length > 0"
+						:touch="true"
+						:interactive="true"
+						placement="bottom-start"
+						theme="search"
+						ref="search"
+						trigger="click"
+						@show="
+							() => {
+								showFiltersDropdown = true;
+							}
+						"
+						@hide="
+							() => {
+								showFiltersDropdown = false;
+							}
+						"
+					>
+						<div class="control has-addons" ref="trigger">
+							<button class="button is-primary">
+								<i class="material-icons icon-with-button"
+									>filter_list</i
+								>
+								Filters
+							</button>
+							<button class="button">
+								<i class="material-icons">
+									{{
+										showFiltersDropdown
+											? "expand_more"
+											: "expand_less"
+									}}
+								</i>
+							</button>
+						</div>
+
+						<template #content>
+							<div class="control is-grouped input-with-button">
+								<p class="control select is-expanded">
+									<select v-model="addFilterValue">
+										<option
+											v-for="type in filters"
+											:key="type.name"
+											:value="type"
+										>
+											{{ type.displayName }}
+										</option>
+									</select>
+								</p>
+								<p class="control">
+									<button
+										:disabled="!addFilterValue"
+										class="button material-icons is-success"
+										@click="addFilterItem()"
+									>
+										control_point
+									</button>
+								</p>
+							</div>
+							<div
+								v-for="(filter, index) in editingFilters"
+								:key="`filter-${index}`"
+								class="
+									advanced-filter
+									control
+									is-grouped is-expanded
+								"
+							>
+								<div class="control select">
+									<select
+										v-model="filter.filter"
+										@change="changeFilterType(index)"
+									>
+										<option
+											v-for="type in filters"
+											:key="type.name"
+											:value="type"
+										>
+											{{ type.displayName }}
+										</option>
+									</select>
+								</div>
+								<div class="control select">
+									<select
+										v-model="filter.filterType"
+										:disabled="!filter.filterType"
+									>
+										<option
+											v-for="filterType in filterTypes(
+												filter.filter
+											)"
+											:key="filterType.name"
+											:value="filterType"
+											:selected="
+												filter.filter
+													.defaultFilterType ===
+												filterType.name
+											"
+										>
+											{{ filterType.displayName }}
+										</option>
+									</select>
+								</div>
+								<div
+									v-if="
+										filter.filterType.name &&
+										(filter.filterType.name === 'exact' ||
+											filter.filterType.name ===
+												'boolean') &&
+										filter.filter.dropdown
+									"
+									class="control is-expanded select"
+								>
+									<select
+										v-model="filter.data"
+										:disabled="!filter.filterType"
+									>
+										<option
+											v-for="[
+												dropdownValue,
+												dropdownDisplay
+											] in filter.filter.dropdown"
+											:key="dropdownValue"
+											:value="dropdownValue"
+										>
+											{{ dropdownDisplay }}
+										</option>
+									</select>
+								</div>
+								<div
+									v-else-if="
+										filter.filterType.name &&
+										filter.filterType.name === 'boolean'
+									"
+									class="control is-expanded select"
+								>
+									<select
+										v-model="filter.data"
+										:disabled="!filter.filterType"
+									>
+										<option :value="true">true</option>
+										<option :value="false">false</option>
+									</select>
+								</div>
+								<div v-else class="control is-expanded">
+									<input
+										v-if="
+											filter.filterType.name &&
+											filter.filterType.name.startsWith(
+												'datetime'
+											)
+										"
+										v-model="filter.data"
+										class="input"
+										type="datetime-local"
+									/>
+									<input
+										v-else-if="
+											filter.filterType.name &&
+											filter.filterType.name.startsWith(
+												'number'
+											)
+										"
+										v-model="filter.data"
+										class="input"
+										type="number"
+										:disabled="!filter.filterType"
+										@keydown.enter="applyFilterAndGetData()"
+									/>
+									<auto-suggest
+										v-else
+										v-model="filter.data"
+										placeholder="Search value"
+										:disabled="!filter.filterType"
+										:all-items="
+											autosuggest.allItems[
+												filter.filter.name
+											]
+										"
+										@submitted="applyFilterAndGetData()"
+									/>
+								</div>
+								<div class="control">
+									<button
+										class="button material-icons is-danger"
+										@click="removeFilterItem(index)"
+									>
+										remove_circle_outline
+									</button>
+								</div>
+							</div>
+							<div
+								v-if="editingFilters.length > 0"
+								class="control is-expanded is-grouped"
+							>
+								<label class="control label"
+									>Filter operator</label
+								>
+								<div class="control select is-expanded">
+									<select v-model="filterOperator">
+										<option
+											v-for="operator in filterOperators"
+											:key="operator.name"
+											:value="operator.name"
+										>
+											{{ operator.displayName }}
+										</option>
+									</select>
+								</div>
+							</div>
+							<div
+								class="advanced-filter-bottom"
+								v-if="editingFilters.length > 0"
+							>
+								<div class="control is-expanded">
+									<button
+										class="button is-info"
+										@click="applyFilterAndGetData()"
+									>
+										<i
+											class="
+												material-icons
+												icon-with-button
+											"
+											>filter_list</i
+										>
+										Apply filters
+									</button>
+								</div>
+							</div>
+							<div
+								class="advanced-filter-bottom"
+								v-else-if="editingFilters.length === 0"
+							>
+								<div class="control is-expanded">
+									<button
+										class="button is-info"
+										@click="applyFilterAndGetData()"
+									>
+										<i
+											class="
+												material-icons
+												icon-with-button
+											"
+											>filter_list</i
+										>
+										Apply filters
+									</button>
+								</div>
+							</div>
+						</template>
+					</tippy>
+					<tippy
+						v-if="appliedFilters.length > 0"
+						:touch="true"
+						:interactive="true"
+						theme="info"
+						ref="activeFilters"
+					>
+						<div class="filters-indicator">
+							{{ appliedFilters.length }}
+							<i class="material-icons" @click.prevent="true"
+								>filter_list</i
+							>
+						</div>
+
+						<template #content>
+							<p
+								v-for="(filter, index) in appliedFilters"
+								:key="`filter-${index}`"
+							>
+								{{ filter.filter.displayName }}
+								{{
+									appliedFilters.length === 1 &&
+									appliedFilterOperator === "nor"
+										? "not"
+										: ""
+								}}
+								{{
+									filter.filterType.displayName.toLowerCase()
+								}}
+								"{{ filter.data }}"
+								{{
+									appliedFilters.length === index + 1
+										? ""
+										: appliedFilterOperator
+								}}
+							</p>
+						</template>
+					</tippy>
+					<i
+						v-else
+						class="filters-indicator material-icons"
+						content="No active filters"
+						v-tippy="{ theme: 'info' }"
+					>
+						filter_list_off
+					</i>
+				</div>
+				<div>
+					<tippy
+						v-if="hidableSortedColumns.length > 0"
+						:touch="true"
+						:interactive="true"
+						placement="bottom-end"
+						theme="dropdown"
+						ref="editColumns"
+						trigger="click"
+						@show="
+							() => {
+								showColumnsDropdown = true;
+							}
+						"
+						@hide="
+							() => {
+								showColumnsDropdown = false;
+							}
+						"
+					>
+						<div class="control has-addons" ref="trigger">
+							<button class="button is-primary">
+								<i class="material-icons icon-with-button"
+									>tune</i
+								>
+								Columns
+							</button>
+							<button class="button">
+								<i class="material-icons">
+									{{
+										showColumnsDropdown
+											? "expand_more"
+											: "expand_less"
+									}}
+								</i>
+							</button>
+						</div>
+
+						<template #content>
+							<draggable
+								item-key="name"
+								v-model="orderedColumns"
+								v-bind="columnDragOptions"
+								tag="div"
+								draggable=".item-draggable"
+								class="nav-dropdown-items"
+								@change="columnOrderChanged"
+							>
+								<template #item="{ element: column }">
+									<button
+										v-if="
+											column.name !== 'select' &&
+											column.name !== 'placeholder' &&
+											column.name !== 'updatedPlaceholder'
+										"
+										:class="{
+											sortable: column.sortable,
+											'item-draggable': column.draggable,
+											'nav-item': true
+										}"
+										@click.prevent="
+											toggleColumnVisibility(column)
+										"
+									>
+										<p
+											class="
+												control
+												is-expanded
+												checkbox-control
+											"
+										>
+											<label class="switch">
+												<input
+													type="checkbox"
+													:id="index"
+													:checked="
+														shownColumns.indexOf(
+															column.name
+														) !== -1
+													"
+													@click="
+														toggleColumnVisibility(
+															column
+														)
+													"
+												/>
+												<span
+													:class="{
+														slider: true,
+														round: true,
+														disabled:
+															!column.hidable
+													}"
+												></span>
+											</label>
+											<label :for="index">
+												<span></span>
+												<p>{{ column.displayName }}</p>
+											</label>
+										</p>
+									</button>
+								</template>
+							</draggable>
+						</template>
+					</tippy>
+				</div>
+			</div>
+			<div class="table-container">
+				<table
+					:class="{
+						table: true,
+						'has-checkboxes': hasCheckboxes
+					}"
+				>
+					<thead>
+						<draggable
+							item-key="name"
+							v-model="orderedColumns"
+							v-bind="columnDragOptions"
+							tag="tr"
+							handle=".handle"
+							draggable=".item-draggable"
+							@change="columnOrderChanged"
+						>
+							<template #item="{ element: column }">
+								<th
+									v-if="
+										shownColumns.indexOf(column.name) !==
+											-1 &&
+										(column.name !== 'updatedPlaceholder' ||
+											rows.length > 0)
+									"
+									:class="{
+										sortable: column.sortable,
+										'item-draggable': column.draggable
+									}"
+									:style="{
+										minWidth: Number.isNaN(column.minWidth)
+											? column.minWidth
+											: `${column.minWidth}px`,
+										width: Number.isNaN(column.width)
+											? column.width
+											: `${column.width}px`,
+										maxWidth: Number.isNaN(column.maxWidth)
+											? column.maxWidth
+											: `${column.maxWidth}px`
+									}"
+								>
+									<div v-if="column.name === 'select'">
+										<p class="checkbox">
+											<input
+												v-if="rows.length === 0"
+												type="checkbox"
+												disabled
+											/>
+											<input
+												v-else
+												type="checkbox"
+												:checked="
+													rows.filter(
+														row => !row.removed
+													).length ===
+													selectedRows.length
+												"
+												@click="toggleAllRows()"
+											/>
+										</p>
+									</div>
+									<div v-else class="handle">
+										<span>
+											{{ column.displayName }}
+										</span>
+										<span
+											v-if="column.sortable"
+											:content="`Sort by ${column.displayName}`"
+											v-tippy
+										>
+											<span
+												v-if="
+													!sort[column.sortProperty]
+												"
+												class="material-icons"
+												@click="changeSort(column)"
+											>
+												unfold_more
+											</span>
+											<span
+												v-if="
+													sort[
+														column.sortProperty
+													] === 'ascending'
+												"
+												class="material-icons active"
+												@click="changeSort(column)"
+											>
+												expand_more
+											</span>
+											<span
+												v-if="
+													sort[
+														column.sortProperty
+													] === 'descending'
+												"
+												class="material-icons active"
+												@click="changeSort(column)"
+											>
+												expand_less
+											</span>
+										</span>
+									</div>
+									<div
+										class="resizer"
+										v-if="column.resizable"
+										@mousedown.prevent.stop="
+											columnResizingStart(column, $event)
+										"
+										@touchstart.prevent.stop="
+											columnResizingStart(column, $event)
+										"
+										@mouseup="columnResizingStop()"
+										@touchend="columnResizingStop()"
+										@dblclick="columnResetWidth(column)"
+									></div>
+								</th>
+							</template>
+						</draggable>
+					</thead>
+					<tbody>
+						<tr
+							v-for="(item, itemIndex) in rows"
+							:key="item._id"
+							:class="{
+								selected: item.selected,
+								highlighted: item.highlighted,
+								updated: item.updated,
+								removed: item.removed
+							}"
+							:ref="`row-${itemIndex}`"
+							tabindex="0"
+							@blur="unhighlightRow(itemIndex)"
+							@keydown.up.prevent
+							@keydown.down.prevent
+							@keydown.space.prevent
+							@click="highlightRow(itemIndex)"
+							@keyup.up.exact="highlightUp(itemIndex)"
+							@keyup.down.exact="highlightDown(itemIndex)"
+							@keyup.shift.up.exact="selectUp(itemIndex)"
+							@keyup.shift.down.exact="selectDown(itemIndex)"
+							@keyup.ctrl.up.exact="unselectUp(itemIndex)"
+							@keyup.ctrl.down.exact="unselectDown(itemIndex)"
+							@keyup.space.exact="
+								toggleSelectedRow(itemIndex, {})
+							"
+						>
+							<td
+								v-for="column in sortedFilteredColumns"
+								:key="`${item._id}-${column.name}`"
+							>
+								<slot
+									:name="`column-${column.name}`"
+									:item="item"
+									v-if="
+										column.properties.length === 0 ||
+										column.properties.every(
+											property =>
+												property
+													.split('.')
+													.reduce(
+														(previous, current) =>
+															previous &&
+															previous[
+																current
+															] !== null &&
+															previous[
+																current
+															] !== undefined
+																? previous[
+																		current
+																  ]
+																: null,
+														item
+													) !== null
+										)
+									"
+								></slot>
+								<div
+									v-if="
+										column.name === 'updatedPlaceholder' &&
+										item.updated
+									"
+									class="updated-tooltip"
+									content="Row updated"
+									v-tippy="{
+										theme: 'info',
+										placement: 'right'
+									}"
+								></div>
+								<p
+									class="checkbox"
+									v-if="column.name === 'select'"
+								>
+									<input
+										type="checkbox"
+										:checked="item.selected"
+										@click="
+											toggleSelectedRow(itemIndex, $event)
+										"
+										:disabled="item.removed"
+									/>
+								</p>
+								<span
+									v-if="item.removed"
+									class="removed-overlay"
+									content="Item removed"
+									v-tippy="{ theme: 'info' }"
+								></span>
+								<div
+									class="resizer"
+									v-if="column.resizable"
+									@mousedown.prevent.stop="
+										columnResizingStart(column, $event)
+									"
+									@touchstart.prevent.stop="
+										columnResizingStart(column, $event)
+									"
+									@mouseup="columnResizingStop()"
+									@touchend="columnResizingStop()"
+									@dblclick="columnResetWidth(column)"
+								></div>
+							</td>
+						</tr>
+					</tbody>
+				</table>
+			</div>
+			<div v-if="rows.length === 0" class="table-no-results">
+				No results found
+			</div>
+			<div class="table-footer">
+				<div class="page-controls">
+					<button
+						:class="{ disabled: page === 1 }"
+						class="button is-primary material-icons"
+						:disabled="page === 1"
+						@click="changePage(1)"
+						content="First Page"
+						v-tippy
+					>
+						skip_previous
+					</button>
+					<button
+						:class="{ disabled: page === 1 }"
+						class="button is-primary material-icons"
+						:disabled="page === 1"
+						@click="changePage(page - 1)"
+						content="Previous Page"
+						v-tippy
+					>
+						fast_rewind
+					</button>
+
+					<p>Page {{ page }} / {{ lastPage > 0 ? lastPage : 1 }}</p>
+
+					<button
+						:class="{
+							disabled: page === lastPage || lastPage === 0
+						}"
+						class="button is-primary material-icons"
+						:disabled="page === lastPage"
+						@click="changePage(page + 1)"
+						content="Next Page"
+						v-tippy
+					>
+						fast_forward
+					</button>
+					<button
+						:class="{
+							disabled: page === lastPage || lastPage === 0
+						}"
+						class="button is-primary material-icons"
+						:disabled="page === lastPage"
+						@click="changePage(lastPage)"
+						content="Last Page"
+						v-tippy
+					>
+						skip_next
+					</button>
+				</div>
+				<div class="page-size">
+					<div class="control">
+						<label class="label">Items per page</label>
+						<p class="control select">
+							<select
+								v-model.number="pageSize"
+								@change="changePageSize()"
+							>
+								<option value="10">10</option>
+								<option value="25">25</option>
+								<option value="50">50</option>
+								<option value="100">100</option>
+								<option value="250">250</option>
+								<option value="500">500</option>
+								<option value="1000">1000</option>
+							</select>
+						</p>
+					</div>
+				</div>
+			</div>
+		</div>
+		<div
+			v-if="hasCheckboxes && selectedRows.length > 0"
+			class="bulk-popup"
+			:style="{
+				top: bulkPopup.top + 'px',
+				left: bulkPopup.left + 'px'
+			}"
+			ref="bulk-popup"
+		>
+			<button
+				class="button is-primary"
+				:content="
+					selectedRows.length === 1
+						? `${selectedRows.length} row selected`
+						: `${selectedRows.length} rows selected`
+				"
+				v-tippy="{ theme: 'info' }"
+			>
+				{{ selectedRows.length }}
+			</button>
+			<slot name="bulk-actions" :item="selectedRows" />
+			<div class="right">
+				<span
+					class="material-icons drag-icon"
+					@mousedown.left="onDragBox"
+					@touchstart="onDragBox"
+					@dblclick="resetBulkActionsPosition()"
+				>
+					drag_indicator
+				</span>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapGetters, mapState } from "vuex";
+import draggable from "vuedraggable";
+
+import Toast from "toasters";
+import AutoSuggest from "@/components/AutoSuggest.vue";
+
+import keyboardShortcuts from "@/keyboardShortcuts";
+import ws from "@/ws";
+
+export default {
+	components: {
+		draggable,
+		AutoSuggest
+	},
+	props: {
+		/*
+		Column properties:
+		name: Unique lowercase name
+		displayName: Nice name for the column header
+		properties: The properties this column needs to show data
+		sortable: Boolean for whether the order of a particular column can be changed
+		sortProperty: The property the backend will sort on if this column gets sorted, e.g. title
+		hidable: Boolean for whether a column can be hidden
+		defaultVisibility: Default visibility for a column, either "shown" or "hidden"
+		draggable: Boolean for whether a column can be dragged/reordered,
+		resizable: Boolean for whether a column can be resized
+		minWidth: Minimum width of column, e.g. 50px
+		width: Width of column, e.g. 100px
+		maxWidth: Maximum width of column, e.g. 150px
+		*/
+		columnDefault: { type: Object, default: () => {} },
+		columns: { type: Array, default: null },
+		filters: { type: Array, default: null },
+		dataAction: { type: String, default: null },
+		name: { type: String, default: null },
+		maxWidth: { type: Number, default: 1880 },
+		query: { type: Boolean, default: true },
+		keyboardShortcuts: { type: Boolean, default: true },
+		events: { type: Object, default: () => {} }
+	},
+	data() {
+		return {
+			page: 1,
+			pageSize: 10,
+			rows: [],
+			count: 0, // TODO Rename
+			sort: {},
+			orderedColumns: [],
+			shownColumns: [],
+			columnDragOptions() {
+				return {
+					animation: 200,
+					group: "columns",
+					disabled: false,
+					ghostClass: "draggable-list-ghost",
+					filter: ".ignore-elements",
+					fallbackTolerance: 50
+				};
+			},
+			editingFilters: [],
+			appliedFilters: [],
+			filterOperator: "or",
+			appliedFilterOperator: "or",
+			filterOperators: [
+				{
+					name: "or",
+					displayName: "OR"
+				},
+				{
+					name: "and",
+					displayName: "AND"
+				},
+				{
+					name: "nor",
+					displayName: "NOR"
+				}
+			],
+			resizing: {},
+			allFilterTypes: {
+				contains: {
+					name: "contains",
+					displayName: "Contains"
+				},
+				exact: {
+					name: "exact",
+					displayName: "Exact"
+				},
+				regex: {
+					name: "regex",
+					displayName: "Regex"
+				},
+				datetimeBefore: {
+					name: "datetimeBefore",
+					displayName: "Before"
+				},
+				datetimeAfter: {
+					name: "datetimeAfter",
+					displayName: "After"
+				},
+				numberLesserEqual: {
+					name: "numberLesserEqual",
+					displayName: "Less than or equal to"
+				},
+				numberLesser: {
+					name: "numberLesser",
+					displayName: "Less than"
+				},
+				numberGreater: {
+					name: "numberGreater",
+					displayName: "Greater than"
+				},
+				numberGreaterEqual: {
+					name: "numberGreaterEqual",
+					displayName: "Greater than or equal to"
+				},
+				numberEquals: {
+					name: "numberEquals",
+					displayName: "Equals"
+				},
+				boolean: {
+					name: "boolean",
+					displayName: "Boolean"
+				}
+			},
+			bulkPopup: {
+				top: 0,
+				left: 0,
+				pos1: 0,
+				pos2: 0,
+				pos3: 0,
+				pos4: 0
+			},
+			addFilterValue: null,
+			showFiltersDropdown: false,
+			showColumnsDropdown: false,
+			lastColumnResizerTapped: null,
+			lastColumnResizerTappedDate: 0,
+			lastBulkActionsTappedDate: 0,
+			autosuggest: {
+				allItems: {}
+			}
+		};
+	},
+	computed: {
+		properties() {
+			return Array.from(
+				new Set(
+					this.sortedFilteredColumns.flatMap(
+						column => column.properties
+					)
+				)
+			);
+		},
+		lastPage() {
+			return Math.ceil(this.count / this.pageSize);
+		},
+		sortedFilteredColumns() {
+			return this.orderedColumns.filter(
+				column => this.shownColumns.indexOf(column.name) !== -1
+			);
+		},
+		hidableSortedColumns() {
+			return this.orderedColumns.filter(column => column.hidable);
+		},
+		lastSelectedItemIndex() {
+			return this.rows.findIndex(item => item.highlighted);
+		},
+		selectedRows() {
+			return this.rows.filter(row => row.selected);
+		},
+		hasCheckboxes() {
+			return (
+				this.$slots["bulk-actions"] != null ||
+				this.$slots["bulk-actions-right"] != null
+			);
+		},
+		aModalIsOpen() {
+			return Object.keys(this.currentlyActive).length > 0;
+		},
+		...mapState({
+			currentlyActive: state => state.modalVisibility.currentlyActive
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	watch: {
+		selectedRows(newSelectedRows, oldSelectedRows) {
+			// If selected rows goes from zero to one or more selected, trigger onWindowResize, as otherwise the popup could be out of bounds
+			if (oldSelectedRows.length === 0 && newSelectedRows.length > 0)
+				this.onWindowResize();
+		}
+	},
+	mounted() {
+		const tableSettings = this.getTableSettings();
+
+		const columns = [
+			...this.columns.map(column => ({
+				...this.columnDefault,
+				...column
+			})),
+			{
+				name: "placeholder",
+				displayName: "",
+				properties: [],
+				sortable: false,
+				hidable: false,
+				draggable: false,
+				resizable: false,
+				minWidth: "auto",
+				width: "auto",
+				maxWidth: "auto"
+			}
+		];
+
+		if (this.hasCheckboxes)
+			columns.unshift({
+				name: "select",
+				displayName: "",
+				properties: [],
+				sortable: false,
+				hidable: false,
+				draggable: false,
+				resizable: false,
+				minWidth: 47,
+				defaultWidth: 47,
+				maxWidth: 47
+			});
+
+		if (this.events && this.events.updated)
+			columns.unshift({
+				name: "updatedPlaceholder",
+				displayName: "",
+				properties: [],
+				sortable: false,
+				hidable: false,
+				draggable: false,
+				resizable: false,
+				minWidth: 5,
+				width: 5,
+				maxWidth: 5
+			});
+
+		this.orderedColumns = columns.sort((columnA, columnB) => {
+			// Always places updatedPlaceholder column in the first position
+			if (columnA.name === "updatedPlaceholder") return -1;
+			if (columnB.name === "updatedPlaceholder") return 1;
+			// Always places select column in the second position
+			if (columnA.name === "select") return -1;
+			if (columnB.name === "select") return 1;
+			// Always places placeholder column in the last position
+			if (columnA.name === "placeholder") return 1;
+			if (columnB.name === "placeholder") return -1;
+
+			// If there are no table settings stored, use default ordering
+			if (!tableSettings || !tableSettings.columnOrder) return 0;
+
+			const indexA = tableSettings.columnOrder.indexOf(columnA.name);
+			const indexB = tableSettings.columnOrder.indexOf(columnB.name);
+
+			// If either of the columns is not stored in the table settings, use default ordering
+			if (indexA === -1 || indexB === -1) return 0;
+
+			return indexA - indexB;
+		});
+
+		this.shownColumns = this.orderedColumns
+			.filter(column => {
+				// If table settings exist, use shownColumns from settings to determine which columns to show
+				if (tableSettings && tableSettings.shownColumns)
+					return (
+						tableSettings.shownColumns.indexOf(column.name) !== -1
+					);
+				// Table settings don't exist, only show if the default visibility isn't hidden
+				return column.defaultVisibility !== "hidden";
+			})
+			.map(column => column.name);
+
+		this.recalculateWidths();
+
+		if (tableSettings) {
+			// If table settings' page is an integer, use it for the page
+			if (Number.isInteger(tableSettings?.page))
+				this.page = tableSettings.page;
+
+			// If table settings' pageSize is an integer, use it for the pageSize
+			if (Number.isInteger(tableSettings?.pageSize))
+				this.pageSize = tableSettings.pageSize;
+
+			// If table settings' columnSort exists, sort all still existing columns based on table settings' columnSort object
+			if (tableSettings.columnSort) {
+				Object.entries(tableSettings.columnSort).forEach(
+					([columnName, sortDirection]) => {
+						if (
+							this.columns.find(
+								column => column.name === columnName
+							)
+						)
+							this.sort[columnName] = sortDirection;
+					}
+				);
+			}
+
+			// If table settings' columnWidths exists, load the stored widths into the columns
+			if (tableSettings.columnWidths) {
+				this.orderedColumns = this.orderedColumns.map(orderedColumn => {
+					const columnWidth = tableSettings.columnWidths.find(
+						column => column.name === orderedColumn.name
+					)?.width;
+					if (orderedColumn.resizable && columnWidth)
+						return { ...orderedColumn, width: columnWidth };
+					return orderedColumn;
+				});
+			}
+
+			if (
+				tableSettings.filter &&
+				tableSettings.filter.appliedFilters &&
+				tableSettings.filter.appliedFilterOperator
+			) {
+				const { appliedFilters, appliedFilterOperator } =
+					tableSettings.filter;
+				// Set the applied filter operator and filter operator to the value stored in table settings
+				this.appliedFilterOperator = this.filterOperator =
+					appliedFilterOperator;
+				// Set the applied filters and editing filters to the value stored in table settings, for all filters that are allowed
+				this.appliedFilters = appliedFilters.filter(appliedFilter =>
+					this.filters.find(
+						filter => appliedFilter.filter.name === filter.name
+					)
+				);
+				this.editingFilters = appliedFilters.filter(appliedFilter =>
+					this.filters.find(
+						filter => appliedFilter.filter.name === filter.name
+					)
+				);
+			}
+		}
+
+		this.resetBulkActionsPosition();
+
+		this.$nextTick(() => {
+			this.onWindowResize();
+			window.addEventListener("resize", this.onWindowResize);
+		});
+
+		ws.onConnect(this.init);
+
+		if (this.events && this.events.updated)
+			this.socket.on(`event:${this.events.updated.event}`, res => {
+				const index = this.rows
+					.map(row => row._id)
+					.indexOf(
+						this.events.updated.id
+							.split(".")
+							.reduce(
+								(previous, current) =>
+									previous &&
+									previous[current] !== null &&
+									previous[current] !== undefined
+										? previous[current]
+										: null,
+								res.data
+							)
+					);
+				const row = this.events.updated.item
+					.split(".")
+					.reduce(
+						(previous, current) =>
+							previous &&
+							previous[current] !== null &&
+							previous[current] !== undefined
+								? previous[current]
+								: null,
+						res.data
+					);
+				this.updateData(index, row);
+			});
+		if (this.events && this.events.removed)
+			this.socket.on(`event:${this.events.removed.event}`, res => {
+				const index = this.rows
+					.map(row => row._id)
+					.indexOf(
+						this.events.removed.id
+							.split(".")
+							.reduce(
+								(previous, current) =>
+									previous &&
+									previous[current] !== null &&
+									previous[current] !== undefined
+										? previous[current]
+										: null,
+								res.data
+							)
+					);
+				this.removeData(index);
+			});
+
+		if (this.keyboardShortcuts) {
+			// Navigation section
+
+			// Page navigation section
+			keyboardShortcuts.registerShortcut("advancedTable.previousPage", {
+				keyCode: 37, // 'Left arrow' key
+				ctrl: true,
+				preventDefault: false,
+				handler: event => {
+					// Previous page
+					if (this.aModalIsOpen) return;
+					if (
+						document.activeElement.nodeName === "INPUT" ||
+						document.activeElement.nodeName === "TEXTAREA"
+					)
+						return;
+					event.preventDefault();
+					this.changePage(this.page - 1);
+				}
+			});
+
+			keyboardShortcuts.registerShortcut("advancedTable.nextPage", {
+				keyCode: 39, // 'Right arrow' key
+				ctrl: true,
+				preventDefault: false,
+				handler: event => {
+					// Next page
+					if (this.aModalIsOpen) return;
+					if (
+						document.activeElement.nodeName === "INPUT" ||
+						document.activeElement.nodeName === "TEXTAREA"
+					)
+						return;
+					event.preventDefault();
+					this.changePage(this.page + 1);
+				}
+			});
+
+			keyboardShortcuts.registerShortcut("advancedTable.firstPage", {
+				keyCode: 37, // 'Left arrow' key
+				ctrl: true,
+				shift: true,
+				preventDefault: false,
+				handler: event => {
+					// First page
+					if (this.aModalIsOpen) return;
+					if (
+						document.activeElement.nodeName === "INPUT" ||
+						document.activeElement.nodeName === "TEXTAREA"
+					)
+						return;
+					event.preventDefault();
+					this.changePage(1);
+				}
+			});
+
+			keyboardShortcuts.registerShortcut("advancedTable.lastPage", {
+				keyCode: 39, // 'Right arrow' key
+				ctrl: true,
+				shift: true,
+				preventDefault: false,
+				handler: event => {
+					// Last page
+					if (this.aModalIsOpen) return;
+					if (
+						document.activeElement.nodeName === "INPUT" ||
+						document.activeElement.nodeName === "TEXTAREA"
+					)
+						return;
+					event.preventDefault();
+					this.changePage(this.lastPage);
+				}
+			});
+
+			// Reset localStorage section
+			keyboardShortcuts.registerShortcut(
+				"advancedTable.resetLocalStorage",
+				{
+					keyCode: 116, // 'F5' key
+					ctrl: true,
+					preventDefault: false,
+					handler: () => {
+						// Reset local storage
+						if (this.aModalIsOpen) return;
+						console.log("Reset local storage");
+						localStorage.removeItem(
+							`advancedTableSettings:${this.name}`
+						);
+						this.$router.push({ query: "" });
+					}
+				}
+			);
+
+			// Selecting section
+			keyboardShortcuts.registerShortcut("advancedTable.selectAll", {
+				keyCode: 65, // 'A' key
+				ctrl: true,
+				preventDefault: false,
+				handler: event => {
+					if (this.aModalIsOpen) return;
+					if (
+						document.activeElement.nodeName === "INPUT" ||
+						document.activeElement.nodeName === "TEXTAREA"
+					)
+						return;
+					event.preventDefault();
+					this.toggleAllRows();
+				}
+			});
+
+			// Popup actions section
+			for (let i = 1; i <= 9; i += 1) {
+				keyboardShortcuts.registerShortcut(
+					`advancedTable.executePopupAction${i}`,
+					{
+						keyCode: 48 + i, // '1-9' keys, where 49 is 1 and 57 is 9
+						ctrl: true,
+						preventDefault: true,
+						handler: () => {
+							// Execute popup action 1-9
+							if (this.aModalIsOpen) return;
+							if (this.selectedRows.length === 0) return;
+
+							const bulkActionsElement =
+								this.$refs["bulk-popup"].querySelector(
+									".bulk-actions"
+								);
+
+							bulkActionsElement.children[i - 1].click();
+						}
+					}
+				);
+			}
+
+			keyboardShortcuts.registerShortcut(
+				`advancedTable.selectPopupAction1`,
+				{
+					keyCode: 48, // '0' key
+					ctrl: true,
+					preventDefault: true,
+					handler: () => {
+						// Select popup action 0
+						if (this.aModalIsOpen) return;
+						if (this.selectedRows.length === 0) return;
+
+						const bulkActionsElement =
+							this.$refs["bulk-popup"].querySelector(
+								".bulk-actions"
+							);
+
+						bulkActionsElement.children[
+							bulkActionsElement.children.length - 1
+						].focus();
+					}
+				}
+			);
+		}
+	},
+	unmounted() {
+		window.removeEventListener("resize", this.onWindowResize);
+		if (this.storeTableSettingsDebounceTimeout)
+			clearTimeout(this.storeTableSettingsDebounceTimeout);
+
+		if (this.keyboardShortcuts) {
+			const shortcutNames = [
+				// Navigation
+				"advancedTable.previousPage",
+				"advancedTable.nextPage",
+				"advancedTable.firstPage",
+				"advancedTable.lastPage",
+				// Reset localStorage
+				"advancedTable.resetLocalStorage",
+				// Selecting
+				"advancedTable.selectAll",
+				// Popup actions
+				"advancedTable.executePopupAction1",
+				"advancedTable.executePopupAction2",
+				"advancedTable.executePopupAction3",
+				"advancedTable.executePopupAction4",
+				"advancedTable.executePopupAction5",
+				"advancedTable.executePopupAction6",
+				"advancedTable.executePopupAction7",
+				"advancedTable.executePopupAction8",
+				"advancedTable.executePopupAction9",
+				"advancedTable.selectPopupAction1"
+			];
+
+			shortcutNames.forEach(shortcutName => {
+				keyboardShortcuts.unregisterShortcut(shortcutName);
+			});
+		}
+	},
+	methods: {
+		init() {
+			this.getData();
+			if (this.query) this.setQuery();
+			if (this.events) {
+				if (this.events.room)
+					this.socket.dispatch(
+						"apis.joinRoom",
+						this.events.room,
+						() => {}
+					);
+				if (this.events.adminRoom)
+					this.socket.dispatch(
+						"apis.joinAdminRoom",
+						this.events.adminRoom,
+						() => {}
+					);
+			}
+			this.filters.forEach(filter => {
+				if (filter.autosuggest && filter.autosuggestDataAction) {
+					this.socket.dispatch(filter.autosuggestDataAction, res => {
+						if (res.status === "success") {
+							const { items } = res.data;
+							this.autosuggest.allItems[filter.name] = items;
+						} else {
+							new Toast(res.message);
+						}
+					});
+				}
+			});
+		},
+		getData() {
+			this.socket.dispatch(
+				this.dataAction,
+				this.page,
+				this.pageSize,
+				this.properties,
+				this.sort,
+				this.appliedFilters.map(filter => ({
+					...filter,
+					filterType: filter.filterType.name
+				})),
+				this.appliedFilterOperator,
+				res => {
+					if (res.status === "success") {
+						const { data, count } = res.data;
+						this.rows = data.map(row => ({
+							...row,
+							selected: false
+						}));
+						this.count = count;
+					} else {
+						new Toast(res.message);
+					}
+				}
+			);
+		},
+		changePageSize() {
+			this.page = 1;
+			this.getData();
+			this.storeTableSettings();
+		},
+		changePage(page) {
+			if (page < 1) return;
+			if (page > this.lastPage) return;
+			if (page === this.page) return;
+			this.page = page;
+			this.getData();
+			if (this.query) this.setQuery();
+		},
+		changeSort(column) {
+			if (column.sortable) {
+				const { sortProperty } = column;
+				if (this.sort[sortProperty] === undefined)
+					this.sort[sortProperty] = "ascending";
+				else if (this.sort[sortProperty] === "ascending")
+					this.sort[sortProperty] = "descending";
+				else if (this.sort[sortProperty] === "descending")
+					delete this.sort[sortProperty];
+				this.getData();
+				this.storeTableSettings();
+			}
+		},
+		toggleColumnVisibility(column) {
+			if (!column.hidable) return false;
+			if (this.shownColumns.indexOf(column.name) !== -1) {
+				if (this.shownColumns.length <= 3)
+					return new Toast(
+						`Unable to hide column ${column.displayName}, there must be at least 1 visibile column`
+					);
+				this.shownColumns.splice(
+					this.shownColumns.indexOf(column.name),
+					1
+				);
+			} else {
+				this.shownColumns.push(column.name);
+			}
+			this.recalculateWidths();
+			this.getData();
+			return this.storeTableSettings();
+		},
+		toggleSelectedRow(itemIndex, event) {
+			const { shiftKey, ctrlKey } = event;
+			// Shift was pressed, so attempt to select all items between the clicked item and last clicked item
+			if (shiftKey) {
+				// If the clicked item is already selected, prevent default, otherwise the checkbox will be unchecked
+				if (this.rows[itemIndex].selected) event.preventDefault();
+				// If there is a last clicked item
+				if (this.lastSelectedItemIndex >= 0) {
+					// Clicked item is lower than last item, so select upwards until it reaches the last selected item
+					if (itemIndex > this.lastSelectedItemIndex) {
+						for (
+							let itemIndexUp = itemIndex;
+							itemIndexUp > this.lastSelectedItemIndex;
+							itemIndexUp -= 1
+						) {
+							if (!this.rows[itemIndexUp].removed)
+								this.rows[itemIndexUp].selected = true;
+						}
+					}
+					// Clicked item is higher than last item, so select downwards until it reaches the last selected item
+					else if (itemIndex < this.lastSelectedItemIndex) {
+						for (
+							let itemIndexDown = itemIndex;
+							itemIndexDown < this.lastSelectedItemIndex;
+							itemIndexDown += 1
+						) {
+							if (!this.rows[itemIndexDown].removed)
+								this.rows[itemIndexDown].selected = true;
+						}
+					}
+				}
+			}
+			// Ctrl was pressed, so attempt to unselect all items between the clicked item and last clicked item
+			else if (ctrlKey) {
+				// If the clicked item is already unselected, prevent default, otherwise the checkbox will be checked
+				if (!this.rows[itemIndex].selected) event.preventDefault();
+				// If there is a last clicked item
+				if (this.lastSelectedItemIndex >= 0) {
+					// Clicked item is lower than last item, so unselect upwards until it reaches the last selected item
+					if (itemIndex > this.lastSelectedItemIndex) {
+						for (
+							let itemIndexUp = itemIndex;
+							itemIndexUp >= this.lastSelectedItemIndex;
+							itemIndexUp -= 1
+						) {
+							if (!this.rows[itemIndexUp].removed)
+								this.rows[itemIndexUp].selected = false;
+						}
+					}
+					// Clicked item is higher than last item, so unselect downwards until it reaches the last selected item
+					else if (itemIndex < this.lastSelectedItemIndex) {
+						for (
+							let itemIndexDown = itemIndex;
+							itemIndexDown <= this.lastSelectedItemIndex;
+							itemIndexDown += 1
+						) {
+							if (!this.rows[itemIndexDown].removed)
+								this.rows[itemIndexDown].selected = false;
+						}
+					}
+				}
+			}
+			// Neither ctrl nor shift were pressed, so toggle clicked item
+			else {
+				this.rows[itemIndex].selected = !this.rows[itemIndex].selected;
+			}
+
+			// Set the last clicked item to no longer be highlighted, if it exists
+			if (this.lastSelectedItemIndex >= 0)
+				this.rows[this.lastSelectedItemIndex].highlighted = false;
+			// Set the clicked item to be highlighted
+			this.rows[itemIndex].highlighted = true;
+		},
+		toggleAllRows() {
+			if (
+				this.rows.filter(row => !row.removed).length >
+				this.selectedRows.length
+			) {
+				this.rows = this.rows.map(row => {
+					if (row.removed) return row;
+					return { ...row, selected: true };
+				});
+			} else {
+				this.rows = this.rows.map(row => {
+					if (row.removed) return row;
+					return { ...row, selected: false };
+				});
+			}
+		},
+		highlightUp(itemIndex) {
+			if (itemIndex === 0) return;
+			const newItemIndex = itemIndex - 1;
+			this.highlightRow(newItemIndex);
+		},
+		highlightDown(itemIndex) {
+			if (itemIndex === this.rows.length - 1) return;
+			const newItemIndex = itemIndex + 1;
+			this.highlightRow(newItemIndex);
+		},
+		highlightRow(itemIndex) {
+			const rowElement = this.$refs[`row-${itemIndex}`];
+			// Set the last clicked item to no longer be highlighted, if it exists
+			if (this.lastSelectedItemIndex >= 0)
+				this.rows[this.lastSelectedItemIndex].highlighted = false;
+			if (rowElement) rowElement.focus();
+			// Set the item to be highlighted
+			this.rows[itemIndex].highlighted = true;
+		},
+		unhighlightRow(itemIndex) {
+			const rowElement = this.$refs[`row-${itemIndex}`];
+			if (rowElement) rowElement.blur();
+			// Set the item to no longer be highlighted
+			this.rows[itemIndex].highlighted = false;
+		},
+		selectUp(itemIndex) {
+			if (itemIndex === 0) return;
+			const newItemIndex = itemIndex - 1;
+			if (!this.rows[itemIndex].removed)
+				this.rows[itemIndex].selected = true;
+			if (!this.rows[newItemIndex].removed)
+				this.rows[newItemIndex].selected = true;
+			this.highlightRow(newItemIndex);
+		},
+		selectDown(itemIndex) {
+			if (itemIndex === this.rows.length - 1) return;
+			const newItemIndex = itemIndex + 1;
+			if (!this.rows[itemIndex].removed)
+				this.rows[itemIndex].selected = true;
+			if (!this.rows[newItemIndex].removed)
+				this.rows[newItemIndex].selected = true;
+			this.highlightRow(newItemIndex);
+		},
+		unselectUp(itemIndex) {
+			if (itemIndex === 0) return;
+			const newItemIndex = itemIndex - 1;
+			if (!this.rows[itemIndex].removed)
+				this.rows[itemIndex].selected = false;
+			if (!this.rows[newItemIndex].removed)
+				this.rows[newItemIndex].selected = false;
+			this.highlightRow(newItemIndex);
+		},
+		unselectDown(itemIndex) {
+			if (itemIndex === this.rows.length - 1) return;
+			const newItemIndex = itemIndex + 1;
+			if (!this.rows[itemIndex].removed)
+				this.rows[itemIndex].selected = false;
+			if (!this.rows[newItemIndex].removed)
+				this.rows[newItemIndex].selected = false;
+			this.highlightRow(newItemIndex);
+		},
+		addFilterItem() {
+			let data = "";
+			if (this.addFilterValue.defaultFilterType.startsWith("datetime")) {
+				const now = new Date();
+				now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
+				data = now.toISOString().slice(0, 16);
+			}
+
+			this.editingFilters.push({
+				data,
+				filter: this.addFilterValue,
+				filterType:
+					this.allFilterTypes[this.addFilterValue.defaultFilterType]
+			});
+		},
+		removeFilterItem(index) {
+			this.editingFilters.splice(index, 1);
+		},
+		columnResizingStart(column, event) {
+			const eventIsTouch = event.type === "touchstart";
+			if (eventIsTouch) {
+				// Handle double click from touch (if this method is called for the same column twice in a row within one second)
+				if (
+					this.lastColumnResizerTapped === column &&
+					Date.now() - this.lastColumnResizerTappedDate <= 1000
+				) {
+					this.columnResetWidth(column);
+					this.lastColumnResizerTapped = null;
+					this.lastColumnResizerTappedDate = 0;
+					return;
+				}
+				this.lastColumnResizerTapped = column;
+				this.lastColumnResizerTappedDate = Date.now();
+			}
+			this.resizing.resizing = true;
+			this.resizing.resizingColumn = column;
+			this.resizing.width = event.target.parentElement.offsetWidth;
+			this.resizing.lastX = eventIsTouch
+				? event.targetTouches[0].clientX
+				: event.x;
+		},
+		columnResizing(event) {
+			if (this.resizing.resizing) {
+				const eventIsTouch = event.type === "touchmove";
+				if (!eventIsTouch && event.buttons !== 1) {
+					this.resizing.resizing = false;
+					this.storeTableSettings();
+				}
+				const x = eventIsTouch
+					? event.changedTouches[0].clientX
+					: event.x;
+
+				this.resizing.width -= this.resizing.lastX - x;
+				this.resizing.lastX = x;
+				if (
+					this.resizing.resizingColumn.minWidth &&
+					this.resizing.resizingColumn.maxWidth
+				) {
+					this.resizing.resizingColumn.width = Math.max(
+						Math.min(
+							this.resizing.resizingColumn.maxWidth,
+							this.resizing.width
+						),
+						this.resizing.resizingColumn.minWidth
+					);
+				} else if (this.resizing.resizingColumn.minWidth) {
+					this.resizing.resizingColumn.width = Math.max(
+						this.resizing.width,
+						this.resizing.resizingColumn.minWidth
+					);
+				} else if (this.resizing.resizingColumn.maxWidth) {
+					this.resizing.resizingColumn.width = Math.min(
+						this.resizing.resizingColumn.maxWidth,
+						this.resizing.width
+					);
+				} else {
+					this.resizing.resizingColumn.width = this.resizing.width;
+				}
+				this.resizing.width = this.resizing.resizingColumn.width;
+				console.log(`New width: ${this.resizing.width}px`);
+				this.storeTableSettings();
+			}
+		},
+		columnResizingStop() {
+			this.resizing.resizing = false;
+			this.storeTableSettings();
+		},
+		columnResetWidth(column) {
+			const index = this.orderedColumns.indexOf(column);
+			if (column.defaultWidth && !Number.isNaN(column.defaultWidth))
+				this.orderedColumns[index].width = column.defaultWidth;
+			else if (
+				column.calculatedWidth &&
+				!Number.isNaN(column.calculatedWidth)
+			)
+				this.orderedColumns[index].width = column.calculatedWidth;
+			this.storeTableSettings();
+		},
+		filterTypes(filter) {
+			if (!filter || !filter.filterTypes) return [];
+			return filter.filterTypes.map(
+				filterType => this.allFilterTypes[filterType]
+			);
+		},
+		changeFilterType(index) {
+			this.editingFilters[index].filterType =
+				this.allFilterTypes[
+					this.editingFilters[index].filter.defaultFilterType
+				];
+		},
+		onDragBox(e) {
+			const e1 = e || window.event;
+			const e1IsTouch = e1.type === "touchstart";
+			e1.preventDefault();
+
+			if (e1IsTouch) {
+				// Handle double click from touch (if this method is twice in a row within one second)
+				if (Date.now() - this.lastBulkActionsTappedDate <= 1000) {
+					this.resetBulkActionsPosition();
+					this.lastBulkActionsTappedDate = 0;
+					return;
+				}
+				this.lastBulkActionsTappedDate = Date.now();
+			}
+
+			this.bulkPopup.pos3 = e1IsTouch
+				? e1.changedTouches[0].clientX
+				: e1.clientX;
+			this.bulkPopup.pos4 = e1IsTouch
+				? e1.changedTouches[0].clientY
+				: e1.clientY;
+
+			document.onmousemove = document.ontouchmove = e => {
+				const e2 = e || window.event;
+				const e2IsTouch = e2.type === "touchmove";
+				if (!e2IsTouch) e2.preventDefault();
+
+				// Get the clientX and clientY
+				const e2ClientX = e2IsTouch
+					? e2.changedTouches[0].clientX
+					: e2.clientX;
+				const e2ClientY = e2IsTouch
+					? e2.changedTouches[0].clientY
+					: e2.clientY;
+
+				// calculate the new cursor position:
+				this.bulkPopup.pos1 = this.bulkPopup.pos3 - e2ClientX;
+				this.bulkPopup.pos2 = this.bulkPopup.pos4 - e2ClientY;
+				this.bulkPopup.pos3 = e2ClientX;
+				this.bulkPopup.pos4 = e2ClientY;
+				// set the element's new position:
+				this.bulkPopup.top -= this.bulkPopup.pos2;
+				this.bulkPopup.left -= this.bulkPopup.pos1;
+
+				if (this.bulkPopup.top < 0) this.bulkPopup.top = 0;
+				if (this.bulkPopup.top > document.body.clientHeight - 50)
+					this.bulkPopup.top = document.body.clientHeight - 50;
+				if (this.bulkPopup.left < 0) this.bulkPopup.left = 0;
+				if (this.bulkPopup.left > document.body.clientWidth - 400)
+					this.bulkPopup.left = document.body.clientWidth - 400;
+			};
+
+			document.onmouseup = document.ontouchend = () => {
+				document.onmouseup = null;
+				document.ontouchend = null;
+				document.onmousemove = null;
+				document.ontouchmove = null;
+			};
+		},
+		resetBulkActionsPosition() {
+			this.bulkPopup.top = document.body.clientHeight - 56;
+			this.bulkPopup.left = document.body.clientWidth / 2 - 200;
+		},
+		applyFilterAndGetData() {
+			this.appliedFilters = JSON.parse(
+				JSON.stringify(this.editingFilters)
+			);
+			this.appliedFilterOperator = this.filterOperator;
+			this.page = 1;
+			this.getData();
+			this.storeTableSettings();
+		},
+		recalculateWidths() {
+			let noWidthCount = 0;
+			let calculatedWidth = 0;
+			this.orderedColumns.forEach(column => {
+				if (this.shownColumns.indexOf(column.name) !== -1)
+					if (
+						Number.isFinite(column.width) &&
+						!Number.isFinite(column.calculatedWidth)
+					) {
+						calculatedWidth += column.width;
+					} else if (Number.isFinite(column.defaultWidth)) {
+						calculatedWidth += column.defaultWidth;
+					} else {
+						noWidthCount += 1;
+					}
+			});
+			calculatedWidth = Math.floor(
+				(Math.min(this.maxWidth, document.body.clientWidth) -
+					calculatedWidth) /
+					(noWidthCount - 1)
+			);
+			this.orderedColumns = this.orderedColumns.map(column => {
+				const orderedColumn = column;
+				if (this.shownColumns.indexOf(orderedColumn.name) !== -1) {
+					let newWidth;
+					if (Number.isFinite(orderedColumn.defaultWidth)) {
+						newWidth = orderedColumn.defaultWidth;
+					} else {
+						// eslint-disable-next-line no-param-reassign
+						newWidth = orderedColumn.calculatedWidth = Math.min(
+							Math.max(
+								orderedColumn.minWidth || 100, // fallback 100px min width
+								calculatedWidth
+							),
+							orderedColumn.maxWidth || 1000 // fallback 1000px max width
+						);
+					}
+					if (newWidth && !Number.isFinite(orderedColumn.width))
+						orderedColumn.width = newWidth;
+				}
+				return orderedColumn;
+			});
+		},
+		columnOrderChanged() {
+			this.storeTableSettings();
+		},
+		getTableSettings() {
+			const urlTableSettings = {};
+			if (this.query) {
+				if (this.$route.query.page)
+					urlTableSettings.page = Number.parseInt(
+						this.$route.query.page
+					);
+				if (this.$route.query.pageSize)
+					urlTableSettings.pageSize = Number.parseInt(
+						this.$route.query.pageSize
+					);
+				if (this.$route.query.shownColumns)
+					urlTableSettings.shownColumns = JSON.parse(
+						this.$route.query.shownColumns
+					);
+				if (this.$route.query.columnOrder)
+					urlTableSettings.columnOrder = JSON.parse(
+						this.$route.query.columnOrder
+					);
+				if (this.$route.query.columnWidths)
+					urlTableSettings.columnWidths = JSON.parse(
+						this.$route.query.columnWidths
+					);
+				if (this.$route.query.columnSort)
+					urlTableSettings.columnSort = JSON.parse(
+						this.$route.query.columnSort
+					);
+				if (this.$route.query.filter)
+					urlTableSettings.filter = JSON.parse(
+						this.$route.query.filter
+					);
+			}
+
+			const localStorageTableSettings = JSON.parse(
+				localStorage.getItem(`advancedTableSettings:${this.name}`)
+			);
+
+			return {
+				...localStorageTableSettings,
+				...urlTableSettings
+			};
+		},
+		storeTableSettings() {
+			// Clear debounce timeout
+			if (this.storeTableSettingsDebounceTimeout)
+				clearTimeout(this.storeTableSettingsDebounceTimeout);
+
+			// Resizing calls this function a lot, so rather than saving dozens of times a second, use debouncing
+			this.storeTableSettingsDebounceTimeout = setTimeout(() => {
+				if (this.query) this.setQuery();
+				this.setLocalStorage();
+			}, 250);
+		},
+		setQuery() {
+			const queryObject = {
+				...this.$route.query,
+				page: this.page,
+				pageSize: this.pageSize,
+				filter: JSON.stringify({
+					appliedFilters: this.appliedFilters,
+					appliedFilterOperator: this.appliedFilterOperator
+				}),
+				columnSort: JSON.stringify(this.sort),
+				columnOrder: JSON.stringify(
+					this.orderedColumns.map(column => column.name)
+				),
+				columnWidths: JSON.stringify(
+					this.orderedColumns.map(column => ({
+						name: column.name,
+						width: column.width
+					}))
+				),
+				shownColumns: JSON.stringify(this.shownColumns)
+			};
+
+			const queryString = `?${Object.keys(queryObject)
+				.map(key => `${key}=${queryObject[key]}`)
+				.join("&")}`;
+
+			window.history.replaceState(null, null, queryString);
+		},
+		setLocalStorage() {
+			localStorage.setItem(
+				`advancedTableSettings:${this.name}`,
+				JSON.stringify({
+					pageSize: this.pageSize,
+					filter: {
+						appliedFilters: this.appliedFilters,
+						appliedFilterOperator: this.appliedFilterOperator
+					},
+					columnSort: this.sort,
+					columnOrder: this.orderedColumns.map(column => column.name),
+					columnWidths: this.orderedColumns.map(column => ({
+						name: column.name,
+						width: column.width
+					})),
+					shownColumns: this.shownColumns
+				})
+			);
+		},
+		onWindowResize() {
+			// Only change the position if the popup is actually visible
+			if (this.selectedRows.length === 0) return;
+			if (this.bulkPopup.top < 0) this.bulkPopup.top = 0;
+			if (this.bulkPopup.top > document.body.clientHeight - 50)
+				this.bulkPopup.top = document.body.clientHeight - 50;
+			if (this.bulkPopup.left < 0) this.bulkPopup.left = 0;
+			if (this.bulkPopup.left > document.body.clientWidth - 400)
+				this.bulkPopup.left = document.body.clientWidth - 400;
+		},
+		updateData(index, data) {
+			this.rows[index] = { ...this.rows[index], ...data, updated: true };
+		},
+		removeData(index) {
+			this.rows[index] = {
+				...this.rows[index],
+				selected: false,
+				removed: true
+			};
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.night-mode {
+	.table-outer-container {
+		.table-container .table {
+			&,
+			thead th {
+				background-color: var(--dark-grey-3) !important;
+				color: var(--light-grey-2);
+			}
+
+			tr {
+				th,
+				td {
+					border-color: var(--dark-grey) !important;
+					background-color: var(--dark-grey-3) !important;
+				}
+
+				&:nth-child(even) td {
+					background-color: var(--dark-grey-2) !important;
+				}
+
+				&:hover,
+				&:focus,
+				&.highlighted {
+					th,
+					td {
+						background-color: var(--dark-grey-4) !important;
+					}
+				}
+
+				&.updated td:first-child {
+					background-color: var(--primary-color) !important;
+				}
+			}
+
+			&.has-checkboxes tbody tr {
+				td:nth-child(2) {
+					background-color: var(--dark-grey-3) !important;
+				}
+				&:nth-child(even) td:nth-child(2) {
+					background-color: var(--dark-grey-2) !important;
+				}
+				&.updated td:first-child {
+					background-color: var(--primary-color) !important;
+				}
+				&:hover,
+				&:focus,
+				&.highlighted {
+					th,
+					td {
+						&:nth-child(2) {
+							background-color: var(--dark-grey-4) !important;
+						}
+					}
+				}
+			}
+		}
+
+		.table-header,
+		.table-footer {
+			background-color: var(--dark-grey-3);
+			color: var(--light-grey-2);
+		}
+
+		.table-no-results {
+			background-color: var(--dark-grey-3);
+			color: var(--light-grey-2);
+			border-color: var(--dark-grey) !important;
+		}
+
+		.label.control {
+			background-color: var(--dark-grey) !important;
+			border-color: var(--grey-3) !important;
+			color: var(--white) !important;
+		}
+	}
+	.bulk-popup {
+		border: 0;
+		background-color: var(--dark-grey-2);
+		color: var(--white);
+
+		.material-icons {
+			color: var(--white);
+		}
+	}
+}
+
+.table-outer-container {
+	border-radius: 5px;
+	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
+	margin: 10px 0;
+	overflow: hidden;
+
+	.table-container {
+		overflow-x: auto;
+
+		table {
+			border-collapse: separate;
+			table-layout: fixed;
+
+			thead {
+				tr {
+					th {
+						height: 40px;
+						line-height: 40px;
+						border: 1px solid var(--light-grey-2);
+						border-width: 1px 1px 1px 0;
+						padding: 0;
+
+						&:last-child {
+							border-width: 1px 0 1px;
+						}
+
+						&.sortable {
+							cursor: pointer;
+						}
+
+						& > div {
+							display: flex;
+							white-space: nowrap;
+							padding: 8px 10px;
+
+							& > span {
+								margin-left: 5px;
+
+								&:first-child {
+									margin-left: 0;
+									margin-right: auto;
+								}
+
+								& > .material-icons {
+									font-size: 22px;
+									position: relative;
+									top: 6px;
+									cursor: pointer;
+
+									&.active {
+										color: var(--primary-color);
+									}
+
+									&:hover,
+									&:focus {
+										filter: brightness(90%);
+									}
+								}
+							}
+						}
+					}
+				}
+			}
+
+			tbody {
+				tr {
+					&.updated {
+						td:first-child {
+							background-color: var(--primary-color) !important;
+						}
+					}
+
+					&:nth-child(even) td {
+						background-color: rgb(250, 250, 250);
+					}
+
+					td {
+						border: 1px solid var(--light-grey-2);
+						border-width: 0 1px 1px 0;
+
+						&:last-child {
+							border-width: 0 0 1px;
+						}
+
+						/deep/ .row-options {
+							display: flex;
+							justify-content: space-evenly;
+
+							.icon-with-button {
+								height: 30px;
+								width: 30px;
+							}
+						}
+					}
+
+					&.removed {
+						filter: grayscale(100%);
+						cursor: not-allowed;
+						user-select: none;
+
+						td .removed-overlay {
+							position: absolute;
+							top: 0;
+							left: 0;
+							bottom: 0;
+							right: 5px;
+							z-index: 5;
+						}
+					}
+				}
+			}
+		}
+
+		table {
+			thead tr,
+			tbody tr {
+				th,
+				td {
+					position: relative;
+					white-space: nowrap;
+					text-overflow: ellipsis;
+					overflow: hidden;
+					background-color: var(--white);
+
+					&:first-child {
+						display: table-cell;
+						position: sticky;
+						left: 0;
+						z-index: 2;
+
+						& > .updated-tooltip {
+							position: absolute;
+							top: 0;
+							left: 0;
+							bottom: 0;
+							right: 0;
+						}
+					}
+
+					.resizer {
+						height: 100%;
+						width: 5px;
+						background-color: transparent;
+						cursor: col-resize;
+						position: absolute;
+						right: 0;
+						top: 0;
+					}
+				}
+
+				&:hover,
+				&:focus,
+				&.highlighted {
+					th,
+					td {
+						background-color: rgb(240, 240, 240);
+					}
+				}
+			}
+
+			&.has-checkboxes {
+				thead,
+				tbody {
+					tr {
+						th,
+						td {
+							&:nth-child(2) {
+								display: table-cell;
+								position: sticky;
+								left: 5px;
+								z-index: 2;
+							}
+						}
+						&.updated td:first-child {
+							background-color: var(--primary-color);
+						}
+					}
+				}
+			}
+		}
+	}
+
+	.table-header,
+	.table-footer {
+		display: flex;
+		flex-direction: row;
+		flex-wrap: wrap;
+		justify-content: space-between;
+		line-height: 36px;
+		background-color: var(--white);
+	}
+
+	.table-header {
+		& > div {
+			display: flex;
+			flex-direction: row;
+
+			> span > .control {
+				margin: 5px;
+			}
+
+			.filters-indicator {
+				line-height: 46px;
+				display: flex;
+				align-items: center;
+				column-gap: 4px;
+			}
+		}
+
+		@media screen and (max-width: 400px) {
+			flex-direction: column;
+
+			& > div {
+				justify-content: center;
+			}
+		}
+	}
+
+	.table-footer {
+		.page-controls,
+		.page-size > .control {
+			display: flex;
+			flex-direction: row;
+			margin-bottom: 0 !important;
+
+			button {
+				margin: 5px;
+				font-size: 20px;
+			}
+
+			p,
+			label {
+				margin: 5px;
+				font-size: 14px;
+				font-weight: 600;
+			}
+
+			&.select::after {
+				top: 18px;
+			}
+		}
+
+		@media screen and (max-width: 600px) {
+			flex-direction: column;
+
+			.page-controls,
+			.page-size > .control {
+				justify-content: center;
+			}
+		}
+	}
+
+	.table-no-results {
+		display: flex;
+		flex-direction: row;
+		justify-content: center;
+		border-bottom: 1px solid var(--light-grey-2);
+		font-size: 18px;
+		line-height: 50px;
+		background-color: var(--white);
+	}
+}
+
+.control.is-grouped {
+	display: flex;
+
+	& > .control {
+		&.label {
+			height: 36px;
+			background-color: var(--white);
+			border: 1px solid var(--light-grey-2);
+			color: var(--dark-grey-2);
+			appearance: none;
+			border-radius: 3px;
+			font-size: 14px;
+			line-height: 34px;
+			padding-left: 8px;
+			padding-right: 8px;
+		}
+		&.select.is-expanded > select {
+			width: 100%;
+		}
+		& > input,
+		/deep/ & > div > input,
+		& > select,
+		& > .button,
+		&.label {
+			border-radius: 0;
+		}
+		&:first-child {
+			& > input,
+			& > select,
+			& > .button,
+			&.label {
+				border-radius: 5px 0 0 5px;
+			}
+		}
+		&:last-child {
+			& > input,
+			& > select,
+			& > .button,
+			&.label {
+				border-radius: 0 5px 5px 0;
+			}
+		}
+		& > .button {
+			font-size: 22px;
+		}
+	}
+
+	@media screen and (max-width: 600px) {
+		&.advanced-filter {
+			flex-wrap: wrap;
+			.control.select {
+				width: 50%;
+				select {
+					width: 100%;
+				}
+			}
+			.control {
+				margin-bottom: 0 !important;
+				&:nth-child(1) > select {
+					border-radius: 5px 0 0 0;
+				}
+				&:nth-child(2) > select {
+					border-radius: 0 5px 0 0;
+				}
+				/deep/ &:nth-child(3) {
+					& > input,
+					& > div > input,
+					& > select {
+						border-radius: 0 0 0 5px;
+					}
+				}
+				&:nth-child(4) > button {
+					border-radius: 0 0 5px 0;
+				}
+			}
+		}
+	}
+}
+
+.advanced-filter {
+	.control {
+		position: relative;
+	}
+}
+
+.advanced-filter-bottom {
+	display: flex;
+
+	.button {
+		font-size: 16px !important;
+		width: 100%;
+	}
+
+	.control {
+		margin: 0 !important;
+	}
+}
+
+/deep/ .bulk-popup {
+	display: flex;
+	position: fixed;
+	flex-direction: row;
+	width: 100%;
+	max-width: 400px;
+	line-height: 36px;
+	z-index: 5;
+	border: 1px solid var(--light-grey-3);
+	border-radius: 5px;
+	box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
+	background-color: var(--white);
+	color: var(--dark-grey);
+	padding: 5px;
+
+	.right {
+		display: flex;
+		flex-direction: row;
+		margin-left: auto;
+	}
+
+	.drag-icon {
+		position: relative;
+		top: 6px;
+		color: var(--dark-grey);
+		cursor: move;
+	}
+
+	.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);
+			height: 25px;
+
+			&:hover,
+			&:focus {
+				filter: brightness(90%);
+			}
+		}
+		.delete-icon {
+			color: var(--dark-red);
+		}
+	}
+}
+</style>

+ 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);

+ 34 - 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;
 		}
@@ -197,6 +217,8 @@ export default {
 			.delete.material-icons {
 				font-size: 28px;
 				cursor: pointer;
+				user-select: none;
+				-webkit-user-drag: none;
 				&:hover,
 				&:focus {
 					filter: brightness(90%);
@@ -207,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 {
@@ -223,6 +245,10 @@ export default {
 				justify-content: flex-end;
 				column-gap: 16px;
 			}
+
+			&.blank {
+				padding: 10px;
+			}
 		}
 
 		.modal-card-body {

+ 5 - 4
frontend/src/components/Queue.vue

@@ -36,7 +36,7 @@
 							v-if="isAdminOnly() || isOwnerOnly()"
 							#tippyActions
 						>
-							<confirm
+							<quick-confirm
 								v-if="isOwnerOnly() || isAdminOnly()"
 								placement="left"
 								@confirm="removeFromQueue(element.youtubeId)"
@@ -47,7 +47,7 @@
 									v-tippy
 									>delete_forever</i
 								>
-							</confirm>
+							</quick-confirm>
 							<i
 								class="material-icons"
 								v-if="index > 0"
@@ -141,10 +141,10 @@ import draggable from "vuedraggable";
 import Toast from "toasters";
 
 import SongItem from "@/components/SongItem.vue";
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: { draggable, SongItem, Confirm },
+	components: { draggable, SongItem, QuickConfirm },
 	props: {
 		sector: {
 			type: String,
@@ -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%;

+ 2 - 2
frontend/src/components/Confirm.vue → frontend/src/components/QuickConfirm.vue

@@ -3,8 +3,8 @@
 		:interactive="true"
 		:touch="true"
 		:placement="placement"
-		theme="confirm"
-		ref="confirm"
+		theme="quickConfirm"
+		ref="quickConfirm"
 		trigger="click"
 		:append-to="body"
 		@hide="delayedHide()"

+ 112 - 0
frontend/src/components/RunJobDropdown.vue

@@ -0,0 +1,112 @@
+<template>
+	<tippy
+		class="runJobDropdown"
+		:touch="true"
+		:interactive="true"
+		placement="bottom-start"
+		theme="dropdown"
+		ref="dropdown"
+		trigger="click"
+		append-to="parent"
+		@show="
+			() => {
+				showJobDropdown = true;
+			}
+		"
+		@hide="
+			() => {
+				showJobDropdown = false;
+			}
+		"
+	>
+		<div class="control has-addons" ref="trigger">
+			<button class="button is-primary">Run Job</button>
+			<button class="button">
+				<i class="material-icons">
+					{{ showJobDropdown ? "expand_more" : "expand_less" }}
+				</i>
+			</button>
+		</div>
+
+		<template #content>
+			<div class="nav-dropdown-items" v-if="jobs.length > 0">
+				<quick-confirm
+					v-for="(job, index) in jobs"
+					:key="`job-${index}`"
+					placement="top"
+					@confirm="runJob(job)"
+				>
+					<button class="nav-item button" :title="job.name">
+						<i
+							class="material-icons icon-with-button"
+							content="Run Job"
+							v-tippy
+							>play_arrow</i
+						>
+						<p>{{ job.name }}</p>
+					</button>
+				</quick-confirm>
+			</div>
+			<p v-else class="no-jobs">No jobs available.</p>
+		</template>
+	</tippy>
+</template>
+
+<script>
+import { mapGetters } from "vuex";
+
+import Toast from "toasters";
+import QuickConfirm from "@/components/QuickConfirm.vue";
+
+export default {
+	components: {
+		QuickConfirm
+	},
+	props: {
+		jobs: {
+			type: Array,
+			default: () => []
+		}
+	},
+	data() {
+		return {
+			showJobDropdown: false
+		};
+	},
+	computed: {
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		runJob(job) {
+			new Toast(`Running job: ${job.name}`);
+			this.socket.dispatch(job.socket, data => {
+				if (data.status !== "success")
+					new Toast({
+						content: `Error: ${data.message}`,
+						timeout: 8000
+					});
+				else new Toast({ content: data.message, timeout: 4000 });
+			});
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.nav-dropdown-items {
+	& > span:not(:last-child) .nav-item.button {
+		margin-bottom: 10px !important;
+	}
+	.nav-item.button .icon-with-button {
+		font-size: 22px;
+		color: var(--primary-color);
+	}
+}
+
+.no-jobs {
+	text-align: center;
+	margin: 10px 0;
+}
+</style>

+ 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 - 0
frontend/src/components/SongThumbnail.vue

@@ -1,5 +1,6 @@
 <template>
 	<div class="thumbnail">
+		<slot name="icon" />
 		<div
 			v-if="
 				song.youtubeId &&

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

@@ -3,11 +3,11 @@
 		<div class="container">
 			<div class="footer-content">
 				<div id="footer-copyright">
-					<p>© Copyright Musare 2015 - 2021</p>
+					<p>© Copyright Musare 2015 - 2022</p>
 				</div>
-				<a id="footer-logo" href="/"
+				<router-link id="footer-logo" to="/"
 					><img src="/assets/blue_wordmark.png" alt="Musare"
-				/></a>
+				/></router-link>
 				<div id="footer-links">
 					<a :href="github" target="_blank" title="GitHub Repository"
 						>GitHub</a
@@ -90,10 +90,11 @@ export default {
 		margin-right: auto;
 		width: 160px;
 		order: 1;
-		user-select: none;
 
 		img {
 			max-width: 100%;
+			user-select: none;
+			-webkit-user-drag: none;
 		}
 	}
 

+ 1 - 0
frontend/src/components/layout/MainHeader.vue

@@ -290,6 +290,7 @@ export default {
 				max-height: 38px;
 				color: var(--primary-color);
 				user-select: none;
+				-webkit-user-drag: none;
 			}
 		}
 	}

+ 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";

+ 24 - 1
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -86,9 +86,32 @@ export default {
 					new Toast({ content: res.message, timeout: 20000 });
 					if (res.status === "success") {
 						isImportingPlaylist = false;
+
+						const {
+							songsInPlaylistTotal,
+							videosInPlaylistTotal,
+							alreadyInLikedPlaylist,
+							alreadyInDislikedPlaylist
+						} = res.data.stats;
+
 						if (this.youtubeSearch.playlist.isImportingOnlyMusic) {
 							new Toast({
-								content: `${res.data.stats.songsInPlaylistTotal} of the ${res.data.stats.videosInPlaylistTotal} videos in the playlist were songs.`,
+								content: `${songsInPlaylistTotal} of the ${videosInPlaylistTotal} videos in the playlist were songs.`,
+								timeout: 20000
+							});
+						}
+						if (
+							alreadyInLikedPlaylist > 0 ||
+							alreadyInDislikedPlaylist > 0
+						) {
+							let message = "";
+							if (alreadyInLikedPlaylist > 0) {
+								message = `${alreadyInLikedPlaylist} songs were already in your Liked Songs playlist. A song cannot be in both the Liked Songs playlist and the Disliked Songs playlist at the same time.`;
+							} else {
+								message = `${alreadyInDislikedPlaylist} songs were already in your Disliked Songs playlist. A song cannot be in both the Liked Songs playlist and the Disliked Songs playlist at the same time.`;
+							}
+							new Toast({
+								content: message,
 								timeout: 20000
 							});
 						}

+ 13 - 16
frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue

@@ -1,14 +1,14 @@
 <template>
 	<div class="settings-tab section">
-		<div v-if="isEditable()">
-			<h4 class="section-title">Edit Details</h4>
-
-			<p class="section-description">
-				Change the display name and privacy of the playlist
-			</p>
-
-			<hr class="section-horizontal-rule" />
-
+		<div
+			v-if="
+				isEditable() &&
+				!(
+					playlist.type === 'user-liked' ||
+					playlist.type === 'user-disliked'
+				)
+			"
+		>
 			<label class="label"> Change display name </label>
 
 			<div class="control is-grouped input-with-button">
@@ -32,12 +32,7 @@
 			</div>
 		</div>
 
-		<div
-			v-if="
-				userId === playlist.createdBy ||
-				(playlist.type === 'genre' && isAdmin())
-			"
-		>
+		<div v-if="isEditable() || (playlist.type === 'genre' && isAdmin())">
 			<label class="label"> Change privacy </label>
 			<div class="control is-grouped input-with-button">
 				<div class="control is-expanded select">
@@ -84,7 +79,9 @@ export default {
 	methods: {
 		isEditable() {
 			return (
-				this.playlist.isUserModifiable &&
+				(this.playlist.type === "user" ||
+					this.playlist.type === "user-liked" ||
+					this.playlist.type === "user-disliked") &&
 				(this.userId === this.playlist.createdBy ||
 					this.userRole === "admin")
 			);

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

@@ -7,7 +7,7 @@
 			'edit-playlist-modal': true,
 			'view-only': !isEditable()
 		}"
-		:wide="true"
+		:size="isEditable() ? 'wide' : null"
 		:split="true"
 	>
 		<template #body>
@@ -130,7 +130,7 @@
 												v-tippy
 												>queue</i
 											>
-											<confirm
+											<quick-confirm
 												v-if="
 													userId ===
 														playlist.createdBy ||
@@ -152,7 +152,7 @@
 													v-tippy
 													>delete_forever</i
 												>
-											</confirm>
+											</quick-confirm>
 											<i
 												class="material-icons"
 												v-if="isEditable() && index > 0"
@@ -207,25 +207,34 @@
 				Download Playlist
 			</button>
 			<div class="right">
-				<confirm
+				<quick-confirm
 					v-if="playlist.type === 'station'"
 					@confirm="clearAndRefillStationPlaylist()"
 				>
 					<a class="button is-danger">
 						Clear and refill station playlist
 					</a>
-				</confirm>
-				<confirm
+				</quick-confirm>
+				<quick-confirm
 					v-if="playlist.type === 'genre'"
 					@confirm="clearAndRefillGenrePlaylist()"
 				>
 					<a class="button is-danger">
 						Clear and refill genre playlist
 					</a>
-				</confirm>
-				<confirm v-if="isEditable()" @confirm="removePlaylist()">
+				</quick-confirm>
+				<quick-confirm
+					v-if="
+						isEditable() &&
+						!(
+							playlist.type === 'user-liked' ||
+							playlist.type === 'user-disliked'
+						)
+					"
+					@confirm="removePlaylist()"
+				>
 					<a class="button is-danger"> Remove Playlist </a>
-				</confirm>
+				</quick-confirm>
 			</div>
 		</template>
 	</modal>
@@ -237,7 +246,7 @@ import draggable from "vuedraggable";
 import Toast from "toasters";
 
 import ws from "@/ws";
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 import Modal from "../../Modal.vue";
 import SongItem from "../../SongItem.vue";
 
@@ -251,7 +260,7 @@ export default {
 	components: {
 		Modal,
 		draggable,
-		Confirm,
+		QuickConfirm,
 		SongItem,
 		Settings,
 		AddSongs,
@@ -370,7 +379,9 @@ export default {
 		},
 		isEditable() {
 			return (
-				this.playlist.isUserModifiable &&
+				(this.playlist.type === "user" ||
+					this.playlist.type === "user-liked" ||
+					this.playlist.type === "user-disliked") &&
 				(this.userId === this.playlist.createdBy ||
 					this.userRole === "admin")
 			);
@@ -448,16 +459,6 @@ export default {
 			);
 		},
 		removeSongFromPlaylist(id) {
-			if (this.playlist.displayName === "Liked Songs")
-				return this.socket.dispatch("songs.unlike", id, res => {
-					new Toast(res.message);
-				});
-
-			if (this.playlist.displayName === "Disliked Songs")
-				return this.socket.dispatch("songs.undislike", id, res => {
-					new Toast(res.message);
-				});
-
 			return this.socket.dispatch(
 				"playlists.removeSongFromPlaylist",
 				id,
@@ -539,7 +540,6 @@ export default {
 				"playlists.clearAndRefillStationPlaylist",
 				this.playlist._id,
 				data => {
-					console.log(data.message);
 					if (data.status !== "success")
 						new Toast({
 							content: `Error: ${data.message}`,

+ 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: {

+ 17 - 21
frontend/src/components/modals/EditSong/Tabs/Songs.vue

@@ -61,37 +61,33 @@ export default {
 	},
 	mounted() {
 		this.musareSearch.query = this.song.title;
-		this.searchForMusareSongs(1);
+		this.searchForMusareSongs(1, false);
 	}
 };
 </script>
 
 <style lang="scss" scoped>
-.musare-songs-tab {
-	height: calc(100% - 32px);
+.musare-songs-tab #song-query-results {
+	height: calc(100% - 74px);
+	overflow: auto;
 
-	#song-query-results {
-		height: calc(100% - 74px);
-		overflow: auto;
-
-		.search-query-item {
-			.icon-selected {
-				color: var(--green) !important;
-			}
-
-			.icon-not-selected {
-				color: var(--grey) !important;
-			}
+	.search-query-item {
+		.icon-selected {
+			color: var(--green) !important;
 		}
 
-		.search-query-item:not(:last-of-type) {
-			margin-bottom: 10px;
+		.icon-not-selected {
+			color: var(--grey) !important;
 		}
+	}
 
-		.load-more-button {
-			width: 100%;
-			margin-top: 10px;
-		}
+	.search-query-item:not(:last-of-type) {
+		margin-bottom: 10px;
+	}
+
+	.load-more-button {
+		width: 100%;
+		margin-top: 10px;
 	}
 }
 </style>

+ 16 - 20
frontend/src/components/modals/EditSong/Tabs/Youtube.vue

@@ -86,31 +86,27 @@ export default {
 </script>
 
 <style lang="scss" scoped>
-.youtube-tab {
-	height: calc(100% - 32px);
+.youtube-tab #song-query-results {
+	height: calc(100% - 74px);
+	overflow: auto;
 
-	#song-query-results {
-		height: calc(100% - 74px);
-		overflow: auto;
-
-		.search-query-item {
-			.icon-selected {
-				color: var(--green) !important;
-			}
-
-			.icon-not-selected {
-				color: var(--grey) !important;
-			}
+	.search-query-item {
+		.icon-selected {
+			color: var(--green) !important;
 		}
 
-		.search-query-item:not(:last-of-type) {
-			margin-bottom: 10px;
+		.icon-not-selected {
+			color: var(--grey) !important;
 		}
+	}
 
-		.load-more-button {
-			width: 100%;
-			margin-top: 10px;
-		}
+	.search-query-item:not(:last-of-type) {
+		margin-bottom: 10px;
+	}
+
+	.load-more-button {
+		width: 100%;
+		margin-top: 10px;
 	}
 }
 </style>

文件差异内容过多而无法显示
+ 437 - 364
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>

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

@@ -76,9 +76,18 @@
 				</div>
 			</template>
 			<template #footer>
-				<button class="button is-warning" @click="removeSessions()">
-					<span>&nbsp;Remove all sessions</span>
-				</button>
+				<quick-confirm @confirm="resendVerificationEmail()">
+					<a class="button is-warning"> Resend verification email </a>
+				</quick-confirm>
+				<quick-confirm @confirm="requestPasswordReset()">
+					<a class="button is-warning"> Request password reset </a>
+				</quick-confirm>
+				<quick-confirm @confirm="removeSessions()">
+					<a class="button is-warning"> Remove all sessions </a>
+				</quick-confirm>
+				<quick-confirm @confirm="removeAccount()">
+					<a class="button is-danger"> Remove account </a>
+				</quick-confirm>
 			</template>
 		</modal>
 	</div>
@@ -91,9 +100,10 @@ import Toast from "toasters";
 import validation from "@/validation";
 import ws from "@/ws";
 import Modal from "../Modal.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: { Modal },
+	components: { Modal, QuickConfirm },
 	props: {
 		userId: { type: String, default: "" },
 		sector: { type: String, default: "admin" }
@@ -116,12 +126,33 @@ export default {
 	mounted() {
 		ws.onConnect(this.init);
 	},
+	beforeUnmount() {
+		this.socket.dispatch(
+			"apis.leaveRoom",
+			`edit-user.${this.userId}`,
+			() => {}
+		);
+	},
 	methods: {
 		init() {
 			this.socket.dispatch(`users.getUserFromId`, this.userId, res => {
 				if (res.status === "success") {
 					const user = res.data;
 					this.editUser(user);
+
+					this.socket.dispatch(
+						"apis.joinRoom",
+						`edit-user.${this.userId}`
+					);
+
+					this.socket.on(
+						"event:user.removed",
+						res => {
+							if (res.data.userId === this.userId)
+								this.closeModal("editUser");
+						},
+						{ modal: "editUser" }
+					);
 				} else {
 					new Toast("User with that ID not found");
 					this.closeModal("editUser");
@@ -207,6 +238,29 @@ export default {
 				}
 			);
 		},
+		resendVerificationEmail() {
+			this.socket.dispatch(
+				`users.resendVerifyEmail`,
+				this.user._id,
+				res => {
+					new Toast(res.message);
+				}
+			);
+		},
+		requestPasswordReset() {
+			this.socket.dispatch(
+				`users.adminRequestPasswordReset`,
+				this.user._id,
+				res => {
+					new Toast(res.message);
+				}
+			);
+		},
+		removeAccount() {
+			this.socket.dispatch(`users.adminRemove`, this.user._id, res => {
+				new Toast(res.message);
+			});
+		},
 		removeSessions() {
 			this.socket.dispatch(`users.removeSessions`, this.user._id, res => {
 				new Toast(res.message);

文件差异内容过多而无法显示
+ 526 - 360
frontend/src/components/modals/ImportAlbum.vue


+ 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 -->

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

@@ -113,7 +113,7 @@
 								v-tippy="{ theme: 'info' }"
 								>play_disabled</i
 							>
-							<confirm
+							<quick-confirm
 								v-if="
 									isPartyMode() &&
 									isSelected(featuredPlaylist._id)
@@ -129,8 +129,8 @@
 								>
 									stop
 								</i>
-							</confirm>
-							<confirm
+							</quick-confirm>
+							<quick-confirm
 								v-if="
 									isOwnerOrAdmin() &&
 									isPlaylistMode() &&
@@ -147,7 +147,7 @@
 								>
 									stop
 								</i>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="
 									isPartyMode() &&
@@ -173,7 +173,7 @@
 								v-tippy
 								>play_arrow</i
 							>
-							<confirm
+							<quick-confirm
 								v-if="
 									isOwnerOrAdmin() &&
 									!isExcluded(featuredPlaylist._id)
@@ -188,8 +188,8 @@
 									v-tippy
 									>block</i
 								>
-							</confirm>
-							<confirm
+							</quick-confirm>
+							<quick-confirm
 								v-if="
 									isOwnerOrAdmin() &&
 									isExcluded(featuredPlaylist._id)
@@ -205,7 +205,7 @@
 								>
 									stop
 								</i>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="featuredPlaylist.createdBy === myUserId"
 								@click="showPlaylist(featuredPlaylist._id)"
@@ -312,7 +312,7 @@
 								v-tippy="{ theme: 'info' }"
 								>play_disabled</i
 							>
-							<confirm
+							<quick-confirm
 								v-if="isPartyMode() && isSelected(playlist._id)"
 								@confirm="deselectPartyPlaylist(playlist._id)"
 							>
@@ -323,8 +323,8 @@
 								>
 									stop
 								</i>
-							</confirm>
-							<confirm
+							</quick-confirm>
+							<quick-confirm
 								v-if="
 									isOwnerOrAdmin() &&
 									isPlaylistMode() &&
@@ -339,7 +339,7 @@
 								>
 									stop
 								</i>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="
 									isPartyMode() &&
@@ -365,7 +365,7 @@
 								v-tippy
 								>play_arrow</i
 							>
-							<confirm
+							<quick-confirm
 								v-if="
 									isOwnerOrAdmin() &&
 									!isExcluded(playlist._id)
@@ -378,8 +378,8 @@
 									v-tippy
 									>block</i
 								>
-							</confirm>
-							<confirm
+							</quick-confirm>
+							<quick-confirm
 								v-if="
 									isOwnerOrAdmin() && isExcluded(playlist._id)
 								"
@@ -392,7 +392,7 @@
 								>
 									stop
 								</i>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="playlist.createdBy === myUserId"
 								@click="showPlaylist(playlist._id)"
@@ -529,7 +529,7 @@
 										v-tippy
 										>play_arrow</i
 									>
-									<confirm
+									<quick-confirm
 										v-if="
 											isPartyMode() &&
 											isSelected(element._id)
@@ -544,8 +544,8 @@
 											v-tippy
 											>stop</i
 										>
-									</confirm>
-									<confirm
+									</quick-confirm>
+									<quick-confirm
 										v-if="
 											isPlaylistMode() &&
 											isOwnerOrAdmin() &&
@@ -561,8 +561,8 @@
 											v-tippy
 											>stop</i
 										>
-									</confirm>
-									<confirm
+									</quick-confirm>
+									<quick-confirm
 										v-if="
 											isOwnerOrAdmin() &&
 											!isExcluded(element._id)
@@ -577,8 +577,8 @@
 											v-tippy
 											>block</i
 										>
-									</confirm>
-									<confirm
+									</quick-confirm>
+									<quick-confirm
 										v-if="
 											isOwnerOrAdmin() &&
 											isExcluded(element._id)
@@ -594,7 +594,7 @@
 										>
 											stop
 										</i>
-									</confirm>
+									</quick-confirm>
 									<i
 										@click="showPlaylist(element._id)"
 										class="material-icons edit-icon"
@@ -631,7 +631,7 @@
 						</template>
 
 						<template #actions>
-							<confirm
+							<quick-confirm
 								v-if="isOwnerOrAdmin()"
 								@confirm="deselectPartyPlaylist(playlist._id)"
 							>
@@ -642,8 +642,8 @@
 								>
 									stop
 								</i>
-							</confirm>
-							<confirm
+							</quick-confirm>
+							<quick-confirm
 								v-if="isOwnerOrAdmin()"
 								@confirm="blacklistPlaylist(playlist._id)"
 							>
@@ -653,7 +653,7 @@
 									v-tippy
 									>block</i
 								>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="playlist.createdBy === myUserId"
 								@click="showPlaylist(playlist._id)"
@@ -703,7 +703,7 @@
 						</template>
 
 						<template #actions>
-							<confirm
+							<quick-confirm
 								v-if="isOwnerOrAdmin()"
 								@confirm="removeIncludedPlaylist(playlist._id)"
 							>
@@ -714,8 +714,8 @@
 								>
 									stop
 								</i>
-							</confirm>
-							<confirm
+							</quick-confirm>
+							<quick-confirm
 								v-if="isOwnerOrAdmin()"
 								@confirm="blacklistPlaylist(playlist._id)"
 							>
@@ -725,7 +725,7 @@
 									v-tippy
 									>block</i
 								>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="playlist.createdBy === myUserId"
 								@click="showPlaylist(playlist._id)"
@@ -774,7 +774,7 @@
 						</template>
 
 						<template #actions>
-							<confirm
+							<quick-confirm
 								@confirm="removeExcludedPlaylist(playlist._id)"
 							>
 								<i
@@ -784,7 +784,7 @@
 									v-tippy
 									>stop</i
 								>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="playlist.createdBy === userId"
 								@click="showPlaylist(playlist._id)"
@@ -817,14 +817,14 @@ import Toast from "toasters";
 import ws from "@/ws";
 
 import PlaylistItem from "@/components/PlaylistItem.vue";
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 
 import SortablePlaylists from "@/mixins/SortablePlaylists.vue";
 
 export default {
 	components: {
 		PlaylistItem,
-		Confirm
+		QuickConfirm
 	},
 	mixins: [SortablePlaylists],
 	data() {
@@ -886,7 +886,7 @@ export default {
 	},
 	methods: {
 		init() {
-			this.socket.dispatch("playlists.indexMyPlaylists", true, res => {
+			this.socket.dispatch("playlists.indexMyPlaylists", res => {
 				if (res.status === "success")
 					this.setPlaylists(res.data.playlists);
 				this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database

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

@@ -560,7 +560,7 @@ export default {
 		display: flex;
 		flex-direction: column;
 
-		* >>> .tippy-box[data-theme~="addToPlaylist"] .tippy-content > span {
+		* >>> .tippy-box[data-theme~="dropdown"] .tippy-content > span {
 			max-width: 150px !important;
 		}
 

+ 80 - 64
frontend/src/components/modals/ManageStation/index.vue

@@ -2,14 +2,16 @@
 	<modal
 		v-if="station"
 		:title="
-			!isOwnerOrAdmin() && station.partyMode
+			sector === 'home' && !isOwnerOrAdmin()
+				? 'View Queue'
+				: !isOwnerOrAdmin() && station.partyMode
 				? 'Add Song to Queue'
 				: 'Manage Station'
 		"
 		:style="`--primary-color: var(--${station.theme})`"
 		class="manage-station-modal"
-		:wide="true"
-		:split="true"
+		:size="isOwnerOrAdmin() || sector !== 'home' ? 'wide' : null"
+		:split="isOwnerOrAdmin() || sector !== 'home'"
 	>
 		<template #body v-if="station && station._id">
 			<div class="left-section">
@@ -44,11 +46,11 @@
 							<p>{{ station.description }}</p>
 						</div>
 
-						<div id="admin-buttons" v-if="isOwnerOrAdmin()">
+						<div id="admin-buttons">
 							<!-- (Admin) Pause/Resume Button -->
 							<button
+								v-if="isOwnerOrAdmin() && stationPaused"
 								class="button is-danger"
-								v-if="stationPaused"
 								@click="resumeStation()"
 							>
 								<i class="material-icons icon-with-button"
@@ -57,9 +59,9 @@
 								<span> Resume Station </span>
 							</button>
 							<button
+								v-if="isOwnerOrAdmin() && !stationPaused"
 								class="button is-danger"
 								@click="pauseStation()"
-								v-else
 							>
 								<i class="material-icons icon-with-button"
 									>pause</i
@@ -69,6 +71,7 @@
 
 							<!-- (Admin) Skip Button -->
 							<button
+								v-if="isOwnerOrAdmin()"
 								class="button is-danger"
 								@click="skipStation()"
 							>
@@ -90,50 +93,52 @@
 							</router-link>
 						</div>
 					</div>
-					<div class="tab-selection">
-						<button
+					<div v-if="isOwnerOrAdmin() || sector !== 'home'">
+						<div class="tab-selection">
+							<button
+								v-if="isOwnerOrAdmin()"
+								class="button is-default"
+								:class="{ selected: tab === 'settings' }"
+								ref="settings-tab"
+								@click="showTab('settings')"
+							>
+								Settings
+							</button>
+							<button
+								v-if="isAllowedToParty() || isOwnerOrAdmin()"
+								class="button is-default"
+								:class="{ selected: tab === 'playlists' }"
+								ref="playlists-tab"
+								@click="showTab('playlists')"
+							>
+								Playlists
+							</button>
+							<button
+								v-if="isAllowedToParty() || isOwnerOrAdmin()"
+								class="button is-default"
+								:class="{ selected: tab === 'songs' }"
+								ref="songs-tab"
+								@click="showTab('songs')"
+							>
+								Songs
+							</button>
+						</div>
+						<settings
 							v-if="isOwnerOrAdmin()"
-							class="button is-default"
-							:class="{ selected: tab === 'settings' }"
-							ref="settings-tab"
-							@click="showTab('settings')"
-						>
-							Settings
-						</button>
-						<button
+							class="tab"
+							v-show="tab === 'settings'"
+						/>
+						<playlists
 							v-if="isAllowedToParty() || isOwnerOrAdmin()"
-							class="button is-default"
-							:class="{ selected: tab === 'playlists' }"
-							ref="playlists-tab"
-							@click="showTab('playlists')"
-						>
-							Playlists
-						</button>
-						<button
+							class="tab"
+							v-show="tab === 'playlists'"
+						/>
+						<songs
 							v-if="isAllowedToParty() || isOwnerOrAdmin()"
-							class="button is-default"
-							:class="{ selected: tab === 'songs' }"
-							ref="songs-tab"
-							@click="showTab('songs')"
-						>
-							Songs
-						</button>
+							class="tab"
+							v-show="tab === 'songs'"
+						/>
 					</div>
-					<settings
-						v-if="isOwnerOrAdmin()"
-						class="tab"
-						v-show="tab === 'settings'"
-					/>
-					<playlists
-						v-if="isAllowedToParty() || isOwnerOrAdmin()"
-						class="tab"
-						v-show="tab === 'playlists'"
-					/>
-					<songs
-						v-if="isAllowedToParty() || isOwnerOrAdmin()"
-						class="tab"
-						v-show="tab === 'songs'"
-					/>
 				</div>
 			</div>
 			<div class="right-section">
@@ -166,14 +171,14 @@
 				<span> Request Song </span>
 			</button>
 			<div v-if="isOwnerOrAdmin()" class="right">
-				<confirm @confirm="clearAndRefillStationQueue()">
+				<quick-confirm @confirm="clearAndRefillStationQueue()">
 					<a class="button is-danger">
 						Clear and refill station queue
 					</a>
-				</confirm>
-				<confirm @confirm="removeStation()">
+				</quick-confirm>
+				<quick-confirm @confirm="removeStation()">
 					<button class="button is-danger">Delete station</button>
-				</confirm>
+				</quick-confirm>
 			</div>
 		</template>
 	</modal>
@@ -184,7 +189,7 @@ import { mapState, mapGetters, mapActions } from "vuex";
 
 import Toast from "toasters";
 
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 import Queue from "@/components/Queue.vue";
 import SongItem from "@/components/SongItem.vue";
 import Modal from "../../Modal.vue";
@@ -196,7 +201,7 @@ import Songs from "./Tabs/Songs.vue";
 export default {
 	components: {
 		Modal,
-		Confirm,
+		QuickConfirm,
 		Queue,
 		SongItem,
 		Settings,
@@ -423,34 +428,46 @@ export default {
 		});
 
 		this.socket.on(
-			"event:station.queue.updated",
-			res => this.updateSongsList(res.data.queue),
+			"event:manageStation.queue.updated",
+			res => {
+				if (res.data.stationId === this.station._id)
+					this.updateSongsList(res.data.queue);
+			},
 			{ modal: "manageStation" }
 		);
 
 		this.socket.on(
-			"event:station.queue.song.repositioned",
-			res => this.repositionSongInList(res.data.song),
+			"event:manageStation.queue.song.repositioned",
+			res => {
+				if (res.data.stationId === this.station._id)
+					this.repositionSongInList(res.data.song);
+			},
 			{ modal: "manageStation" }
 		);
 
 		this.socket.on(
 			"event:station.pause",
-			() => this.updateStationPaused(true),
+			res => {
+				if (res.data.stationId === this.station._id)
+					this.updateStationPaused(true);
+			},
 			{ modal: "manageStation" }
 		);
 
 		this.socket.on(
 			"event:station.resume",
-			() => this.updateStationPaused(false),
+			res => {
+				if (res.data.stationId === this.station._id)
+					this.updateStationPaused(false);
+			},
 			{ modal: "manageStation" }
 		);
 
 		this.socket.on(
 			"event:station.nextSong",
 			res => {
-				const { currentSong } = res.data;
-				this.updateCurrentSong(currentSong || {});
+				if (res.data.stationId === this.station._id)
+					this.updateCurrentSong(res.data.currentSong || {});
 			},
 			{ modal: "manageStation" }
 		);
@@ -687,10 +704,6 @@ export default {
 	height: 100%;
 
 	.left-section {
-		.section:first-child {
-			padding: 0 15px 15px !important;
-		}
-
 		#about-station-container {
 			padding: 20px;
 			display: flex;
@@ -803,5 +816,8 @@ export default {
 			}
 		}
 	}
+	&.modal-wide .left-section .section:first-child {
+		padding: 0 15px 15px !important;
+	}
 }
 </style>

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

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

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

@@ -162,12 +162,12 @@
 				</p>
 
 				<div class="content-box-inputs">
-					<confirm placement="right" @confirm="remove()">
+					<quick-confirm placement="right" @confirm="remove()">
 						<button class="button">
 							<i class="material-icons">delete</i>
 							&nbsp;Remove Account
 						</button>
-					</confirm>
+					</quick-confirm>
 				</div>
 			</div>
 		</template>
@@ -179,11 +179,11 @@ import { mapActions, mapGetters } from "vuex";
 
 import Toast from "toasters";
 
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 import Modal from "../Modal.vue";
 
 export default {
-	components: { Modal, Confirm },
+	components: { Modal, QuickConfirm },
 	data() {
 		return {
 			name: "RemoveAccount",

+ 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";

+ 9 - 5
frontend/src/main.js

@@ -3,13 +3,15 @@ import { createApp } from "vue";
 
 import VueTippy, { Tippy } from "vue-tippy";
 import { createRouter, createWebHistory } from "vue-router";
+import "lofig";
 
 import ws from "@/ws";
+import ms from "@/ms";
 import store from "./store";
 
 import AppComponent from "./App.vue";
 
-const REQUIRED_CONFIG_VERSION = 8;
+const REQUIRED_CONFIG_VERSION = 9;
 
 const handleMetadata = attrs => {
 	document.title = `Musare | ${attrs.title}`;
@@ -174,14 +176,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();
 		};
 
@@ -220,6 +222,8 @@ lofig.folder = "../config/default.json";
 	const websocketsDomain = await lofig.get("backend.websocketsDomain");
 	ws.init(websocketsDomain);
 
+	if (await lofig.get("siteSettings.mediasession")) ms.init();
+
 	ws.socket.on("ready", res => {
 		const { loggedIn, role, username, userId, email } = res.data;
 
@@ -236,7 +240,7 @@ lofig.folder = "../config/default.json";
 		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)
 	);
 

+ 0 - 51
frontend/src/mixins/ScrollAndFetchHandler.vue

@@ -1,51 +0,0 @@
-<script>
-export default {
-	data() {
-		return {
-			position: 1,
-			maxPosition: 1,
-			isGettingSet: false,
-			loadAllSongs: false,
-			interval: null
-		};
-	},
-	computed: {
-		setsLoaded() {
-			return this.position - 1;
-		},
-		maxSets() {
-			return this.maxPosition - 1;
-		}
-	},
-	mounted() {
-		window.addEventListener("scroll", this.handleScroll);
-	},
-	unmounted() {
-		clearInterval(this.interval);
-		window.removeEventListener("scroll", this.handleScroll);
-	},
-	methods: {
-		handleScroll() {
-			const scrollPosition = document.body.clientHeight + window.scrollY;
-			const bottomPosition = document.body.scrollHeight;
-
-			if (this.loadAllSongs) return false;
-
-			if (scrollPosition + 400 >= bottomPosition) this.getSet();
-
-			return this.maxPosition === this.position;
-		},
-		loadAll() {
-			this.loadAllSongs = true;
-			this.interval = setInterval(() => {
-				if (this.loadAllSongs && this.maxPosition > this.position)
-					this.getSet();
-				else {
-					clearInterval(this.interval);
-					this.loadAllSongs = false;
-				}
-			}, 500);
-		}
-	}
-};
-</script>

+ 2 - 2
frontend/src/mixins/SearchMusare.vue

@@ -23,7 +23,7 @@ export default {
 		}
 	},
 	methods: {
-		searchForMusareSongs(page) {
+		searchForMusareSongs(page, toast = true) {
 			if (
 				this.musareSearch.page >= page ||
 				this.musareSearch.searchedQuery !== this.musareSearch.query
@@ -65,7 +65,7 @@ export default {
 						this.musareSearch.count = 0;
 						this.musareSearch.resultsLeft = 0;
 						this.musareSearch.pageSize = 0;
-						new Toast(res.message);
+						if (toast) new Toast(res.message);
 					}
 				}
 			);

+ 102 - 0
frontend/src/ms.js

@@ -0,0 +1,102 @@
+/* global MediaMetadata */
+
+export default {
+	mediaSessionData: {},
+	listeners: {},
+	audio: null,
+	ytReady: false,
+	playSuccessful: false,
+	loopInterval: null,
+	setYTReady(ytReady) {
+		if (ytReady)
+			setTimeout(() => {
+				this.ytReady = true;
+			}, 1000);
+		else this.ytReady = false;
+	},
+	setListeners(priority, listeners) {
+		this.listeners[priority] = listeners;
+	},
+	removeListeners(priority) {
+		delete this.listeners[priority];
+	},
+	setMediaSessionData(priority, playing, title, artist, album, artwork) {
+		this.mediaSessionData[priority] = {
+			playing, // True = playing, false = paused
+			mediaMetadata: new MediaMetadata({
+				title,
+				artist,
+				album,
+				artwork: [{ src: artwork }]
+			})
+		};
+	},
+	removeMediaSessionData(priority) {
+		delete this.mediaSessionData[priority];
+	},
+	// Gets the highest priority media session data and updates the media session
+	updateMediaSession() {
+		const highestPriority = this.getHighestPriority();
+
+		if (typeof highestPriority === "number") {
+			const mediaSessionDataObject =
+				this.mediaSessionData[highestPriority];
+			navigator.mediaSession.metadata =
+				mediaSessionDataObject.mediaMetadata;
+
+			if (
+				mediaSessionDataObject.playing ||
+				!this.ytReady ||
+				!this.playSuccessful
+			) {
+				navigator.mediaSession.playbackState = "playing";
+				this.audio
+					.play()
+					.then(() => {
+						if (this.audio.currentTime > 1.0) {
+							this.audio.muted = true;
+						}
+						this.playSuccessful = true;
+					})
+					.catch(() => {
+						this.playSuccessful = false;
+					});
+			} else {
+				this.audio.pause();
+				navigator.mediaSession.playbackState = "paused";
+			}
+		} else {
+			this.audio.pause();
+			navigator.mediaSession.playbackState = "none";
+			navigator.mediaSession.metadata = null;
+		}
+	},
+	getHighestPriority() {
+		return Object.keys(this.mediaSessionData)
+			.map(priority => Number(priority))
+			.sort((a, b) => a > b)
+			.reverse()[0];
+	},
+	init() {
+		this.audio = new Audio("/assets/15-seconds-of-silence.mp3");
+
+		this.audio.loop = true;
+		this.audio.volume = 0.1;
+
+		navigator.mediaSession.setActionHandler("play", () => {
+			this.listeners[this.getHighestPriority()].play();
+		});
+
+		navigator.mediaSession.setActionHandler("pause", () => {
+			this.listeners[this.getHighestPriority()].pause();
+		});
+
+		navigator.mediaSession.setActionHandler("nexttrack", () => {
+			this.listeners[this.getHighestPriority()].nexttrack();
+		});
+
+		this.loopInterval = setInterval(() => {
+			this.updateMediaSession();
+		}, 100);
+	}
+};

+ 177 - 71
frontend/src/pages/Admin/index.vue

@@ -4,42 +4,13 @@
 		<div class="tabs is-centered">
 			<ul>
 				<li
-					:class="{ 'is-active': currentTab == 'hiddensongs' }"
-					ref="hiddensongs-tab"
-					@click="showTab('hiddensongs')"
+					:class="{ 'is-active': currentTab == 'songs' }"
+					ref="songs-tab"
+					@click="showTab('songs')"
 				>
-					<router-link
-						class="tab hiddensongs"
-						to="/admin/hiddensongs"
-					>
+					<router-link class="tab songs" to="/admin/songs">
 						<i class="material-icons">music_note</i>
-						<span>&nbsp;Hidden Songs</span>
-					</router-link>
-				</li>
-				<li
-					:class="{ 'is-active': currentTab == 'unverifiedsongs' }"
-					ref="unverifiedsongs-tab"
-					@click="showTab('unverifiedsongs')"
-				>
-					<router-link
-						class="tab unverifiedsongs"
-						to="/admin/unverifiedsongs"
-					>
-						<i class="material-icons">unpublished</i>
-						<span>&nbsp;Unverified Songs</span>
-					</router-link>
-				</li>
-				<li
-					:class="{ 'is-active': currentTab == 'verifiedsongs' }"
-					ref="verifiedsongs-tab"
-					@click="showTab('verifiedsongs')"
-				>
-					<router-link
-						class="tab verifiedsongs"
-						to="/admin/verifiedsongs"
-					>
-						<i class="material-icons">check_circle</i>
-						<span>&nbsp;Verified Songs</span>
+						<span>&nbsp;Songs</span>
 					</router-link>
 				</li>
 				<li
@@ -118,16 +89,88 @@
 			</ul>
 		</div>
 
-		<unverified-songs v-if="currentTab == 'unverifiedsongs'" />
-		<verified-songs v-if="currentTab == 'verifiedsongs'" />
-		<hidden-songs v-if="currentTab == 'hiddensongs'" />
-		<stations v-if="currentTab == 'stations'" />
-		<playlists v-if="currentTab == 'playlists'" />
-		<reports v-if="currentTab == 'reports'" />
-		<news v-if="currentTab == 'news'" />
-		<users v-if="currentTab == 'users'" />
-		<statistics v-if="currentTab == 'statistics'" />
-		<punishments v-if="currentTab == 'punishments'" />
+		<div class="admin-container">
+			<songs v-if="currentTab == 'songs'" />
+			<stations v-if="currentTab == 'stations'" />
+			<playlists v-if="currentTab == 'playlists'" />
+			<reports v-if="currentTab == 'reports'" />
+			<news v-if="currentTab == 'news'" />
+			<users v-if="currentTab == 'users'" />
+			<statistics v-if="currentTab == 'statistics'" />
+			<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>
 
@@ -135,20 +178,18 @@
 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,
-		UnverifiedSongs: defineAsyncComponent(() =>
-			import("./tabs/UnverifiedSongs.vue")
-		),
-		VerifiedSongs: defineAsyncComponent(() =>
-			import("./tabs/VerifiedSongs.vue")
-		),
-		HiddenSongs: defineAsyncComponent(() =>
-			import("./tabs/HiddenSongs.vue")
-		),
+		MainFooter,
+		FloatingBox,
+		Songs: defineAsyncComponent(() => import("./tabs/Songs.vue")),
 		Stations: defineAsyncComponent(() => import("./tabs/Stations.vue")),
 		Playlists: defineAsyncComponent(() => import("./tabs/Playlists.vue")),
 		Reports: defineAsyncComponent(() => import("./tabs/Reports.vue")),
@@ -174,21 +215,49 @@ 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/unverifiedsongs":
-					this.showTab("unverifiedsongs");
-					break;
-				case "/admin/verifiedsongs":
-					this.showTab("verifiedsongs");
-					break;
-				case "/admin/hiddensongs":
-					this.showTab("hiddensongs");
+				case "/admin/songs":
+					this.showTab("songs");
 					break;
 				case "/admin/stations":
 					this.showTab("stations");
@@ -212,7 +281,17 @@ export default {
 					this.showTab("punishments");
 					break;
 				default:
-					this.showTab("verifiedsongs");
+					if (path.startsWith("/admin")) {
+						if (localStorage.getItem("lastAdminPage")) {
+							this.$router.push(
+								`/admin/${localStorage.getItem(
+									"lastAdminPage"
+								)}`
+							);
+						} else {
+							this.$router.push(`/admin/songs`);
+						}
+					}
 			}
 		},
 		showTab(tab) {
@@ -222,6 +301,13 @@ export default {
 					block: "nearest"
 				});
 			this.currentTab = tab;
+			localStorage.setItem("lastAdminPage", tab);
+		},
+		toggleKeyboardShortcutsHelper() {
+			this.$refs.keyboardShortcutsHelper.toggleBox();
+		},
+		resetKeyboardShortcutsHelper() {
+			this.$refs.keyboardShortcutsHelper.resetBox();
 		}
 	}
 };
@@ -232,6 +318,7 @@ export default {
 	top: 102px !important;
 }
 
+.main-container .admin-tab,
 .main-container .container {
 	.button-row {
 		display: flex;
@@ -249,6 +336,12 @@ export default {
 		}
 	}
 }
+
+.main-container .admin-container .admin-tab {
+	max-width: 1900px;
+	margin: 0 auto;
+	padding: 0 10px;
+}
 </style>
 
 <style lang="scss" scoped>
@@ -278,6 +371,11 @@ export default {
 
 .main-container {
 	height: auto;
+
+	.admin-container {
+		flex: 1 0 auto;
+		margin-bottom: 20px;
+	}
 }
 
 .tabs {
@@ -301,18 +399,10 @@ export default {
 		border-bottom: 1px solid var(--light-grey-2);
 	}
 
-	.unverifiedsongs {
-		color: var(--teal);
-		border-color: var(--teal);
-	}
-	.verifiedsongs {
+	.songs {
 		color: var(--primary-color);
 		border-color: var(--primary-color);
 	}
-	.hiddensongs {
-		color: var(--grey);
-		border-color: var(--grey);
-	}
 	.stations {
 		color: var(--purple);
 		border-color: var(--purple);
@@ -360,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;

+ 0 - 616
frontend/src/pages/Admin/tabs/HiddenSongs.vue

@@ -1,616 +0,0 @@
-<template>
-	<div>
-		<page-metadata title="Admin | Hidden songs" />
-		<div class="container">
-			<div class="button-row">
-				<button
-					v-if="!loadAllSongs"
-					class="button is-primary"
-					@click="loadAll()"
-				>
-					Load all sets
-				</button>
-				<button
-					class="button is-primary"
-					@click="toggleKeyboardShortcutsHelper"
-					@dblclick="resetKeyboardShortcutsHelper"
-				>
-					Keyboard shortcuts helper
-				</button>
-				<button
-					class="button is-primary"
-					@click="openModal('requestSong')"
-				>
-					Request song
-				</button>
-			</div>
-			<br />
-			<div class="box">
-				<p @click="toggleSearchBox()">
-					Search<i class="material-icons" v-show="searchBoxShown"
-						>expand_more</i
-					>
-					<i class="material-icons" v-show="!searchBoxShown"
-						>expand_less</i
-					>
-				</p>
-				<input
-					v-model="searchQuery"
-					type="text"
-					class="input"
-					placeholder="Search for Songs"
-					v-show="searchBoxShown"
-				/>
-			</div>
-			<div class="box">
-				<p @click="toggleFilterArtistsBox()">
-					Filter artists<i
-						class="material-icons"
-						v-show="filterArtistBoxShown"
-						>expand_more</i
-					>
-					<i class="material-icons" v-show="!filterArtistBoxShown"
-						>expand_less</i
-					>
-				</p>
-				<input
-					type="text"
-					class="input"
-					placeholder="Filter artist checkboxes"
-					v-model="artistFilterQuery"
-					v-show="filterArtistBoxShown"
-				/>
-				<label
-					v-for="artist in filteredArtists"
-					:key="artist"
-					v-show="filterArtistBoxShown"
-				>
-					<input
-						type="checkbox"
-						:checked="artistFilterSelected.indexOf(artist) !== -1"
-						@click="toggleArtistSelected(artist)"
-					/>
-					<span>{{ artist }}</span>
-				</label>
-			</div>
-			<div class="box">
-				<p @click="toggleFilterGenresBox()">
-					Filter genres<i
-						class="material-icons"
-						v-show="filterGenreBoxShown"
-						>expand_more</i
-					>
-					<i class="material-icons" v-show="!filterGenreBoxShown"
-						>expand_less</i
-					>
-				</p>
-				<input
-					type="text"
-					class="input"
-					placeholder="Filter genre checkboxes"
-					v-model="genreFilterQuery"
-					v-show="filterGenreBoxShown"
-				/>
-				<label
-					v-for="genre in filteredGenres"
-					:key="genre"
-					v-show="filterGenreBoxShown"
-				>
-					<input
-						type="checkbox"
-						:checked="genreFilterSelected.indexOf(genre) !== -1"
-						@click="toggleGenreSelected(genre)"
-					/>
-					<span>{{ genre }}</span>
-				</label>
-			</div>
-			<p>
-				<span>Sets loaded: {{ setsLoaded }} / {{ maxSets }}</span>
-				<br />
-				<span>Loaded songs: {{ songs.length }}</span>
-			</p>
-			<br />
-			<table class="table">
-				<thead>
-					<tr>
-						<td>Thumbnail</td>
-						<td>Title</td>
-						<td>Artists</td>
-						<td>Genres</td>
-						<td>ID / YouTube ID</td>
-						<td>Requested By</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr
-						v-for="(song, index) in filteredSongs"
-						:key="song._id"
-						tabindex="0"
-						@keydown.up.prevent
-						@keydown.down.prevent
-						@keyup.up="selectPrevious($event)"
-						@keyup.down="selectNext($event)"
-						@keyup.e="edit(song, index)"
-						@keyup.a="add(song)"
-						@keyup.x="remove(song._id, index)"
-					>
-						<td>
-							<img
-								class="song-thumbnail"
-								:src="song.thumbnail"
-								onerror="this.src='/assets/notes-transparent.png'"
-							/>
-						</td>
-						<td>
-							<strong>{{ song.title }}</strong>
-						</td>
-						<td>{{ song.artists.join(", ") }}</td>
-						<td>{{ song.genres.join(", ") }}</td>
-						<td>
-							{{ song._id }}
-							<br />
-							<a
-								:href="
-									'https://www.youtube.com/watch?v=' +
-									`${song.youtubeId}`
-								"
-								target="_blank"
-							>
-								{{ song.youtubeId }}</a
-							>
-						</td>
-						<td>
-							<user-id-to-username
-								:user-id="song.requestedBy"
-								:link="true"
-							/>
-						</td>
-						<td class="optionsColumn">
-							<div>
-								<button
-									class="button is-primary"
-									@click="edit(song, index)"
-									content="Edit Song"
-									v-tippy
-								>
-									<i class="material-icons">edit</i>
-								</button>
-								<button
-									class="button is-success"
-									@click="unhide(song._id)"
-									content="Unhide Song"
-									v-tippy
-								>
-									<i class="material-icons">visibility</i>
-								</button>
-							</div>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
-		<import-album v-if="modals.importAlbum" />
-		<edit-song v-if="modals.editSong" song-type="songs" :key="song._id" />
-		<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>Hidden songs page</b></span>
-						<span
-							><b>Arrow keys up/down</b> - Moves between
-							songs</span
-						>
-						<span><b>E</b> - Edit selected song</span>
-						<span><b>A</b> - Add selected song</span>
-						<span><b>X</b> - Delete selected song</span>
-					</div>
-					<hr />
-					<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 />
-						<span class="bigger"><b>Player controls</b></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 />
-						<span class="bigger"><b>Form control</b></span>
-						<span
-							><b>Ctrl + D</b> - Executes purple button in that
-							input</span
-						>
-						<span
-							><b>Ctrl + Alt + D</b> - Fill in all Discogs
-							fields</span
-						>
-						<span
-							><b>Ctrl + R</b> - Executes red button in that
-							input</span
-						>
-						<span
-							><b>Ctrl + Alt + R</b> - Reset duration field</span
-						>
-						<hr />
-						<span class="bigger"><b>Modal control</b></span>
-						<span><b>Ctrl + S</b> - Save</span>
-						<span><b>Ctrl + X</b> - Exit</span>
-					</div>
-				</div>
-			</template>
-		</floating-box>
-	</div>
-</template>
-
-<script>
-import { mapState, mapActions, mapGetters } from "vuex";
-import { defineAsyncComponent } from "vue";
-
-import Toast from "toasters";
-
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
-import FloatingBox from "@/components/FloatingBox.vue";
-
-import ScrollAndFetchHandler from "@/mixins/ScrollAndFetchHandler.vue";
-
-import ws from "@/ws";
-
-export default {
-	components: {
-		EditSong: defineAsyncComponent(() =>
-			import("@/components/modals/EditSong")
-		),
-		Report: defineAsyncComponent(() =>
-			import("@/components/modals/Report.vue")
-		),
-		ImportAlbum: defineAsyncComponent(() =>
-			import("@/components/modals/ImportAlbum.vue")
-		),
-		RequestSong: defineAsyncComponent(() =>
-			import("@/components/modals/RequestSong.vue")
-		),
-		UserIdToUsername,
-		FloatingBox
-	},
-	mixins: [ScrollAndFetchHandler],
-	data() {
-		return {
-			searchQuery: "",
-			artistFilterQuery: "",
-			artistFilterSelected: [],
-			genreFilterQuery: "",
-			genreFilterSelected: [],
-			searchBoxShown: true,
-			filterArtistBoxShown: false,
-			filterGenreBoxShown: false
-		};
-	},
-	computed: {
-		filteredSongs() {
-			return this.songs.filter(
-				song =>
-					JSON.stringify(Object.values(song))
-						.toLowerCase()
-						.indexOf(this.searchQuery.toLowerCase()) !== -1 &&
-					(this.artistFilterSelected.length === 0 ||
-						song.artists.some(
-							artist =>
-								this.artistFilterSelected
-									.map(artistFilterSelected =>
-										artistFilterSelected.toLowerCase()
-									)
-									.indexOf(artist.toLowerCase()) !== -1
-						)) &&
-					(this.genreFilterSelected.length === 0 ||
-						song.genres.some(
-							genre =>
-								this.genreFilterSelected
-									.map(genreFilterSelected =>
-										genreFilterSelected.toLowerCase()
-									)
-									.indexOf(genre.toLowerCase()) !== -1
-						))
-			);
-		},
-		artists() {
-			const artists = [];
-			this.songs.forEach(song => {
-				song.artists.forEach(artist => {
-					if (artists.indexOf(artist) === -1) artists.push(artist);
-				});
-			});
-			return artists.sort();
-		},
-		filteredArtists() {
-			return this.artists
-				.filter(
-					artist =>
-						this.artistFilterSelected.indexOf(artist) !== -1 ||
-						artist
-							.toLowerCase()
-							.indexOf(this.artistFilterQuery.toLowerCase()) !==
-							-1
-				)
-				.sort(
-					(a, b) =>
-						(this.artistFilterSelected.indexOf(a) === -1 ? 1 : 0) -
-						(this.artistFilterSelected.indexOf(b) === -1 ? 1 : 0)
-				);
-		},
-		genres() {
-			const genres = [];
-			this.songs.forEach(song => {
-				song.genres.forEach(genre => {
-					if (genres.indexOf(genre) === -1) genres.push(genre);
-				});
-			});
-			return genres.sort();
-		},
-		filteredGenres() {
-			return this.genres
-				.filter(
-					genre =>
-						this.genreFilterSelected.indexOf(genre) !== -1 ||
-						genre
-							.toLowerCase()
-							.indexOf(this.genreFilterQuery.toLowerCase()) !== -1
-				)
-				.sort(
-					(a, b) =>
-						(this.genreFilterSelected.indexOf(a) === -1 ? 1 : 0) -
-						(this.genreFilterSelected.indexOf(b) === -1 ? 1 : 0)
-				);
-		},
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		}),
-		...mapState("admin/hiddenSongs", {
-			songs: state => state.songs
-		}),
-		...mapState("modals/editSong", {
-			song: state => state.song
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	mounted() {
-		ws.onConnect(this.init);
-
-		this.socket.on("event:admin.hiddenSong.created", res => {
-			console.log("CREATED");
-			this.addSong(res.data.song);
-		});
-
-		this.socket.on("event:admin.hiddenSong.deleted", res => {
-			this.removeSong(res.data.songId);
-		});
-
-		this.socket.on("event:admin.hiddenSong.updated", res => {
-			this.updateSong(res.data.song);
-		});
-	},
-	methods: {
-		edit(song) {
-			this.editSong(song);
-			this.openModal("editSong");
-		},
-		unhide(id) {
-			this.socket.dispatch("songs.unhide", id, res => {
-				new Toast(res.message);
-			});
-		},
-		getSet() {
-			if (this.isGettingSet) return;
-			if (this.position >= this.maxPosition) return;
-			this.isGettingSet = true;
-
-			this.socket.dispatch(
-				"songs.getSet",
-				this.position,
-				"hidden",
-				res => {
-					if (res.status === "success") {
-						res.data.songs.forEach(song => {
-							this.addSong(song);
-						});
-
-						this.position += 1;
-						this.isGettingSet = false;
-					}
-				}
-			);
-		},
-		selectPrevious(event) {
-			if (event.srcElement.previousElementSibling)
-				event.srcElement.previousElementSibling.focus();
-		},
-		selectNext(event) {
-			if (event.srcElement.nextElementSibling)
-				event.srcElement.nextElementSibling.focus();
-		},
-		toggleArtistSelected(artist) {
-			if (this.artistFilterSelected.indexOf(artist) === -1)
-				this.artistFilterSelected.push(artist);
-			else
-				this.artistFilterSelected.splice(
-					this.artistFilterSelected.indexOf(artist),
-					1
-				);
-		},
-		toggleGenreSelected(genre) {
-			if (this.genreFilterSelected.indexOf(genre) === -1)
-				this.genreFilterSelected.push(genre);
-			else
-				this.genreFilterSelected.splice(
-					this.genreFilterSelected.indexOf(genre),
-					1
-				);
-		},
-		toggleSearchBox() {
-			this.searchBoxShown = !this.searchBoxShown;
-		},
-		toggleFilterArtistsBox() {
-			this.filterArtistBoxShown = !this.filterArtistBoxShown;
-		},
-		toggleFilterGenresBox() {
-			this.filterGenreBoxShown = !this.filterGenreBoxShown;
-		},
-		toggleKeyboardShortcutsHelper() {
-			this.$refs.keyboardShortcutsHelper.toggleBox();
-		},
-		resetKeyboardShortcutsHelper() {
-			this.$refs.keyboardShortcutsHelper.resetBox();
-		},
-		init() {
-			this.position = 1;
-			this.maxPosition = 1;
-			this.resetSongs();
-
-			if (this.songs.length > 0)
-				this.position = Math.ceil(this.songs.length / 15) + 1;
-
-			this.socket.dispatch("songs.length", "hidden", res => {
-				if (res.status === "success") {
-					this.maxPosition = Math.ceil(res.data.length / 15) + 1;
-					return this.getSet();
-				}
-				return new Toast(`Error: ${res.mesage}`);
-			});
-
-			this.socket.dispatch("apis.joinAdminRoom", "hiddenSongs");
-		},
-		...mapActions("admin/hiddenSongs", [
-			"resetSongs",
-			"addSong",
-			"removeSong",
-			"updateSong"
-		]),
-		...mapActions("modals/editSong", ["editSong"]),
-		...mapActions("modalVisibility", ["openModal"])
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-.night-mode {
-	.box {
-		background-color: var(--dark-grey-3) !important;
-	}
-
-	.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);
-		}
-	}
-}
-
-.box {
-	background-color: var(--light-grey);
-	border-radius: 5px;
-	padding: 8px 16px;
-
-	p {
-		text-align: center;
-		font-size: 24px;
-		user-select: none;
-		cursor: pointer;
-		display: flex;
-		justify-content: center;
-		align-items: center;
-	}
-
-	input[type="text"] {
-		margin-top: 8px;
-		margin-bottom: 8px;
-	}
-
-	label {
-		margin-right: 8px;
-		display: inline-flex;
-		align-items: center;
-
-		input[type="checkbox"] {
-			margin-right: 2px;
-			height: 16px;
-			width: 16px;
-		}
-	}
-}
-
-.optionsColumn {
-	width: 140px;
-
-	div {
-		button {
-			width: 35px;
-
-			&:not(:last-child) {
-				margin-right: 5px;
-			}
-		}
-	}
-}
-
-.song-thumbnail {
-	display: block;
-	max-width: 50px;
-	margin: 0 auto;
-}
-
-td {
-	vertical-align: middle;
-}
-
-#keyboardShortcutsHelper {
-	.box-body {
-		.biggest {
-			font-size: 18px;
-		}
-
-		.bigger {
-			font-size: 16px;
-		}
-
-		span {
-			display: block;
-		}
-	}
-}
-
-.is-primary:focus {
-	background-color: var(--primary-color) !important;
-}
-</style>

+ 161 - 161
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>
-								<confirm @confirm="remove(news._id)">
-									<button class="button is-danger">
-										Remove
-									</button>
-								</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,14 +88,14 @@ import { mapActions, mapState, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 import Toast from "toasters";
 
-import ws from "@/ws";
-
-import Confirm from "@/components/Confirm.vue";
+import AdvancedTable from "@/components/AdvancedTable.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 export default {
 	components: {
-		Confirm,
+		AdvancedTable,
+		QuickConfirm,
 		UserIdToUsername,
 		EditNews: defineAsyncComponent(() =>
 			import("@/components/modals/EditNews.vue")
@@ -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>

+ 299 - 262
frontend/src/pages/Admin/tabs/Playlists.vue

@@ -1,96 +1,87 @@
 <template>
 	<div>
 		<page-metadata title="Admin | Playlists" />
-		<div class="container">
+		<div class="admin-tab">
 			<div class="button-row">
-				<confirm
-					placement="bottom"
-					@confirm="deleteOrphanedStationPlaylists()"
-				>
-					<button class="button is-danger">
-						Delete orphaned station playlists
-					</button>
-				</confirm>
-				<confirm
-					placement="bottom"
-					@confirm="deleteOrphanedGenrePlaylists()"
-				>
-					<button class="button is-danger">
-						Delete orphaned genre playlists
-					</button>
-				</confirm>
-				<confirm
-					placement="bottom"
-					@confirm="requestOrphanedPlaylistSongs()"
-				>
-					<button class="button is-danger">
-						Request orphaned playlist songs
-					</button>
-				</confirm>
-				<confirm
-					placement="bottom"
-					@confirm="clearAndRefillAllStationPlaylists()"
-				>
-					<button class="button is-danger">
-						Clear and refill all station playlists
-					</button>
-				</confirm>
-				<confirm
-					placement="bottom"
-					@confirm="clearAndRefillAllGenrePlaylists()"
-				>
-					<button class="button is-danger">
-						Clear and refill all genre playlists
-					</button>
-				</confirm>
+				<run-job-dropdown :jobs="jobs" />
 			</div>
-			<table class="table">
-				<thead>
-					<tr>
-						<td>Display name</td>
-						<td>Type</td>
-						<td>Is user modifiable</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.isUserModifiable }}</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" />
@@ -100,15 +91,13 @@
 </template>
 
 <script>
-import { mapState, mapActions, mapGetters } from "vuex";
+import { mapState, mapActions } from "vuex";
 import { defineAsyncComponent } from "vue";
 
-import Toast from "toasters";
-import Confirm from "@/components/Confirm.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 {
@@ -116,90 +105,242 @@ export default {
 		EditPlaylist: defineAsyncComponent(() =>
 			import("@/components/modals/EditPlaylist")
 		),
-		UserIdToUsername,
 		Report: defineAsyncComponent(() =>
 			import("@/components/modals/Report.vue")
 		),
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong")
 		),
-		Confirm
+		AdvancedTable,
+		RunJobDropdown,
+		UserIdToUsername
 	},
 	data() {
 		return {
-			utils
+			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",
+					socket: "playlists.deleteOrphanedStationPlaylists"
+				},
+				{
+					name: "Delete orphaned genre playlists",
+					socket: "playlists.deleteOrphanedGenrePlaylists"
+				},
+				{
+					name: "Request orphaned playlist songs",
+					socket: "playlists.requestOrphanedPlaylistSongs"
+				},
+				{
+					name: "Clear and refill all station playlists",
+					socket: "playlists.clearAndRefillAllStationPlaylists"
+				},
+				{
+					name: "Clear and refill all genre playlists",
+					socket: "playlists.clearAndRefillAllGenrePlaylists"
+				},
+				{
+					name: "Create missing genre playlists",
+					socket: "playlists.createMissingGenrePlaylists"
+				}
+			]
 		};
 	},
 	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();
@@ -209,115 +350,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);
 		},
-		deleteOrphanedStationPlaylists() {
-			this.socket.dispatch(
-				"playlists.deleteOrphanedStationPlaylists",
-				res => {
-					if (res.status === "success") new Toast(res.message);
-					else new Toast(`Error: ${res.message}`);
-				}
-			);
-		},
-		deleteOrphanedGenrePlaylists() {
-			this.socket.dispatch(
-				"playlists.deleteOrphanedGenrePlaylists",
-				res => {
-					if (res.status === "success") new Toast(res.message);
-					else new Toast(`Error: ${res.message}`);
-				}
-			);
-		},
-		requestOrphanedPlaylistSongs() {
-			this.socket.dispatch(
-				"playlists.requestOrphanedPlaylistSongs",
-				res => {
-					if (res.status === "success") new Toast(res.message);
-					else new Toast(`Error: ${res.message}`);
-				}
-			);
-		},
-		clearAndRefillAllStationPlaylists() {
-			this.socket.dispatch(
-				"playlists.clearAndRefillAllStationPlaylists",
-				res => {
-					if (res.status === "success")
-						new Toast({ content: res.message, timeout: 4000 });
-					else
-						new Toast({
-							content: `Error: ${res.message}`,
-							timeout: 4000
-						});
-				}
-			);
-		},
-		clearAndRefillAllGenrePlaylists() {
-			this.socket.dispatch(
-				"playlists.clearAndRefillAllGenrePlaylists",
-				res => {
-					if (res.status === "success")
-						new Toast({ content: res.message, timeout: 4000 });
-					else
-						new Toast({
-							content: `Error: ${res.message}`,
-							timeout: 4000
-						});
-				}
-			);
-		},
 		...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>

+ 222 - 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,13 +103,13 @@
 </template>
 
 <script>
-import { mapState, mapActions, mapGetters } from "vuex";
+import { mapState, mapActions } from "vuex";
 import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
-import ws from "@/ws";
 
-import ReportInfoItem from "@/components/ReportInfoItem.vue";
+import AdvancedTable from "@/components/AdvancedTable.vue";
+import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 export default {
 	components: {
@@ -102,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");
@@ -160,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>

+ 829 - 0
frontend/src/pages/Admin/tabs/Songs.vue

@@ -0,0 +1,829 @@
+<template>
+	<div>
+		<page-metadata title="Admin | Songs" />
+		<div class="admin-tab">
+			<div class="button-row">
+				<button
+					class="button is-primary"
+					@click="openModal('requestSong')"
+				>
+					Request song
+				</button>
+				<button
+					class="button is-primary"
+					@click="openModal('importAlbum')"
+				>
+					Import album
+				</button>
+				<run-job-dropdown :jobs="jobs" />
+			</div>
+			<advanced-table
+				:column-default="columnDefault"
+				:columns="columns"
+				: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"
+						: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-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-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
+					}}</span>
+				</template>
+				<template #column-dislikes="slotProps">
+					<span :title="slotProps.item.dislikes">{{
+						slotProps.item.dislikes
+					}}</span>
+				</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-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">
+					<user-id-to-username
+						:user-id="slotProps.item.requestedBy"
+						: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="bulk-actions">
+						<i
+							class="material-icons edit-songs-icon"
+							@click.prevent="editMany(slotProps.item)"
+							content="Edit Songs"
+							v-tippy
+							tabindex="0"
+						>
+							edit
+						</i>
+						<i
+							class="material-icons verify-songs-icon"
+							@click.prevent="verifyMany(slotProps.item)"
+							content="Verify Songs"
+							v-tippy
+							tabindex="0"
+						>
+							check_circle
+						</i>
+						<quick-confirm
+							placement="left"
+							@confirm="unverifyMany(slotProps.item)"
+							tabindex="0"
+						>
+							<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="setTags(slotProps.item)"
+							content="Set Tags"
+							v-tippy
+							tabindex="0"
+						>
+							local_offer
+						</i>
+						<i
+							class="material-icons artists-songs-icon"
+							@click.prevent="setArtists(slotProps.item)"
+							content="Set Artists"
+							v-tippy
+							tabindex="0"
+						>
+							group
+						</i>
+						<i
+							class="material-icons genres-songs-icon"
+							@click.prevent="setGenres(slotProps.item)"
+							content="Set Genres"
+							v-tippy
+							tabindex="0"
+						>
+							theater_comedy
+						</i>
+						<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"
+						>
+							delete_forever
+						</i>
+					</div>
+				</template>
+			</advanced-table>
+		</div>
+		<import-album v-if="modals.importAlbum" />
+		<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" />
+		<bulk-actions v-if="modals.bulkActions" :type="bulkActionsType" />
+		<confirm v-if="modals.confirm" @confirmed="handleConfirmed()" />
+	</div>
+</template>
+
+<script>
+import { mapState, mapActions, mapGetters } 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";
+import RunJobDropdown from "@/components/RunJobDropdown.vue";
+
+export default {
+	components: {
+		EditSong: defineAsyncComponent(() =>
+			import("@/components/modals/EditSong")
+		),
+		EditSongs: defineAsyncComponent(() =>
+			import("@/components/modals/EditSongs.vue")
+		),
+		Report: defineAsyncComponent(() =>
+			import("@/components/modals/Report.vue")
+		),
+		ImportAlbum: defineAsyncComponent(() =>
+			import("@/components/modals/ImportAlbum.vue")
+		),
+		RequestSong: defineAsyncComponent(() =>
+			import("@/components/modals/RequestSong.vue")
+		),
+		BulkActions: defineAsyncComponent(() =>
+			import("@/components/modals/BulkActions.vue")
+		),
+		Confirm: defineAsyncComponent(() =>
+			import("@/components/modals/Confirm.vue")
+		),
+		AdvancedTable,
+		UserIdToUsername,
+		QuickConfirm,
+		RunJobDropdown
+	},
+	data() {
+		return {
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 200,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "options",
+					displayName: "Options",
+					properties: ["_id", "verified"],
+					sortable: false,
+					hidable: false,
+					resizable: false,
+					minWidth: 129,
+					defaultWidth: 129
+				},
+				{
+					name: "thumbnailImage",
+					displayName: "Thumb",
+					properties: ["thumbnail"],
+					sortable: false,
+					minWidth: 75,
+					defaultWidth: 75,
+					maxWidth: 75,
+					resizable: false
+				},
+				{
+					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: "tags",
+					displayName: "Tags",
+					properties: ["tags"],
+					sortable: false
+				},
+				{
+					name: "likes",
+					displayName: "Likes",
+					properties: ["likes"],
+					sortProperty: "likes",
+					minWidth: 100,
+					defaultWidth: 100,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "dislikes",
+					displayName: "Dislikes",
+					properties: ["dislikes"],
+					sortProperty: "dislikes",
+					minWidth: 100,
+					defaultWidth: 100,
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "_id",
+					displayName: "Song ID",
+					properties: ["_id"],
+					sortProperty: "_id",
+					minWidth: 215,
+					defaultWidth: 215
+				},
+				{
+					name: "youtubeId",
+					displayName: "YouTube ID",
+					properties: ["youtubeId"],
+					sortProperty: "youtubeId",
+					minWidth: 120,
+					defaultWidth: 120
+				},
+				{
+					name: "verified",
+					displayName: "Verified",
+					properties: ["verified"],
+					sortProperty: "verified",
+					minWidth: 120,
+					defaultWidth: 120
+				},
+				{
+					name: "thumbnailUrl",
+					displayName: "Thumbnail (URL)",
+					properties: ["thumbnail"],
+					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,
+					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: "Song 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",
+					autosuggest: true,
+					autosuggestDataAction: "songs.getArtists"
+				},
+				{
+					name: "genres",
+					displayName: "Genres",
+					property: "genres",
+					filterTypes: ["contains", "exact", "regex"],
+					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",
+					displayName: "Thumbnail",
+					property: "thumbnail",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "requestedBy",
+					displayName: "Requested By",
+					property: "requestedBy",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "requestedAt",
+					displayName: "Requested At",
+					property: "requestedAt",
+					filterTypes: ["datetimeBefore", "datetimeAfter"],
+					defaultFilterType: "datetimeBefore"
+				},
+				{
+					name: "verifiedBy",
+					displayName: "Verified By",
+					property: "verifiedBy",
+					filterTypes: ["contains", "exact", "regex"],
+					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: [
+						"numberLesserEqual",
+						"numberLesser",
+						"numberGreater",
+						"numberGreaterEqual",
+						"numberEquals"
+					],
+					defaultFilterType: "numberLesser"
+				},
+				{
+					name: "dislikes",
+					displayName: "Dislikes",
+					property: "dislikes",
+					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",
+					socket: "songs.updateAll"
+				},
+				{
+					name: "Recalculate all song ratings",
+					socket: "songs.recalculateAllRatings"
+				}
+			],
+			confirm: {
+				message: "",
+				action: "",
+				params: null
+			},
+			bulkActionsType: null
+		};
+	},
+	computed: {
+		...mapState("modalVisibility", {
+			modals: state => state.modals
+		}),
+		...mapState("modals/editSong", {
+			song: state => state.song
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		if (this.$route.query.songId) {
+			this.socket.dispatch(
+				"songs.getSongFromSongId",
+				this.$route.query.songId,
+				res => {
+					if (res.status === "success")
+						this.editMany([res.data.song]);
+					else new Toast("Song with that ID not found");
+				}
+			);
+		}
+	},
+	methods: {
+		editOne(song) {
+			this.editSong({ songId: song._id });
+			this.openModal("editSong");
+		},
+		editMany(selectedRows) {
+			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) {
+			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) {
+			this.socket.dispatch(
+				"songs.unverifyMany",
+				selectedRows.map(row => row._id),
+				res => {
+					new Toast(res.message);
+				}
+			);
+		},
+		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(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(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");
+		},
+		deleteOne(songId) {
+			this.socket.dispatch("songs.remove", songId, res => {
+				new Toast(res.message);
+			});
+		},
+		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}`;
+		},
+		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>
+.song-thumbnail {
+	display: block;
+	max-width: 50px;
+	margin: 0 auto;
+}
+
+/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;
+			}
+		}
+	}
+}
+</style>

+ 322 - 153
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"
@@ -9,73 +9,112 @@
 				>
 					Create Station
 				</button>
-				<confirm placement="bottom" @confirm="clearEveryStationQueue()">
-					<button class="button is-danger">
-						Clear every station queue
-					</button>
-				</confirm>
+				<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
-							>
-							<confirm @confirm="removeStation(index)">
-								<a class="button is-danger">Remove</a>
-							</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" />
@@ -97,9 +136,11 @@ import { mapState, mapActions, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
+
+import AdvancedTable from "@/components/AdvancedTable.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
-import Confirm from "@/components/Confirm.vue";
-import ws from "@/ws";
+import RunJobDropdown from "@/components/RunJobDropdown.vue";
 
 export default {
 	components: {
@@ -124,18 +165,219 @@ export default {
 		CreateStation: defineAsyncComponent(() =>
 			import("@/components/modals/CreateStation.vue")
 		),
+		AdvancedTable,
+		QuickConfirm,
 		UserIdToUsername,
-		Confirm
+		RunJobDropdown
 	},
 	data() {
 		return {
-			editingStationId: ""
+			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",
+					socket: "stations.clearEveryStationQueue"
+				}
+			]
 		};
 	},
 	computed: {
-		...mapState("admin/stations", {
-			stations: state => state.stations
-		}),
 		...mapState("modalVisibility", {
 			modals: state => state.modals
 		}),
@@ -143,92 +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");
-		},
-		clearEveryStationQueue() {
-			this.socket.dispatch("stations.clearEveryStationQueue", res => {
-				if (res.status === "success") new Toast(res.message);
-				else new Toast(`Error: ${res.message}`);
-			});
-		},
-		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 - 645
frontend/src/pages/Admin/tabs/UnverifiedSongs.vue

@@ -1,645 +0,0 @@
-<template>
-	<div>
-		<page-metadata title="Admin | Unverified songs" />
-		<div class="container">
-			<div class="button-row">
-				<button
-					v-if="!loadAllSongs"
-					class="button is-primary"
-					@click="loadAll()"
-				>
-					Load all sets
-				</button>
-				<button
-					class="button is-primary"
-					@click="toggleKeyboardShortcutsHelper"
-					@dblclick="resetKeyboardShortcutsHelper"
-				>
-					Keyboard shortcuts helper
-				</button>
-				<button
-					class="button is-primary"
-					@click="openModal('requestSong')"
-				>
-					Request song
-				</button>
-			</div>
-			<br />
-			<div class="box">
-				<p @click="toggleSearchBox()">
-					Search<i class="material-icons" v-show="searchBoxShown"
-						>expand_more</i
-					>
-					<i class="material-icons" v-show="!searchBoxShown"
-						>expand_less</i
-					>
-				</p>
-				<input
-					v-model="searchQuery"
-					type="text"
-					class="input"
-					placeholder="Search for Songs"
-					v-show="searchBoxShown"
-				/>
-			</div>
-			<div class="box">
-				<p @click="toggleFilterArtistsBox()">
-					Filter artists<i
-						class="material-icons"
-						v-show="filterArtistBoxShown"
-						>expand_more</i
-					>
-					<i class="material-icons" v-show="!filterArtistBoxShown"
-						>expand_less</i
-					>
-				</p>
-				<input
-					type="text"
-					class="input"
-					placeholder="Filter artist checkboxes"
-					v-model="artistFilterQuery"
-					v-show="filterArtistBoxShown"
-				/>
-				<label
-					v-for="artist in filteredArtists"
-					:key="artist"
-					v-show="filterArtistBoxShown"
-				>
-					<input
-						type="checkbox"
-						:checked="artistFilterSelected.indexOf(artist) !== -1"
-						@click="toggleArtistSelected(artist)"
-					/>
-					<span>{{ artist }}</span>
-				</label>
-			</div>
-			<div class="box">
-				<p @click="toggleFilterGenresBox()">
-					Filter genres<i
-						class="material-icons"
-						v-show="filterGenreBoxShown"
-						>expand_more</i
-					>
-					<i class="material-icons" v-show="!filterGenreBoxShown"
-						>expand_less</i
-					>
-				</p>
-				<input
-					type="text"
-					class="input"
-					placeholder="Filter genre checkboxes"
-					v-model="genreFilterQuery"
-					v-show="filterGenreBoxShown"
-				/>
-				<label
-					v-for="genre in filteredGenres"
-					:key="genre"
-					v-show="filterGenreBoxShown"
-				>
-					<input
-						type="checkbox"
-						:checked="genreFilterSelected.indexOf(genre) !== -1"
-						@click="toggleGenreSelected(genre)"
-					/>
-					<span>{{ genre }}</span>
-				</label>
-			</div>
-			<p>
-				<span>Sets loaded: {{ setsLoaded }} / {{ maxSets }}</span>
-				<br />
-				<span>Loaded songs: {{ songs.length }}</span>
-			</p>
-			<br />
-			<table class="table">
-				<thead>
-					<tr>
-						<td>Thumbnail</td>
-						<td>Title</td>
-						<td>Artists</td>
-						<td>Genres</td>
-						<td>ID / YouTube ID</td>
-						<td>Requested By</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr
-						v-for="song in filteredSongs"
-						:key="song._id"
-						tabindex="0"
-						@keydown.up.prevent
-						@keydown.down.prevent
-						@keyup.up="selectPrevious($event)"
-						@keyup.down="selectNext($event)"
-						@keyup.e="edit(song, index)"
-						@keyup.a="add(song)"
-						@keyup.x="remove(song._id, index)"
-					>
-						<td>
-							<img
-								class="song-thumbnail"
-								:src="song.thumbnail"
-								onerror="this.src='/assets/notes-transparent.png'"
-							/>
-						</td>
-						<td>
-							<strong>{{ song.title }}</strong>
-						</td>
-						<td>{{ song.artists.join(", ") }}</td>
-						<td>{{ song.genres.join(", ") }}</td>
-						<td>
-							{{ song._id }}
-							<br />
-							<a
-								:href="
-									'https://www.youtube.com/watch?v=' +
-									`${song.youtubeId}`
-								"
-								target="_blank"
-							>
-								{{ song.youtubeId }}</a
-							>
-						</td>
-						<td>
-							<user-id-to-username
-								:user-id="song.requestedBy"
-								:link="true"
-							/>
-						</td>
-						<td class="optionsColumn">
-							<div>
-								<button
-									class="button is-primary"
-									@click="edit(song, index)"
-									content="Edit Song"
-									v-tippy
-								>
-									<i class="material-icons">edit</i>
-								</button>
-								<button
-									class="button is-success"
-									@click="verify(song._id)"
-									content="Verify Song"
-									v-tippy
-								>
-									<i class="material-icons">check_circle</i>
-								</button>
-								<confirm
-									placement="left"
-									@confirm="hide(song._id)"
-								>
-									<button
-										class="button is-danger"
-										content="Hide Song"
-										v-tippy
-									>
-										<i class="material-icons"
-											>visibility_off</i
-										>
-									</button>
-								</confirm>
-							</div>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
-		<import-album v-if="modals.importAlbum" />
-		<edit-song v-if="modals.editSong" song-type="songs" :key="song._id" />
-		<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>Unverified songs page</b></span
-						>
-						<span
-							><b>Arrow keys up/down</b> - Moves between
-							songs</span
-						>
-						<span><b>E</b> - Edit selected song</span>
-						<span><b>A</b> - Add selected song</span>
-						<span><b>X</b> - Delete selected song</span>
-					</div>
-					<hr />
-					<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 />
-						<span class="bigger"><b>Player controls</b></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 />
-						<span class="bigger"><b>Form control</b></span>
-						<span
-							><b>Ctrl + D</b> - Executes purple button in that
-							input</span
-						>
-						<span
-							><b>Ctrl + Alt + D</b> - Fill in all Discogs
-							fields</span
-						>
-						<span
-							><b>Ctrl + R</b> - Executes red button in that
-							input</span
-						>
-						<span
-							><b>Ctrl + Alt + R</b> - Reset duration field</span
-						>
-						<hr />
-						<span class="bigger"><b>Modal control</b></span>
-						<span><b>Ctrl + S</b> - Save</span>
-						<span><b>Ctrl + X</b> - Exit</span>
-					</div>
-				</div>
-			</template>
-		</floating-box>
-	</div>
-</template>
-
-<script>
-import { mapState, mapActions, mapGetters } from "vuex";
-import { defineAsyncComponent } from "vue";
-
-import Toast from "toasters";
-
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
-import FloatingBox from "@/components/FloatingBox.vue";
-import Confirm from "@/components/Confirm.vue";
-
-import ScrollAndFetchHandler from "@/mixins/ScrollAndFetchHandler.vue";
-
-import ws from "@/ws";
-
-export default {
-	components: {
-		EditSong: defineAsyncComponent(() =>
-			import("@/components/modals/EditSong")
-		),
-		Report: defineAsyncComponent(() =>
-			import("@/components/modals/Report.vue")
-		),
-		ImportAlbum: defineAsyncComponent(() =>
-			import("@/components/modals/ImportAlbum.vue")
-		),
-		RequestSong: defineAsyncComponent(() =>
-			import("@/components/modals/RequestSong.vue")
-		),
-		UserIdToUsername,
-		FloatingBox,
-		Confirm
-	},
-	mixins: [ScrollAndFetchHandler],
-	data() {
-		return {
-			searchQuery: "",
-			artistFilterQuery: "",
-			artistFilterSelected: [],
-			genreFilterQuery: "",
-			genreFilterSelected: [],
-			searchBoxShown: true,
-			filterArtistBoxShown: false,
-			filterGenreBoxShown: false
-		};
-	},
-	computed: {
-		filteredSongs() {
-			return this.songs.filter(
-				song =>
-					JSON.stringify(Object.values(song))
-						.toLowerCase()
-						.indexOf(this.searchQuery.toLowerCase()) !== -1 &&
-					(this.artistFilterSelected.length === 0 ||
-						song.artists.some(
-							artist =>
-								this.artistFilterSelected
-									.map(artistFilterSelected =>
-										artistFilterSelected.toLowerCase()
-									)
-									.indexOf(artist.toLowerCase()) !== -1
-						)) &&
-					(this.genreFilterSelected.length === 0 ||
-						song.genres.some(
-							genre =>
-								this.genreFilterSelected
-									.map(genreFilterSelected =>
-										genreFilterSelected.toLowerCase()
-									)
-									.indexOf(genre.toLowerCase()) !== -1
-						))
-			);
-		},
-		artists() {
-			const artists = [];
-			this.songs.forEach(song => {
-				song.artists.forEach(artist => {
-					if (artists.indexOf(artist) === -1) artists.push(artist);
-				});
-			});
-			return artists.sort();
-		},
-		filteredArtists() {
-			return this.artists
-				.filter(
-					artist =>
-						this.artistFilterSelected.indexOf(artist) !== -1 ||
-						artist
-							.toLowerCase()
-							.indexOf(this.artistFilterQuery.toLowerCase()) !==
-							-1
-				)
-				.sort(
-					(a, b) =>
-						(this.artistFilterSelected.indexOf(a) === -1 ? 1 : 0) -
-						(this.artistFilterSelected.indexOf(b) === -1 ? 1 : 0)
-				);
-		},
-		genres() {
-			const genres = [];
-			this.songs.forEach(song => {
-				song.genres.forEach(genre => {
-					if (genres.indexOf(genre) === -1) genres.push(genre);
-				});
-			});
-			return genres.sort();
-		},
-		filteredGenres() {
-			return this.genres
-				.filter(
-					genre =>
-						this.genreFilterSelected.indexOf(genre) !== -1 ||
-						genre
-							.toLowerCase()
-							.indexOf(this.genreFilterQuery.toLowerCase()) !== -1
-				)
-				.sort(
-					(a, b) =>
-						(this.genreFilterSelected.indexOf(a) === -1 ? 1 : 0) -
-						(this.genreFilterSelected.indexOf(b) === -1 ? 1 : 0)
-				);
-		},
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		}),
-		...mapState("admin/unverifiedSongs", {
-			songs: state => state.songs
-		}),
-		...mapState("modals/editSong", {
-			song: state => state.song
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	mounted() {
-		this.socket.on("event:admin.unverifiedSong.created", res => {
-			this.addSong(res.data.song);
-		});
-
-		this.socket.on("event:admin.unverifiedSong.deleted", res => {
-			this.removeSong(res.data.songId);
-		});
-
-		this.socket.on("event:admin.unverifiedSong.updated", res => {
-			this.updateSong(res.data.song);
-		});
-
-		ws.onConnect(this.init);
-	},
-	methods: {
-		edit(song) {
-			this.editSong(song);
-			this.openModal("editSong");
-		},
-		verify(id) {
-			this.socket.dispatch("songs.verify", id, res => {
-				new Toast(res.message);
-			});
-		},
-		hide(id) {
-			this.socket.dispatch("songs.hide", id, res => {
-				new Toast(res.message);
-			});
-		},
-		getSet() {
-			if (this.isGettingSet) return;
-			if (this.position >= this.maxPosition) return;
-			this.isGettingSet = true;
-
-			this.socket.dispatch(
-				"songs.getSet",
-				this.position,
-				"unverified",
-				res => {
-					if (res.status === "success") {
-						res.data.songs.forEach(song => {
-							this.addSong(song);
-						});
-
-						this.position += 1;
-						this.isGettingSet = false;
-					}
-				}
-			);
-		},
-		selectPrevious(event) {
-			if (event.srcElement.previousElementSibling)
-				event.srcElement.previousElementSibling.focus();
-		},
-		selectNext(event) {
-			if (event.srcElement.nextElementSibling)
-				event.srcElement.nextElementSibling.focus();
-		},
-		toggleArtistSelected(artist) {
-			if (this.artistFilterSelected.indexOf(artist) === -1)
-				this.artistFilterSelected.push(artist);
-			else
-				this.artistFilterSelected.splice(
-					this.artistFilterSelected.indexOf(artist),
-					1
-				);
-		},
-		toggleGenreSelected(genre) {
-			if (this.genreFilterSelected.indexOf(genre) === -1)
-				this.genreFilterSelected.push(genre);
-			else
-				this.genreFilterSelected.splice(
-					this.genreFilterSelected.indexOf(genre),
-					1
-				);
-		},
-		toggleSearchBox() {
-			this.searchBoxShown = !this.searchBoxShown;
-		},
-		toggleFilterArtistsBox() {
-			this.filterArtistBoxShown = !this.filterArtistBoxShown;
-		},
-		toggleFilterGenresBox() {
-			this.filterGenreBoxShown = !this.filterGenreBoxShown;
-		},
-		toggleKeyboardShortcutsHelper() {
-			this.$refs.keyboardShortcutsHelper.toggleBox();
-		},
-		resetKeyboardShortcutsHelper() {
-			this.$refs.keyboardShortcutsHelper.resetBox();
-		},
-		init() {
-			this.position = 1;
-			this.maxPosition = 1;
-			this.resetSongs();
-
-			if (this.songs.length > 0)
-				this.position = Math.ceil(this.songs.length / 15) + 1;
-
-			this.socket.dispatch("songs.length", "unverified", res => {
-				if (res.status === "success") {
-					this.maxPosition = Math.ceil(res.data.length / 15) + 1;
-					this.getSet();
-				}
-			});
-
-			this.socket.dispatch(
-				"apis.joinAdminRoom",
-				"unverifiedSongs",
-				() => {}
-			);
-		},
-		...mapActions("admin/unverifiedSongs", [
-			"resetSongs",
-			"addSong",
-			"removeSong",
-			"updateSong"
-		]),
-		...mapActions("modals/editSong", ["editSong"]),
-		...mapActions("modalVisibility", ["openModal"])
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-.night-mode {
-	.box {
-		background-color: var(--dark-grey-3) !important;
-	}
-
-	.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);
-		}
-	}
-}
-
-.box {
-	background-color: var(--light-grey);
-	border-radius: 5px;
-	padding: 8px 16px;
-
-	p {
-		text-align: center;
-		font-size: 24px;
-		user-select: none;
-		cursor: pointer;
-		display: flex;
-		justify-content: center;
-		align-items: center;
-	}
-
-	input[type="text"] {
-		margin-top: 8px;
-		margin-bottom: 8px;
-	}
-
-	label {
-		margin-right: 8px;
-		display: inline-flex;
-		align-items: center;
-
-		input[type="checkbox"] {
-			margin-right: 2px;
-			height: 16px;
-			width: 16px;
-		}
-	}
-}
-
-.optionsColumn {
-	width: 140px;
-
-	div {
-		button {
-			width: 35px;
-
-			&:not(:last-child) {
-				margin-right: 5px;
-			}
-		}
-	}
-}
-
-.song-thumbnail {
-	display: block;
-	max-width: 50px;
-	margin: 0 auto;
-}
-
-td {
-	vertical-align: middle;
-
-	& > div {
-		display: inline-flex;
-	}
-}
-
-#keyboardShortcutsHelper {
-	.box-body {
-		.biggest {
-			font-size: 18px;
-		}
-
-		.bigger {
-			font-size: 16px;
-		}
-
-		span {
-			display: block;
-		}
-	}
-}
-
-.is-primary:focus {
-	background-color: var(--primary-color) !important;
-}
-</style>

+ 397 - 152
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,43 +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
-			);
-		});
+		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);
@@ -174,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;
 }
@@ -221,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>

+ 0 - 713
frontend/src/pages/Admin/tabs/VerifiedSongs.vue

@@ -1,713 +0,0 @@
-<template>
-	<div>
-		<page-metadata title="Admin | Songs" />
-		<div class="container">
-			<div class="button-row">
-				<button
-					v-if="!loadAllSongs"
-					class="button is-primary"
-					@click="loadAll()"
-				>
-					Load all sets
-				</button>
-				<button
-					class="button is-primary"
-					@click="toggleKeyboardShortcutsHelper"
-					@dblclick="resetKeyboardShortcutsHelper"
-				>
-					Keyboard shortcuts helper
-				</button>
-				<button
-					class="button is-primary"
-					@click="openModal('requestSong')"
-				>
-					Request song
-				</button>
-				<button
-					class="button is-primary"
-					@click="openModal('importAlbum')"
-				>
-					Import album
-				</button>
-				<confirm placement="bottom" @confirm="updateAllSongs()">
-					<button class="button is-danger">Update all songs</button>
-				</confirm>
-			</div>
-			<br />
-			<div class="box">
-				<p @click="toggleSearchBox()">
-					Search
-					<i class="material-icons" v-show="searchBoxShown"
-						>expand_more</i
-					>
-					<i class="material-icons" v-show="!searchBoxShown"
-						>expand_less</i
-					>
-				</p>
-				<input
-					v-model="searchQuery"
-					type="text"
-					class="input"
-					placeholder="Search for Songs"
-					v-show="searchBoxShown"
-				/>
-			</div>
-			<div class="box">
-				<p @click="toggleFilterArtistsBox()">
-					Filter artists<i
-						class="material-icons"
-						v-show="filterArtistBoxShown"
-						>expand_more</i
-					>
-					<i class="material-icons" v-show="!filterArtistBoxShown"
-						>expand_less</i
-					>
-				</p>
-				<input
-					type="text"
-					class="input"
-					placeholder="Filter artist checkboxes"
-					v-model="artistFilterQuery"
-					v-show="filterArtistBoxShown"
-				/>
-				<label
-					v-for="artist in filteredArtists"
-					:key="artist"
-					v-show="filterArtistBoxShown"
-				>
-					<input
-						type="checkbox"
-						:checked="artistFilterSelected.indexOf(artist) !== -1"
-						@click="toggleArtistSelected(artist)"
-					/>
-					<span>{{ artist }}</span>
-				</label>
-			</div>
-			<div class="box">
-				<p @click="toggleFilterGenresBox()">
-					Filter genres<i
-						class="material-icons"
-						v-show="filterGenreBoxShown"
-						>expand_more</i
-					>
-					<i class="material-icons" v-show="!filterGenreBoxShown"
-						>expand_less</i
-					>
-				</p>
-				<input
-					type="text"
-					class="input"
-					placeholder="Filter genre checkboxes"
-					v-model="genreFilterQuery"
-					v-show="filterGenreBoxShown"
-				/>
-				<label
-					v-for="genre in filteredGenres"
-					:key="genre"
-					v-show="filterGenreBoxShown"
-				>
-					<input
-						type="checkbox"
-						:checked="genreFilterSelected.indexOf(genre) !== -1"
-						@click="toggleGenreSelected(genre)"
-					/>
-					<span>{{ genre }}</span>
-				</label>
-			</div>
-			<p>
-				<span>Sets loaded: {{ setsLoaded }} / {{ maxSets }}</span>
-				<br />
-				<span>Loaded songs: {{ songs.length }}</span>
-			</p>
-			<br />
-			<table class="table">
-				<thead>
-					<tr>
-						<td>Thumbnail</td>
-						<td>Title</td>
-						<td>Artists</td>
-						<td>Genres</td>
-						<td class="likesColumn">
-							<i class="material-icons thumbLike">thumb_up</i>
-						</td>
-						<td class="dislikesColumn">
-							<i class="material-icons thumbDislike"
-								>thumb_down</i
-							>
-						</td>
-						<td>ID / Youtube ID</td>
-						<td>Requested By</td>
-						<td>Options</td>
-					</tr>
-				</thead>
-				<tbody>
-					<tr v-for="song in filteredSongs" :key="song._id">
-						<td>
-							<img
-								class="song-thumbnail"
-								:src="song.thumbnail"
-								onerror="this.src='/assets/notes-transparent.png'"
-							/>
-						</td>
-						<td>
-							<strong>{{ song.title }}</strong>
-						</td>
-						<td>{{ song.artists.join(", ") }}</td>
-						<td>{{ song.genres.join(", ") }}</td>
-						<td>{{ song.likes }}</td>
-						<td>{{ song.dislikes }}</td>
-						<td>
-							{{ song._id }}
-							<br />
-							<a
-								:href="
-									'https://www.youtube.com/watch?v=' +
-									`${song.youtubeId}`
-								"
-								target="_blank"
-							>
-								{{ song.youtubeId }}</a
-							>
-						</td>
-						<td>
-							<user-id-to-username
-								:user-id="song.requestedBy"
-								:link="true"
-							/>
-						</td>
-						<td class="optionsColumn">
-							<div>
-								<button
-									class="button is-primary"
-									@click="edit(song)"
-									content="Edit Song"
-									v-tippy
-								>
-									<i class="material-icons">edit</i>
-								</button>
-								<confirm
-									placement="left"
-									@confirm="unverify(song._id)"
-								>
-									<button
-										class="button is-danger"
-										content="Unverify Song"
-										v-tippy
-									>
-										<i class="material-icons">cancel</i>
-									</button>
-								</confirm>
-							</div>
-						</td>
-					</tr>
-				</tbody>
-			</table>
-		</div>
-		<import-album v-if="modals.importAlbum" />
-		<edit-song v-if="modals.editSong" song-type="songs" :key="song._id" />
-		<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>
-	</div>
-</template>
-
-<script>
-import { mapState, mapActions, mapGetters } from "vuex";
-import { defineAsyncComponent } from "vue";
-
-import Toast from "toasters";
-
-import keyboardShortcuts from "@/keyboardShortcuts";
-
-import UserIdToUsername from "@/components/UserIdToUsername.vue";
-import FloatingBox from "@/components/FloatingBox.vue";
-import Confirm from "@/components/Confirm.vue";
-
-import ScrollAndFetchHandler from "@/mixins/ScrollAndFetchHandler.vue";
-
-import ws from "@/ws";
-
-export default {
-	components: {
-		EditSong: defineAsyncComponent(() =>
-			import("@/components/modals/EditSong")
-		),
-		Report: defineAsyncComponent(() =>
-			import("@/components/modals/Report.vue")
-		),
-		ImportAlbum: defineAsyncComponent(() =>
-			import("@/components/modals/ImportAlbum.vue")
-		),
-		RequestSong: defineAsyncComponent(() =>
-			import("@/components/modals/RequestSong.vue")
-		),
-		UserIdToUsername,
-		FloatingBox,
-		Confirm
-	},
-	mixins: [ScrollAndFetchHandler],
-	data() {
-		return {
-			searchQuery: "",
-			artistFilterQuery: "",
-			artistFilterSelected: [],
-			genreFilterQuery: "",
-			genreFilterSelected: [],
-			editing: {
-				index: 0,
-				song: {}
-			},
-			searchBoxShown: true,
-			filterArtistBoxShown: false,
-			filterGenreBoxShown: false
-		};
-	},
-	computed: {
-		filteredSongs() {
-			return this.songs.filter(
-				song =>
-					JSON.stringify(Object.values(song))
-						.toLowerCase()
-						.indexOf(this.searchQuery.toLowerCase()) !== -1 &&
-					(this.artistFilterSelected.length === 0 ||
-						song.artists.some(
-							artist =>
-								this.artistFilterSelected
-									.map(artistFilterSelected =>
-										artistFilterSelected.toLowerCase()
-									)
-									.indexOf(artist.toLowerCase()) !== -1
-						)) &&
-					(this.genreFilterSelected.length === 0 ||
-						song.genres.some(
-							genre =>
-								this.genreFilterSelected
-									.map(genreFilterSelected =>
-										genreFilterSelected.toLowerCase()
-									)
-									.indexOf(genre.toLowerCase()) !== -1
-						))
-			);
-		},
-		artists() {
-			const artists = [];
-			this.songs.forEach(song => {
-				song.artists.forEach(artist => {
-					if (artists.indexOf(artist) === -1) artists.push(artist);
-				});
-			});
-			return artists.sort();
-		},
-		filteredArtists() {
-			return this.artists
-				.filter(
-					artist =>
-						this.artistFilterSelected.indexOf(artist) !== -1 ||
-						artist
-							.toLowerCase()
-							.indexOf(this.artistFilterQuery.toLowerCase()) !==
-							-1
-				)
-				.sort(
-					(a, b) =>
-						(this.artistFilterSelected.indexOf(a) === -1 ? 1 : 0) -
-						(this.artistFilterSelected.indexOf(b) === -1 ? 1 : 0)
-				);
-		},
-		genres() {
-			const genres = [];
-			this.songs.forEach(song => {
-				song.genres.forEach(genre => {
-					if (genres.indexOf(genre) === -1) genres.push(genre);
-				});
-			});
-			return genres.sort();
-		},
-		filteredGenres() {
-			return this.genres
-				.filter(
-					genre =>
-						this.genreFilterSelected.indexOf(genre) !== -1 ||
-						genre
-							.toLowerCase()
-							.indexOf(this.genreFilterQuery.toLowerCase()) !== -1
-				)
-				.sort(
-					(a, b) =>
-						(this.genreFilterSelected.indexOf(a) === -1 ? 1 : 0) -
-						(this.genreFilterSelected.indexOf(b) === -1 ? 1 : 0)
-				);
-		},
-		...mapState("modalVisibility", {
-			modals: state => state.modals
-		}),
-		...mapState("admin/verifiedSongs", {
-			songs: state => state.songs
-		}),
-		...mapState("modals/editSong", {
-			song: state => state.song
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	mounted() {
-		this.socket.on("event:admin.verifiedSong.created", res =>
-			this.addSong(res.data.song)
-		);
-
-		this.socket.on("event:admin.verifiedSong.deleted", res =>
-			this.removeSong(res.data.songId)
-		);
-
-		this.socket.on("event:admin.verifiedSong.updated", res =>
-			this.updateSong(res.data.song)
-		);
-
-		ws.onConnect(this.init);
-
-		if (this.$route.query.songId) {
-			this.socket.dispatch(
-				"songs.getSongFromSongId",
-				this.$route.query.songId,
-				res => {
-					if (res.status === "success") this.edit(res.data.song);
-					else new Toast("Song with that ID not found");
-				}
-			);
-		}
-
-		keyboardShortcuts.registerShortcut(
-			"verifiedSongs.toggleKeyboardShortcutsHelper",
-			{
-				keyCode: 191, // '/' key
-				ctrl: true,
-				preventDefault: true,
-				handler: () => {
-					this.toggleKeyboardShortcutsHelper();
-				}
-			}
-		);
-
-		keyboardShortcuts.registerShortcut(
-			"verifiedSongs.resetKeyboardShortcutsHelper",
-			{
-				keyCode: 191, // '/' key
-				ctrl: true,
-				shift: true,
-				preventDefault: true,
-				handler: () => {
-					this.resetKeyboardShortcutsHelper();
-				}
-			}
-		);
-	},
-	beforeUnmount() {
-		const shortcutNames = [
-			"verifiedSongs.toggleKeyboardShortcutsHelper",
-			"verifiedSongs.resetKeyboardShortcutsHelper"
-		];
-
-		shortcutNames.forEach(shortcutName => {
-			keyboardShortcuts.unregisterShortcut(shortcutName);
-		});
-	},
-	methods: {
-		edit(song) {
-			this.editSong(song);
-			this.openModal("editSong");
-		},
-		unverify(id) {
-			this.socket.dispatch("songs.unverify", id, res => {
-				new Toast(res.message);
-			});
-		},
-		updateAllSongs() {
-			new Toast("Updating all songs, this could take a very long time.");
-			this.socket.dispatch("songs.updateAll", res => {
-				if (res.status === "success") new Toast(res.message);
-				else new Toast(res.message);
-			});
-		},
-		getSet() {
-			if (this.isGettingSet) return;
-			if (this.position >= this.maxPosition) return;
-			this.isGettingSet = true;
-
-			this.socket.dispatch(
-				"songs.getSet",
-				this.position,
-				"verified",
-				res => {
-					if (res.status === "success") {
-						res.data.songs.forEach(song => {
-							this.addSong(song);
-						});
-
-						this.position += 1;
-						this.isGettingSet = false;
-					}
-				}
-			);
-		},
-		toggleArtistSelected(artist) {
-			if (this.artistFilterSelected.indexOf(artist) === -1)
-				this.artistFilterSelected.push(artist);
-			else
-				this.artistFilterSelected.splice(
-					this.artistFilterSelected.indexOf(artist),
-					1
-				);
-		},
-		toggleGenreSelected(genre) {
-			if (this.genreFilterSelected.indexOf(genre) === -1)
-				this.genreFilterSelected.push(genre);
-			else
-				this.genreFilterSelected.splice(
-					this.genreFilterSelected.indexOf(genre),
-					1
-				);
-		},
-		toggleSearchBox() {
-			this.searchBoxShown = !this.searchBoxShown;
-		},
-		toggleFilterArtistsBox() {
-			this.filterArtistBoxShown = !this.filterArtistBoxShown;
-		},
-		toggleFilterGenresBox() {
-			this.filterGenreBoxShown = !this.filterGenreBoxShown;
-		},
-		toggleKeyboardShortcutsHelper() {
-			this.$refs.keyboardShortcutsHelper.toggleBox();
-		},
-		resetKeyboardShortcutsHelper() {
-			this.$refs.keyboardShortcutsHelper.resetBox();
-		},
-		init() {
-			this.position = 1;
-			this.maxPosition = 1;
-			this.resetSongs();
-
-			if (this.songs.length > 0)
-				this.position = Math.ceil(this.songs.length / 15) + 1;
-
-			this.socket.dispatch("songs.length", "verified", res => {
-				if (res.status === "success") {
-					this.maxPosition = Math.ceil(res.data.length / 15) + 1;
-					this.getSet();
-				}
-			});
-
-			this.socket.dispatch("apis.joinAdminRoom", "songs", () => {});
-		},
-		...mapActions("admin/verifiedSongs", [
-			"resetSongs",
-			"addSong",
-			"removeSong",
-			"updateSong"
-		]),
-		...mapActions("modals/editSong", ["editSong"]),
-		...mapActions("modalVisibility", ["openModal", "closeModal"])
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-.night-mode {
-	.box {
-		background-color: var(--dark-grey-3) !important;
-	}
-
-	.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);
-		}
-	}
-}
-
-.box {
-	background-color: var(--light-grey);
-	border-radius: 5px;
-	padding: 8px 16px;
-
-	p {
-		text-align: center;
-		font-size: 24px;
-		user-select: none;
-		cursor: pointer;
-		display: flex;
-		justify-content: center;
-		align-items: center;
-	}
-
-	input[type="text"] {
-		margin-top: 8px;
-		margin-bottom: 8px;
-	}
-
-	label {
-		margin-right: 8px;
-		display: inline-flex;
-		align-items: center;
-
-		input[type="checkbox"] {
-			margin-right: 2px;
-			height: 16px;
-			width: 16px;
-		}
-	}
-}
-
-.optionsColumn {
-	width: 100px;
-
-	div {
-		button {
-			width: 35px;
-
-			&:not(:last-child) {
-				margin-right: 5px;
-			}
-		}
-	}
-}
-
-.likesColumn,
-.dislikesColumn {
-	width: 40px;
-	i {
-		font-size: 20px;
-	}
-	.thumbLike {
-		color: var(--green) !important;
-	}
-	.thumbDislike {
-		color: var(--dark-red) !important;
-	}
-}
-
-.song-thumbnail {
-	display: block;
-	max-width: 50px;
-	margin: 0 auto;
-}
-
-td {
-	vertical-align: middle;
-
-	& > div {
-		display: inline-flex;
-	}
-}
-
-#keyboardShortcutsHelper {
-	.box-body {
-		.biggest {
-			font-size: 18px;
-		}
-
-		.bigger {
-			font-size: 16px;
-		}
-
-		span {
-			display: block;
-		}
-	}
-}
-
-.is-primary:focus {
-	background-color: var(--primary-color) !important;
-}
-</style>

+ 139 - 5
frontend/src/pages/Home.vue

@@ -63,7 +63,40 @@
 								'--primary-color: var(--' + element.theme + ')'
 							"
 						>
-							<song-thumbnail :song="element.currentSong" />
+							<song-thumbnail :song="element.currentSong">
+								<template #icon>
+									<div class="icon-container">
+										<div
+											v-if="isOwnerOrAdmin(element)"
+											class="
+												material-icons
+												manage-station
+											"
+											@click.prevent="
+												manageStation(element._id)
+											"
+											content="Manage Station"
+											v-tippy
+										>
+											settings
+										</div>
+										<div
+											v-else
+											class="
+												material-icons
+												manage-station
+											"
+											@click.prevent="
+												manageStation(element._id)
+											"
+											content="View Queue"
+											v-tippy
+										>
+											queue_music
+										</div>
+									</div>
+								</template>
+							</song-thumbnail>
 							<div class="card-content">
 								<div class="media">
 									<div class="media-left displayName">
@@ -287,7 +320,30 @@
 					}"
 					:style="'--primary-color: var(--' + station.theme + ')'"
 				>
-					<song-thumbnail :song="station.currentSong" />
+					<song-thumbnail :song="station.currentSong">
+						<template #icon>
+							<div class="icon-container">
+								<div
+									v-if="isOwnerOrAdmin(station)"
+									class="material-icons manage-station"
+									@click.prevent="manageStation(station._id)"
+									content="Manage Station"
+									v-tippy
+								>
+									settings
+								</div>
+								<div
+									v-else
+									class="material-icons manage-station"
+									@click.prevent="manageStation(station._id)"
+									content="View Queue"
+									v-tippy
+								>
+									queue_music
+								</div>
+							</div>
+						</template>
+					</song-thumbnail>
 					<div class="card-content">
 						<div class="media">
 							<div class="media-left displayName">
@@ -428,6 +484,16 @@
 			<main-footer />
 		</div>
 		<create-station v-if="modals.createStation" />
+		<manage-station
+			v-if="modals.manageStation"
+			:station-id="editingStationId"
+			sector="home"
+		/>
+		<request-song v-if="modals.requestSong" />
+		<create-playlist v-if="modals.createPlaylist" />
+		<edit-playlist v-if="modals.editPlaylist" />
+		<edit-song v-if="modals.editSong" song-type="songs" sector="home" />
+		<report v-if="modals.report" />
 	</div>
 </template>
 
@@ -452,6 +518,24 @@ export default {
 		CreateStation: defineAsyncComponent(() =>
 			import("@/components/modals/CreateStation.vue")
 		),
+		ManageStation: defineAsyncComponent(() =>
+			import("@/components/modals/ManageStation/index.vue")
+		),
+		RequestSong: defineAsyncComponent(() =>
+			import("@/components/modals/RequestSong.vue")
+		),
+		EditPlaylist: defineAsyncComponent(() =>
+			import("@/components/modals/EditPlaylist")
+		),
+		CreatePlaylist: defineAsyncComponent(() =>
+			import("@/components/modals/CreatePlaylist.vue")
+		),
+		Report: defineAsyncComponent(() =>
+			import("@/components/modals/Report.vue")
+		),
+		EditSong: defineAsyncComponent(() =>
+			import("@/components/modals/EditSong")
+		),
 		UserIdToUsername,
 		draggable
 	},
@@ -463,13 +547,15 @@ export default {
 			searchQuery: "",
 			sitename: "Musare",
 			orderOfFavoriteStations: [],
-			handledLoginRegisterRedirect: false
+			handledLoginRegisterRedirect: false,
+			editingStationId: null
 		};
 	},
 	computed: {
 		...mapState({
 			loggedIn: state => state.user.auth.loggedIn,
 			userId: state => state.user.auth.userId,
+			role: state => state.user.auth.role,
 			modals: state => state.modalVisibility.modals
 		}),
 		...mapGetters({
@@ -726,7 +812,13 @@ export default {
 			this.socket.dispatch("apis.joinRoom", "home");
 		},
 		isOwner(station) {
-			return station.owner === this.userId;
+			return this.loggedIn && station.owner === this.userId;
+		},
+		isAdmin() {
+			return this.loggedIn && this.role === "admin";
+		},
+		isOwnerOrAdmin(station) {
+			return this.isOwner(station) || this.isAdmin();
 		},
 		isPlaying(station) {
 			return typeof station.currentSong.title !== "undefined";
@@ -770,6 +862,10 @@ export default {
 				res => new Toast(res.message)
 			);
 		},
+		manageStation(stationId) {
+			this.editingStationId = stationId;
+			this.openModal("manageStation");
+		},
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("station", ["updateIfStationIsFavorited"])
 	}
@@ -881,11 +977,13 @@ html {
 .header {
 	display: flex;
 	height: 35vh;
+	min-height: 300px;
 	margin-top: -64px;
 	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
 
 	img.background {
 		height: 35vh;
+		min-height: 300px;
 		width: 100%;
 		object-fit: cover;
 		object-position: center;
@@ -904,6 +1002,7 @@ html {
 		);
 		position: absolute;
 		height: 35vh;
+		min-height: 300px;
 		width: 100%;
 		border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
 		overflow: hidden;
@@ -915,8 +1014,8 @@ html {
 		margin-left: auto;
 		margin-right: auto;
 		text-align: center;
-		height: 100%;
 		height: 35vh;
+		min-height: 300px;
 		.content {
 			position: absolute;
 			top: 50%;
@@ -962,10 +1061,12 @@ html {
 	}
 	&.loggedIn {
 		height: 20vh;
+		min-height: 200px;
 		.overlay,
 		.content-container,
 		img.background {
 			height: 20vh;
+			min-height: 200px;
 		}
 	}
 }
@@ -1157,6 +1258,39 @@ html {
 			position: relative;
 			padding-top: 100%;
 		}
+
+		.icon-container {
+			display: flex;
+			position: absolute;
+			z-index: 2;
+			top: 0;
+			bottom: 0;
+			left: 0;
+			right: 0;
+
+			.material-icons.manage-station {
+				display: inline-flex;
+				opacity: 0;
+				background: var(--primary-color);
+				color: var(--white);
+				margin: auto;
+				font-size: 40px;
+				border-radius: 100%;
+				padding: 10px;
+				transition: all 0.2s ease-in-out;
+			}
+
+			&:hover,
+			&:focus {
+				.material-icons.manage-station {
+					opacity: 1;
+					&:hover,
+					&:focus {
+						filter: brightness(90%);
+					}
+				}
+			}
+		}
 	}
 
 	.bottomBar {

+ 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;
 

+ 4 - 6
frontend/src/pages/Profile/Tabs/RecentActivity.vue

@@ -19,7 +19,7 @@
 					:activity="activity"
 				>
 					<template #actions>
-						<confirm
+						<quick-confirm
 							v-if="userId === myUserId"
 							@confirm="hideActivity(activity._id)"
 						>
@@ -28,7 +28,7 @@
 									>visibility_off</i
 								>
 							</a>
-						</confirm>
+						</quick-confirm>
 					</template>
 				</activity-item>
 			</div>
@@ -45,10 +45,10 @@ import Toast from "toasters";
 
 import ActivityItem from "@/components/ActivityItem.vue";
 import ws from "@/ws";
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: { ActivityItem, Confirm },
+	components: { ActivityItem, QuickConfirm },
 	props: {
 		userId: {
 			type: String,
@@ -154,8 +154,6 @@ export default {
 			const scrollPosition = document.body.clientHeight + window.scrollY;
 			const bottomPosition = document.body.scrollHeight;
 
-			if (this.loadAllSongs) return false;
-
 			if (scrollPosition + 400 >= bottomPosition) this.getSet();
 
 			return this.maxPosition === this.position;

+ 4 - 4
frontend/src/pages/Settings/Tabs/Account.vue

@@ -66,12 +66,12 @@
 		<hr class="section-horizontal-rule" />
 
 		<div class="row">
-			<confirm @confirm="removeActivities()">
+			<quick-confirm @confirm="removeActivities()">
 				<a class="button is-warning">
 					<i class="material-icons icon-with-button">cancel</i>
 					Clear my activities
 				</a>
-			</confirm>
+			</quick-confirm>
 
 			<a class="button is-danger" @click="openModal('removeAccount')">
 				<i class="material-icons icon-with-button">delete</i>
@@ -88,13 +88,13 @@ import Toast from "toasters";
 import InputHelpBox from "@/components/InputHelpBox.vue";
 import SaveButton from "@/components/SaveButton.vue";
 import validation from "@/validation";
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
 	components: {
 		InputHelpBox,
 		SaveButton,
-		Confirm
+		QuickConfirm
 	},
 	data() {
 		return {

+ 11 - 8
frontend/src/pages/Settings/Tabs/Security.vue

@@ -125,18 +125,21 @@
 			<hr class="section-horizontal-rule" />
 
 			<div class="row">
-				<confirm v-if="isPasswordLinked" @confirm="unlinkPassword()">
+				<quick-confirm
+					v-if="isPasswordLinked"
+					@confirm="unlinkPassword()"
+				>
 					<a class="button is-danger">
 						<i class="material-icons icon-with-button">close</i>
 						Remove password
 					</a>
-				</confirm>
-				<confirm v-if="isGithubLinked" @confirm="unlinkGitHub()">
+				</quick-confirm>
+				<quick-confirm v-if="isGithubLinked" @confirm="unlinkGitHub()">
 					<a class="button is-danger">
 						<i class="material-icons icon-with-button">link_off</i>
 						Remove GitHub from account
 					</a>
-				</confirm>
+				</quick-confirm>
 			</div>
 
 			<div class="section-margin-bottom" />
@@ -150,14 +153,14 @@
 
 			<hr class="section-horizontal-rule" />
 			<div class="row">
-				<confirm @confirm="removeSessions()">
+				<quick-confirm @confirm="removeSessions()">
 					<a class="button is-warning">
 						<i class="material-icons icon-with-button"
 							>exit_to_app</i
 						>
 						Logout everywhere
 					</a>
-				</confirm>
+				</quick-confirm>
 			</div>
 		</div>
 	</div>
@@ -169,10 +172,10 @@ import { mapGetters, mapState } from "vuex";
 
 import InputHelpBox from "@/components/InputHelpBox.vue";
 import validation from "@/validation";
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: { InputHelpBox, Confirm },
+	components: { InputHelpBox, QuickConfirm },
 	data() {
 		return {
 			apiDomain: "",

+ 7 - 7
frontend/src/pages/Station/Sidebar/Playlists.vue

@@ -40,7 +40,7 @@
 								v-tippy
 								>play_arrow</i
 							>
-							<confirm
+							<quick-confirm
 								v-if="
 									station.type === 'community' &&
 									(isOwnerOrAdmin() || station.partyMode) &&
@@ -58,8 +58,8 @@
 									v-tippy
 									>stop</i
 								>
-							</confirm>
-							<confirm
+							</quick-confirm>
+							<quick-confirm
 								v-if="
 									station.type === 'community' &&
 									isOwnerOrAdmin() &&
@@ -73,7 +73,7 @@
 									v-tippy
 									>block</i
 								>
-							</confirm>
+							</quick-confirm>
 							<i
 								@click="edit(element._id)"
 								class="material-icons edit-icon"
@@ -107,10 +107,10 @@ import ws from "@/ws";
 
 import PlaylistItem from "@/components/PlaylistItem.vue";
 import SortablePlaylists from "@/mixins/SortablePlaylists.vue";
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: { PlaylistItem, Confirm },
+	components: { PlaylistItem, QuickConfirm },
 	mixins: [SortablePlaylists],
 	computed: {
 		currentPlaylists() {
@@ -174,7 +174,7 @@ export default {
 	methods: {
 		init() {
 			/** Get playlists for user */
-			this.socket.dispatch("playlists.indexMyPlaylists", true, res => {
+			this.socket.dispatch("playlists.indexMyPlaylists", res => {
 				if (res.status === "success")
 					this.setPlaylists(res.data.playlists);
 				this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database

部分文件因为文件数量过多而无法显示