Răsfoiți Sursa

Merge remote-tracking branch 'origin/staging' into staging

Owen Diffey 3 ani în urmă
părinte
comite
6c65ad0274
71 a modificat fișierele cu 4865 adăugiri și 2843 ștergeri
  1. 1 0
      .wiki/Backend_Commands.md
  2. 9 0
      .wiki/Configuration.md
  3. 13 0
      SECURITY.md
  4. 28 0
      backend/index.js
  5. 236 26
      backend/logic/actions/playlists.js
  6. 0 2
      backend/logic/actions/reports.js
  7. 113 67
      backend/logic/actions/songs.js
  8. 0 1
      backend/logic/actions/stations.js
  9. 133 8
      backend/logic/actions/users.js
  10. 4 4
      backend/logic/app.js
  11. 1 1
      backend/logic/db/index.js
  12. 2 3
      backend/logic/db/schemas/playlist.js
  13. 65 0
      backend/logic/migration/migrations/migration16.js
  14. 3 4
      backend/logic/playlists.js
  15. 107 2
      backend/logic/songs.js
  16. 1 1
      backend/package-lock.json
  17. 1 1
      backend/package.json
  18. 4 2
      docker-compose.yml
  19. BIN
      frontend/dist/assets/15-seconds-of-silence.mp3
  20. 12 1
      frontend/dist/config/template.json
  21. BIN
      frontend/dist/fonts/MaterialIcons-Regular.ttf
  22. 10 2
      frontend/dist/index.tpl.html
  23. 0 23
      frontend/dist/vendor/can-autoplay.min.js
  24. 386 89
      frontend/package-lock.json
  25. 3 1
      frontend/package.json
  26. 179 31
      frontend/src/App.vue
  27. 8 16
      frontend/src/components/AddToPlaylistDropdown.vue
  28. 1646 0
      frontend/src/components/AdvancedTable.vue
  29. 2 0
      frontend/src/components/Modal.vue
  30. 4 4
      frontend/src/components/Queue.vue
  31. 2 2
      frontend/src/components/QuickConfirm.vue
  32. 112 0
      frontend/src/components/RunJobDropdown.vue
  33. 1 0
      frontend/src/components/SongThumbnail.vue
  34. 3 2
      frontend/src/components/layout/MainFooter.vue
  35. 1 0
      frontend/src/components/layout/MainHeader.vue
  36. 24 1
      frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue
  37. 13 3
      frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue
  38. 25 25
      frontend/src/components/modals/EditPlaylist/index.vue
  39. 17 21
      frontend/src/components/modals/EditSong/Tabs/Songs.vue
  40. 16 20
      frontend/src/components/modals/EditSong/Tabs/Youtube.vue
  41. 11 13
      frontend/src/components/modals/EditSong/index.vue
  42. 30 6
      frontend/src/components/modals/EditUser.vue
  43. 30 14
      frontend/src/components/modals/ImportAlbum.vue
  44. 6 4
      frontend/src/components/modals/ManageStation/Tabs/Blacklist.vue
  45. 19 19
      frontend/src/components/modals/ManageStation/Tabs/Playlists.vue
  46. 1 1
      frontend/src/components/modals/ManageStation/Tabs/Settings.vue
  47. 34 17
      frontend/src/components/modals/ManageStation/index.vue
  48. 4 4
      frontend/src/components/modals/RemoveAccount.vue
  49. 5 1
      frontend/src/main.js
  50. 0 51
      frontend/src/mixins/ScrollAndFetchHandler.vue
  51. 2 2
      frontend/src/mixins/SearchMusare.vue
  52. 102 0
      frontend/src/ms.js
  53. 41 60
      frontend/src/pages/Admin/index.vue
  54. 0 613
      frontend/src/pages/Admin/tabs/HiddenSongs.vue
  55. 4 4
      frontend/src/pages/Admin/tabs/News.vue
  56. 42 140
      frontend/src/pages/Admin/tabs/Playlists.vue
  57. 639 0
      frontend/src/pages/Admin/tabs/Songs.vue
  58. 14 16
      frontend/src/pages/Admin/tabs/Stations.vue
  59. 358 0
      frontend/src/pages/Admin/tabs/Test.vue
  60. 0 643
      frontend/src/pages/Admin/tabs/UnverifiedSongs.vue
  61. 0 711
      frontend/src/pages/Admin/tabs/VerifiedSongs.vue
  62. 139 5
      frontend/src/pages/Home.vue
  63. 4 6
      frontend/src/pages/Profile/Tabs/RecentActivity.vue
  64. 4 4
      frontend/src/pages/Settings/Tabs/Account.vue
  65. 11 8
      frontend/src/pages/Settings/Tabs/Security.vue
  66. 7 7
      frontend/src/pages/Station/Sidebar/Playlists.vue
  67. 0 17
      frontend/src/pages/Station/Sidebar/index.vue
  68. 67 13
      frontend/src/pages/Station/index.vue
  69. 4 93
      frontend/src/store/modules/admin.js
  70. 8 3
      frontend/src/store/modules/modals/importAlbum.js
  71. 94 5
      frontend/webpack.common.js

+ 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

+ 9 - 0
.wiki/Configuration.md

@@ -73,12 +73,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`
 

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

+ 28 - 0
backend/index.js

@@ -2,6 +2,9 @@ import "./loadEnvVariables.js";
 
 import util from "util";
 import config from "config";
+import fs from "fs";
+
+import package_json from "./package.json";
 
 const REQUIRED_CONFIG_VERSION = 8;
 
@@ -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();

+ 236 - 26
backend/logic/actions/playlists.js

@@ -416,7 +416,7 @@ export default {
 
 					const match = {
 						createdBy: userId,
-						type: "user"
+						type: { $in: ["user", "user-liked", "user-disliked"] }
 					};
 
 					// if a playlist order exists
@@ -472,10 +472,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);
 
@@ -490,12 +489,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 };
 
@@ -959,12 +955,30 @@ export default {
 										return next("That song is already in the playlist");
 									return nextSong();
 								},
-								err => next(err)
+								err => next(err, playlist)
 							);
 						})
 						.catch(next);
 				},
 
+				(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 => {
@@ -1014,9 +1028,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(
@@ -1033,7 +1059,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;
@@ -1069,6 +1095,55 @@ export default {
 					}
 				});
 
+				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",
@@ -1115,6 +1190,8 @@ export default {
 					let successful = 0;
 					let failed = 0;
 					let alreadyInPlaylist = 0;
+					let alreadyInLikedPlaylist = 0;
+					let alreadyInDislikedPlaylist = 0;
 
 					if (youtubeIds.length === 0) next();
 
@@ -1138,6 +1215,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;
@@ -1145,7 +1236,13 @@ export default {
 								.finally(() => next());
 						},
 						() => {
-							addSongsStats = { successful, failed, alreadyInPlaylist };
+							addSongsStats = {
+								successful,
+								failed,
+								alreadyInPlaylist,
+								alreadyInLikedPlaylist,
+								alreadyInDislikedPlaylist
+							};
 							next(null);
 						}
 					);
@@ -1159,7 +1256,6 @@ export default {
 
 				(playlist, next) => {
 					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found.");
-					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
 
 					return next(null, playlist);
 				}
@@ -1188,7 +1284,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({
@@ -1198,7 +1294,9 @@ export default {
 						songs: playlist.songs,
 						stats: {
 							videosInPlaylistTotal,
-							songsInPlaylistTotal
+							songsInPlaylistTotal,
+							alreadyInLikedPlaylist: addSongsStats.alreadyInLikedPlaylist,
+							alreadyInDislikedPlaylist: addSongsStats.alreadyInDislikedPlaylist
 						}
 					}
 				});
@@ -1247,9 +1345,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(() => {
@@ -1259,14 +1359,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", {
@@ -1274,13 +1386,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);
 				}
 			],
@@ -1339,7 +1503,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);
 				},
 
@@ -1422,7 +1586,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);
 				},
 
@@ -1502,7 +1666,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);
 				},
 
@@ -2202,5 +2366,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"
+				});
+			}
+		);
 	})
 };

+ 0 - 2
backend/logic/actions/reports.js

@@ -78,8 +78,6 @@ export default {
 					async.each(
 						_reports,
 						(report, cb) => {
-							console.log(typeof report.createdBy);
-
 							userModel
 								.findById(report.createdBy)
 								.select({ avatar: -1, name: -1, username: -1 })

+ 113 - 67
backend/logic/actions/songs.js

@@ -150,65 +150,70 @@ export default {
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param cb
 	 */
-	length: isAdminRequired(async function length(session, status, cb) {
+	length: isAdminRequired(async function length(session, cb) {
 		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
 		async.waterfall(
 			[
 				next => {
-					songModel.countDocuments({ status }, next);
+					songModel.countDocuments({}, next);
 				}
 			],
 			async (err, count) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"SONGS_LENGTH",
-						`Failed to get length from songs that have the status ${status}. "${err}"`
-					);
+					this.log("ERROR", "SONGS_LENGTH", `Failed to get length from songs. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
-				this.log(
-					"SUCCESS",
-					"SONGS_LENGTH",
-					`Got length from songs that have the status ${status} successfully.`
-				);
+				this.log("SUCCESS", "SONGS_LENGTH", `Got length from songs successfully.`);
 				return cb({ status: "success", message: "Successfully got length of songs.", data: { length: count } });
 			}
 		);
 	}),
 
 	/**
-	 * Gets a set of songs
+	 * Gets songs, used in the admin songs page by the AdvancedTable component
 	 *
 	 * @param {object} session - the session object automatically added by the websocket
-	 * @param set - the set number to return
+	 * @param page - the page
+	 * @param pageSize - the size per page
+	 * @param properties - the properties to return for each song
+	 * @param sort - the sort object
+	 * @param queries - the queries array
+	 * @param operator - the operator for queries
 	 * @param cb
 	 */
-	getSet: isAdminRequired(async function getSet(session, set, status, cb) {
-		const songModel = await DBModule.runJob("GET_MODEL", { modelName: "song" }, this);
+	getData: isAdminRequired(async function getSet(session, page, pageSize, properties, sort, queries, operator, cb) {
 		async.waterfall(
 			[
 				next => {
-					songModel
-						.find({ status })
-						.skip(15 * (set - 1))
-						.limit(15)
-						.exec(next);
+					SongsModule.runJob(
+						"GET_DATA",
+						{
+							page,
+							pageSize,
+							properties,
+							sort,
+							queries,
+							operator
+						},
+						this
+					)
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
 				}
 			],
-			async (err, songs) => {
+			async (err, response) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"SONGS_GET_SET",
-						`Failed to get set from songs that have the status ${status}. "${err}"`
-					);
+					this.log("ERROR", "SONGS_GET_DATA", `Failed to get data from songs. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
-				this.log("SUCCESS", "SONGS_GET_SET", `Got set from songs that have the status ${status} successfully.`);
-				return cb({ status: "success", message: "Successfully got set of songs.", data: { songs } });
+				this.log("SUCCESS", "SONGS_GET_DATA", `Got data from songs successfully.`);
+				return cb({ status: "success", message: "Successfully got data from songs.", data: response });
 			}
 		);
 	}),
@@ -219,7 +224,7 @@ export default {
 	 * @param {object} session - the session object automatically added by the websocket
 	 * @param cb
 	 */
-	updateAll: isAdminRequired(async function length(session, cb) {
+	updateAll: isAdminRequired(async function updateAll(session, cb) {
 		async.waterfall(
 			[
 				next => {
@@ -244,6 +249,41 @@ export default {
 		);
 	}),
 
+	/**
+	 * Recalculates all song ratings
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param cb
+	 */
+	recalculateAllRatings: isAdminRequired(async function recalculateAllRatings(session, cb) {
+		async.waterfall(
+			[
+				next => {
+					SongsModule.runJob("RECALCULATE_ALL_SONG_RATINGS", {}, this)
+						.then(() => {
+							next();
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"SONGS_RECALCULATE_ALL_RATINGS",
+						`Failed to recalculate all song ratings. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "SONGS_RECALCULATE_ALL_RATINGS", `Recalculated all song ratings successfully.`);
+				return cb({ status: "success", message: "Successfully recalculated all song ratings." });
+			}
+		);
+	}),
+
 	/**
 	 * Gets a song from the Musare song id
 	 *
@@ -584,7 +624,7 @@ export default {
 					});
 
 					SongsModule.runJob("UPDATE_SONG", { songId: song._id, oldStatus });
-					next(null, song);
+					next(null, song, oldStatus);
 				}
 			],
 			async err => {
@@ -801,42 +841,41 @@ export default {
 							{
 								session,
 								namespace: "playlists",
-								action: "addSongToPlaylist",
-								args: [false, youtubeId, user.likedSongsPlaylist]
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.dislikedSongsPlaylist]
 							},
 							this
 						)
 						.then(res => {
-							if (res.status === "error") {
-								if (res.message === "That song is already in the playlist")
-									return next("You have already liked this song.");
-								return next("Unable to add song to the 'Liked Songs' playlist.");
-							}
-
-							return next(null, song, user.dislikedSongsPlaylist);
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							return next(null, song, user.likedSongsPlaylist);
 						})
 						.catch(err => next(err));
 				},
 
-				(song, dislikedSongsPlaylist, next) => {
+				(song, likedSongsPlaylist, next) =>
 					this.module
 						.runJob(
 							"RUN_ACTION2",
 							{
 								session,
 								namespace: "playlists",
-								action: "removeSongFromPlaylist",
-								args: [youtubeId, dislikedSongsPlaylist]
+								action: "addSongToPlaylist",
+								args: [false, youtubeId, likedSongsPlaylist]
 							},
 							this
 						)
 						.then(res => {
-							if (res.status === "error")
-								return next("Unable to remove song from the 'Disliked Songs' playlist.");
+							if (res.status === "error") {
+								if (res.message === "That song is already in the playlist")
+									return next("You have already liked this song.");
+								return next("Unable to add song to the 'Liked Songs' playlist.");
+							}
+
 							return next(null, song);
 						})
-						.catch(err => next(err));
-				},
+						.catch(err => next(err)),
 
 				(song, next) => {
 					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
@@ -844,7 +883,7 @@ export default {
 						.catch(err => next(err));
 				}
 			],
-			async (err, song, { likes, dislikes }) => {
+			async (err, song, ratings) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log(
@@ -855,6 +894,8 @@ export default {
 					return cb({ status: "error", message: err });
 				}
 
+				const { likes, dislikes } = ratings;
+
 				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
 
 				CacheModule.runJob("PUB", {
@@ -918,42 +959,41 @@ export default {
 							{
 								session,
 								namespace: "playlists",
-								action: "addSongToPlaylist",
-								args: [false, youtubeId, user.dislikedSongsPlaylist]
+								action: "removeSongFromPlaylist",
+								args: [youtubeId, user.likedSongsPlaylist]
 							},
 							this
 						)
 						.then(res => {
-							if (res.status === "error") {
-								if (res.message === "That song is already in the playlist")
-									return next("You have already disliked this song.");
-								return next("Unable to add song to the 'Disliked Songs' playlist.");
-							}
-
-							return next(null, song, user.likedSongsPlaylist);
+							if (res.status === "error")
+								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							return next(null, song, user.dislikedSongsPlaylist);
 						})
 						.catch(err => next(err));
 				},
 
-				(song, likedSongsPlaylist, next) => {
+				(song, dislikedSongsPlaylist, next) =>
 					this.module
 						.runJob(
 							"RUN_ACTION2",
 							{
 								session,
 								namespace: "playlists",
-								action: "removeSongFromPlaylist",
-								args: [youtubeId, likedSongsPlaylist]
+								action: "addSongToPlaylist",
+								args: [false, youtubeId, dislikedSongsPlaylist]
 							},
 							this
 						)
 						.then(res => {
-							if (res.status === "error")
-								return next("Unable to remove song from the 'Liked Songs' playlist.");
+							if (res.status === "error") {
+								if (res.message === "That song is already in the playlist")
+									return next("You have already disliked this song.");
+								return next("Unable to add song to the 'Disliked Songs' playlist.");
+							}
+
 							return next(null, song);
 						})
-						.catch(err => next(err));
-				},
+						.catch(err => next(err)),
 
 				(song, next) => {
 					SongsModule.runJob("RECALCULATE_SONG_RATINGS", { songId: song._id, youtubeId })
@@ -961,7 +1001,7 @@ export default {
 						.catch(err => next(err));
 				}
 			],
-			async (err, song, { likes, dislikes }) => {
+			async (err, song, ratings) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log(
@@ -972,6 +1012,8 @@ export default {
 					return cb({ status: "error", message: err });
 				}
 
+				const { likes, dislikes } = ratings;
+
 				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
 
 				CacheModule.runJob("PUB", {
@@ -1074,7 +1116,7 @@ export default {
 						.catch(err => next(err));
 				}
 			],
-			async (err, song, { likes, dislikes }) => {
+			async (err, song, ratings) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log(
@@ -1085,6 +1127,8 @@ export default {
 					return cb({ status: "error", message: err });
 				}
 
+				const { likes, dislikes } = ratings;
+
 				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
 
 				CacheModule.runJob("PUB", {
@@ -1189,7 +1233,7 @@ export default {
 						.catch(err => next(err));
 				}
 			],
-			async (err, song, { likes, dislikes }) => {
+			async (err, song, ratings) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log(
@@ -1200,6 +1244,8 @@ export default {
 					return cb({ status: "error", message: err });
 				}
 
+				const { likes, dislikes } = ratings;
+
 				SongsModule.runJob("UPDATE_SONG", { songId: song._id });
 
 				CacheModule.runJob("PUB", {

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

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

+ 133 - 8
backend/logic/actions/users.js

@@ -273,7 +273,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)
@@ -288,7 +288,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)
@@ -441,7 +441,7 @@ export default {
 				},
 
 				next => {
-					playlistModel.findOne({ createdBy: userId, displayName: "Liked Songs" }, next);
+					playlistModel.findOne({ createdBy: userId, type: "user-liked" }, next);
 				},
 
 				// get all liked songs (as the global rating values for these songs will need adjusted)
@@ -456,7 +456,7 @@ export default {
 				},
 
 				next => {
-					playlistModel.findOne({ createdBy: userId, displayName: "Disliked Songs" }, next);
+					playlistModel.findOne({ createdBy: userId, type: "user-disliked" }, next);
 				},
 
 				// get all disliked songs (as the global rating values for these songs will need adjusted)
@@ -753,10 +753,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);
@@ -766,10 +766,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);
@@ -2546,6 +2546,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
 	 *
@@ -2658,6 +2733,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
 	 *

+ 4 - 4
backend/logic/app.js

@@ -325,10 +325,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 +338,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);

+ 1 - 1
backend/logic/db/index.js

@@ -8,7 +8,7 @@ import CoreClass from "../../core";
 const REQUIRED_DOCUMENT_VERSIONS = {
 	activity: 2,
 	news: 2,
-	playlist: 4,
+	playlist: 5,
 	punishment: 1,
 	queueSong: 1,
 	report: 5,

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

@@ -2,7 +2,6 @@ import mongoose from "mongoose";
 
 export default {
 	displayName: { type: String, min: 2, max: 96, 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", "artist"], required: true },
-	documentVersion: { type: Number, default: 4, required: true }
+	type: { type: String, enum: ["user", "user-liked", "user-disliked", "genre", "artist", "station"], required: true },
+	documentVersion: { type: Number, default: 5, required: true }
 };

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

+ 3 - 4
backend/logic/playlists.js

@@ -134,18 +134,18 @@ class _PlaylistsModule extends CoreClass {
 	// }
 
 	/**
-	 * 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 +178,6 @@ class _PlaylistsModule extends CoreClass {
 					if (err.message === "Playlist not found") {
 						PlaylistsModule.playlistModel.create(
 							{
-								isUserModifiable: false,
 								displayName: `Genre - ${payload.genre}`,
 								songs: [],
 								createdBy: "Musare",

+ 107 - 2
backend/logic/songs.js

@@ -206,6 +206,71 @@ class _SongsModule extends CoreClass {
 		);
 	}
 
+	/**
+	 * Gets songs data
+	 *
+	 * @param {object} payload - object containing the payload
+	 * @param {string} payload.page - the page
+	 * @param {string} payload.pageSize - the page size
+	 * @param {string} payload.properties - the properties to return for each song
+	 * @param {string} payload.sort - the sort object
+	 * @param {string} payload.queries - the queries array
+	 * @param {string} payload.operator - the operator for queries
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_DATA(payload) {
+		return new Promise((resolve, reject) => {
+			const { page, pageSize, properties, sort, queries, operator } = payload;
+
+			console.log("GET_DATA", payload);
+
+			const newQueries = queries.map(query => {
+				const { data, filter, filterType } = query;
+				const newQuery = {};
+				if (filterType === "regex") {
+					newQuery[filter.property] = new RegExp(`${data.slice(1, data.length - 1)}`, "i");
+				} else if (filterType === "contains") {
+					newQuery[filter.property] = new RegExp(`${data.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "i");
+				} else if (filterType === "exact") {
+					newQuery[filter.property] = data.toString();
+				}
+				return newQuery;
+			});
+
+			const queryObject = {};
+			if (newQueries.length > 0) {
+				if (operator === "and") queryObject.$and = newQueries;
+				else if (operator === "or") queryObject.$or = newQueries;
+				else if (operator === "nor") queryObject.$nor = newQueries;
+			}
+
+			async.waterfall(
+				[
+					next => {
+						SongsModule.SongModel.find(queryObject).count((err, count) => {
+							next(err, count);
+						});
+					},
+
+					(count, next) => {
+						SongsModule.SongModel.find(queryObject)
+							.sort(sort)
+							.skip(pageSize * (page - 1))
+							.limit(pageSize)
+							.select(properties.join(" "))
+							.exec((err, songs) => {
+								next(err, count, songs);
+							});
+					}
+				],
+				(err, count, songs) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve({ data: songs, count });
+				}
+			);
+		});
+	}
+
 	/**
 	 * Makes sure that if a song is not currently in the songs db, to add it
 	 *
@@ -729,7 +794,7 @@ class _SongsModule extends CoreClass {
 				[
 					next => {
 						playlistModel.countDocuments(
-							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, displayName: "Liked Songs" },
+							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, type: "user-liked" },
 							(err, likes) => {
 								if (err) return next(err);
 								return next(null, likes);
@@ -739,7 +804,7 @@ class _SongsModule extends CoreClass {
 
 					(likes, next) => {
 						playlistModel.countDocuments(
-							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, displayName: "Disliked Songs" },
+							{ songs: { $elemMatch: { youtubeId: payload.youtubeId } }, type: "user-disliked" },
 							(err, dislikes) => {
 								if (err) return next(err);
 								return next(err, { likes, dislikes });
@@ -768,6 +833,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
 	 *

+ 1 - 1
backend/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "musare-backend",
-  "version": "3.2.2",
+  "version": "3.3.0-dev",
   "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-dev",
   "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
-

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

BIN
frontend/dist/fonts/MaterialIcons-Regular.ttf


+ 10 - 2
frontend/dist/index.tpl.html

@@ -34,8 +34,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)}})}}});

Fișier diff suprimat deoarece este prea mare
+ 386 - 89
frontend/package-lock.json


+ 3 - 1
frontend/package.json

@@ -5,7 +5,7 @@
     "*.vue"
   ],
   "private": true,
-  "version": "3.2.2",
+  "version": "3.3.0-dev",
   "description": "An open-source collaborative music listening and catalogue curation application. Currently supporting YouTube based content.",
   "main": "main.js",
   "author": "Musare Team",
@@ -42,11 +42,13 @@
   },
   "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",
+    "lofig": "^1.3.4",
     "marked": "^3.0.7",
     "normalize.css": "^8.0.1",
     "toasters": "^2.3.1",

+ 179 - 31
frontend/src/App.vue

@@ -258,17 +258,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 +320,10 @@ export default {
 }
 
 .night-mode {
+	body {
+		background-color: var(--black) !important;
+	}
+
 	div {
 		color: var(--light-grey-2);
 	}
@@ -336,12 +340,13 @@ export default {
 		}
 	}
 
-	.input,
-	.textarea,
-	.select select {
-		background-color: var(--dark-grey);
-		border-color: var(--grey-3);
-		color: var(--white);
+	.control.has-addons .button {
+		background-color: var(--dark-grey-2);
+		border: 0;
+
+		i {
+			color: var(--white);
+		}
 	}
 
 	h1,
@@ -381,6 +386,16 @@ 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);
+		}
+	}
 }
 
 .christmas-mode {
@@ -536,10 +551,6 @@ code {
 	color: var(--dark-red) !important;
 }
 
-body.night-mode {
-	background-color: var(--black) !important;
-}
-
 #toasts-container {
 	z-index: 10000 !important;
 
@@ -836,7 +847,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;
@@ -856,7 +868,7 @@ img {
 			}
 		}
 
-		&[data-theme~="addToPlaylist"] {
+		&[data-theme~="dropdown"] {
 			background-color: var(--dark-grey-2);
 			border: 0 !important;
 
@@ -874,11 +886,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);
 			}
@@ -887,7 +909,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);
@@ -897,7 +920,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);
 			}
@@ -906,7 +930,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);
 			}
@@ -919,7 +944,7 @@ img {
 	letter-spacing: 1px;
 }
 
-.tippy-box[data-theme~="confirm"] {
+.tippy-box[data-theme~="quickConfirm"] {
 	background-color: var(--dark-red);
 	border: 0;
 
@@ -998,49 +1023,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);
 	}
 }
@@ -1055,7 +1084,7 @@ img {
 	}
 }
 
-.tippy-box[data-theme~="addToPlaylist"] {
+.tippy-box[data-theme~="dropdown"] {
 	font-size: 15px;
 	padding: 0;
 	border: 1px solid var(--light-grey-3);
@@ -1071,7 +1100,7 @@ img {
 	.nav-dropdown-items {
 		max-height: 220px;
 		overflow-y: auto;
-		padding: 10px 10px 0 10px;
+		padding: 10px;
 
 		.nav-item {
 			width: 100%;
@@ -1120,6 +1149,10 @@ img {
 					background-color: var(--light-grey-3);
 					transition: 0.2s;
 					border-radius: 34px;
+
+					&.disabled {
+						cursor: not-allowed;
+					}
 				}
 
 				.slider:before {
@@ -1173,6 +1206,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: 500px !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;
 }
@@ -1211,6 +1267,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;
@@ -1323,6 +1419,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,
@@ -1878,4 +1984,46 @@ 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 {
+	height: 10px;
+	width: 10px;
+}
+
+::-webkit-scrollbar-track {
+	background-color: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+	background-color: var(--primary-color);
+}
+
+::-webkit-scrollbar-corner {
+	background-color: transparent;
+}
+
+:disabled,
+.disabled {
+	cursor: not-allowed;
+}
 </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];

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

@@ -0,0 +1,1646 @@
+<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.name"
+											:selected="
+												filter.filter
+													.defaultFilterType ===
+												filterType.name
+											"
+										>
+											{{ filterType.displayName }}
+										</option>
+									</select>
+								</div>
+								<p class="control is-expanded">
+									<input
+										v-model="filter.data"
+										class="input"
+										type="text"
+										placeholder="Search value"
+										:disabled="!filter.filterType"
+										@keydown.enter="applyFilterAndGetData()"
+									/>
+								</p>
+								<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 }} "{{ 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'
+										"
+										: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
+													v-if="column.hidable"
+													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">
+					<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="
+										!(
+											column.name === 'select' &&
+											data.length === 0
+										) &&
+										shownColumns.indexOf(column.name) !== -1
+									"
+									: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
+												type="checkbox"
+												:checked="
+													data.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 data"
+							:key="item._id"
+							:class="{
+								selected: item.selected,
+								highlighted: item.highlighted
+							}"
+						>
+							<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 =>
+												item[property] !== undefined
+										)
+									"
+								></slot>
+								<p
+									class="checkbox"
+									v-if="column.name === 'select'"
+								>
+									<input
+										type="checkbox"
+										:checked="item.selected"
+										@click="
+											toggleSelectedRow(itemIndex, $event)
+										"
+									/>
+								</p>
+								<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 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 }}</p>
+
+					<button
+						:class="{ disabled: page === lastPage }"
+						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 }"
+						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="selectedRows.length > 0"
+			class="bulk-popup"
+			:style="{
+				top: bulkPopup.top + 'px',
+				left: bulkPopup.left + 'px'
+			}"
+		>
+			<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">
+				<slot name="bulk-actions-right" :item="selectedRows" />
+				<span
+					class="material-icons drag-icon"
+					@mousedown.left="onDragBox"
+					@touchstart="onDragBox"
+					@dblclick="resetBulkActionsPosition()"
+				>
+					drag_indicator
+				</span>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapGetters } from "vuex";
+import draggable from "vuedraggable";
+
+import Toast from "toasters";
+
+import ws from "@/ws";
+
+export default {
+	components: {
+		draggable
+	},
+	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 }
+	},
+	data() {
+		return {
+			page: 1,
+			pageSize: 10,
+			data: [],
+			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"
+				}
+			},
+			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
+		};
+	},
+	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.data.findIndex(item => item.highlighted);
+		},
+		selectedRows() {
+			return this.data.filter(data => data.selected);
+		},
+		...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();
+
+		this.orderedColumns = [
+			{
+				name: "select",
+				displayName: "",
+				properties: [],
+				sortable: false,
+				hidable: false,
+				draggable: false,
+				resizable: false,
+				minWidth: 47,
+				defaultWidth: 47,
+				maxWidth: 47
+			},
+			...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"
+			}
+		].sort((columnA, columnB) => {
+			// Always places select column in the first position
+			if (columnA.name === "select") return -1;
+			// Always places placeholder column in the last position
+			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' 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 (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);
+	},
+	unmounted() {
+		window.removeEventListener("resize", this.onWindowResize);
+	},
+	methods: {
+		init() {
+			this.getData();
+		},
+		getData() {
+			this.socket.dispatch(
+				this.dataAction,
+				this.page,
+				this.pageSize,
+				this.properties,
+				this.sort,
+				this.appliedFilters,
+				this.appliedFilterOperator,
+				res => {
+					console.log(111, res);
+					if (res.status === "success") {
+						const { data, count } = res.data;
+						this.data = data.map(row => ({
+							...row,
+							selected: false
+						}));
+						this.count = count;
+					} else {
+						new Toast(res.message);
+					}
+				}
+			);
+		},
+		changePageSize() {
+			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();
+		},
+		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 (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.data[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
+						) {
+							this.data[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
+						) {
+							this.data[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.data[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
+						) {
+							this.data[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
+						) {
+							this.data[itemIndexDown].selected = false;
+						}
+					}
+				}
+			}
+			// Neither ctrl nor shift were pressed, so toggle clicked item
+			else {
+				this.data[itemIndex].selected = !this.data[itemIndex].selected;
+			}
+
+			// Set the last clicked item to no longer be highlighted, if it exists
+			if (this.lastSelectedItemIndex >= 0)
+				this.data[this.lastSelectedItemIndex].highlighted = false;
+			// Set the clicked item to be highlighted
+			this.data[itemIndex].highlighted = true;
+		},
+		toggleAllRows() {
+			if (this.data.length > this.selectedRows.length) {
+				this.data = this.data.map(row => ({ ...row, selected: true }));
+			} else {
+				this.data = this.data.map(row => ({ ...row, selected: false }));
+			}
+		},
+		addFilterItem() {
+			this.editingFilters.push({
+				data: "",
+				filter: this.addFilterValue,
+				filterType: 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.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.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(
+				// max-width of table is 1880px
+				(Math.min(1880, 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() {
+			return JSON.parse(
+				localStorage.getItem(`advancedTableSettings:${this.name}`)
+			);
+		},
+		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(() => {
+				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
+					})
+				);
+			}, 250);
+		},
+		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;
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.night-mode {
+	.table-outer-container {
+		.table-container .table {
+			&,
+			thead th {
+				background-color: var(--dark-grey-3);
+				color: var(--light-grey-2);
+			}
+
+			tr {
+				th,
+				td {
+					border-color: var(--dark-grey) !important;
+
+					&:first-child {
+						background-color: var(--dark-grey-3) !important;
+					}
+				}
+
+				&:nth-child(even) {
+					&,
+					td:first-child {
+						background-color: var(--dark-grey-2) !important;
+					}
+				}
+
+				&:hover,
+				&:focus,
+				&.highlighted {
+					th,
+					td {
+						&,
+						&:first-child {
+							background-color: var(--dark-grey-4) !important;
+						}
+					}
+				}
+			}
+		}
+
+		.table-header,
+		.table-footer {
+			background-color: var(--dark-grey-3);
+			color: var(--light-grey-2);
+		}
+
+		.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 {
+					&.highlighted {
+						background-color: var(--light-grey);
+					}
+
+					td {
+						border: 1px solid var(--light-grey-2);
+						border-width: 0 1px 1px 0;
+
+						&:last-child {
+							border-width: 0 0 1px;
+						}
+					}
+				}
+			}
+		}
+
+		table thead tr,
+		table tbody tr {
+			th,
+			td {
+				position: relative;
+				white-space: nowrap;
+				text-overflow: ellipsis;
+				overflow: hidden;
+
+				&:first-child {
+					position: sticky;
+					left: 0;
+					background-color: var(--white);
+					z-index: 2;
+				}
+
+				.resizer {
+					height: 100%;
+					width: 5px;
+					background-color: transparent;
+					cursor: col-resize;
+					position: absolute;
+					right: 0;
+					top: 0;
+				}
+			}
+
+			&:nth-child(even) td:first-child {
+				background-color: #fafafa;
+			}
+
+			&:hover,
+			&:focus,
+			&.highlighted {
+				th,
+				td {
+					&,
+					&:first-child {
+						background-color: var(--light-grey);
+					}
+				}
+			}
+		}
+	}
+
+	.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;
+		}
+	}
+
+	.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;
+			}
+		}
+	}
+}
+
+.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,
+		& > 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: 500px) {
+		&.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;
+				}
+				&:nth-child(3) input {
+					border-radius: 0 0 0 5px;
+				}
+				&:nth-child(4) button {
+					border-radius: 0 0 5px 0;
+				}
+			}
+		}
+	}
+}
+.advanced-filter-bottom {
+	display: flex;
+
+	.button {
+		font-size: 16px !important;
+		width: 100%;
+	}
+
+	.control {
+		margin: 0 !important;
+	}
+}
+
+.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;
+	}
+}
+</style>

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

@@ -197,6 +197,8 @@ export default {
 			.delete.material-icons {
 				font-size: 28px;
 				cursor: pointer;
+				user-select: none;
+				-webkit-user-drag: none;
 				&:hover,
 				&:focus {
 					filter: brightness(90%);

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

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

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

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

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

@@ -5,14 +5,14 @@
 				<div id="footer-copyright">
 					<p>© Copyright {{ siteSettings.sitename }} 2015 - 2021</p>
 				</div>
-				<a id="footer-logo" href="/">
+				<router-link id="footer-logo" to="/">
 					<img
 						v-if="siteSettings.sitename === 'Musare'"
 						:src="siteSettings.logo_blue"
 						:alt="siteSettings.sitename || `Musare`"
 					/>
 					<span v-else>{{ siteSettings.sitename }}</span>
-				</a>
+				</router-link>
 				<div id="footer-links">
 					<a
 						:href="siteSettings.github"
@@ -172,6 +172,7 @@ export default {
 			max-width: 100%;
 			color: var(--primary-color);
 			user-select: none;
+			-webkit-user-drag: none;
 		}
 	}
 

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

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

+ 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 - 3
frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue

@@ -1,6 +1,14 @@
 <template>
 	<div class="settings-tab section">
-		<div v-if="isEditable()">
+		<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">
 				<p class="control is-expanded">
@@ -25,7 +33,7 @@
 
 		<div
 			v-if="
-				userId === playlist.createdBy ||
+				isEditable() ||
 				((playlist.type === 'genre' || playlist.type === 'artist') &&
 					isAdmin())
 			"
@@ -76,7 +84,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")
 			);

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

@@ -7,7 +7,7 @@
 			'edit-playlist-modal': true,
 			'view-only': !isEditable()
 		}"
-		:wide="true"
+		:wide="isEditable()"
 		:split="true"
 	>
 		<template #body>
@@ -134,7 +134,7 @@
 												v-tippy
 												>queue</i
 											>
-											<confirm
+											<quick-confirm
 												v-if="
 													userId ===
 														playlist.createdBy ||
@@ -156,7 +156,7 @@
 													v-tippy
 													>delete_forever</i
 												>
-											</confirm>
+											</quick-confirm>
 											<i
 												class="material-icons"
 												v-if="isEditable() && index > 0"
@@ -211,33 +211,42 @@
 				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
+				</quick-confirm>
+				<quick-confirm
 					v-if="playlist.type === 'artist'"
 					@confirm="clearAndRefillArtistPlaylist()"
 				>
 					<a class="button is-danger">
 						Clear and refill artist 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>
@@ -249,7 +258,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";
 
@@ -263,7 +272,7 @@ export default {
 	components: {
 		Modal,
 		draggable,
-		Confirm,
+		QuickConfirm,
 		SongItem,
 		Settings,
 		AddSongs,
@@ -382,7 +391,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")
 			);
@@ -460,16 +471,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,
@@ -551,7 +552,6 @@ export default {
 				"playlists.clearAndRefillStationPlaylist",
 				this.playlist._id,
 				data => {
-					console.log(data.message);
 					if (data.status !== "success")
 						new Toast({
 							content: `Error: ${data.message}`,

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

+ 11 - 13
frontend/src/components/modals/EditSong/index.vue

@@ -466,7 +466,7 @@
 						>
 							<i class="material-icons">check_circle</i>
 						</button>
-						<confirm
+						<quick-confirm
 							v-if="song.status === 'verified'"
 							placement="left"
 							@confirm="unverify(song._id)"
@@ -478,8 +478,8 @@
 							>
 								<i class="material-icons">cancel</i>
 							</button>
-						</confirm>
-						<confirm
+						</quick-confirm>
+						<quick-confirm
 							v-if="song.status !== 'hidden'"
 							placement="left"
 							@confirm="hide(song._id)"
@@ -491,7 +491,7 @@
 							>
 								<i class="material-icons">visibility_off</i>
 							</button>
-						</confirm>
+						</quick-confirm>
 						<button
 							v-if="song.status === 'hidden'"
 							class="button is-success"
@@ -501,7 +501,7 @@
 						>
 							<i class="material-icons">visibility</i>
 						</button>
-						<!-- <confirm placement="left" @confirm="remove(song._id)">
+						<!-- <quick-confirm placement="left" @confirm="remove(song._id)">
 						<button
 							class="button is-danger"
 							content="Remove Song"
@@ -509,7 +509,7 @@
 						>
 							<i class="material-icons">delete</i>
 						</button>
-					</confirm> -->
+					</quick-confirm> -->
 					</div>
 				</div>
 			</template>
@@ -542,7 +542,7 @@ import ws from "@/ws";
 import validation from "@/validation";
 import keyboardShortcuts from "@/keyboardShortcuts";
 
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 import Modal from "../../Modal.vue";
 import FloatingBox from "../../FloatingBox.vue";
 import SaveButton from "../../SaveButton.vue";
@@ -557,7 +557,7 @@ export default {
 		Modal,
 		FloatingBox,
 		SaveButton,
-		Confirm,
+		QuickConfirm,
 		Discogs,
 		Reports,
 		Youtube,
@@ -2007,10 +2007,6 @@ export default {
 
 	#tabs-container {
 		width: 376px;
-		background-color: var(--light-grey);
-		border: 1px rgba(163, 224, 255, 0.75) solid;
-		border-radius: 5px;
-		overflow: auto;
 
 		#tab-selection {
 			display: flex;
@@ -2039,8 +2035,10 @@ export default {
 		}
 		.tab {
 			border: 1px solid var(--light-grey-3);
-			border-radius: 3px;
+			border-radius: 0 0 5px 5px;
 			padding: 15px;
+			height: calc(100% - 32px);
+			overflow: auto;
 		}
 	}
 }

+ 30 - 6
frontend/src/components/modals/EditUser.vue

@@ -76,12 +76,18 @@
 				</div>
 			</template>
 			<template #footer>
-				<confirm @confirm="removeSessions()">
+				<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>
-				</confirm>
-				<confirm @confirm="removeAccount()">
+				</quick-confirm>
+				<quick-confirm @confirm="removeAccount()">
 					<a class="button is-danger"> Remove account </a>
-				</confirm>
+				</quick-confirm>
 			</template>
 		</modal>
 	</div>
@@ -94,10 +100,10 @@ import Toast from "toasters";
 import validation from "@/validation";
 import ws from "@/ws";
 import Modal from "../Modal.vue";
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
-	components: { Modal, Confirm },
+	components: { Modal, QuickConfirm },
 	props: {
 		userId: { type: String, default: "" },
 		sector: { type: String, default: "admin" }
@@ -232,6 +238,24 @@ 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);

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

@@ -308,16 +308,20 @@
 				<button class="button is-primary" @click="editSongs()">
 					Edit songs
 				</button>
-				<button
-					:class="{
-						button: true,
-						'is-success': prefillDiscogs,
-						'is-danger': !prefillDiscogs
-					}"
-					@click="togglePrefillDiscogs()"
-				>
-					Prefill Discogs
-				</button>
+				<p class="is-expanded checkbox-control">
+					<label class="switch">
+						<input
+							type="checkbox"
+							id="prefill-discogs"
+							v-model="localPrefillDiscogs"
+						/>
+						<span class="slider round"></span>
+					</label>
+
+					<label for="prefill-discogs">
+						<p>Prefill Discogs</p>
+					</label>
+				</p>
 			</template>
 		</modal>
 	</div>
@@ -371,6 +375,17 @@ export default {
 				);
 			}
 		},
+		localPrefillDiscogs: {
+			get() {
+				return this.$store.state.modals.importAlbum.prefillDiscogs;
+			},
+			set(prefillDiscogs) {
+				this.$store.commit(
+					"modals/importAlbum/updatePrefillDiscogs",
+					prefillDiscogs
+				);
+			}
+		},
 		...mapState("modals/importAlbum", {
 			discogsTab: state => state.discogsTab,
 			discogsAlbum: state => state.discogsAlbum,
@@ -643,9 +658,11 @@ export default {
 		},
 		updateTrackSong(updatedSong) {
 			this.updatePlaylistSong(updatedSong);
-			this.trackSongs.forEach((song, index) => {
-				if (song[0]._id === updatedSong._id)
-					this.trackSongs[index][0] = updatedSong;
+			this.trackSongs.forEach((songs, indexA) => {
+				songs.forEach((song, indexB) => {
+					if (song._id === updatedSong._id)
+						this.trackSongs[indexA][indexB] = updatedSong;
+				});
 			});
 		},
 		...mapActions({
@@ -810,7 +827,6 @@ export default {
 	.search-discogs-album {
 		background-color: var(--light-grey);
 		border: 1px rgba(143, 40, 140, 0.75) solid;
-
 		> label {
 			margin-top: 12px;
 		}

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

@@ -30,7 +30,9 @@
 						:show-owner="true"
 					>
 						<template #actions>
-							<confirm @confirm="deselectPlaylist(playlist._id)">
+							<quick-confirm
+								@confirm="deselectPlaylist(playlist._id)"
+							>
 								<i
 									class="material-icons stop-icon"
 									content="Stop blacklisting songs from this playlist
@@ -38,7 +40,7 @@
 									v-tippy
 									>stop</i
 								>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="playlist.createdBy === userId"
 								@click="showPlaylist(playlist._id)"
@@ -73,12 +75,12 @@ import { mapActions, mapState, mapGetters } from "vuex";
 
 import Toast from "toasters";
 import PlaylistItem from "@/components/PlaylistItem.vue";
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 
 export default {
 	components: {
 		PlaylistItem,
-		Confirm
+		QuickConfirm
 	},
 	data() {
 		return {

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

@@ -37,7 +37,7 @@
 						:show-owner="true"
 					>
 						<template #actions>
-							<confirm
+							<quick-confirm
 								v-if="isOwnerOrAdmin()"
 								@confirm="deselectPlaylist(playlist._id)"
 							>
@@ -48,8 +48,8 @@
 								>
 									stop
 								</i>
-							</confirm>
-							<confirm
+							</quick-confirm>
+							<quick-confirm
 								v-if="isOwnerOrAdmin()"
 								@confirm="blacklistPlaylist(playlist._id)"
 							>
@@ -59,7 +59,7 @@
 									v-tippy
 									>block</i
 								>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="playlist.createdBy === myUserId"
 								@click="showPlaylist(playlist._id)"
@@ -103,7 +103,7 @@
 								v-tippy="{ theme: 'info' }"
 								>play_disabled</i
 							>
-							<confirm
+							<quick-confirm
 								v-if="
 									(isOwnerOrAdmin() ||
 										(station.type === 'community' &&
@@ -121,7 +121,7 @@
 								>
 									stop
 								</i>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="
 									(isOwnerOrAdmin() ||
@@ -140,7 +140,7 @@
 								v-tippy
 								>play_arrow</i
 							>
-							<confirm
+							<quick-confirm
 								v-if="
 									isOwnerOrAdmin() &&
 									!isExcluded(featuredPlaylist._id)
@@ -155,7 +155,7 @@
 									v-tippy
 									>block</i
 								>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="featuredPlaylist.createdBy === myUserId"
 								@click="showPlaylist(featuredPlaylist._id)"
@@ -214,7 +214,7 @@
 								v-tippy="{ theme: 'info' }"
 								>play_disabled</i
 							>
-							<confirm
+							<quick-confirm
 								v-if="
 									(isOwnerOrAdmin() ||
 										(station.type === 'community' &&
@@ -230,7 +230,7 @@
 								>
 									stop
 								</i>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="
 									(isOwnerOrAdmin() ||
@@ -249,7 +249,7 @@
 								v-tippy
 								>play_arrow</i
 							>
-							<confirm
+							<quick-confirm
 								v-if="
 									isOwnerOrAdmin() &&
 									!isExcluded(playlist._id)
@@ -262,7 +262,7 @@
 									v-tippy
 									>block</i
 								>
-							</confirm>
+							</quick-confirm>
 							<i
 								v-if="playlist.createdBy === myUserId"
 								@click="showPlaylist(playlist._id)"
@@ -352,7 +352,7 @@
 										v-tippy
 										>play_arrow</i
 									>
-									<confirm
+									<quick-confirm
 										v-if="
 											station.type === 'community' &&
 											(isOwnerOrAdmin() ||
@@ -371,8 +371,8 @@
 											v-tippy
 											>stop</i
 										>
-									</confirm>
-									<confirm
+									</quick-confirm>
+									<quick-confirm
 										v-if="
 											isOwnerOrAdmin() &&
 											!isExcluded(element._id)
@@ -387,7 +387,7 @@
 											v-tippy
 											>block</i
 										>
-									</confirm>
+									</quick-confirm>
 									<i
 										@click="showPlaylist(element._id)"
 										class="material-icons edit-icon"
@@ -413,14 +413,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() {
@@ -475,7 +475,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;
 		}
 

+ 34 - 17
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"
+		:wide="isOwnerOrAdmin() || sector !== 'home'"
+		:split="isOwnerOrAdmin() || sector !== 'home'"
 	>
 		<template #body v-if="station && station._id">
 			<div class="left-section">
@@ -122,7 +124,7 @@
 						>
 							pause
 						</i>
-						<confirm
+						<quick-confirm
 							v-if="isOwnerOrAdmin()"
 							@confirm="skipStation()"
 						>
@@ -133,7 +135,7 @@
 							>
 								skip_next
 							</i>
-						</confirm>
+						</quick-confirm>
 					</div>
 					<hr class="section-horizontal-rule" />
 					<song-item
@@ -177,14 +179,14 @@
 				<span class="optional-desktop-only-text"> 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>
@@ -196,7 +198,7 @@ import { mapState, mapGetters, mapActions } from "vuex";
 import Toast from "toasters";
 import ws from "@/ws";
 
-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";
@@ -209,7 +211,7 @@ import Blacklist from "./Tabs/Blacklist.vue";
 export default {
 	components: {
 		Modal,
-		Confirm,
+		QuickConfirm,
 		Queue,
 		SongItem,
 		Settings,
@@ -246,33 +248,45 @@ export default {
 
 		this.socket.on(
 			"event:station.queue.updated",
-			res => this.updateSongsList(res.data.queue),
+			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),
+			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,5 +701,8 @@ export default {
 			}
 		}
 	}
+	&.modal-wide .left-section .section:first-child {
+		padding: 0 15px 15px !important;
+	}
 }
 </style>

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

+ 5 - 1
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;
 
 lofig.folder = "../config/default.json";
 
@@ -226,6 +228,8 @@ app.use(router);
 	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;
 

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

+ 41 - 60
frontend/src/pages/Admin/index.vue

@@ -4,42 +4,23 @@
 		<div class="tabs is-centered">
 			<ul>
 				<li
-					:class="{ 'is-active': currentTab == 'hiddensongs' }"
-					ref="hiddensongs-tab"
-					@click="showTab('hiddensongs')"
+					:class="{ 'is-active': currentTab == 'test' }"
+					ref="test-tab"
+					@click="showTab('test')"
 				>
-					<router-link
-						class="tab hiddensongs"
-						to="/admin/hiddensongs"
-					>
+					<router-link class="tab test" to="/admin/test">
 						<i class="material-icons">music_note</i>
-						<span>&nbsp;Hidden Songs</span>
+						<span>&nbsp;Test</span>
 					</router-link>
 				</li>
 				<li
-					:class="{ 'is-active': currentTab == 'unverifiedsongs' }"
-					ref="unverifiedsongs-tab"
-					@click="showTab('unverifiedsongs')"
+					:class="{ 'is-active': currentTab == 'songs' }"
+					ref="songs-tab"
+					@click="showTab('songs')"
 				>
-					<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>
+					<router-link class="tab songs" to="/admin/songs">
+						<i class="material-icons">music_note</i>
+						<span>&nbsp;Songs</span>
 					</router-link>
 				</li>
 				<li
@@ -119,9 +100,8 @@
 		</div>
 
 		<div class="admin-container">
-			<unverified-songs v-if="currentTab == 'unverifiedsongs'" />
-			<verified-songs v-if="currentTab == 'verifiedsongs'" />
-			<hidden-songs v-if="currentTab == 'hiddensongs'" />
+			<test v-if="currentTab == 'test'" />
+			<songs v-if="currentTab == 'songs'" />
 			<stations v-if="currentTab == 'stations'" />
 			<playlists v-if="currentTab == 'playlists'" />
 			<reports v-if="currentTab == 'reports'" />
@@ -146,15 +126,8 @@ export default {
 	components: {
 		MainHeader,
 		MainFooter,
-		UnverifiedSongs: defineAsyncComponent(() =>
-			import("./tabs/UnverifiedSongs.vue")
-		),
-		VerifiedSongs: defineAsyncComponent(() =>
-			import("./tabs/VerifiedSongs.vue")
-		),
-		HiddenSongs: defineAsyncComponent(() =>
-			import("./tabs/HiddenSongs.vue")
-		),
+		Test: defineAsyncComponent(() => import("./tabs/Test.vue")),
+		Songs: defineAsyncComponent(() => import("./tabs/Songs.vue")),
 		Stations: defineAsyncComponent(() => import("./tabs/Stations.vue")),
 		Playlists: defineAsyncComponent(() => import("./tabs/Playlists.vue")),
 		Reports: defineAsyncComponent(() => import("./tabs/Reports.vue")),
@@ -187,14 +160,11 @@ export default {
 	methods: {
 		changeTab(path) {
 			switch (path) {
-				case "/admin/unverifiedsongs":
-					this.showTab("unverifiedsongs");
-					break;
-				case "/admin/verifiedsongs":
-					this.showTab("verifiedsongs");
+				case "/admin/test":
+					this.showTab("test");
 					break;
-				case "/admin/hiddensongs":
-					this.showTab("hiddensongs");
+				case "/admin/songs":
+					this.showTab("songs");
 					break;
 				case "/admin/stations":
 					this.showTab("stations");
@@ -218,7 +188,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) {
@@ -228,6 +208,7 @@ export default {
 					block: "nearest"
 				});
 			this.currentTab = tab;
+			localStorage.setItem("lastAdminPage", tab);
 		}
 	}
 };
@@ -238,6 +219,7 @@ export default {
 	top: 102px !important;
 }
 
+.main-container .admin-tab,
 .main-container .container {
 	.button-row {
 		display: flex;
@@ -255,6 +237,12 @@ export default {
 		}
 	}
 }
+
+.main-container .admin-container .admin-tab {
+	max-width: 1900px;
+	margin: 0 auto;
+	padding: 0 10px;
+}
 </style>
 
 <style lang="scss" scoped>
@@ -284,9 +272,10 @@ export default {
 
 .main-container {
 	height: auto;
+
 	.admin-container {
 		flex: 1 0 auto;
-		margin-bottom: 10px;
+		margin-bottom: 20px;
 	}
 }
 
@@ -311,18 +300,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);

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

@@ -1,613 +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.song.updated", res => {
-			const { song } = res.data;
-			if (res.data.oldStatus && res.data.oldStatus === "hidden") {
-				this.removeSong(song._id);
-			} else {
-				this.addSong(song);
-				this.updateSong(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>

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

@@ -39,11 +39,11 @@
 								>
 									Edit
 								</button>
-								<confirm @confirm="remove(news._id)">
+								<quick-confirm @confirm="remove(news._id)">
 									<button class="button is-danger">
 										Remove
 									</button>
-								</confirm>
+								</quick-confirm>
 							</div>
 						</td>
 					</tr>
@@ -66,12 +66,12 @@ import Toast from "toasters";
 
 import ws from "@/ws";
 
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
 export default {
 	components: {
-		Confirm,
+		QuickConfirm,
 		UserIdToUsername,
 		EditNews: defineAsyncComponent(() =>
 			import("@/components/modals/EditNews.vue")

+ 42 - 140
frontend/src/pages/Admin/tabs/Playlists.vue

@@ -3,69 +3,13 @@
 		<page-metadata title="Admin | Playlists" />
 		<div class="container">
 			<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="deleteOrphanedArtistPlaylists()"
-				>
-					<button class="button is-danger">
-						Delete orphaned artist 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>
-				<confirm
-					placement="bottom"
-					@confirm="clearAndRefillAllArtistPlaylists()"
-				>
-					<button class="button is-danger">
-						Clear and refill all artist 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>
@@ -80,7 +24,6 @@
 					<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>
@@ -119,8 +62,7 @@
 import { mapState, mapActions, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 
-import Toast from "toasters";
-import Confirm from "@/components/Confirm.vue";
+import RunJobDropdown from "@/components/RunJobDropdown.vue";
 
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
 
@@ -139,11 +81,49 @@ export default {
 		EditSong: defineAsyncComponent(() =>
 			import("@/components/modals/EditSong")
 		),
-		Confirm
+		RunJobDropdown
 	},
 	data() {
 		return {
-			utils
+			utils,
+			jobs: [
+				{
+					name: "Delete orphaned station playlists",
+					socket: "playlists.deleteOrphanedStationPlaylists"
+				},
+				{
+					name: "Delete orphaned genre playlists",
+					socket: "playlists.deleteOrphanedGenrePlaylists"
+				},
+				{
+					name: "Delete orphaned artist playlists",
+					socket: "playlists.deleteOrphanedArtistPlaylists"
+				},
+				{
+					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: "Clear and refill all artist playlists",
+					socket: "playlists.clearAndRefillAllArtistPlaylists"
+				},
+				{
+					name: "Create missing genre playlists",
+					socket: "playlists.createMissingGenrePlaylists"
+				},
+				{
+					name: "Create missing artist playlists",
+					socket: "playlists.createMissingArtistPlaylists"
+				}
+			]
 		};
 	},
 	computed: {
@@ -232,84 +212,6 @@ export default {
 			});
 			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}`);
-				}
-			);
-		},
-		deleteOrphanedArtistPlaylists() {
-			this.socket.dispatch(
-				"playlists.deleteOrphanedArtistPlaylists",
-				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
-						});
-				}
-			);
-		},
-		clearAndRefillAllArtistPlaylists() {
-			this.socket.dispatch(
-				"playlists.clearAndRefillAllArtistPlaylists",
-				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", [

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

@@ -0,0 +1,639 @@
+<template>
+	<div>
+		<page-metadata title="Admin | Songs" />
+		<div class="admin-tab">
+			<div class="button-row">
+				<button
+					class="button is-primary"
+					@click="toggleKeyboardShortcutsHelper"
+					@dblclick="resetKeyboardShortcutsHelper"
+				>
+					Keyboard shortcuts helper
+				</button>
+				<button
+					class="button is-primary"
+					@click="openModal('requestSong')"
+				>
+					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"
+			>
+				<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-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-status="slotProps">
+					<span :title="slotProps.item.status">{{
+						slotProps.item.status
+					}}</span>
+				</template>
+				<template #column-requestedBy="slotProps">
+					<user-id-to-username
+						:user-id="slotProps.item.requestedBy"
+						:link="true"
+					/>
+				</template>
+				<template #bulk-actions="slotProps">
+					<div class="song-bulk-actions">
+						<i
+							class="material-icons edit-songs-icon"
+							@click.prevent="editMany(slotProps.item)"
+							content="Edit Songs"
+							v-tippy
+						>
+							edit
+						</i>
+						<i
+							class="material-icons verify-songs-icon"
+							@click.prevent="verifyMany(slotProps.item)"
+							content="Verify Songs"
+							v-tippy
+						>
+							check_circle
+						</i>
+						<i
+							class="material-icons unverify-songs-icon"
+							@click.prevent="unverifyMany(slotProps.item)"
+							content="Unverify Songs"
+							v-tippy
+						>
+							cancel
+						</i>
+						<i
+							class="material-icons tag-songs-icon"
+							@click.prevent="tagMany(slotProps.item)"
+							content="Tag Songs"
+							v-tippy
+						>
+							local_offer
+						</i>
+						<i
+							class="material-icons artists-songs-icon"
+							@click.prevent="setArtists(slotProps.item)"
+							content="Set Artists"
+							v-tippy
+						>
+							group
+						</i>
+						<i
+							class="material-icons genres-songs-icon"
+							@click.prevent="setGenres(slotProps.item)"
+							content="Set Genres"
+							v-tippy
+						>
+							theater_comedy
+						</i>
+						<quick-confirm
+							placement="left"
+							@confirm="deleteMany(slotProps.item)"
+						>
+							<i
+								class="material-icons delete-songs-icon"
+								content="Delete Songs"
+								v-tippy
+							>
+								delete_forever
+							</i>
+						</quick-confirm>
+					</div>
+				</template>
+			</advanced-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 AdvancedTable from "@/components/AdvancedTable.vue";
+import UserIdToUsername from "@/components/UserIdToUsername.vue";
+import FloatingBox from "@/components/FloatingBox.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
+import RunJobDropdown from "@/components/RunJobDropdown.vue";
+
+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")
+		),
+		AdvancedTable,
+		UserIdToUsername,
+		FloatingBox,
+		QuickConfirm,
+		RunJobDropdown
+	},
+	data() {
+		return {
+			columnDefault: {
+				sortable: true,
+				hidable: true,
+				defaultVisibility: "shown",
+				draggable: true,
+				resizable: true,
+				minWidth: 200,
+				maxWidth: 600
+			},
+			columns: [
+				{
+					name: "thumbnailImage",
+					displayName: "Thumb",
+					properties: ["thumbnail"],
+					sortable: false,
+					minWidth: 75,
+					defaultWidth: 75,
+					maxWidth: 75,
+					resizable: false
+				},
+				{
+					name: "title",
+					displayName: "Title",
+					properties: ["title"],
+					sortProperty: "title"
+				},
+				{
+					name: "artists",
+					displayName: "Artists",
+					properties: ["artists"],
+					sortable: false
+				},
+				{
+					name: "genres",
+					displayName: "Genres",
+					properties: ["genres"],
+					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: "Musare ID",
+					properties: ["_id"],
+					sortProperty: "_id",
+					minWidth: 215,
+					defaultWidth: 215
+				},
+				{
+					name: "youtubeId",
+					displayName: "YouTube ID",
+					properties: ["youtubeId"],
+					sortProperty: "youtubeId",
+					minWidth: 120,
+					defaultWidth: 120
+				},
+				{
+					name: "status",
+					displayName: "Status",
+					properties: ["status"],
+					sortProperty: "status",
+					defaultVisibility: "hidden",
+					minWidth: 120,
+					defaultWidth: 120
+				},
+				{
+					name: "thumbnailUrl",
+					displayName: "Thumbnail (URL)",
+					properties: ["thumbnail"],
+					sortProperty: "thumbnail",
+					defaultVisibility: "hidden"
+				},
+				{
+					name: "requestedBy",
+					displayName: "Requested By",
+					properties: ["requestedBy"],
+					sortProperty: "requestedBy",
+					defaultWidth: 200
+				}
+			],
+			filters: [
+				{
+					name: "_id",
+					displayName: "Musare ID",
+					property: "_id",
+					filterTypes: ["exact"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "youtubeId",
+					displayName: "YouTube ID",
+					property: "youtubeId",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "title",
+					displayName: "Title",
+					property: "title",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "artists",
+					displayName: "Artists",
+					property: "artists",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "genres",
+					displayName: "Genres",
+					property: "genres",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "thumbnail",
+					displayName: "Thumbnail",
+					property: "thumbnail",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "requestedBy",
+					displayName: "Requested By",
+					property: "requestedBy",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "contains"
+				},
+				{
+					name: "status",
+					displayName: "Status",
+					property: "status",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "likes",
+					displayName: "Likes",
+					property: "likes",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "exact"
+				},
+				{
+					name: "dislikes",
+					displayName: "Dislikes",
+					property: "dislikes",
+					filterTypes: ["contains", "exact", "regex"],
+					defaultFilterType: "exact"
+				}
+			],
+			jobs: [
+				{
+					name: "Update all songs",
+					socket: "songs.updateAll"
+				},
+				{
+					name: "Recalculate all song ratings",
+					socket: "songs.recalculateAllRatings"
+				}
+			]
+		};
+	},
+	computed: {
+		...mapState("modalVisibility", {
+			modals: state => state.modals
+		}),
+		...mapState("modals/editSong", {
+			song: state => state.song
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		// TODO: Implement song update events in advanced table
+		// this.socket.on("event:admin.song.updated", res => {
+		// 	const { song } = res.data;
+		// 	if (this.songs.filter(s => s._id === song._id).length === 0)
+		// 		this.addSong(song);
+		// 	else this.updateSong(song);
+		// });
+
+		if (this.$route.query.songId) {
+			this.socket.dispatch(
+				"songs.getSongFromSongId",
+				this.$route.query.songId,
+				res => {
+					if (res.status === "success")
+						this.editMany([res.data.song]);
+					else new Toast("Song with that ID not found");
+				}
+			);
+		}
+
+		keyboardShortcuts.registerShortcut(
+			"songs.toggleKeyboardShortcutsHelper",
+			{
+				keyCode: 191, // '/' key
+				ctrl: true,
+				preventDefault: true,
+				handler: () => {
+					this.toggleKeyboardShortcutsHelper();
+				}
+			}
+		);
+
+		keyboardShortcuts.registerShortcut(
+			"songs.resetKeyboardShortcutsHelper",
+			{
+				keyCode: 191, // '/' key
+				ctrl: true,
+				shift: true,
+				preventDefault: true,
+				handler: () => {
+					this.resetKeyboardShortcutsHelper();
+				}
+			}
+		);
+	},
+	beforeUnmount() {
+		const shortcutNames = [
+			"songs.toggleKeyboardShortcutsHelper",
+			"songs.resetKeyboardShortcutsHelper"
+		];
+
+		shortcutNames.forEach(shortcutName => {
+			keyboardShortcuts.unregisterShortcut(shortcutName);
+		});
+	},
+	methods: {
+		editMany(selectedRows) {
+			if (selectedRows.length === 1) {
+				this.editSong(selectedRows[0]);
+				this.openModal("editSong");
+			} else {
+				new Toast("Bulk editing not yet implemented.");
+			}
+		},
+		verifyMany(selectedRows) {
+			if (selectedRows.length === 1) {
+				this.socket.dispatch(
+					"songs.verify",
+					selectedRows[0]._id,
+					res => {
+						new Toast(res.message);
+					}
+				);
+			} else {
+				new Toast("Bulk verifying not yet implemented.");
+			}
+		},
+		unverifyMany(selectedRows) {
+			if (selectedRows.length === 1) {
+				this.socket.dispatch(
+					"songs.unverify",
+					selectedRows[0]._id,
+					res => {
+						new Toast(res.message);
+					}
+				);
+			} else {
+				new Toast("Bulk unverifying not yet implemented.");
+			}
+		},
+		tagMany() {
+			new Toast("Bulk tagging not yet implemented.");
+		},
+		setArtists() {
+			new Toast("Bulk setting artists not yet implemented.");
+		},
+		setGenres() {
+			new Toast("Bulk setting genres not yet implemented.");
+		},
+		deleteMany() {
+			new Toast("Bulk deleting not yet implemented.");
+		},
+		toggleKeyboardShortcutsHelper() {
+			this.$refs.keyboardShortcutsHelper.toggleBox();
+		},
+		resetKeyboardShortcutsHelper() {
+			this.$refs.keyboardShortcutsHelper.resetBox();
+		},
+		...mapActions("modals/editSong", ["editSong"]),
+		...mapActions("modalVisibility", ["openModal"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+#keyboardShortcutsHelper {
+	.box-body {
+		.biggest {
+			font-size: 18px;
+		}
+
+		.bigger {
+			font-size: 16px;
+		}
+
+		span {
+			display: block;
+		}
+	}
+}
+
+.song-thumbnail {
+	display: block;
+	max-width: 50px;
+	margin: 0 auto;
+}
+
+.bulk-popup {
+	.song-bulk-actions {
+		display: flex;
+		flex-direction: row;
+		width: 100%;
+		justify-content: space-evenly;
+
+		.material-icons {
+			position: relative;
+			top: 6px;
+			margin-left: 5px;
+			cursor: pointer;
+			color: var(--primary-color);
+
+			&:hover,
+			&:focus {
+				filter: brightness(90%);
+			}
+		}
+		.verify-songs-icon {
+			color: var(--green);
+		}
+		.unverify-songs-icon,
+		.delete-songs-icon {
+			color: var(--dark-red);
+		}
+	}
+}
+</style>

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

@@ -9,11 +9,7 @@
 				>
 					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>
@@ -69,9 +65,9 @@
 							<a class="button is-info" @click="manage(station)"
 								>Manage</a
 							>
-							<confirm @confirm="removeStation(index)">
+							<quick-confirm @confirm="removeStation(index)">
 								<a class="button is-danger">Remove</a>
-							</confirm>
+							</quick-confirm>
 						</td>
 					</tr>
 				</tbody>
@@ -98,7 +94,8 @@ import { defineAsyncComponent } from "vue";
 
 import Toast from "toasters";
 import UserIdToUsername from "@/components/UserIdToUsername.vue";
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
+import RunJobDropdown from "@/components/RunJobDropdown.vue";
 import ws from "@/ws";
 
 export default {
@@ -125,11 +122,18 @@ export default {
 			import("@/components/modals/CreateStation.vue")
 		),
 		UserIdToUsername,
-		Confirm
+		QuickConfirm,
+		RunJobDropdown
 	},
 	data() {
 		return {
-			editingStationId: ""
+			editingStationId: "",
+			jobs: [
+				{
+					name: "Clear every station queue",
+					socket: "stations.clearEveryStationQueue"
+				}
+			]
 		};
 	},
 	computed: {
@@ -166,12 +170,6 @@ export default {
 			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")

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

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

+ 0 - 643
frontend/src/pages/Admin/tabs/UnverifiedSongs.vue

@@ -1,643 +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() {
-		ws.onConnect(this.init);
-
-		this.socket.on("event:admin.song.updated", res => {
-			const { song } = res.data;
-			if (res.data.oldStatus && res.data.oldStatus === "unverified") {
-				this.removeSong(song._id);
-			} else {
-				this.addSong(song);
-				this.updateSong(song);
-			}
-		});
-	},
-	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>

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

@@ -1,711 +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() {
-		ws.onConnect(this.init);
-
-		this.socket.on("event:admin.song.updated", res => {
-			const { song } = res.data;
-			if (res.data.oldStatus && res.data.oldStatus === "verified") {
-				this.removeSong(song._id);
-			} else {
-				this.addSong(song);
-				this.updateSong(song);
-			}
-		});
-
-		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

@@ -67,7 +67,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">
@@ -291,7 +324,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">
@@ -432,6 +488,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>
 
@@ -456,6 +522,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
 	},
@@ -470,13 +554,15 @@ export default {
 				sitename: ""
 			},
 			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({
@@ -733,7 +819,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";
@@ -777,6 +869,10 @@ export default {
 				res => new Toast(res.message)
 			);
 		},
+		manageStation(stationId) {
+			this.editingStationId = stationId;
+			this.openModal("manageStation");
+		},
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("station", ["updateIfStationIsFavorited"])
 	}
@@ -888,11 +984,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;
@@ -911,6 +1009,7 @@ html {
 		);
 		position: absolute;
 		height: 35vh;
+		min-height: 300px;
 		width: 100%;
 		border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
 		overflow: hidden;
@@ -922,8 +1021,8 @@ html {
 		margin-left: auto;
 		margin-right: auto;
 		text-align: center;
-		height: 100%;
 		height: 35vh;
+		min-height: 300px;
 		.content {
 			position: absolute;
 			top: 50%;
@@ -970,10 +1069,12 @@ html {
 	}
 	&.loggedIn {
 		height: 20vh;
+		min-height: 200px;
 		.overlay,
 		.content-container,
 		img.background {
 			height: 20vh;
+			min-height: 200px;
 		}
 	}
 }
@@ -1165,6 +1266,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 {

+ 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

+ 0 - 17
frontend/src/pages/Station/Sidebar/index.vue

@@ -157,21 +157,4 @@ export default {
 		margin-bottom: 10px;
 	}
 }
-
-/deep/ ::-webkit-scrollbar {
-	width: 10px;
-}
-
-/deep/ ::-webkit-scrollbar-track {
-	background-color: var(--white);
-	border: 1px solid var(--light-grey-3);
-}
-
-/deep/ ::-webkit-scrollbar-thumb {
-	background-color: var(--dark-grey);
-
-	&:hover {
-		filter: brightness(95%);
-	}
-}
 </style>

+ 67 - 13
frontend/src/pages/Station/index.vue

@@ -321,7 +321,7 @@
 										<i class="material-icons">pause</i>
 									</button>
 									<!-- (Admin) Pause/Resume Button -->
-									<confirm
+									<quick-confirm
 										v-if="isOwnerOrAdmin() && stationPaused"
 										@confirm="resumeStation()"
 									>
@@ -334,8 +334,8 @@
 												>play_arrow</i
 											>
 										</button>
-									</confirm>
-									<confirm
+									</quick-confirm>
+									<quick-confirm
 										v-if="
 											isOwnerOrAdmin() && !stationPaused
 										"
@@ -348,7 +348,7 @@
 										>
 											<i class="material-icons">pause</i>
 										</button>
-									</confirm>
+									</quick-confirm>
 
 									<!-- Vote to Skip Button -->
 									<button
@@ -380,7 +380,7 @@
 									</button>
 
 									<!-- (Admin) Skip Button -->
-									<confirm
+									<quick-confirm
 										v-if="isOwnerOrAdmin()"
 										@confirm="skipStation()"
 									>
@@ -393,7 +393,7 @@
 												>skip_next</i
 											>
 										</button>
-									</confirm>
+									</quick-confirm>
 								</div>
 								<div id="duration">
 									<p>
@@ -850,15 +850,17 @@ import { mapState, mapActions, mapGetters } from "vuex";
 import { defineAsyncComponent } from "vue";
 import Toast from "toasters";
 import { ContentLoader } from "vue-content-loader";
+import canAutoPlay from "can-autoplay";
 
 import aw from "@/aw";
+import ms from "@/ms";
 import ws from "@/ws";
 import keyboardShortcuts from "@/keyboardShortcuts";
 
 import MainHeader from "@/components/layout/MainHeader.vue";
 import MainFooter from "@/components/layout/MainFooter.vue";
 
-import Confirm from "@/components/Confirm.vue";
+import QuickConfirm from "@/components/QuickConfirm.vue";
 import FloatingBox from "@/components/FloatingBox.vue";
 import AddToPlaylistDropdown from "@/components/AddToPlaylistDropdown.vue";
 import SongItem from "@/components/SongItem.vue";
@@ -889,7 +891,7 @@ export default {
 			import("@/components/modals/Report.vue")
 		),
 		Z404,
-		Confirm,
+		QuickConfirm,
 		FloatingBox,
 		StationSidebar,
 		AddToPlaylistDropdown,
@@ -935,6 +937,7 @@ export default {
 			persistentToastCheckerInterval: null,
 			persistentToasts: [],
 			partyPlaylistLock: false,
+			mediasession: false,
 			christmas: false
 		};
 	},
@@ -1067,7 +1070,7 @@ export default {
 		});
 
 		this.frontendDevMode = await lofig.get("mode");
-
+		this.mediasession = await lofig.get("siteSettings.mediasession");
 		this.christmas = await lofig.get("siteSettings.christmas");
 
 		this.socket.dispatch(
@@ -1090,6 +1093,21 @@ export default {
 			}
 		);
 
+		ms.setListeners(0, {
+			play: () => {
+				if (this.isOwnerOrAdmin()) this.resumeStation();
+				else this.resumeLocalStation();
+			},
+			pause: () => {
+				if (this.isOwnerOrAdmin()) this.pauseStation();
+				else this.pauseLocalStation();
+			},
+			nexttrack: () => {
+				if (this.isOwnerOrAdmin()) this.skipStation();
+				else this.voteSkipStation();
+			}
+		});
+
 		this.socket.on("event:station.nextSong", res => {
 			const previousSong = this.currentSong.youtubeId
 				? this.currentSong
@@ -1219,7 +1237,9 @@ export default {
 		this.socket.on("event:station.theme.updated", res => {
 			const { theme } = res.data;
 			this.station.theme = theme;
-			document.body.style.cssText = `--primary-color: var(--${theme})`;
+			document.getElementsByTagName(
+				"html"
+			)[0].style.cssText = `--primary-color: var(--${theme})`;
 		});
 
 		this.socket.on("event:station.name.updated", async res => {
@@ -1285,7 +1305,12 @@ export default {
 		}
 	},
 	beforeUnmount() {
-		document.body.style.cssText = "";
+		document.getElementsByTagName("html")[0].style.cssText = "";
+
+		if (this.mediasession) {
+			ms.removeListeners(0);
+			ms.removeMediaSessionData(0);
+		}
 
 		/** Reset Songslist */
 		this.updateSongsList([]);
@@ -1327,6 +1352,18 @@ export default {
 		isOwnerOrAdmin() {
 			return this.isOwnerOnly() || this.isAdminOnly();
 		},
+		updateMediaSessionData(currentSong) {
+			if (currentSong) {
+				ms.setMediaSessionData(
+					0,
+					!this.localPaused && !this.stationPaused, // This should be improved later
+					this.currentSong.title,
+					this.currentSong.artists.join(", "),
+					null,
+					this.currentSong.thumbnail
+				);
+			} else ms.removeMediaSessionData(0);
+		},
 		removeFromQueue(youtubeId) {
 			window.socket.dispatch(
 				"stations.removeFromQueue",
@@ -1401,6 +1438,8 @@ export default {
 
 			clearTimeout(window.stationNextSongTimeout);
 
+			if (this.mediasession) this.updateMediaSessionData(currentSong);
+
 			this.startedAt = startedAt;
 			this.updateStationPaused(paused);
 			this.timePaused = timePaused;
@@ -1513,6 +1552,7 @@ export default {
 		},
 		youtubeReady() {
 			if (!this.player) {
+				ms.setYTReady(false);
 				this.player = new window.YT.Player("stationPlayer", {
 					height: 270,
 					width: 480,
@@ -1532,6 +1572,7 @@ export default {
 					events: {
 						onReady: () => {
 							this.playerReady = true;
+							ms.setYTReady(true);
 
 							let volume = parseInt(
 								localStorage.getItem("volume")
@@ -1695,7 +1736,7 @@ export default {
 						2000
 					) {
 						this.lastTimeRequestedIfCanAutoplay = Date.now();
-						window.canAutoplay.video().then(({ result }) => {
+						canAutoPlay.video().then(({ result }) => {
 							if (result) {
 								this.attemptsToPlayVideo = 0;
 								this.canAutoplay = true;
@@ -1810,6 +1851,8 @@ export default {
 			this.pauseLocalPlayer();
 		},
 		resumeLocalPlayer() {
+			if (this.mediasession)
+				this.updateMediaSessionData(this.currentSong);
 			if (!this.noSong) {
 				if (this.playerReady) {
 					this.player.seekTo(
@@ -1821,6 +1864,8 @@ export default {
 			}
 		},
 		pauseLocalPlayer() {
+			if (this.mediasession)
+				this.updateMediaSessionData(this.currentSong);
 			if (!this.noSong) {
 				this.timeBeforePause = this.getTimeElapsed();
 				if (this.playerReady) this.player.pauseVideo();
@@ -2038,7 +2083,9 @@ export default {
 							theme
 						});
 
-						document.body.style.cssText = `--primary-color: var(--${res.data.theme})`;
+						document.getElementsByTagName(
+							"html"
+						)[0].style.cssText = `--primary-color: var(--${res.data.theme})`;
 
 						this.setCurrentSong({
 							currentSong: res.data.currentSong,
@@ -2362,6 +2409,13 @@ export default {
 		}
 	}
 }
+
+#control-bar-container
+	#right-buttons
+	.tippy-box[data-theme~="dropdown"]
+	.nav-dropdown-items {
+	padding-bottom: 0 !important;
+}
 </style>
 
 <style lang="scss" scoped>

+ 4 - 93
frontend/src/store/modules/admin.js

@@ -9,101 +9,12 @@ const actions = {};
 const mutations = {};
 
 const modules = {
-	hiddenSongs: {
+	songs: {
 		namespaced: true,
-		state: {
-			songs: []
-		},
-		getters: {},
-		actions: {
-			resetSongs: ({ commit }) => commit("resetSongs"),
-			addSong: ({ commit }, song) => commit("addSong", song),
-			removeSong: ({ commit }, songId) => commit("removeSong", songId),
-			updateSong: ({ commit }, updatedSong) =>
-				commit("updateSong", updatedSong)
-		},
-		mutations: {
-			resetSongs(state) {
-				state.songs = [];
-			},
-			addSong(state, song) {
-				if (!state.songs.find(s => s._id === song._id))
-					state.songs.push(song);
-			},
-			removeSong(state, songId) {
-				state.songs = state.songs.filter(song => song._id !== songId);
-			},
-			updateSong(state, updatedSong) {
-				state.songs.forEach((song, index) => {
-					if (song._id === updatedSong._id)
-						state.songs[index] = updatedSong;
-				});
-			}
-		}
-	},
-	unverifiedSongs: {
-		namespaced: true,
-		state: {
-			songs: []
-		},
-		getters: {},
-		actions: {
-			resetSongs: ({ commit }) => commit("resetSongs"),
-			addSong: ({ commit }, song) => commit("addSong", song),
-			removeSong: ({ commit }, songId) => commit("removeSong", songId),
-			updateSong: ({ commit }, updatedSong) =>
-				commit("updateSong", updatedSong)
-		},
-		mutations: {
-			resetSongs(state) {
-				state.songs = [];
-			},
-			addSong(state, song) {
-				if (!state.songs.find(s => s._id === song._id))
-					state.songs.push(song);
-			},
-			removeSong(state, songId) {
-				state.songs = state.songs.filter(song => song._id !== songId);
-			},
-			updateSong(state, updatedSong) {
-				state.songs.forEach((song, index) => {
-					if (song._id === updatedSong._id)
-						state.songs[index] = updatedSong;
-				});
-			}
-		}
-	},
-	verifiedSongs: {
-		namespaced: true,
-		state: {
-			songs: []
-		},
+		state: {},
 		getters: {},
-		actions: {
-			resetSongs: ({ commit }) => commit("resetSongs"),
-			addSong: ({ commit }, song) => commit("addSong", song),
-			removeSong: ({ commit }, songId) => commit("removeSong", songId),
-			updateSong: ({ commit }, updatedSong) =>
-				commit("updateSong", updatedSong)
-		},
-		mutations: {
-			resetSongs(state) {
-				state.songs = [];
-			},
-			addSong(state, song) {
-				if (!state.songs.find(s => s._id === song._id))
-					state.songs.push(song);
-			},
-			removeSong(state, songId) {
-				state.songs = state.songs.filter(song => song._id !== songId);
-			},
-			updateSong(state, updatedSong) {
-				state.songs.forEach((song, index) => {
-					if (song._id === updatedSong._id)
-						state.songs[index] = updatedSong;
-				});
-			}
-		}
+		actions: {},
+		mutations: {}
 	},
 	stations: {
 		namespaced: true,

+ 8 - 3
frontend/src/store/modules/modals/importAlbum.js

@@ -25,7 +25,8 @@ export default {
 		updateEditingSongs: ({ commit }, editingSongs) =>
 			commit("updateEditingSongs", editingSongs),
 		resetPlaylistSongs: ({ commit }) => commit("resetPlaylistSongs"),
-		togglePrefillDiscogs: ({ commit }) => commit("togglePrefillDiscogs"),
+		updatePrefillDiscogs: ({ commit }, updatedPrefill) =>
+			commit("updatePrefillDiscogs", updatedPrefill),
 		updatePlaylistSong: ({ commit }, updatedSong) =>
 			commit("updatePlaylistSong", updatedSong)
 	},
@@ -62,14 +63,18 @@ export default {
 				JSON.stringify(state.originalPlaylistSongs)
 			);
 		},
-		togglePrefillDiscogs(state) {
-			state.prefillDiscogs = !state.prefillDiscogs;
+		updatePrefillDiscogs(state, updatedPrefill) {
+			state.prefillDiscogs = updatedPrefill;
 		},
 		updatePlaylistSong(state, updatedSong) {
 			state.playlistSongs.forEach((song, index) => {
 				if (song._id === updatedSong._id)
 					state.playlistSongs[index] = updatedSong;
 			});
+			state.originalPlaylistSongs.forEach((song, index) => {
+				if (song._id === updatedSong._id)
+					state.originalPlaylistSongs[index] = updatedSong;
+			});
 		}
 	}
 };

+ 94 - 5
frontend/webpack.common.js

@@ -1,10 +1,88 @@
 process.env.NODE_CONFIG_DIR = `${__dirname}/dist/config/`;
-const config = require("config");
-
 const path = require("path");
+const fs = require("fs");
+const config = require("config");
 const { VueLoaderPlugin } = require("vue-loader");
 const HtmlWebpackPlugin = require("html-webpack-plugin");
-const ESLintPlugin = require('eslint-webpack-plugin');
+const ESLintPlugin = require("eslint-webpack-plugin");
+
+const fetchVersionAndGitInfo = cb => {
+	const debug = {
+		git: {
+			remote: "",
+			remoteUrl: "",
+			branch: "",
+			latestCommit: "",
+			latestCommitShort: ""
+		},
+		version: ""
+	};
+
+	try {
+		const packageJson = JSON.parse(fs.readFileSync("./package.json").toString());
+		const headContents = fs
+			.readFileSync(".parent_git/HEAD")
+			.toString()
+			.replace(/\n/g, "");
+		const branch = new RegExp("ref: refs/heads/([.A-Za-z0-9_-]+)").exec(
+			headContents
+		)[1];
+		const configContents = fs
+			.readFileSync(".parent_git/config")
+			.toString()
+			.replace(/\t/g, "")
+			.split("\n");
+		const remote = new RegExp("remote = (.+)").exec(
+			configContents[configContents.indexOf(`[branch "${branch}"]`) + 1]
+		)[1];
+		const remoteUrl = new RegExp("url = (.+)").exec(
+			configContents[configContents.indexOf(`[remote "${remote}"]`) + 1]
+		)[1];
+		const latestCommit = fs
+			.readFileSync(`.parent_git/refs/heads/${branch}`)
+			.toString()
+			.replace(/\n/g, "");
+		const latestCommitShort = latestCommit.substr(0, 7);
+
+		console.log(`Musare version: ${packageJson.version}.`);
+		console.log(
+			`Git branch: ${remote}/${branch}. Remote url: ${remoteUrl}. Latest commit: ${latestCommit} (${latestCommitShort}).`
+		);
+
+		if (config.get("debug.version")) debug.version = packageJson.version;
+		if (config.get("debug.git.remote")) debug.git.remote = remote;
+		if (config.get("debug.git.remoteUrl")) debug.git.remoteUrl = remoteUrl;
+		if (config.get("debug.git.branch")) debug.git.branch = branch;
+		if (config.get("debug.git.latestCommit"))
+			debug.git.latestCommit = latestCommit;
+		if (config.get("debug.git.latestCommitShort"))
+			debug.git.latestCommitShort = latestCommitShort;
+	} catch (e) {
+		console.log(`Could not get Git info: ${e.message}.`);
+	}
+
+	cb(debug);
+}
+
+fetchVersionAndGitInfo(() => {});
+
+class InsertDebugInfoPlugin {
+	apply(compiler) {
+		compiler.hooks.compilation.tap("InsertDebugInfoPlugin", (compilation) => {
+			HtmlWebpackPlugin.getHooks(compilation).beforeAssetTagGeneration.tapAsync("InsertDebugInfoPlugin", (data, cb) => {
+				fetchVersionAndGitInfo(debug => {
+					data.plugin.userOptions.debug.version = debug.version;
+					data.plugin.userOptions.debug.git.remote = debug.git.remote;
+					data.plugin.userOptions.debug.git.remoteUrl = debug.git.remoteUrl;
+					data.plugin.userOptions.debug.git.branch = debug.git.branch;
+					data.plugin.userOptions.debug.git.latestCommit = debug.git.latestCommit;
+					data.plugin.userOptions.debug.git.latestCommitShort = debug.git.latestCommitShort;
+					cb(null, data);
+				});
+			})
+		});
+	}
+  }
 
 module.exports = {
 	entry: "./src/main.js",
@@ -25,9 +103,20 @@ module.exports = {
 			hash: true,
 			template: "dist/index.tpl.html",
 			inject: "body",
-			filename: "index.html"
+			filename: "index.html",
+			debug: {
+				git: {
+					remote: "",
+					remoteUrl: "",
+					branch: "",
+					latestCommit: "",
+					latestCommitShort: ""
+				},
+				version: ""
+			}
 		}),
-		new ESLintPlugin()
+		new ESLintPlugin(),
+		new InsertDebugInfoPlugin()
 	],
 	module: {
 		rules: [

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff