Forráskód Böngészése

Merge branch 'polishing' of github.com:Musare/MusareNode into polishing

Jonathan 4 éve
szülő
commit
a485859b13

+ 149 - 0
backend/logic/actions/playlists.js

@@ -219,6 +219,96 @@ export default {
 		);
 	}),
 
+	/**
+	 * Searches through all playlists that can be included in a community station
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} query - the query
+	 * @param {string} query - the page
+	 * @param {Function} cb - gets called with the result
+	 */
+	searchCommunity: isLoginRequired(async function searchCommunity(session, query, page, cb) {
+		async.waterfall(
+			[
+				next => {
+					if ((!query && query !== "") || typeof query !== "string") next("Invalid query.");
+					else next();
+				},
+
+				next => {
+					PlaylistsModule.runJob("SEARCH", {
+						query,
+						includeUser: true,
+						includeGenre: true,
+						includeOwn: true,
+						includeSongs: true,
+						userId: session.userId,
+						page
+					})
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "PLAYLISTS_SEARCH_COMMUNITY", `Searching playlists failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "PLAYLISTS_SEARCH_COMMUNITY", "Searching playlists successful.");
+				return cb({ status: "success", data });
+			}
+		);
+	}),
+
+	/**
+	 * Searches through all playlists that can be included in an official station
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} query - the query
+	 * @param {string} query - the page
+	 * @param {Function} cb - gets called with the result
+	 */
+	searchOfficial: isAdminRequired(async function searchOfficial(session, query, page, cb) {
+		async.waterfall(
+			[
+				next => {
+					if ((!query && query !== "") || typeof query !== "string") next("Invalid query.");
+					else next();
+				},
+
+				next => {
+					PlaylistsModule.runJob("SEARCH", {
+						query,
+						includeGenre: true,
+						includePrivate: true,
+						includeSongs: true,
+						page
+					})
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "PLAYLISTS_SEARCH_OFFICIAL", `Searching playlists failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "PLAYLISTS_SEARCH_OFFICIAL", "Searching playlists successful.");
+				return cb({ status: "success", data });
+			}
+		);
+	}),
+
 	/**
 	 * Gets the first song from a private playlist
 	 *
@@ -591,6 +681,65 @@ export default {
 		);
 	},
 
+	/**
+	 * Gets a playlist from station id
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} stationId - the id of the station we are getting
+	 * @param {string} includeSongs - include songs
+	 * @param {Function} cb - gets called with the result
+	 */
+	getPlaylistForStation: function getPlaylist(session, stationId, includeSongs, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_STATION_PLAYLIST", { stationId, includeSongs }, this)
+						.then(response => next(null, response.playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist) return next("Playlist not found");
+					if (playlist.privacy !== "public" && playlist.createdBy !== session.userId) {
+						if (session)
+							// check if user requested to get a playlist is an admin
+							return DBModule.runJob("GET_MODEL", { modelName: "user" }, this).then(userModel => {
+								userModel.findOne({ _id: session.userId }, (err, user) => {
+									if (user && user.role === "admin") return next(null, playlist);
+									return next("User unauthorised to view playlist.");
+								});
+							});
+						return next("User unauthorised to view playlist.");
+					}
+
+					return next(null, playlist);
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_GET",
+						`Getting playlist for station "${stationId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_GET",
+					`Successfully got playlist for station "${stationId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					data: { playlist }
+				});
+			}
+		);
+	},
+
 	// TODO Remove this
 	/**
 	 * Updates a private playlist

+ 43 - 0
backend/logic/actions/songs.js

@@ -567,6 +567,49 @@ export default {
 	// 	);
 	// }),
 
+	/**
+	 * Searches through official songs
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} query - the query
+	 * @param {string} page - the page
+	 * @param {Function} cb - gets called with the result
+	 */
+	searchOfficial: isLoginRequired(async function searchOfficial(session, query, page, cb) {
+		async.waterfall(
+			[
+				next => {
+					if ((!query && query !== "") || typeof query !== "string") next("Invalid query.");
+					else next();
+				},
+
+				next => {
+					SongsModule.runJob("SEARCH", {
+						query,
+						includeVerified: true,
+						trimmed: true,
+						page
+					})
+						.then(response => {
+							next(null, response);
+						})
+						.catch(err => {
+							next(err);
+						});
+				}
+			],
+			async (err, data) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log("ERROR", "SONGS_SEARCH_OFFICIAL", `Searching songs failed. "${err}"`);
+					return cb({ status: "error", message: err });
+				}
+				this.log("SUCCESS", "SONGS_SEARCH_OFFICIAL", "Searching songs successful.");
+				return cb({ status: "success", data });
+			}
+		);
+	}),
+
 	/**
 	 * Requests a song
 	 *

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

@@ -912,7 +912,9 @@ export default {
 						// privatePlaylist: station.privatePlaylist,
 						// genres: station.genres,
 						// blacklistedGenres: station.blacklistedGenres,
-						theme: station.theme
+						theme: station.theme,
+						paused: station.paused,
+						currentSong: station.currentSong
 					};
 
 					next(null, data);
@@ -2894,6 +2896,39 @@ export default {
 						.catch(next);
 				},
 
+				(song, station, next) => {
+					const excludedPlaylists = [];
+					async.eachLimit(
+						station.excludedPlaylists,
+						1,
+						(playlistId, next) => {
+							PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+								.then(playlist => {
+									excludedPlaylists.push(playlist);
+									next();
+								})
+								.catch(next);
+						},
+						err => {
+							next(err, song, station, excludedPlaylists);
+						}
+					);
+				},
+
+				(song, station, excludedPlaylists, next) => {
+					const excludedSongs = excludedPlaylists
+						.flatMap(excludedPlaylist => excludedPlaylist.songs)
+						.reduce(
+							(items, item) =>
+								items.find(x => x.youtubeId === item.youtubeId) ? [...items] : [...items, item],
+							[]
+						);
+
+					if (excludedSongs.find(excludedSong => excludedSong._id.toString() === song._id.toString()))
+						next("That song is in an excluded playlist and cannot be played.");
+					else next(null, song, station);
+				},
+
 				(song, station, next) => {
 					song.requestedBy = session.userId;
 					song.requestedAt = Date.now();
@@ -3212,6 +3247,274 @@ export default {
 		);
 	}),
 
+	/**
+	 * Includes a playlist in a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param playlistId - the playlist id
+	 * @param cb
+	 */
+	includePlaylist: isOwnerRequired(async function includePlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.includedPlaylists.indexOf(playlistId) !== -1)
+						return next("That playlist is already included.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("INCLUDE_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_INCLUDE_PLAYLIST",
+						`Including playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_INCLUDE_PLAYLIST",
+					`Including playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				// CacheModule.runJob("PUB", {
+				// 	channel: "privatePlaylist.selected",
+				// 	value: {
+				// 		playlistId,
+				// 		stationId
+				// 	}
+				// });
+
+				return cb({
+					status: "success",
+					message: "Successfully included playlist."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Remove included a playlist from a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param playlistId - the playlist id
+	 * @param cb
+	 */
+	removeIncludedPlaylist: isOwnerRequired(async function removeIncludedPlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.includedPlaylists.indexOf(playlistId) === -1)
+						return next("That playlist is not included.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("REMOVE_INCLUDED_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_REMOVE_INCLUDED_PLAYLIST",
+						`Removing included playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_REMOVE_INCLUDED_PLAYLIST",
+					`Removing included playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				// CacheModule.runJob("PUB", {
+				// 	channel: "privatePlaylist.selected",
+				// 	value: {
+				// 		playlistId,
+				// 		stationId
+				// 	}
+				// });
+
+				return cb({
+					status: "success",
+					message: "Successfully removed included playlist."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Excludes a playlist in a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param playlistId - the playlist id
+	 * @param cb
+	 */
+	excludePlaylist: isOwnerRequired(async function excludePlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.excludedPlaylists.indexOf(playlistId) !== -1)
+						return next("That playlist is already excluded.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("EXCLUDE_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_EXCLUDE_PLAYLIST",
+						`Excluding playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_EXCLUDE_PLAYLIST",
+					`Excluding playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				// CacheModule.runJob("PUB", {
+				// 	channel: "privatePlaylist.selected",
+				// 	value: {
+				// 		playlistId,
+				// 		stationId
+				// 	}
+				// });
+
+				return cb({
+					status: "success",
+					message: "Successfully excluded playlist."
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Remove excluded a playlist from a station
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param playlistId - the playlist id
+	 * @param cb
+	 */
+	removeExcludedPlaylist: isOwnerRequired(async function removeExcludedPlaylist(session, stationId, playlistId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					if (station.excludedPlaylists.indexOf(playlistId) === -1)
+						return next("That playlist is not excluded.");
+					return next();
+				},
+
+				next => {
+					StationsModule.runJob("REMOVE_EXCLUDED_PLAYLIST", { stationId, playlistId }, this)
+						.then(() => {
+							next();
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_REMOVE_EXCLUDED_PLAYLIST",
+						`Removing excluded playlist "${playlistId}" for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_REMOVE_EXCLUDED_PLAYLIST",
+					`Removing excluded playlist "${playlistId}" for station "${stationId}" successfully.`
+				);
+
+				PlaylistsModule.runJob("AUTOFILL_STATION_PLAYLIST", { stationId }).then().catch();
+
+				// CacheModule.runJob("PUB", {
+				// 	channel: "privatePlaylist.selected",
+				// 	value: {
+				// 		playlistId,
+				// 		stationId
+				// 	}
+				// });
+
+				return cb({
+					status: "success",
+					message: "Successfully removed excluded playlist."
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Selects a private playlist for a station
 	 *

+ 89 - 0
backend/logic/playlists.js

@@ -979,6 +979,95 @@ class _PlaylistsModule extends CoreClass {
 		);
 	}
 
+	/**
+	 * Searches through playlists
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.query - the query
+	 * @param {string} payload.includePrivate - include private playlists
+	 * @param {string} payload.includeStation - include station playlists
+	 * @param {string} payload.includeUser - include user playlists
+	 * @param {string} payload.includeGenre - include genre playlists
+	 * @param {string} payload.includeOwn - include own user playlists
+	 * @param {string} payload.userId - the user id of the person requesting
+	 * @param {string} payload.includeSongs - include songs
+	 * @param {string} payload.page - page (default 1)
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SEARCH(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						const types = [];
+						if (payload.includeStation) types.push("station");
+						if (payload.includeUser) types.push("user");
+						if (payload.includeGenre) types.push("genre");
+						if (types.length === 0 && !payload.includeOwn) return next("No types have been included.");
+
+						const privacies = ["public"];
+						if (payload.includePrivate) privacies.push("private");
+
+						const includeObject = payload.includeSongs ? null : { songs: false };
+						const filterArray = [
+							{
+								displayName: new RegExp(`${payload.query}`, "i"),
+								privacy: { $in: privacies },
+								type: { $in: types }
+							}
+						];
+
+						if (payload.includeOwn && payload.userId)
+							filterArray.push({
+								displayName: new RegExp(`${payload.query}`, "i"),
+								type: "user",
+								createdBy: payload.userId
+							});
+
+						return next(null, filterArray, includeObject);
+					},
+
+					(filterArray, includeObject, next) => {
+						const page = payload.page ? payload.page : 1;
+						const pageSize = 15;
+						const skipAmount = pageSize * (page - 1);
+
+						PlaylistsModule.playlistModel.find({ $or: filterArray }).count((err, count) => {
+							if (err) next(err);
+							else {
+								PlaylistsModule.playlistModel
+									.find({ $or: filterArray }, includeObject)
+									.skip(skipAmount)
+									.limit(pageSize)
+									.exec((err, playlists) => {
+										if (err) next(err);
+										else {
+											next(null, {
+												playlists,
+												page,
+												pageSize,
+												skipAmount,
+												count
+											});
+										}
+									});
+							}
+						});
+					},
+
+					(data, next) => {
+						if (data.playlists.length > 0) next(null, data);
+						else next("No playlists found");
+					}
+				],
+				(err, data) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(data);
+				}
+			)
+		);
+	}
+
 	/**
 	 * Clears and refills a station playlist
 	 *

+ 93 - 0
backend/logic/songs.js

@@ -407,6 +407,99 @@ class _SongsModule extends CoreClass {
 		);
 	}
 
+	/**
+	 * Searches through songs
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.query - the query
+	 * @param {string} payload.includeHidden - include hidden songs
+	 * @param {string} payload.includeUnverified - include unverified songs
+	 * @param {string} payload.includeVerified - include verified songs
+	 * @param {string} payload.trimmed - include trimmed songs
+	 * @param {string} payload.page - page (default 1)
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	SEARCH(payload) {
+		return new Promise((resolve, reject) =>
+			async.waterfall(
+				[
+					next => {
+						const statuses = [];
+						if (payload.includeHidden) statuses.push("hidden");
+						if (payload.includeUnverified) statuses.push("unverified");
+						if (payload.includeVerified) statuses.push("verified");
+						if (statuses.length === 0) return next("No statuses have been included.");
+
+						const filterArray = [
+							{
+								title: new RegExp(`${payload.query}`, "i"),
+								status: { $in: statuses }
+							},
+							{
+								artists: new RegExp(`${payload.query}`, "i"),
+								status: { $in: statuses }
+							}
+						];
+
+						return next(null, filterArray);
+					},
+
+					(filterArray, next) => {
+						const page = payload.page ? payload.page : 1;
+						const pageSize = 15;
+						const skipAmount = pageSize * (page - 1);
+
+						SongsModule.SongModel.find({ $or: filterArray }).count((err, count) => {
+							if (err) next(err);
+							else {
+								SongsModule.SongModel.find({ $or: filterArray })
+									.skip(skipAmount)
+									.limit(pageSize)
+									.exec((err, songs) => {
+										if (err) next(err);
+										else {
+											next(null, {
+												songs,
+												page,
+												pageSize,
+												skipAmount,
+												count
+											});
+										}
+									});
+							}
+						});
+					},
+
+					(data, next) => {
+						if (data.songs.length === 0) next("No songs found");
+						else if (payload.trimmed) {
+							next(null, {
+								songs: data.songs.map(song => {
+									const { _id, youtubeId, title, artists, thumbnail, duration, status } = song;
+									return {
+										_id,
+										youtubeId,
+										title,
+										artists,
+										thumbnail,
+										duration,
+										status
+									};
+								}),
+								...data
+							});
+						} else next(null, data);
+					}
+				],
+				(err, data) => {
+					if (err && err !== true) return reject(new Error(err));
+					return resolve(data);
+				}
+			)
+		);
+	}
+
 	/**
 	 * Recalculates dislikes and likes for a song
 	 *

+ 2 - 0
docker-compose.yml

@@ -23,6 +23,8 @@ services:
       - /opt/app/node_modules/
     environment:
       - FRONTEND_MODE=${FRONTEND_MODE}
+    links:
+      - backend
 
   mongo:
     image: mongo:4.0

+ 10 - 0
frontend/src/App.vue

@@ -658,6 +658,16 @@ a {
 			}
 		}
 	}
+	.tippy-content > div {
+		display: flex;
+		flex-direction: column;
+		button {
+			width: 150px;
+			&:not(:last-of-type) {
+				margin-bottom: 10px;
+			}
+		}
+	}
 }
 
 .select {

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

@@ -91,6 +91,9 @@ p {
 	& > div {
 		display: flex;
 		flex-grow: 1;
+		div:not(:first-of-type) {
+			margin-left: 10px;
+		}
 	}
 	.right {
 		margin-left: auto;

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

@@ -12,6 +12,16 @@
 				>
 			</p>
 			<p class="item-description">
+				<span v-if="showOwner"
+					><a v-if="playlist.createdBy === 'Musare'" title="Musare"
+						>Musare</a
+					><user-id-to-username
+						v-else
+						:user-id="playlist.createdBy"
+						:link="true"
+					/>
+					•</span
+				>
 				{{ totalLength(playlist) }} •
 				{{ playlist.songs.length }}
 				{{ playlist.songs.length === 1 ? "song" : "songs" }}
@@ -24,11 +34,14 @@
 </template>
 
 <script>
+import UserIdToUsername from "@/components/UserIdToUsername.vue";
 import utils from "../../js/utils";
 
 export default {
+	components: { UserIdToUsername },
 	props: {
-		playlist: { type: Object, default: () => {} }
+		playlist: { type: Object, default: () => {} },
+		showOwner: { type: Boolean, default: false }
 	},
 	data() {
 		return {
@@ -87,6 +100,7 @@ export default {
 		div {
 			display: flex;
 			align-items: center;
+			line-height: 1;
 
 			button,
 			.button {

+ 26 - 5
frontend/src/pages/Station/Sidebar/Queue.vue → frontend/src/components/Queue.vue

@@ -71,21 +71,24 @@
 		<button
 			class="button is-primary tab-actionable-button"
 			v-if="
-				loggedIn &&
+				sector === 'station' &&
+					loggedIn &&
 					station.type === 'community' &&
 					station.partyMode &&
 					((station.locked && isOwnerOnly()) ||
 						!station.locked ||
 						(station.locked && isAdminOnly() && dismissedWarning))
 			"
-			@click="openModal('addSongToQueue')"
+			@click="openModal('manageStation')"
 		>
 			<i class="material-icons icon-with-button">queue</i>
 			<span class="optional-desktop-only-text"> Add Song To Queue </span>
 		</button>
 		<button
 			class="button is-primary tab-actionable-button"
-			v-if="loggedIn && station.type === 'official'"
+			v-if="
+				sector === 'station' && loggedIn && station.type === 'official'
+			"
 			@click="openModal('requestSong')"
 		>
 			<i class="material-icons icon-with-button">queue</i>
@@ -94,7 +97,8 @@
 		<button
 			class="button is-primary tab-actionable-button disabled"
 			v-if="
-				!loggedIn &&
+				sector === 'station' &&
+					!loggedIn &&
 					((station.type === 'community' &&
 						station.partyMode &&
 						!station.locked) ||
@@ -137,6 +141,12 @@ import Confirm from "@/components/Confirm.vue";
 
 export default {
 	components: { draggable, SongItem, Confirm },
+	props: {
+		sector: {
+			type: String,
+			default: "station"
+		}
+	},
 	data() {
 		return {
 			dismissedWarning: false,
@@ -147,10 +157,20 @@ export default {
 	computed: {
 		queue: {
 			get() {
+				if (this.sector === "manageStation") {
+					return this.$store.state.modals.manageStation.songsList;
+				}
 				return this.$store.state.station.songsList;
 			},
 			set(queue) {
-				this.$store.commit("station/updateSongsList", queue);
+				if (this.sector === "manageStation") {
+					this.$store.commit(
+						"modals/manageStation/updateSongsList",
+						queue
+					);
+				} else {
+					this.$store.commit("station/updateSongsList", queue);
+				}
 			}
 		},
 		dragOptions() {
@@ -167,6 +187,7 @@ export default {
 			userRole: state => state.user.auth.role,
 			station: state => state.station.station,
 			songsList: state => state.station.songsList,
+			otherSongsList: state => state.modals.manageStation.songsList,
 			noSong: state => state.station.noSong
 		}),
 		...mapGetters({

+ 8 - 1
frontend/src/components/SongItem.vue

@@ -1,5 +1,8 @@
 <template>
-	<div class="universal-item song-item">
+	<div
+		class="universal-item song-item"
+		:class="{ 'with-duration': duration }"
+	>
 		<div class="thumbnail-and-info">
 			<song-thumbnail :class="{ large: largeThumbnail }" :song="song" />
 			<div class="song-info">
@@ -311,5 +314,9 @@ export default {
 	.edit-icon {
 		color: var(--primary-color);
 	}
+
+	&.with-duration .song-info {
+		width: calc(100% - 150px);
+	}
 }
 </style>

+ 0 - 418
frontend/src/components/modals/AddSongToQueue.vue

@@ -1,418 +0,0 @@
-<template>
-	<modal title="Add Song To Queue">
-		<div slot="body">
-			<div class="vertical-padding">
-				<!-- Choosing a song from youtube -->
-
-				<h4 class="section-title">Choose a song</h4>
-				<p class="section-description">
-					Choose a song by searching or using a link from YouTube.
-				</p>
-
-				<br />
-
-				<div class="control is-grouped input-with-button">
-					<p class="control is-expanded">
-						<input
-							class="input"
-							type="text"
-							placeholder="Enter your YouTube query here..."
-							v-model="search.songs.query"
-							autofocus
-							@keyup.enter="searchForSongs()"
-						/>
-					</p>
-					<p class="control">
-						<a
-							class="button is-info"
-							@click.prevent="searchForSongs()"
-							href="#"
-							><i class="material-icons icon-with-button"
-								>search</i
-							>Search</a
-						>
-					</p>
-				</div>
-
-				<!-- Choosing a song from youtube - query results -->
-
-				<div
-					id="song-query-results"
-					v-if="search.songs.results.length > 0"
-				>
-					<search-query-item
-						v-for="(result, index) in search.songs.results"
-						:key="index"
-						:result="result"
-					>
-						<div slot="actions">
-							<transition
-								name="search-query-actions"
-								mode="out-in"
-							>
-								<a
-									class="button is-success"
-									v-if="result.isAddedToQueue"
-									href="#"
-									key="added-to-playlist"
-								>
-									<i class="material-icons icon-with-button"
-										>done</i
-									>
-									Added to queue
-								</a>
-								<a
-									class="button is-dark"
-									v-else
-									@click.prevent="
-										addSongToQueue(result.id, index)
-									"
-									href="#"
-									key="add-to-queue"
-								>
-									<i class="material-icons icon-with-button"
-										>add</i
-									>
-									Add to queue
-								</a>
-							</transition>
-						</div>
-					</search-query-item>
-
-					<a
-						class="button is-default load-more-button"
-						@click.prevent="loadMoreSongs()"
-						href="#"
-					>
-						Load more...
-					</a>
-				</div>
-
-				<!-- Import a playlist from youtube -->
-
-				<div v-if="station.type === 'official'">
-					<hr class="section-horizontal-rule" />
-
-					<h4 class="section-title">Import a playlist</h4>
-					<p class="section-description">
-						Import a playlist by using a link from YouTube.
-					</p>
-
-					<br />
-
-					<div class="control is-grouped input-with-button">
-						<p class="control is-expanded">
-							<input
-								class="input"
-								type="text"
-								placeholder="YouTube Playlist URL"
-								v-model="search.playlist.query"
-								@keyup.enter="importPlaylist()"
-							/>
-						</p>
-						<p class="control has-addons">
-							<span class="select" id="playlist-import-type">
-								<select
-									v-model="
-										search.playlist.isImportingOnlyMusic
-									"
-								>
-									<option :value="false">Import all</option>
-									<option :value="true">
-										Import only music
-									</option>
-								</select>
-							</span>
-							<a
-								class="button is-info"
-								@click.prevent="importPlaylist()"
-								href="#"
-								><i class="material-icons icon-with-button"
-									>publish</i
-								>Import</a
-							>
-						</p>
-					</div>
-				</div>
-
-				<!-- Choose a playlist from your account -->
-
-				<div
-					v-if="
-						loggedIn &&
-							station.type === 'community' &&
-							playlists.length > 0
-					"
-				>
-					<hr class="section-horizontal-rule" />
-
-					<aside id="playlist-to-queue-selection">
-						<h4 class="section-title">Choose a playlist</h4>
-						<p class="section-description">
-							Choose one of your playlists to add to the queue.
-						</p>
-
-						<br />
-
-						<div id="playlists">
-							<div
-								class="playlist"
-								v-for="(playlist, index) in playlists"
-								:key="index"
-							>
-								<playlist-item :playlist="playlist">
-									<div slot="actions">
-										<a
-											class="button is-danger"
-											href="#"
-											@click.prevent="
-												togglePlaylistSelection(
-													playlist._id
-												)
-											"
-											v-if="
-												isPlaylistSelected(playlist._id)
-											"
-										>
-											<i
-												class="material-icons icon-with-button"
-												>stop</i
-											>
-											Stop playing
-										</a>
-										<a
-											class="button is-success"
-											@click.prevent="
-												togglePlaylistSelection(
-													playlist._id
-												)
-											"
-											href="#"
-											v-else
-											><i
-												class="material-icons icon-with-button"
-												>play_arrow</i
-											>Play in queue
-										</a>
-									</div>
-								</playlist-item>
-							</div>
-						</div>
-					</aside>
-				</div>
-			</div>
-		</div>
-	</modal>
-</template>
-
-<script>
-import { mapState, mapGetters, mapActions } from "vuex";
-
-import Toast from "toasters";
-
-import SearchYoutube from "@/mixins/SearchYoutube.vue";
-
-import PlaylistItem from "../PlaylistItem.vue";
-import SearchQueryItem from "../SearchQueryItem.vue";
-import Modal from "../Modal.vue";
-
-export default {
-	components: { Modal, PlaylistItem, SearchQueryItem },
-	mixins: [SearchYoutube],
-	data() {
-		return {
-			playlists: []
-		};
-	},
-	computed: {
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn,
-			station: state => state.station.station,
-			privatePlaylistQueueSelected: state =>
-				state.station.privatePlaylistQueueSelected
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	mounted() {
-		this.socket.dispatch("playlists.indexMyPlaylists", true, res => {
-			if (res.status === "success") this.playlists = res.data.playlists;
-		});
-	},
-	methods: {
-		isPlaylistSelected(playlistId) {
-			return this.privatePlaylistQueueSelected === playlistId;
-		},
-		togglePlaylistSelection(playlistId) {
-			if (this.station.type === "community") {
-				if (this.isPlaylistSelected(playlistId)) {
-					this.updatePrivatePlaylistQueueSelected(null);
-				} else {
-					this.updatePrivatePlaylistQueueSelected(playlistId);
-					this.$parent.addFirstPrivatePlaylistSongToQueue();
-					console.log(this.isPlaylistSelected(playlistId));
-				}
-			}
-		},
-		addSongToQueue(youtubeId, index) {
-			if (this.station.type === "community") {
-				this.socket.dispatch(
-					"stations.addToQueue",
-					this.station._id,
-					youtubeId,
-					res => {
-						if (res.status !== "success")
-							new Toast(`Error: ${res.message}`);
-						else {
-							this.search.songs.results[
-								index
-							].isAddedToQueue = true;
-
-							new Toast(res.message);
-						}
-					}
-				);
-			} else {
-				this.socket.dispatch("songs.request", youtubeId, res => {
-					if (res.status !== "success")
-						new Toast(`Error: ${res.message}`);
-					else {
-						this.search.songs.results[index].isAddedToQueue = true;
-
-						new Toast(res.message);
-					}
-				});
-			}
-		},
-		importPlaylist() {
-			let isImportingPlaylist = true;
-
-			// import query is blank
-			if (!this.search.playlist.query)
-				return new Toast("Please enter a YouTube playlist URL.");
-
-			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
-			const splitQuery = regex.exec(this.search.playlist.query);
-
-			if (!splitQuery) {
-				return new Toast({
-					content: "Please enter a valid YouTube playlist URL.",
-					timeout: 4000
-				});
-			}
-
-			// don't give starting import message instantly in case of instant error
-			setTimeout(() => {
-				if (isImportingPlaylist) {
-					new Toast(
-						"Starting to import your playlist. This can take some time to do."
-					);
-				}
-			}, 750);
-
-			return this.socket.dispatch(
-				"songs.requestSet",
-				this.search.playlist.query,
-				this.search.playlist.isImportingOnlyMusic,
-				res => {
-					isImportingPlaylist = false;
-					return new Toast({ content: res.message, timeout: 20000 });
-				}
-			);
-		},
-		...mapActions("station", ["updatePrivatePlaylistQueueSelected"]),
-		...mapActions("user/playlists", ["editPlaylist"])
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-.night-mode {
-	div {
-		color: var(--dark-grey);
-	}
-}
-
-.song-actions {
-	.button {
-		height: 36px;
-		width: 140px;
-	}
-}
-
-.song-thumbnail div {
-	width: 96px;
-	height: 54px;
-	background-position: center;
-	background-repeat: no-repeat;
-}
-
-.table {
-	margin-bottom: 0;
-	margin-top: 20px;
-}
-
-#playlist-to-queue-selection {
-	margin-top: 0;
-
-	#playlists {
-		font-size: 18px;
-
-		.playlist {
-			.button {
-				width: 150px;
-			}
-
-			i {
-				color: var(--white);
-			}
-		}
-
-		.playlist:not(:last-of-type) {
-			margin-bottom: 10px;
-		}
-
-		.radio {
-			display: flex;
-			flex-direction: row;
-			align-items: center;
-
-			input {
-				transform: scale(1.25);
-			}
-		}
-	}
-}
-
-#playlist-import-type {
-	&:hover {
-		z-index: initial;
-	}
-
-	select {
-		border-radius: 0;
-	}
-}
-
-.vertical-padding {
-	padding: 20px;
-}
-
-#song-query-results {
-	padding: 10px;
-	max-height: 500px;
-	overflow: auto;
-	border: 1px solid var(--light-grey-3);
-	border-radius: 3px;
-
-	.search-query-item:not(:last-of-type) {
-		margin-bottom: 10px;
-	}
-
-	.load-more-button {
-		width: 100%;
-		margin-top: 10px;
-	}
-}
-</style>

+ 0 - 1362
frontend/src/components/modals/EditStation.vue

@@ -1,1362 +0,0 @@
-<template>
-	<modal title="Edit Station" class="edit-station-modal">
-		<template #body>
-			<div class="custom-modal-body" v-if="station && station._id">
-				<!--  Station Preferences -->
-				<div class="section left-section">
-					<div class="col col-2">
-						<div>
-							<label class="label">Name</label>
-							<p class="control">
-								<input
-									class="input"
-									type="text"
-									v-model="station.name"
-								/>
-							</p>
-						</div>
-						<div>
-							<label class="label">Display name</label>
-							<p class="control">
-								<input
-									class="input"
-									type="text"
-									v-model="station.displayName"
-								/>
-							</p>
-						</div>
-					</div>
-					<div class="col col-1">
-						<div>
-							<label class="label">Description</label>
-							<p class="control">
-								<input
-									class="input"
-									type="text"
-									v-model="station.description"
-								/>
-							</p>
-						</div>
-					</div>
-					<div
-						class="col col-2"
-						v-if="station.type === 'official' && station.genres"
-					>
-						<div>
-							<label class="label">Genre(s)</label>
-							<p class="control has-addons">
-								<input
-									class="input"
-									type="text"
-									id="new-genre"
-									v-model="genreInputValue"
-									@blur="blurGenreInput()"
-									@focus="focusGenreInput()"
-									@keydown="keydownGenreInput()"
-									@keyup.enter="addTag('genres')"
-								/>
-								<button
-									class="button is-info add-button blue"
-									@click="addTag('genres')"
-								>
-									<i class="material-icons">add</i>
-								</button>
-							</p>
-							<div
-								class="autosuggest-container"
-								v-if="
-									(genreInputFocussed ||
-										genreAutosuggestContainerFocussed) &&
-										genreAutosuggestItems.length > 0
-								"
-								@mouseover="focusGenreContainer()"
-								@mouseleave="blurGenreContainer()"
-							>
-								<span
-									class="autosuggest-item"
-									tabindex="0"
-									@click="selectGenreAutosuggest(item)"
-									v-for="(item,
-									index) in genreAutosuggestItems"
-									:key="index"
-									>{{ item }}</span
-								>
-							</div>
-							<div class="list-container">
-								<div
-									class="list-item"
-									v-for="(genre, index) in station.genres"
-									:key="index"
-								>
-									<div
-										class="list-item-circle blue"
-										@click="removeTag('genres', index)"
-									>
-										<i class="material-icons">close</i>
-									</div>
-									<p>{{ genre }}</p>
-								</div>
-							</div>
-						</div>
-						<div>
-							<label class="label">Blacklist genre(s)</label>
-							<p class="control has-addons">
-								<input
-									class="input"
-									type="text"
-									v-model="blacklistGenreInputValue"
-									@blur="blurBlacklistGenreInput()"
-									@focus="focusBlacklistGenreInput()"
-									@keydown="keydownBlacklistGenreInput()"
-									@keyup.enter="addTag('blacklist-genres')"
-								/>
-								<button
-									class="button is-info add-button red"
-									@click="addTag('blacklist-genres')"
-								>
-									<i class="material-icons">add</i>
-								</button>
-							</p>
-							<div
-								class="autosuggest-container"
-								v-if="
-									(blacklistGenreInputFocussed ||
-										blacklistGenreAutosuggestContainerFocussed) &&
-										blacklistGenreAutosuggestItems.length >
-											0
-								"
-								@mouseover="focusBlacklistGenreContainer()"
-								@mouseleave="blurBlacklistGenreContainer()"
-							>
-								<span
-									class="autosuggest-item"
-									tabindex="0"
-									@click="
-										selectBlacklistGenreAutosuggest(item)
-									"
-									v-for="(item,
-									index) in blacklistGenreAutosuggestItems"
-									:key="index"
-									>{{ item }}</span
-								>
-							</div>
-							<div class="list-container">
-								<div
-									class="list-item"
-									v-for="(genre,
-									index) in station.blacklistedGenres"
-									:key="index"
-								>
-									<div
-										class="list-item-circle red"
-										@click="
-											removeTag('blacklist-genres', index)
-										"
-									>
-										<i class="material-icons">close</i>
-									</div>
-									<p>{{ genre }}</p>
-								</div>
-							</div>
-						</div>
-					</div>
-				</div>
-
-				<!--  Buttons changing the privacy settings -->
-				<div class="section right-section">
-					<div>
-						<label class="label">Privacy</label>
-						<div
-							@mouseenter="privacyDropdownActive = true"
-							@mouseleave="privacyDropdownActive = false"
-							class="button-wrapper"
-						>
-							<button
-								:class="privacyButtons[station.privacy].style"
-								@click="updatePrivacyLocal(station.privacy)"
-							>
-								<i class="material-icons">{{
-									privacyButtons[station.privacy].iconName
-								}}</i>
-								{{ station.privacy }}
-							</button>
-							<transition name="slide-down">
-								<button
-									class="green"
-									v-if="
-										privacyDropdownActive &&
-											station.privacy !== 'public'
-									"
-									@click="updatePrivacyLocal('public')"
-								>
-									<i class="material-icons">{{
-										privacyButtons["public"].iconName
-									}}</i>
-									Public
-								</button>
-							</transition>
-							<transition name="slide-down">
-								<button
-									class="orange"
-									v-if="
-										privacyDropdownActive &&
-											station.privacy !== 'unlisted'
-									"
-									@click="updatePrivacyLocal('unlisted')"
-								>
-									<i class="material-icons">{{
-										privacyButtons["unlisted"].iconName
-									}}</i>
-									Unlisted
-								</button>
-							</transition>
-							<transition name="slide-down">
-								<button
-									class="red"
-									v-if="
-										privacyDropdownActive &&
-											station.privacy !== 'private'
-									"
-									@click="updatePrivacyLocal('private')"
-								>
-									<i class="material-icons">{{
-										privacyButtons["private"].iconName
-									}}</i>
-									Private
-								</button>
-							</transition>
-						</div>
-					</div>
-					<!--  Buttons changing the mode of the station -->
-					<div>
-						<label class="label">Station Mode</label>
-						<div
-							@mouseenter="modeDropdownActive = true"
-							@mouseleave="modeDropdownActive = false"
-							class="button-wrapper"
-						>
-							<button
-								:class="{
-									blue: !station.partyMode,
-									yellow: station.partyMode
-								}"
-								@click="
-									station.partyMode
-										? updatePartyModeLocal(true)
-										: updatePartyModeLocal(false)
-								"
-							>
-								<i class="material-icons">{{
-									station.partyMode
-										? "emoji_people"
-										: "playlist_play"
-								}}</i>
-								{{ station.partyMode ? "Party" : "Playlist" }}
-							</button>
-							<transition name="slide-down">
-								<button
-									class="blue"
-									v-if="
-										modeDropdownActive && station.partyMode
-									"
-									@click="updatePartyModeLocal(false)"
-								>
-									<i class="material-icons">playlist_play</i>
-									Playlist
-								</button>
-							</transition>
-							<transition
-								v-if="station.type === 'community'"
-								name="slide-down"
-							>
-								<button
-									class="yellow"
-									v-if="
-										modeDropdownActive && !station.partyMode
-									"
-									@click="updatePartyModeLocal(true)"
-								>
-									<i class="material-icons">emoji_people</i>
-									Party
-								</button>
-							</transition>
-						</div>
-					</div>
-					<div>
-						<label class="label">Play Mode</label>
-						<div
-							@mouseenter="playModeDropdownActive = true"
-							@mouseleave="playModeDropdownActive = false"
-							class="button-wrapper"
-						>
-							<button
-								class="blue"
-								@click="
-									(station.type === 'official' &&
-										station.playMode === 'random') ||
-									station.playMode === 'sequential'
-										? updatePlayModeLocal('random')
-										: updatePlayModeLocal('sequential')
-								"
-							>
-								<i class="material-icons">{{
-									station.playMode === "random"
-										? "shuffle"
-										: "format_list_numbered"
-								}}</i>
-								{{
-									station.playMode === "random"
-										? "Random"
-										: "Sequential"
-								}}
-							</button>
-							<transition name="slide-down">
-								<button
-									class="blue"
-									v-if="
-										playModeDropdownActive &&
-											station.playMode === 'sequential'
-									"
-									@click="updatePlayModeLocal('random')"
-								>
-									<i class="material-icons">shuffle</i>
-									Random
-								</button>
-							</transition>
-							<transition
-								v-if="station.type === 'community'"
-								name="slide-down"
-							>
-								<button
-									class="blue"
-									v-if="
-										playModeDropdownActive &&
-											station.playMode === 'random'
-									"
-									@click="updatePlayModeLocal('sequential')"
-								>
-									<i class="material-icons"
-										>format_list_numbered</i
-									>
-									Sequential
-								</button>
-							</transition>
-						</div>
-					</div>
-					<div
-						v-if="
-							station.type === 'community' &&
-								station.partyMode === true
-						"
-					>
-						<label class="label">Queue lock</label>
-						<div
-							@mouseenter="queueLockDropdownActive = true"
-							@mouseleave="queueLockDropdownActive = false"
-							class="button-wrapper"
-						>
-							<button
-								:class="{
-									green: station.locked,
-									red: !station.locked
-								}"
-								@click="
-									station.locked
-										? updateQueueLockLocal(true)
-										: updateQueueLockLocal(false)
-								"
-							>
-								<i class="material-icons">{{
-									station.locked ? "lock" : "lock_open"
-								}}</i>
-								{{ station.locked ? "Locked" : "Unlocked" }}
-							</button>
-							<transition name="slide-down">
-								<button
-									class="green"
-									v-if="
-										queueLockDropdownActive &&
-											!station.locked
-									"
-									@click="updateQueueLockLocal(true)"
-								>
-									<i class="material-icons">lock</i>
-									Locked
-								</button>
-							</transition>
-							<transition name="slide-down">
-								<button
-									class="red"
-									v-if="
-										queueLockDropdownActive &&
-											station.locked
-									"
-									@click="updateQueueLockLocal(false)"
-								>
-									<i class="material-icons">lock_open</i>
-									Unlocked
-								</button>
-							</transition>
-						</div>
-					</div>
-					<div>
-						<label class="label">Theme</label>
-						<div
-							@mouseenter="themeDropdownActive = true"
-							@mouseleave="themeDropdownActive = false"
-							class="button-wrapper"
-						>
-							<button
-								:class="station.theme"
-								@click="updateThemeLocal(station.theme)"
-							>
-								<i class="material-icons">palette</i>
-								{{ station.theme }}
-							</button>
-							<transition name="slide-down">
-								<button
-									class="blue"
-									v-if="
-										themeDropdownActive &&
-											station.theme !== 'blue'
-									"
-									@click="updateThemeLocal('blue')"
-								>
-									<i class="material-icons">palette</i>
-									Blue
-								</button>
-							</transition>
-							<transition name="slide-down">
-								<button
-									class="purple"
-									v-if="
-										themeDropdownActive &&
-											station.theme !== 'purple'
-									"
-									@click="updateThemeLocal('purple')"
-								>
-									<i class="material-icons">palette</i>
-									Purple
-								</button>
-							</transition>
-							<transition name="slide-down">
-								<button
-									class="teal"
-									v-if="
-										themeDropdownActive &&
-											station.theme !== 'teal'
-									"
-									@click="updateThemeLocal('teal')"
-								>
-									<i class="material-icons">palette</i>
-									Teal
-								</button>
-							</transition>
-							<transition name="slide-down">
-								<button
-									class="orange"
-									v-if="
-										themeDropdownActive &&
-											station.theme !== 'orange'
-									"
-									@click="updateThemeLocal('orange')"
-								>
-									<i class="material-icons">palette</i>
-									Orange
-								</button>
-							</transition>
-						</div>
-					</div>
-				</div>
-			</div>
-		</template>
-		<template #footer>
-			<save-button ref="saveButton" @clicked="saveChanges()" />
-			<div class="right">
-				<confirm @confirm="clearAndRefillStationQueue()">
-					<a class="button is-danger">
-						Clear and refill station queue
-					</a>
-				</confirm>
-				<confirm
-					v-if="station && station.type === 'community'"
-					@confirm="deleteStation()"
-				>
-					<button class="button is-danger">Delete station</button>
-				</confirm>
-			</div>
-		</template>
-	</modal>
-</template>
-
-<script>
-import { mapState, mapGetters, mapActions } from "vuex";
-
-import Toast from "toasters";
-
-import validation from "@/validation";
-import Confirm from "@/components/Confirm.vue";
-import Modal from "../Modal.vue";
-import SaveButton from "../SaveButton.vue";
-
-export default {
-	components: { Modal, Confirm, SaveButton },
-	props: {
-		stationId: { type: String, default: "" },
-		sector: { type: String, default: "admin" }
-	},
-	data() {
-		return {
-			genreInputValue: "",
-			genreInputFocussed: false,
-			genreAutosuggestContainerFocussed: false,
-			keydownGenreInputTimeout: 0,
-			genreAutosuggestItems: [],
-			blacklistGenreInputValue: "",
-			blacklistGenreInputFocussed: false,
-			blacklistGenreAutosuggestContainerFocussed: false,
-			blacklistKeydownGenreInputTimeout: 0,
-			blacklistGenreAutosuggestItems: [],
-			privacyDropdownActive: false,
-			modeDropdownActive: false,
-			playModeDropdownActive: false,
-			queueLockDropdownActive: false,
-			themeDropdownActive: false,
-			genres: [
-				"Blues",
-				"Country",
-				"Disco",
-				"Funk",
-				"Hip-Hop",
-				"Jazz",
-				"Metal",
-				"Oldies",
-				"Other",
-				"Pop",
-				"Rap",
-				"Reggae",
-				"Rock",
-				"Techno",
-				"Trance",
-				"Classical",
-				"Instrumental",
-				"House",
-				"Electronic",
-				"Christian Rap",
-				"Lo-Fi",
-				"Musical",
-				"Rock 'n' Roll",
-				"Opera",
-				"Drum & Bass",
-				"Club-House",
-				"Indie",
-				"Heavy Metal",
-				"Christian rock",
-				"Dubstep"
-			],
-			privacyButtons: {
-				public: {
-					style: "green",
-					iconName: "public"
-				},
-				private: {
-					style: "red",
-					iconName: "lock"
-				},
-				unlisted: {
-					style: "orange",
-					iconName: "link"
-				}
-			}
-		};
-	},
-	computed: {
-		// ...mapState("admin/stations", {
-		// 	stations: state => state.stations
-		// }),
-		...mapState("modals/editStation", {
-			station: state => state.station,
-			originalStation: state => state.originalStation
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	mounted() {
-		this.socket.dispatch(`stations.getStationById`, this.stationId, res => {
-			if (res.status === "success") {
-				const { station } = res.data;
-				// this.song = { ...song };
-				// if (this.song.discogs === undefined)
-				// 	this.song.discogs = null;
-				this.editStation(station);
-
-				// this.songDataLoaded = true;
-
-				this.socket.dispatch(
-					`stations.getStationIncludedPlaylistsById`,
-					this.stationId,
-					res => {
-						if (res.status === "success") {
-							this.setGenres(
-								res.data.playlists.map(playlist => {
-									if (playlist) {
-										if (playlist.type === "genre")
-											return playlist.createdFor;
-										return `Playlist: ${playlist.name}`;
-									}
-									return "Unknown/Error";
-								})
-							);
-							this.originalStation.genres = JSON.parse(
-								JSON.stringify(this.station.genres)
-							);
-						}
-					}
-				);
-
-				this.socket.dispatch(
-					`stations.getStationExcludedPlaylistsById`,
-					this.stationId,
-					res => {
-						if (res.status === "success") {
-							this.setBlacklistedGenres(
-								res.data.playlists.map(playlist => {
-									if (playlist) {
-										if (playlist.type === "genre")
-											return playlist.createdFor;
-										return `Playlist: ${playlist.name}`;
-									}
-									return "Unknown/Error";
-								})
-							);
-							this.originalStation.blacklistedGenres = JSON.parse(
-								JSON.stringify(this.station.blacklistedGenres)
-							);
-						}
-					}
-				);
-
-				// this.station.genres = JSON.parse(
-				// 	JSON.stringify(this.station.genres)
-				// );
-				// this.station.blacklistedGenres = JSON.parse(
-				// 	JSON.stringify(this.station.blacklistedGenres)
-				// );
-			} else {
-				new Toast("Station with that ID not found");
-				this.closeModal("editStation");
-			}
-		});
-	},
-	beforeDestroy() {
-		this.clearStation();
-	},
-	methods: {
-		saveChanges() {
-			const nameChanged = this.originalStation.name !== this.station.name;
-			const displayNameChanged =
-				this.originalStation.displayName !== this.station.displayName;
-			const descriptionChanged =
-				this.originalStation.description !== this.station.description;
-			const privacyChanged =
-				this.originalStation.privacy !== this.station.privacy;
-			const partyModeChanged =
-				this.originalStation.type === "community" &&
-				this.originalStation.partyMode !== this.station.partyMode;
-			const playModeChanged =
-				this.originalStation.playMode !== this.station.playMode;
-			const queueLockChanged =
-				this.originalStation.type === "community" &&
-				this.station.partyMode &&
-				this.originalStation.locked !== this.station.locked;
-			const genresChanged =
-				this.originalStation.genres.toString() !==
-				this.station.genres.toString();
-			const blacklistedGenresChanged =
-				this.originalStation.blacklistedGenres.toString() !==
-				this.station.blacklistedGenres.toString();
-			const themeChanged =
-				this.originalStation.theme !== this.station.theme;
-
-			if (nameChanged) this.updateName();
-			if (displayNameChanged) this.updateDisplayName();
-			if (descriptionChanged) this.updateDescription();
-			if (privacyChanged) this.updatePrivacy();
-			if (partyModeChanged) this.updatePartyMode();
-			if (playModeChanged) this.updatePlayMode();
-			if (queueLockChanged) this.updateQueueLock();
-			if (genresChanged) this.updateGenres();
-			if (blacklistedGenresChanged) this.updateBlacklistedGenres();
-			if (themeChanged) this.updateTheme();
-
-			if (
-				!nameChanged &&
-				!displayNameChanged &&
-				!descriptionChanged &&
-				!privacyChanged &&
-				!partyModeChanged &&
-				!playModeChanged &&
-				!queueLockChanged &&
-				!genresChanged &&
-				!blacklistedGenresChanged &&
-				!themeChanged
-			) {
-				this.$refs.saveButton.handleFailedSave();
-
-				new Toast("Please make a change before saving.");
-			}
-		},
-		updateName() {
-			const { name } = this.station;
-			if (!validation.isLength(name, 2, 16))
-				return new Toast("Name must have between 2 and 16 characters.");
-			if (!validation.regex.az09_.test(name))
-				return new Toast(
-					"Invalid name format. Allowed characters: a-z, 0-9 and _."
-				);
-
-			this.$refs.saveButton.status = "disabled";
-
-			return this.socket.dispatch(
-				"stations.updateName",
-				this.station._id,
-				name,
-				res => {
-					new Toast(res.message);
-
-					if (res.status === "success") {
-						this.originalStation.name = name;
-						return this.$refs.saveButton.handleSuccessfulSave();
-					}
-
-					return this.$refs.saveButton.handleFailedSave();
-				}
-			);
-		},
-		updateDisplayName() {
-			const { displayName } = this.station;
-
-			if (!validation.isLength(displayName, 2, 32))
-				return new Toast(
-					"Display name must have between 2 and 32 characters."
-				);
-
-			if (!validation.regex.ascii.test(displayName))
-				return new Toast(
-					"Invalid display name format. Only ASCII characters are allowed."
-				);
-
-			this.$refs.saveButton.status = "disabled";
-
-			return this.socket.dispatch(
-				"stations.updateDisplayName",
-				this.station._id,
-				displayName,
-				res => {
-					new Toast(res.message);
-
-					if (res.status === "success") {
-						this.originalStation.displayName = displayName;
-						return this.$refs.saveButton.handleSuccessfulSave();
-					}
-
-					return this.$refs.saveButton.handleFailedSave();
-				}
-			);
-		},
-		updateDescription() {
-			const { description } = this.station;
-			if (!validation.isLength(description, 2, 200))
-				return new Toast(
-					"Description must have between 2 and 200 characters."
-				);
-
-			let characters = description.split("");
-			characters = characters.filter(character => {
-				return character.charCodeAt(0) === 21328;
-			});
-			if (characters.length !== 0)
-				return new Toast("Invalid description format.");
-
-			this.$refs.saveButton.status = "disabled";
-
-			return this.socket.dispatch(
-				"stations.updateDescription",
-				this.station._id,
-				description,
-				res => {
-					new Toast(res.message);
-
-					if (res.status === "success") {
-						this.originalStation.description = description;
-						return this.$refs.saveButton.handleSuccessfulSave();
-					}
-
-					return this.$refs.saveButton.handleFailedSave();
-				}
-			);
-		},
-		updatePrivacyLocal(privacy) {
-			if (this.station.privacy === privacy) return;
-			this.station.privacy = privacy;
-			this.privacyDropdownActive = false;
-		},
-		updatePrivacy() {
-			this.$refs.saveButton.status = "disabled";
-
-			this.socket.dispatch(
-				"stations.updatePrivacy",
-				this.station._id,
-				this.station.privacy,
-				res => {
-					new Toast(res.message);
-
-					if (res.status === "success") {
-						this.originalStation.privacy = this.station.privacy;
-						return this.$refs.saveButton.handleSuccessfulSave();
-					}
-
-					return this.$refs.saveButton.handleFailedSave();
-				}
-			);
-		},
-		updateGenres() {
-			this.$refs.saveButton.status = "disabled";
-
-			this.socket.dispatch(
-				"stations.updateGenres",
-				this.station._id,
-				this.station.genres,
-				res => {
-					new Toast(res.message);
-
-					if (res.status === "success") {
-						const genres = JSON.parse(
-							JSON.stringify(this.station.genres)
-						);
-
-						if (this.originalStation)
-							this.originalStation.genres = genres;
-
-						return this.$refs.saveButton.handleSuccessfulSave();
-					}
-
-					return this.$refs.saveButton.handleFailedSave();
-				}
-			);
-		},
-		updateBlacklistedGenres() {
-			this.$refs.saveButton.status = "disabled";
-
-			this.socket.dispatch(
-				"stations.updateBlacklistedGenres",
-				this.station._id,
-				this.station.blacklistedGenres,
-				res => {
-					new Toast(res.message);
-
-					if (res.status === "success") {
-						const blacklistedGenres = JSON.parse(
-							JSON.stringify(this.station.blacklistedGenres)
-						);
-
-						this.originalStation.blacklistedGenres = blacklistedGenres;
-
-						return this.$refs.saveButton.handleSuccessfulSave();
-					}
-
-					return this.$refs.saveButton.handleFailedSave();
-				}
-			);
-		},
-		updatePartyModeLocal(partyMode) {
-			if (this.station.partyMode === partyMode) return;
-			this.station.partyMode = partyMode;
-			this.modeDropdownActive = false;
-		},
-		updatePartyMode() {
-			this.$refs.saveButton.status = "disabled";
-
-			this.socket.dispatch(
-				"stations.updatePartyMode",
-				this.station._id,
-				this.station.partyMode,
-				res => {
-					new Toast(res.message);
-
-					if (res.status === "success") {
-						this.originalStation.partyMode = this.station.partyMode;
-						return this.$refs.saveButton.handleSuccessfulSave();
-					}
-
-					return this.$refs.saveButton.handleFailedSave();
-				}
-			);
-		},
-		updatePlayModeLocal(playMode) {
-			if (this.station.playMode === playMode) return;
-			this.station.playMode = playMode;
-			this.playModeDropdownActive = false;
-		},
-		updatePlayMode() {
-			this.$refs.saveButton.status = "disabled";
-
-			this.socket.dispatch(
-				"stations.updatePlayMode",
-				this.station._id,
-				this.station.playMode,
-				res => {
-					new Toast(res.message);
-
-					if (res.status === "success") {
-						this.originalStation.playMode = this.station.playMode;
-						return this.$refs.saveButton.handleSuccessfulSave();
-					}
-
-					return this.$refs.saveButton.handleFailedSave();
-				}
-			);
-		},
-		updateQueueLockLocal(locked) {
-			if (this.station.locked === locked) return;
-			this.station.locked = locked;
-			this.queueLockDropdownActive = false;
-		},
-		updateQueueLock() {
-			this.$refs.saveButton.status = "disabled";
-
-			this.socket.dispatch(
-				"stations.toggleLock",
-				this.station._id,
-				res => {
-					if (res.status === "success") {
-						if (this.originalStation)
-							this.originalStation.locked = res.data.locked;
-
-						new Toast(
-							`Toggled queue lock successfully to ${res.data.locked}`
-						);
-
-						return this.$refs.saveButton.handleSuccessfulSave();
-					}
-
-					new Toast("Failed to toggle queue lock.");
-
-					return this.$refs.saveButton.handleFailedSave();
-				}
-			);
-		},
-		updateThemeLocal(theme) {
-			if (this.station.theme === theme) return;
-			this.station.theme = theme;
-			this.themeDropdownActive = false;
-		},
-		updateTheme() {
-			this.$refs.saveButton.status = "disabled";
-
-			this.socket.dispatch(
-				"stations.updateTheme",
-				this.station._id,
-				this.station.theme,
-				res => {
-					new Toast(res.message);
-
-					if (res.status === "success") {
-						this.originalStation.theme = this.station.theme;
-						return this.$refs.saveButton.handleSuccessfulSave();
-					}
-
-					return this.$refs.saveButton.handleFailedSave();
-				}
-			);
-		},
-		deleteStation() {
-			this.socket.dispatch("stations.remove", this.station._id, res => {
-				if (res.status === "success") this.closeModal("editStation");
-				return new Toast(res.message);
-			});
-		},
-		blurGenreInput() {
-			this.genreInputFocussed = false;
-		},
-		focusGenreInput() {
-			this.genreInputFocussed = true;
-		},
-		keydownGenreInput() {
-			clearTimeout(this.keydownGenreInputTimeout);
-			this.keydownGenreInputTimeout = setTimeout(() => {
-				if (this.genreInputValue.length > 1) {
-					this.genreAutosuggestItems = this.genres.filter(genre => {
-						return genre
-							.toLowerCase()
-							.startsWith(this.genreInputValue.toLowerCase());
-					});
-				} else this.genreAutosuggestItems = [];
-			}, 1000);
-		},
-		focusGenreContainer() {
-			this.genreAutosuggestContainerFocussed = true;
-		},
-		blurGenreContainer() {
-			this.genreAutosuggestContainerFocussed = false;
-		},
-		selectGenreAutosuggest(value) {
-			this.genreInputValue = value;
-		},
-		blurBlacklistGenreInput() {
-			this.blacklistGenreInputFocussed = false;
-		},
-		focusBlacklistGenreInput() {
-			this.blacklistGenreInputFocussed = true;
-		},
-		keydownBlacklistGenreInput() {
-			clearTimeout(this.keydownBlacklistGenreInputTimeout);
-			this.keydownBlacklistGenreInputTimeout = setTimeout(() => {
-				if (this.blacklistGenreInputValue.length > 1) {
-					this.blacklistGenreAutosuggestItems = this.genres.filter(
-						genre => {
-							return genre
-								.toLowerCase()
-								.startsWith(
-									this.blacklistGenreInputValue.toLowerCase()
-								);
-						}
-					);
-				} else this.blacklistGenreAutosuggestItems = [];
-			}, 1000);
-		},
-		focusBlacklistGenreContainer() {
-			this.blacklistGenreAutosuggestContainerFocussed = true;
-		},
-		blurBlacklistGenreContainer() {
-			this.blacklistGenreAutosuggestContainerFocussed = false;
-		},
-		selectBlacklistGenreAutosuggest(value) {
-			this.blacklistGenreInputValue = value;
-		},
-		addTag(type) {
-			if (type === "genres") {
-				const genre = this.genreInputValue.toLowerCase().trim();
-				if (this.station.genres.indexOf(genre) !== -1)
-					return new Toast("Genre already exists");
-				if (genre) {
-					this.station.genres.push(genre);
-					this.genreInputValue = "";
-					return false;
-				}
-
-				return new Toast("Genre cannot be empty");
-			}
-			if (type === "blacklist-genres") {
-				const genre = this.blacklistGenreInputValue
-					.toLowerCase()
-					.trim();
-				if (this.station.blacklistedGenres.indexOf(genre) !== -1)
-					return new Toast("Blacklist genre already exists");
-				if (genre) {
-					this.station.blacklistedGenres.push(genre);
-					this.blacklistGenreInputValue = "";
-					return false;
-				}
-
-				return new Toast("Blacklist genre cannot be empty");
-			}
-
-			return false;
-		},
-		removeTag(type, index) {
-			if (type === "genres") this.station.genres.splice(index, 1);
-			else if (type === "blacklist-genres")
-				this.station.blacklistedGenres.splice(index, 1);
-		},
-		clearAndRefillStationQueue() {
-			this.socket.dispatch(
-				"stations.clearAndRefillStationQueue",
-				this.station._id,
-				res => {
-					if (res.status !== "success")
-						new Toast({
-							content: `Error: ${res.message}`,
-							timeout: 8000
-						});
-					else new Toast({ content: res.message, timeout: 4000 });
-				}
-			);
-		},
-		...mapActions("modals/editStation", [
-			"editStation",
-			"setGenres",
-			"setBlacklistedGenres",
-			"clearStation"
-		]),
-		...mapActions("modalVisibility", ["closeModal"])
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-.night-mode {
-	.modal-card,
-	.modal-card-head,
-	.modal-card-body,
-	.modal-card-foot {
-		background-color: var(--dark-grey-3);
-	}
-
-	.section {
-		background-color: var(--dark-grey-3) !important;
-		border: 0 !important;
-	}
-
-	.label,
-	p,
-	strong {
-		color: var(--light-grey-2);
-	}
-}
-
-.modal-card-title {
-	text-align: center;
-	margin-left: 24px;
-}
-
-.custom-modal-body {
-	padding: 16px;
-	display: flex;
-}
-
-.section {
-	border: 1px solid var(--light-blue);
-	background-color: var(--light-grey);
-	border-radius: 5px;
-	padding: 16px;
-}
-
-.left-section {
-	width: 595px;
-	display: grid;
-	gap: 16px;
-	grid-template-rows: min-content min-content auto;
-
-	.control {
-		input {
-			width: 100%;
-			height: 36px;
-		}
-
-		.add-button {
-			width: 32px;
-
-			&.blue {
-				background-color: var(--primary-color) !important;
-			}
-
-			&.red {
-				background-color: var(--red) !important;
-			}
-
-			i {
-				font-size: 32px;
-			}
-		}
-	}
-
-	.col {
-		> div {
-			position: relative;
-		}
-	}
-
-	.list-item-circle {
-		width: 16px;
-		height: 16px;
-		border-radius: 8px;
-		cursor: pointer;
-		margin-right: 8px;
-		float: left;
-		-webkit-touch-callout: none;
-		-webkit-user-select: none;
-		-khtml-user-select: none;
-		-moz-user-select: none;
-		-ms-user-select: none;
-		user-select: none;
-
-		&.blue {
-			background-color: var(--primary-color);
-
-			i {
-				color: var(--primary-color);
-			}
-		}
-
-		&.red {
-			background-color: var(--red);
-
-			i {
-				color: var(--red);
-			}
-		}
-
-		i {
-			font-size: 14px;
-			margin-left: 1px;
-		}
-	}
-
-	.list-item-circle:hover,
-	.list-item-circle:focus {
-		i {
-			color: var(--white);
-		}
-	}
-
-	.list-item > p {
-		line-height: 16px;
-		word-wrap: break-word;
-		width: calc(100% - 24px);
-		left: 24px;
-		float: left;
-		margin-bottom: 8px;
-	}
-
-	.list-item:last-child > p {
-		margin-bottom: 0;
-	}
-
-	.autosuggest-container {
-		position: absolute;
-		background: var(--white);
-		width: calc(100% + 1px);
-		top: 57px;
-		z-index: 200;
-		overflow: auto;
-		max-height: 100%;
-		clear: both;
-
-		.autosuggest-item {
-			padding: 8px;
-			display: block;
-			border: 1px solid var(--light-grey-2);
-			margin-top: -1px;
-			line-height: 16px;
-			cursor: pointer;
-			-webkit-user-select: none;
-			-ms-user-select: none;
-			-moz-user-select: none;
-			user-select: none;
-		}
-
-		.autosuggest-item:hover,
-		.autosuggest-item:focus {
-			background-color: var(--light-grey);
-		}
-
-		.autosuggest-item:first-child {
-			border-top: none;
-		}
-
-		.autosuggest-item:last-child {
-			border-radius: 0 0 3px 3px;
-		}
-	}
-}
-
-.right-section {
-	width: 157px;
-	min-height: 515px;
-	margin-left: 16px;
-	display: grid;
-	gap: 16px;
-	grid-template-rows: min-content min-content min-content;
-
-	.button-wrapper {
-		display: flex;
-		flex-direction: column;
-
-		button {
-			width: 100%;
-			height: 36px;
-			border: 0;
-			border-radius: 3px;
-			font-size: 18px;
-			color: var(--white);
-			box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25);
-			display: block;
-			text-align: center;
-			justify-content: center;
-			display: inline-flex;
-			-ms-flex-align: center;
-			align-items: center;
-			-moz-user-select: none;
-			user-select: none;
-			cursor: pointer;
-			margin-bottom: 10px;
-			padding: 0;
-			text-transform: capitalize;
-
-			&.red {
-				background-color: var(--red);
-			}
-
-			&.green {
-				background-color: var(--green);
-			}
-
-			&.blue {
-				background-color: var(--blue);
-			}
-
-			&.orange {
-				background-color: var(--orange);
-			}
-
-			&.yellow {
-				background-color: var(--yellow);
-			}
-
-			&.purple {
-				background-color: var(--purple);
-			}
-
-			&.teal {
-				background-color: var(--teal);
-			}
-
-			i {
-				font-size: 20px;
-				margin-right: 4px;
-			}
-		}
-	}
-}
-
-.col {
-	display: grid;
-	grid-column-gap: 16px;
-}
-
-.col-1 {
-	grid-template-columns: auto;
-}
-
-.col-2 {
-	grid-template-columns: auto auto;
-}
-
-.slide-down-enter-active {
-	transition: transform 0.25s;
-}
-
-.slide-down-enter {
-	transform: translateY(-10px);
-}
-
-.modal-card {
-	overflow: auto;
-}
-
-.modal-card-body {
-	overflow: unset;
-}
-</style>

+ 156 - 0
frontend/src/components/modals/ManageStation/Tabs/Blacklist.vue

@@ -0,0 +1,156 @@
+<template>
+	<div class="station-blacklist">
+		<p class="has-text-centered">
+			Blacklist a playlist to prevent all of its songs playing in this
+			station.
+		</p>
+		<div class="tabs-container">
+			<!-- <div class="tab-selection">
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'playlists' }"
+					@click="showTab('playlists')"
+				>
+					Playlists
+				</button>
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'songs' }"
+					@click="showTab('songs')"
+				>
+					Songs
+				</button>
+			</div> -->
+			<div class="tab" v-show="tab === 'playlists'">
+				<div v-if="excludedPlaylists.length > 0">
+					<playlist-item
+						:playlist="playlist"
+						v-for="(playlist, index) in excludedPlaylists"
+						:key="'key-' + index"
+					>
+						<div class="icons-group" slot="actions">
+							<i
+								@click="deselectPlaylist(playlist._id)"
+								class="material-icons stop-icon"
+								content="Stop blacklisting songs from this playlist
+							"
+								v-tippy
+								>stop</i
+							>
+							<i
+								v-if="playlist.createdBy === userId"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-else
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</div>
+					</playlist-item>
+				</div>
+				<p v-else class="has-text-centered scrollable-list">
+					No playlists currently blacklisted.
+				</p>
+			</div>
+			<!-- <div class="tab" v-show="tab === 'songs'">
+				Blacklisting songs has yet to be added.
+			</div> -->
+		</div>
+	</div>
+</template>
+<script>
+import { mapActions, mapState, mapGetters } from "vuex";
+
+import Toast from "toasters";
+import PlaylistItem from "@/components/PlaylistItem.vue";
+
+export default {
+	components: {
+		PlaylistItem
+	},
+	data() {
+		return {
+			tab: "playlists"
+		};
+	},
+	computed: {
+		...mapState({
+			userId: state => state.user.auth.userId
+		}),
+		...mapState("modals/manageStation", {
+			station: state => state.station,
+			originalStation: state => state.originalStation,
+			excludedPlaylists: state => state.excludedPlaylists
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		showTab(tab) {
+			this.tab = tab;
+		},
+		showPlaylist(playlistId) {
+			this.editPlaylist(playlistId);
+			this.openModal("editPlaylist");
+		},
+		deselectPlaylist(id) {
+			this.socket.dispatch(
+				"stations.removeExcludedPlaylist",
+				this.station._id,
+				id,
+				res => {
+					new Toast(res.message);
+				}
+			);
+		},
+		...mapActions("modalVisibility", ["openModal"]),
+		...mapActions("user/playlists", ["editPlaylist"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.station-blacklist {
+	.tabs-container {
+		margin-top: 10px;
+		.tab-selection {
+			display: flex;
+			.button {
+				border-radius: 0;
+				border: 0;
+				text-transform: uppercase;
+				font-size: 14px;
+				color: var(--dark-grey-3);
+				background-color: var(--light-grey-2);
+				flex-grow: 1;
+				height: 32px;
+
+				&:not(:first-of-type) {
+					margin-left: 5px;
+				}
+			}
+
+			.selected {
+				background-color: var(--dark-grey-3) !important;
+				color: var(--white) !important;
+			}
+		}
+		.tab {
+			padding: 15px 0;
+			border-radius: 0;
+			.playlist-item:not(:last-of-type) {
+				margin-bottom: 10px;
+			}
+		}
+	}
+}
+</style>

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

@@ -0,0 +1,667 @@
+<template>
+	<div class="station-playlists">
+		<div class="tabs-container">
+			<div class="tab-selection">
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'current' }"
+					@click="showTab('current')"
+				>
+					Current
+				</button>
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'search' }"
+					@click="showTab('search')"
+				>
+					Search
+				</button>
+				<button
+					v-if="station.type === 'community'"
+					class="button is-default"
+					:class="{ selected: tab === 'my-playlists' }"
+					@click="showTab('my-playlists')"
+				>
+					My Playlists
+				</button>
+			</div>
+			<div class="tab" v-show="tab === 'current'">
+				<div v-if="currentPlaylists.length > 0">
+					<playlist-item
+						v-for="(playlist, index) in currentPlaylists"
+						:key="'key-' + index"
+						:playlist="playlist"
+						:show-owner="true"
+					>
+						<div class="icons-group" slot="actions">
+							<confirm
+								v-if="isOwnerOrAdmin()"
+								@confirm="deselectPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Stop playing songs from this playlist"
+									v-tippy
+								>
+									stop
+								</i>
+							</confirm>
+							<confirm
+								v-if="isOwnerOrAdmin()"
+								@confirm="blacklistPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Blacklist Playlist"
+									v-tippy
+									>block</i
+								>
+							</confirm>
+							<i
+								v-if="playlist.createdBy === myUserId"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-if="
+									playlist.createdBy !== myUserId &&
+										(playlist.privacy === 'public' ||
+											isAdmin())
+								"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</div>
+					</playlist-item>
+				</div>
+				<p v-else class="has-text-centered scrollable-list">
+					No playlists currently selected.
+				</p>
+			</div>
+			<div class="tab" v-show="tab === 'search'">
+				<label class="label"> Search for a public playlist </label>
+				<div class="control is-grouped input-with-button">
+					<p class="control is-expanded">
+						<input
+							class="input"
+							type="text"
+							placeholder="Enter your playlist query here..."
+							v-model="search.query"
+							@keyup.enter="searchForPlaylists(1)"
+						/>
+					</p>
+					<p class="control">
+						<a class="button is-info" @click="searchForPlaylists(1)"
+							><i class="material-icons icon-with-button"
+								>search</i
+							>Search</a
+						>
+					</p>
+				</div>
+				<div v-if="search.results.length > 0">
+					<playlist-item
+						v-for="(playlist, index) in search.results"
+						:key="'searchKey-' + index"
+						:playlist="playlist"
+						:show-owner="true"
+					>
+						<div class="icons-group" slot="actions">
+							<confirm
+								v-if="
+									(isOwnerOrAdmin() ||
+										(station.type === 'community' &&
+											station.partyMode)) &&
+										isSelected(playlist._id)
+								"
+								@confirm="deselectPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Stop playing songs from this playlist"
+									v-tippy
+								>
+									stop
+								</i>
+							</confirm>
+							<i
+								v-if="
+									(isOwnerOrAdmin() ||
+										(station.type === 'community' &&
+											station.partyMode)) &&
+										!isSelected(playlist._id)
+								"
+								@click="selectPlaylist(playlist)"
+								class="material-icons play-icon"
+								:content="
+									station.partyMode
+										? 'Request songs from this playlist'
+										: 'Play songs from this playlist'
+								"
+								v-tippy
+								>play_arrow</i
+							>
+							<confirm
+								v-if="isOwnerOrAdmin()"
+								@confirm="blacklistPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Blacklist Playlist"
+									v-tippy
+									>block</i
+								>
+							</confirm>
+							<i
+								v-if="playlist.createdBy === myUserId"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-if="
+									playlist.createdBy !== myUserId &&
+										(playlist.privacy === 'public' ||
+											isAdmin())
+								"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</div>
+					</playlist-item>
+					<button
+						v-if="resultsLeftCount > 0"
+						class="button is-primary"
+						@click="searchForPlaylists(search.page + 1)"
+					>
+						Load {{ nextPageResultsCount }} more results
+					</button>
+				</div>
+			</div>
+			<div
+				v-if="station.type === 'community'"
+				class="tab"
+				v-show="tab === 'my-playlists'"
+			>
+				<button
+					class="button is-primary"
+					id="create-new-playlist-button"
+					@click="openModal('createPlaylist')"
+				>
+					Create new playlist
+				</button>
+				<draggable
+					class="menu-list scrollable-list"
+					v-if="playlists.length > 0"
+					v-model="playlists"
+					v-bind="dragOptions"
+					@start="drag = true"
+					@end="drag = false"
+					@change="savePlaylistOrder"
+				>
+					<transition-group
+						type="transition"
+						:name="!drag ? 'draggable-list-transition' : null"
+					>
+						<playlist-item
+							class="item-draggable"
+							v-for="playlist in playlists"
+							:key="playlist._id"
+							:playlist="playlist"
+						>
+							<div slot="actions">
+								<i
+									v-if="
+										station.type === 'community' &&
+											(isOwnerOrAdmin() ||
+												station.partyMode) &&
+											!isSelected(playlist._id)
+									"
+									@click="selectPlaylist(playlist)"
+									class="material-icons play-icon"
+									:content="
+										station.partyMode
+											? 'Request songs from this playlist'
+											: 'Play songs from this playlist'
+									"
+									v-tippy
+									>play_arrow</i
+								>
+								<confirm
+									v-if="
+										station.type === 'community' &&
+											(isOwnerOrAdmin() ||
+												station.partyMode) &&
+											isSelected(playlist._id)
+									"
+									@confirm="deselectPlaylist(playlist._id)"
+								>
+									<i
+										class="material-icons stop-icon"
+										:content="
+											station.partyMode
+												? 'Stop requesting songs from this playlist'
+												: 'Stop playing songs from this playlist'
+										"
+										v-tippy
+										>stop</i
+									>
+								</confirm>
+								<confirm
+									v-if="isOwnerOrAdmin()"
+									@confirm="blacklistPlaylist(playlist._id)"
+								>
+									<i
+										class="material-icons stop-icon"
+										content="Blacklist Playlist"
+										v-tippy
+										>block</i
+									>
+								</confirm>
+								<i
+									@click="showPlaylist(playlist._id)"
+									class="material-icons edit-icon"
+									content="Edit Playlist"
+									v-tippy
+									>edit</i
+								>
+							</div>
+						</playlist-item>
+					</transition-group>
+				</draggable>
+				<p v-else class="has-text-centered scrollable-list">
+					You don't have any playlists!
+				</p>
+			</div>
+		</div>
+	</div>
+</template>
+<script>
+import { mapActions, mapState, mapGetters } from "vuex";
+
+import Toast from "toasters";
+import draggable from "vuedraggable";
+import PlaylistItem from "@/components/PlaylistItem.vue";
+import Confirm from "@/components/Confirm.vue";
+
+import SortablePlaylists from "@/mixins/SortablePlaylists.vue";
+
+export default {
+	components: {
+		draggable,
+		PlaylistItem,
+		Confirm
+		// CreatePlaylist: () => import("@/components/modals/CreatePlaylist.vue")
+	},
+	mixins: [SortablePlaylists],
+	data() {
+		return {
+			tab: "current",
+			search: {
+				query: "",
+				searchedQuery: "",
+				page: 0,
+				count: 0,
+				resultsLeft: 0,
+				results: []
+			}
+		};
+	},
+	computed: {
+		playlists: {
+			get() {
+				return this.$store.state.user.playlists.playlists;
+			},
+			set(playlists) {
+				this.$store.commit("user/playlists/setPlaylists", playlists);
+			}
+		},
+		currentPlaylists() {
+			if (this.station.type === "community" && this.station.partyMode) {
+				return this.partyPlaylists;
+			}
+			return this.includedPlaylists;
+		},
+		resultsLeftCount() {
+			return this.search.count - this.search.results.length;
+		},
+		nextPageResultsCount() {
+			return Math.min(this.search.pageSize, this.resultsLeftCount);
+		},
+		...mapState({
+			loggedIn: state => state.user.auth.loggedIn,
+			role: state => state.user.auth.role,
+			myUserId: state => state.user.auth.userId,
+			userId: state => state.user.auth.userId,
+			partyPlaylists: state => state.station.partyPlaylists
+		}),
+		...mapState("modals/manageStation", {
+			station: state => state.station,
+			originalStation: state => state.originalStation,
+			includedPlaylists: state => state.includedPlaylists,
+			excludedPlaylists: state => state.excludedPlaylists,
+			songsList: state => state.songsList
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		this.socket.dispatch("playlists.indexMyPlaylists", true, res => {
+			if (res.status === "success") this.playlists = res.data.playlists;
+			this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database
+		});
+
+		this.socket.on("event:playlist.create", res => {
+			this.playlists.push(res.data.playlist);
+		});
+
+		this.socket.on("event:playlist.delete", res => {
+			this.playlists.forEach((playlist, index) => {
+				if (playlist._id === res.data.playlistId) {
+					this.playlists.splice(index, 1);
+				}
+			});
+		});
+
+		this.socket.on("event:playlist.addSong", res => {
+			this.playlists.forEach((playlist, index) => {
+				if (playlist._id === res.data.playlistId) {
+					this.playlists[index].songs.push(res.data.song);
+				}
+			});
+		});
+
+		this.socket.on("event:playlist.removeSong", res => {
+			this.playlists.forEach((playlist, index) => {
+				if (playlist._id === res.data.playlistId) {
+					this.playlists[index].songs.forEach((song, index2) => {
+						if (song.youtubeId === res.data.youtubeId) {
+							this.playlists[index].songs.splice(index2, 1);
+						}
+					});
+				}
+			});
+		});
+
+		this.socket.on("event:playlist.updateDisplayName", res => {
+			this.playlists.forEach((playlist, index) => {
+				if (playlist._id === res.data.playlistId) {
+					this.playlists[index].displayName = res.data.displayName;
+				}
+			});
+		});
+
+		this.socket.on("event:playlist.updatePrivacy", res => {
+			this.playlists.forEach((playlist, index) => {
+				if (playlist._id === res.data.playlist._id) {
+					this.playlists[index].privacy = res.data.playlist.privacy;
+				}
+			});
+		});
+
+		this.socket.on(
+			"event:user.orderOfPlaylists.changed",
+			orderOfPlaylists => {
+				const sortedPlaylists = [];
+
+				this.playlists.forEach(playlist => {
+					sortedPlaylists[
+						orderOfPlaylists.indexOf(playlist._id)
+					] = playlist;
+				});
+
+				this.playlists = sortedPlaylists;
+				this.orderOfPlaylists = this.calculatePlaylistOrder();
+			}
+		);
+
+		this.socket.dispatch(
+			`stations.getStationIncludedPlaylistsById`,
+			this.station._id,
+			res => {
+				if (res.status === "success") {
+					this.station.includedPlaylists = res.data.playlists;
+					this.originalStation.includedPlaylists = res.data.playlists;
+				}
+			}
+		);
+
+		this.socket.dispatch(
+			`stations.getStationExcludedPlaylistsById`,
+			this.station._id,
+			res => {
+				if (res.status === "success") {
+					this.station.excludedPlaylists = res.data.playlists;
+					this.originalStation.excludedPlaylists = res.data.playlists;
+				}
+			}
+		);
+	},
+	methods: {
+		showTab(tab) {
+			this.tab = tab;
+		},
+		isOwner() {
+			return this.loggedIn && this.userId === this.station.owner;
+		},
+		isAdmin() {
+			return this.loggedIn && this.role === "admin";
+		},
+		isOwnerOrAdmin() {
+			return this.isOwner() || this.isAdmin();
+		},
+		showPlaylist(playlistId) {
+			this.editPlaylist(playlistId);
+			this.openModal("editPlaylist");
+		},
+		selectPlaylist(playlist) {
+			if (this.station.type === "community" && this.station.partyMode) {
+				if (!this.isSelected(playlist.id)) {
+					this.partyPlaylists.push(playlist);
+					this.addPartyPlaylistSongToQueue();
+					new Toast(
+						"Successfully selected playlist to auto request songs."
+					);
+				} else {
+					new Toast("Error: Playlist already selected.");
+				}
+			} else {
+				this.socket.dispatch(
+					"stations.includePlaylist",
+					this.station._id,
+					playlist._id,
+					res => {
+						new Toast(res.message);
+					}
+				);
+			}
+		},
+		deselectPlaylist(id) {
+			if (this.station.type === "community" && this.station.partyMode) {
+				let selected = false;
+				this.currentPlaylists.forEach((playlist, index) => {
+					if (playlist._id === id) {
+						selected = true;
+						this.partyPlaylists.splice(index, 1);
+					}
+				});
+				if (selected) {
+					new Toast("Successfully deselected playlist.");
+				} else {
+					new Toast("Playlist not selected.");
+				}
+			} else {
+				this.socket.dispatch(
+					"stations.removeIncludedPlaylist",
+					this.station._id,
+					id,
+					res => {
+						new Toast(res.message);
+					}
+				);
+			}
+		},
+		isSelected(id) {
+			// TODO Also change this once it changes for a station
+			let selected = false;
+			this.currentPlaylists.forEach(playlist => {
+				if (playlist._id === id) selected = true;
+			});
+			return selected;
+		},
+		searchForPlaylists(page) {
+			if (
+				this.search.page >= page ||
+				this.search.searchedQuery !== this.search.query
+			) {
+				this.search.results = [];
+				this.search.page = 0;
+				this.search.count = 0;
+				this.search.resultsLeft = 0;
+				this.search.pageSize = 0;
+			}
+
+			const { query } = this.search;
+			const action =
+				this.station.type === "official"
+					? "playlists.searchOfficial"
+					: "playlists.searchCommunity";
+
+			this.search.searchedQuery = this.search.query;
+			this.socket.dispatch(action, query, page, res => {
+				const { data } = res;
+				const { count, pageSize, playlists } = data;
+				if (res.status === "success") {
+					this.search.results = [
+						...this.search.results,
+						...playlists
+					];
+					this.search.page = page;
+					this.search.count = count;
+					this.search.resultsLeft =
+						count - this.search.results.length;
+					this.search.pageSize = pageSize;
+				} else if (res.status === "error") {
+					this.search.results = [];
+					this.search.page = 0;
+					this.search.count = 0;
+					this.search.resultsLeft = 0;
+					this.search.pageSize = 0;
+					new Toast(res.message);
+				}
+			});
+		},
+		blacklistPlaylist(id) {
+			if (this.isSelected(id)) {
+				this.deselectPlaylist(id);
+			}
+			this.socket.dispatch(
+				"stations.excludePlaylist",
+				this.station._id,
+				id,
+				res => {
+					new Toast(res.message);
+				}
+			);
+		},
+		addPartyPlaylistSongToQueue() {
+			let isInQueue = false;
+			if (
+				this.station.type === "community" &&
+				this.station.partyMode === true
+			) {
+				this.songsList.forEach(queueSong => {
+					if (queueSong.requestedBy === this.userId) isInQueue = true;
+				});
+				if (!isInQueue && this.partyPlaylists) {
+					const selectedPlaylist = this.partyPlaylists[
+						Math.floor(Math.random() * this.partyPlaylists.length)
+					];
+					if (
+						selectedPlaylist._id &&
+						selectedPlaylist.songs.length > 0
+					) {
+						const selectedSong =
+							selectedPlaylist.songs[
+								Math.floor(
+									Math.random() *
+										selectedPlaylist.songs.length
+								)
+							];
+						if (selectedSong.youtubeId) {
+							this.socket.dispatch(
+								"stations.addToQueue",
+								this.station._id,
+								selectedSong.youtubeId,
+								data => {
+									if (data.status !== "success")
+										new Toast("Error auto queueing song");
+								}
+							);
+						}
+					}
+				}
+			}
+		},
+		...mapActions("station", ["updatePartyPlaylists"]),
+		...mapActions("modalVisibility", ["openModal"]),
+		...mapActions("user/playlists", ["editPlaylist", "setPlaylists"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.station-playlists {
+	.tabs-container {
+		.tab-selection {
+			display: flex;
+			.button {
+				border-radius: 0;
+				border: 0;
+				text-transform: uppercase;
+				font-size: 14px;
+				color: var(--dark-grey-3);
+				background-color: var(--light-grey-2);
+				flex-grow: 1;
+				height: 32px;
+
+				&:not(:first-of-type) {
+					margin-left: 5px;
+				}
+			}
+
+			.selected {
+				background-color: var(--dark-grey-3) !important;
+				color: var(--white) !important;
+			}
+		}
+		.tab {
+			padding: 15px 0;
+			border-radius: 0;
+			.playlist-item:not(:last-of-type),
+			.item.item-draggable:not(:last-of-type) {
+				margin-bottom: 10px;
+			}
+		}
+	}
+}
+.draggable-list-transition-move {
+	transition: transform 0.5s;
+}
+
+.draggable-list-ghost {
+	opacity: 0.5;
+	filter: brightness(95%);
+}
+</style>

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

@@ -0,0 +1,251 @@
+<template>
+	<div class="search">
+		<div class="musare-search">
+			<label class="label"> Search for a song on Musare </label>
+			<div class="control is-grouped input-with-button">
+				<p class="control is-expanded">
+					<input
+						class="input"
+						type="text"
+						placeholder="Enter your song query here..."
+						v-model="musareSearch.query"
+						@keyup.enter="searchForMusareSongs(1)"
+					/>
+				</p>
+				<p class="control">
+					<a class="button is-info" @click="searchForMusareSongs(1)"
+						><i class="material-icons icon-with-button">search</i
+						>Search</a
+					>
+				</p>
+			</div>
+			<div v-if="musareSearch.results.length > 0">
+				<song-item
+					v-for="(song, index) in musareSearch.results"
+					:key="index + song._id"
+					:song="song"
+				>
+					<div class="song-actions" slot="actions">
+						<i
+							class="material-icons add-to-queue-icon"
+							v-if="station.partyMode && !station.locked"
+							@click="addSongToQueue(song.youtubeId)"
+							content="Add Song to Queue"
+							v-tippy
+							>queue</i
+						>
+					</div>
+				</song-item>
+				<button
+					v-if="resultsLeftCount > 0"
+					class="button is-primary load-more-button"
+					@click="searchForMusareSongs(musareSearch.page + 1)"
+				>
+					Load {{ nextPageResultsCount }} more results
+				</button>
+			</div>
+		</div>
+		<div class="youtube-search">
+			<label class="label"> Search for a song on YouTube </label>
+			<div class="control is-grouped input-with-button">
+				<p class="control is-expanded">
+					<input
+						class="input"
+						type="text"
+						placeholder="Enter your YouTube query here..."
+						v-model="search.songs.query"
+						autofocus
+						@keyup.enter="searchForSongs()"
+					/>
+				</p>
+				<p class="control">
+					<a
+						class="button is-info"
+						@click.prevent="searchForSongs()"
+						href="#"
+						><i class="material-icons icon-with-button">search</i
+						>Search</a
+					>
+				</p>
+			</div>
+
+			<div v-if="search.songs.results.length > 0" id="song-query-results">
+				<search-query-item
+					v-for="(result, index) in search.songs.results"
+					:key="index"
+					:result="result"
+				>
+					<div slot="actions">
+						<transition name="search-query-actions" mode="out-in">
+							<a
+								class="button is-success"
+								v-if="result.isAddedToQueue"
+								href="#"
+								key="added-to-queue"
+							>
+								<i class="material-icons icon-with-button"
+									>done</i
+								>
+								Added to queue
+							</a>
+							<a
+								class="button is-dark"
+								v-else
+								@click.prevent="
+									addSongToQueue(result.id, index)
+								"
+								href="#"
+								key="add-to-queue"
+							>
+								<i class="material-icons icon-with-button"
+									>add</i
+								>
+								Add to queue
+							</a>
+						</transition>
+					</div>
+				</search-query-item>
+
+				<a
+					class="button is-default load-more-button"
+					@click.prevent="loadMoreSongs()"
+					href="#"
+				>
+					Load more...
+				</a>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapState, mapGetters } from "vuex";
+
+import Toast from "toasters";
+import SearchYoutube from "@/mixins/SearchYoutube.vue";
+
+import SongItem from "@/components/SongItem.vue";
+import SearchQueryItem from "../../../SearchQueryItem.vue";
+
+export default {
+	components: {
+		SongItem,
+		SearchQueryItem
+	},
+	mixins: [SearchYoutube],
+	data() {
+		return {
+			musareSearch: {
+				query: "",
+				searchedQuery: "",
+				page: 0,
+				count: 0,
+				resultsLeft: 0,
+				results: []
+			}
+		};
+	},
+	computed: {
+		resultsLeftCount() {
+			return this.musareSearch.count - this.musareSearch.results.length;
+		},
+		nextPageResultsCount() {
+			return Math.min(this.musareSearch.pageSize, this.resultsLeftCount);
+		},
+		...mapState("modals/manageStation", {
+			station: state => state.station,
+			originalStation: state => state.originalStation
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		addSongToQueue(youtubeId, index) {
+			if (this.station.type === "community") {
+				this.socket.dispatch(
+					"stations.addToQueue",
+					this.station._id,
+					youtubeId,
+					res => {
+						if (res.status !== "success")
+							new Toast(`Error: ${res.message}`);
+						else {
+							if (index)
+								this.search.songs.results[
+									index
+								].isAddedToQueue = true;
+
+							new Toast(res.message);
+						}
+					}
+				);
+			} else {
+				this.socket.dispatch("songs.request", youtubeId, res => {
+					if (res.status !== "success")
+						new Toast(`Error: ${res.message}`);
+					else {
+						this.search.songs.results[index].isAddedToQueue = true;
+
+						new Toast(res.message);
+					}
+				});
+			}
+		},
+		searchForMusareSongs(page) {
+			if (
+				this.musareSearch.page >= page ||
+				this.musareSearch.searchedQuery !== this.musareSearch.query
+			) {
+				this.musareSearch.results = [];
+				this.musareSearch.page = 0;
+				this.musareSearch.count = 0;
+				this.musareSearch.resultsLeft = 0;
+				this.musareSearch.pageSize = 0;
+			}
+
+			this.musareSearch.searchedQuery = this.musareSearch.query;
+			this.socket.dispatch(
+				"songs.searchOfficial",
+				this.musareSearch.query,
+				page,
+				res => {
+					const { data } = res;
+					const { count, pageSize, songs } = data;
+					if (res.status === "success") {
+						this.musareSearch.results = [
+							...this.musareSearch.results,
+							...songs
+						];
+						this.musareSearch.page = page;
+						this.musareSearch.count = count;
+						this.musareSearch.resultsLeft =
+							count - this.musareSearch.results.length;
+						this.musareSearch.pageSize = pageSize;
+					} else if (res.status === "error") {
+						this.musareSearch.results = [];
+						this.musareSearch.page = 0;
+						this.musareSearch.count = 0;
+						this.musareSearch.resultsLeft = 0;
+						this.musareSearch.pageSize = 0;
+						new Toast(res.message);
+					}
+				}
+			);
+		}
+	}
+};
+</script>
+
+<style lang="scss">
+.search {
+	.musare-search,
+	.universal-item:not(:last-of-type) {
+		margin-bottom: 10px;
+	}
+	.load-more-button {
+		width: 100%;
+		margin-top: 10px;
+	}
+}
+</style>

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

@@ -0,0 +1,590 @@
+<template>
+	<div class="station-settings">
+		<label class="label">Name</label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input class="input" type="text" v-model="station.name" />
+			</p>
+			<p class="control">
+				<a class="button is-info" @click.prevent="updateName()">Save</a>
+			</p>
+		</div>
+		<label class="label">Display Name</label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					v-model="station.displayName"
+				/>
+			</p>
+			<p class="control">
+				<a class="button is-info" @click.prevent="updateDisplayName()"
+					>Save</a
+				>
+			</p>
+		</div>
+		<label class="label">Description</label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					v-model="station.description"
+				/>
+			</p>
+			<p class="control">
+				<a class="button is-info" @click.prevent="updateDescription()"
+					>Save</a
+				>
+			</p>
+		</div>
+		<div class="settings-buttons">
+			<div class="small-section">
+				<label class="label">Theme</label>
+				<tippy
+					class="button-wrapper"
+					theme="addToPlaylist"
+					interactive="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button :class="station.theme">
+							<i class="material-icons">palette</i>
+							{{ station.theme }}
+						</button>
+					</template>
+					<button
+						class="blue"
+						v-if="station.theme !== 'blue'"
+						@click="updateTheme('blue')"
+					>
+						<i class="material-icons">palette</i>
+						Blue
+					</button>
+					<button
+						class="purple"
+						v-if="station.theme !== 'purple'"
+						@click="updateTheme('purple')"
+					>
+						<i class="material-icons">palette</i>
+						Purple
+					</button>
+					<button
+						class="teal"
+						v-if="station.theme !== 'teal'"
+						@click="updateTheme('teal')"
+					>
+						<i class="material-icons">palette</i>
+						Teal
+					</button>
+					<button
+						class="orange"
+						v-if="station.theme !== 'orange'"
+						@click="updateTheme('orange')"
+					>
+						<i class="material-icons">palette</i>
+						Orange
+					</button>
+				</tippy>
+			</div>
+			<div class="small-section">
+				<label class="label">Privacy</label>
+				<tippy
+					class="button-wrapper"
+					theme="addToPlaylist"
+					interactive="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button :class="privacyButtons[station.privacy].style">
+							<i class="material-icons">{{
+								privacyButtons[station.privacy].iconName
+							}}</i>
+							{{ station.privacy }}
+						</button>
+					</template>
+					<button
+						class="green"
+						v-if="station.privacy !== 'public'"
+						@click="updatePrivacy('public')"
+					>
+						<i class="material-icons">{{
+							privacyButtons["public"].iconName
+						}}</i>
+						Public
+					</button>
+					<button
+						class="orange"
+						v-if="station.privacy !== 'unlisted'"
+						@click="updatePrivacy('unlisted')"
+					>
+						<i class="material-icons">{{
+							privacyButtons["unlisted"].iconName
+						}}</i>
+						Unlisted
+					</button>
+					<button
+						class="red"
+						v-if="station.privacy !== 'private'"
+						@click="updatePrivacy('private')"
+					>
+						<i class="material-icons">{{
+							privacyButtons["private"].iconName
+						}}</i>
+						Private
+					</button>
+				</tippy>
+			</div>
+			<div class="small-section">
+				<label class="label">Station Mode</label>
+				<tippy
+					v-if="station.type === 'community'"
+					class="button-wrapper"
+					theme="addToPlaylist"
+					interactive="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button
+							:class="{
+								blue: !station.partyMode,
+								yellow: station.partyMode
+							}"
+						>
+							<i class="material-icons">{{
+								station.partyMode
+									? "emoji_people"
+									: "playlist_play"
+							}}</i>
+							{{ station.partyMode ? "Party" : "Playlist" }}
+						</button>
+					</template>
+					<button
+						class="blue"
+						v-if="station.partyMode"
+						@click="updatePartyMode(false)"
+					>
+						<i class="material-icons">playlist_play</i>
+						Playlist
+					</button>
+					<button
+						class="yellow"
+						v-if="!station.partyMode"
+						@click="updatePartyMode(true)"
+					>
+						<i class="material-icons">emoji_people</i>
+						Party
+					</button>
+				</tippy>
+				<div v-else class="button-wrapper">
+					<button
+						class="blue"
+						content="Can not be changed on official stations."
+						v-tippy
+					>
+						<i class="material-icons">playlist_play</i>
+						Playlist
+					</button>
+				</div>
+			</div>
+			<div v-if="!station.partyMode" class="small-section">
+				<label class="label">Play Mode</label>
+				<tippy
+					v-if="station.type === 'community'"
+					class="button-wrapper"
+					theme="addToPlaylist"
+					interactive="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button class="blue">
+							<i class="material-icons">{{
+								station.playMode === "random"
+									? "shuffle"
+									: "format_list_numbered"
+							}}</i>
+							{{
+								station.playMode === "random"
+									? "Random"
+									: "Sequential"
+							}}
+						</button>
+					</template>
+					<button
+						class="blue"
+						v-if="station.playMode === 'sequential'"
+						@click="updatePlayMode('random')"
+					>
+						<i class="material-icons">shuffle</i>
+						Random
+					</button>
+					<button
+						class="blue"
+						v-if="station.playMode === 'random'"
+						@click="updatePlayMode('sequential')"
+					>
+						<i class="material-icons">format_list_numbered</i>
+						Sequential
+					</button>
+				</tippy>
+				<div v-else class="button-wrapper">
+					<button
+						class="blue"
+						content="Can not be changed on official stations."
+						v-tippy
+					>
+						<i class="material-icons">shuffle</i>
+						Random
+					</button>
+				</div>
+			</div>
+			<div
+				v-if="
+					station.type === 'community' && station.partyMode === true
+				"
+				class="small-section"
+			>
+				<label class="label">Queue lock</label>
+				<tippy
+					class="button-wrapper"
+					theme="addToPlaylist"
+					interactive="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button
+							:class="{
+								green: station.locked,
+								red: !station.locked
+							}"
+						>
+							<i class="material-icons">{{
+								station.locked ? "lock" : "lock_open"
+							}}</i>
+							{{ station.locked ? "Locked" : "Unlocked" }}
+						</button>
+					</template>
+					<button
+						class="green"
+						v-if="!station.locked"
+						@click="updateQueueLock(true)"
+					>
+						<i class="material-icons">lock</i>
+						Locked
+					</button>
+					<button
+						class="red"
+						v-if="station.locked"
+						@click="updateQueueLock(false)"
+					>
+						<i class="material-icons">lock_open</i>
+						Unlocked
+					</button>
+				</tippy>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapState, mapGetters } from "vuex";
+
+import Toast from "toasters";
+
+import validation from "@/validation";
+
+export default {
+	data() {
+		return {
+			privacyButtons: {
+				public: {
+					style: "green",
+					iconName: "public"
+				},
+				private: {
+					style: "red",
+					iconName: "lock"
+				},
+				unlisted: {
+					style: "orange",
+					iconName: "link"
+				}
+			}
+		};
+	},
+	computed: {
+		...mapState("modals/manageStation", {
+			station: state => state.station,
+			originalStation: state => state.originalStation
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		updateName() {
+			if (this.originalStation.name !== this.station.name) {
+				const { name } = this.station;
+				if (!validation.isLength(name, 2, 16)) {
+					new Toast("Name must have between 2 and 16 characters.");
+				} else if (!validation.regex.az09_.test(name)) {
+					new Toast(
+						"Invalid name format. Allowed characters: a-z, 0-9 and _."
+					);
+				} else {
+					this.socket.dispatch(
+						"stations.updateName",
+						this.station._id,
+						name,
+						res => {
+							new Toast(res.message);
+
+							if (res.status === "success") {
+								this.station.name = name;
+								this.originalStation.name = name;
+							}
+						}
+					);
+				}
+			} else {
+				new Toast("Please make a change before saving.");
+			}
+		},
+		updateDisplayName() {
+			if (this.originalStation.displayName !== this.station.displayName) {
+				const { displayName } = this.station;
+				if (!validation.isLength(displayName, 2, 32)) {
+					new Toast(
+						"Display name must have between 2 and 32 characters."
+					);
+				} else if (!validation.regex.ascii.test(displayName)) {
+					new Toast(
+						"Invalid display name format. Only ASCII characters are allowed."
+					);
+				} else {
+					this.socket.dispatch(
+						"stations.updateDisplayName",
+						this.station._id,
+						displayName,
+						res => {
+							new Toast(res.message);
+
+							if (res.status === "success") {
+								this.station.displayName = displayName;
+								this.originalStation.displayName = displayName;
+							}
+						}
+					);
+				}
+			} else {
+				new Toast("Please make a change before saving.");
+			}
+		},
+		updateDescription() {
+			if (this.originalStation.description !== this.station.description) {
+				const { description } = this.station;
+				const characters = description.split("").filter(character => {
+					return character.charCodeAt(0) === 21328;
+				});
+				if (!validation.isLength(description, 2, 200)) {
+					new Toast(
+						"Description must have between 2 and 200 characters."
+					);
+				} else if (characters.length !== 0) {
+					new Toast("Invalid description format.");
+				} else {
+					this.socket.dispatch(
+						"stations.updateDescription",
+						this.station._id,
+						description,
+						res => {
+							new Toast(res.message);
+
+							if (res.status === "success") {
+								this.station.description = description;
+								this.originalStation.description = description;
+							}
+						}
+					);
+				}
+			} else {
+				new Toast("Please make a change before saving.");
+			}
+		},
+		updateTheme(theme) {
+			if (this.station.theme !== theme) {
+				this.socket.dispatch(
+					"stations.updateTheme",
+					this.station._id,
+					theme,
+					res => {
+						new Toast(res.message);
+
+						if (res.status === "success") {
+							this.station.theme = theme;
+							this.originalStation.theme = theme;
+						}
+					}
+				);
+			}
+		},
+		updatePrivacy(privacy) {
+			if (this.station.privacy !== privacy) {
+				this.socket.dispatch(
+					"stations.updatePrivacy",
+					this.station._id,
+					privacy,
+					res => {
+						new Toast(res.message);
+
+						if (res.status === "success") {
+							this.station.privacy = privacy;
+							this.originalStation.privacy = privacy;
+						}
+					}
+				);
+			}
+		},
+		updatePartyMode(partyMode) {
+			if (this.station.partyMode !== partyMode) {
+				this.socket.dispatch(
+					"stations.updatePartyMode",
+					this.station._id,
+					partyMode,
+					res => {
+						new Toast(res.message);
+
+						if (res.status === "success") {
+							this.station.partyMode = partyMode;
+							this.originalStation.partyMode = partyMode;
+						}
+					}
+				);
+			}
+		},
+		updatePlayMode(playMode) {
+			if (this.station.playMode !== playMode) {
+				this.socket.dispatch(
+					"stations.updatePlayMode",
+					this.station._id,
+					playMode,
+					res => {
+						new Toast(res.message);
+
+						if (res.status === "success") {
+							this.station.playMode = playMode;
+							this.originalStation.playMode = playMode;
+						}
+					}
+				);
+			}
+		},
+		updateQueueLock(locked) {
+			if (this.station.locked !== locked) {
+				this.socket.dispatch(
+					"stations.toggleLock",
+					this.station._id,
+					res => {
+						if (res.status === "success") {
+							if (this.originalStation) {
+								this.station.locked = res.data.locked;
+								this.originalStation.locked = res.data.locked;
+							}
+
+							new Toast(
+								`Toggled queue lock successfully to ${res.data.locked}`
+							);
+						} else {
+							new Toast("Failed to toggle queue lock.");
+						}
+					}
+				);
+			}
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.station-settings {
+	.settings-buttons {
+		display: flex;
+		justify-content: center;
+		flex-wrap: wrap;
+		.small-section {
+			width: calc(50% - 10px);
+			min-width: 150px;
+			margin: 5px auto;
+		}
+	}
+	.button-wrapper {
+		display: flex;
+		flex-direction: column;
+
+		button {
+			width: 100%;
+			height: 36px;
+			border: 0;
+			border-radius: 3px;
+			font-size: 18px;
+			color: var(--white);
+			box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25);
+			display: block;
+			text-align: center;
+			justify-content: center;
+			display: inline-flex;
+			-ms-flex-align: center;
+			align-items: center;
+			-moz-user-select: none;
+			user-select: none;
+			cursor: pointer;
+			padding: 0;
+			text-transform: capitalize;
+
+			&.red {
+				background-color: var(--red);
+			}
+
+			&.green {
+				background-color: var(--green);
+			}
+
+			&.blue {
+				background-color: var(--blue);
+			}
+
+			&.orange {
+				background-color: var(--orange);
+			}
+
+			&.yellow {
+				background-color: var(--yellow);
+			}
+
+			&.purple {
+				background-color: var(--purple);
+			}
+
+			&.teal {
+				background-color: var(--teal);
+			}
+
+			i {
+				font-size: 20px;
+				margin-right: 4px;
+			}
+		}
+	}
+}
+</style>

+ 509 - 0
frontend/src/components/modals/ManageStation/index.vue

@@ -0,0 +1,509 @@
+<template>
+	<modal
+		:title="
+			!isOwnerOrAdmin() && station.partyMode
+				? 'Add Song to Queue'
+				: 'Manage Station'
+		"
+		class="manage-station-modal"
+	>
+		<template #body>
+			<div class="custom-modal-body" v-if="station && station._id">
+				<div class="left-section">
+					<div class="section tabs-container">
+						<div class="tab-selection">
+							<button
+								v-if="isOwnerOrAdmin()"
+								class="button is-default"
+								:class="{ selected: tab === 'settings' }"
+								@click="showTab('settings')"
+							>
+								Settings
+							</button>
+							<button
+								v-if="
+									isOwnerOrAdmin() ||
+										(loggedIn &&
+											station.type === 'community' &&
+											station.partyMode &&
+											((station.locked &&
+												isOwnerOrAdmin()) ||
+												!station.locked))
+								"
+								class="button is-default"
+								:class="{ selected: tab === 'playlists' }"
+								@click="showTab('playlists')"
+							>
+								Playlists
+							</button>
+							<button
+								v-if="
+									loggedIn &&
+										station.type === 'community' &&
+										station.partyMode &&
+										((station.locked && isOwnerOrAdmin()) ||
+											!station.locked)
+								"
+								class="button is-default"
+								:class="{ selected: tab === 'search' }"
+								@click="showTab('search')"
+							>
+								Search
+							</button>
+							<button
+								v-if="isOwnerOrAdmin()"
+								class="button is-default"
+								:class="{ selected: tab === 'blacklist' }"
+								@click="showTab('blacklist')"
+							>
+								Blacklist
+							</button>
+						</div>
+						<settings
+							v-if="isOwnerOrAdmin()"
+							class="tab"
+							v-show="tab === 'settings'"
+						/>
+						<playlists
+							v-if="
+								isOwnerOrAdmin() ||
+									(loggedIn &&
+										station.type === 'community' &&
+										station.partyMode &&
+										((station.locked && isOwnerOrAdmin()) ||
+											!station.locked))
+							"
+							class="tab"
+							v-show="tab === 'playlists'"
+						/>
+						<search
+							v-if="
+								loggedIn &&
+									station.type === 'community' &&
+									station.partyMode &&
+									((station.locked && isOwnerOrAdmin()) ||
+										!station.locked)
+							"
+							class="tab"
+							v-show="tab === 'search'"
+						/>
+						<blacklist
+							v-if="isOwnerOrAdmin()"
+							class="tab"
+							v-show="tab === 'blacklist'"
+						/>
+					</div>
+				</div>
+				<div class="right-section">
+					<div class="section">
+						<div class="queue-title">
+							<h4 class="section-title">Queue</h4>
+							<i
+								v-if="isOwnerOrAdmin() && stationPaused"
+								@click="resumeStation()"
+								class="material-icons resume-station"
+								content="Resume Station"
+								v-tippy
+							>
+								play_arrow
+							</i>
+							<i
+								v-if="isOwnerOrAdmin() && !stationPaused"
+								@click="pauseStation()"
+								class="material-icons pause-station"
+								content="Pause Station"
+								v-tippy
+							>
+								pause
+							</i>
+							<confirm
+								v-if="isOwnerOrAdmin()"
+								@confirm="skipStation()"
+							>
+								<i
+									class="material-icons skip-station"
+									content="Force Skip Station"
+									v-tippy
+								>
+									skip_next
+								</i>
+							</confirm>
+						</div>
+						<hr class="section-horizontal-rule" />
+						<song-item
+							v-if="currentSong._id"
+							:song="currentSong"
+							:large-thumbnail="true"
+							:requested-by="
+								station.type === 'community' &&
+									station.partyMode === true
+							"
+							header="Currently Playing.."
+							class="currently-playing"
+						/>
+						<queue sector="manageStation" />
+					</div>
+				</div>
+			</div>
+		</template>
+		<template #footer>
+			<a
+				class="button is-default"
+				v-if="isOwnerOrAdmin() && !station.partyMode"
+				@click="stationPlaylist()"
+			>
+				View Station Playlist
+			</a>
+			<button
+				class="button is-primary tab-actionable-button"
+				v-if="loggedIn && station.type === 'official'"
+				@click="openModal('requestSong')"
+			>
+				<i class="material-icons icon-with-button">queue</i>
+				<span class="optional-desktop-only-text"> Request Song </span>
+			</button>
+			<div v-if="isOwnerOrAdmin()" class="right">
+				<confirm @confirm="clearAndRefillStationQueue()">
+					<a class="button is-danger">
+						Clear and refill station queue
+					</a>
+				</confirm>
+				<confirm
+					v-if="station && station.type === 'community'"
+					@confirm="deleteStation()"
+				>
+					<button class="button is-danger">Delete station</button>
+				</confirm>
+			</div>
+		</template>
+	</modal>
+</template>
+
+<script>
+import { mapState, mapGetters, mapActions } from "vuex";
+
+import Toast from "toasters";
+
+import Confirm from "@/components/Confirm.vue";
+import Queue from "@/components/Queue.vue";
+import SongItem from "@/components/SongItem.vue";
+import Modal from "../../Modal.vue";
+
+import Settings from "./Tabs/Settings.vue";
+import Playlists from "./Tabs/Playlists.vue";
+import Search from "./Tabs/Search.vue";
+import Blacklist from "./Tabs/Blacklist.vue";
+
+export default {
+	components: {
+		Modal,
+		Confirm,
+		Queue,
+		SongItem,
+		Settings,
+		Playlists,
+		Search,
+		Blacklist
+	},
+	props: {
+		stationId: { type: String, default: "" },
+		sector: { type: String, default: "admin" }
+	},
+	data() {
+		return {
+			tab: "settings"
+		};
+	},
+	computed: {
+		...mapState({
+			loggedIn: state => state.user.auth.loggedIn,
+			userId: state => state.user.auth.userId,
+			role: state => state.user.auth.role
+		}),
+		...mapState("modals/manageStation", {
+			station: state => state.station,
+			originalStation: state => state.originalStation,
+			songsList: state => state.songsList,
+			stationPaused: state => state.stationPaused,
+			currentSong: state => state.currentSong
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		this.socket.dispatch(`stations.getStationById`, this.stationId, res => {
+			if (res.status === "success") {
+				const { station } = res.data;
+				this.editStation(station);
+
+				if (!this.isOwnerOrAdmin() && this.station.partyMode)
+					this.tab = "search";
+
+				const currentSong = res.data.station.currentSong
+					? res.data.station.currentSong
+					: {};
+
+				this.updateCurrentSong(currentSong);
+
+				this.updateStationPaused(res.data.station.paused);
+
+				this.socket.dispatch(
+					"stations.getStationIncludedPlaylistsById",
+					this.stationId,
+					res => {
+						if (res.status === "success") {
+							this.setIncludedPlaylists(res.data.playlists);
+						}
+					}
+				);
+
+				this.socket.dispatch(
+					"stations.getStationExcludedPlaylistsById",
+					this.stationId,
+					res => {
+						if (res.status === "success") {
+							this.setExcludedPlaylists(res.data.playlists);
+						}
+					}
+				);
+
+				this.socket.dispatch(
+					"stations.getQueue",
+					this.stationId,
+					res => {
+						if (res.status === "success") {
+							this.updateSongsList(res.data.queue);
+						}
+					}
+				);
+			} else {
+				new Toast(`Station with that ID not found`);
+				this.closeModal("manageStation");
+			}
+		});
+
+		this.socket.on("event:queue.update", res => {
+			this.updateSongsList(res.data.queue);
+		});
+
+		this.socket.on("event:queue.repositionSong", res => {
+			this.repositionSongInList(res.data.song);
+		});
+
+		this.socket.on("event:stations.pause", res => {
+			this.pausedAt = res.data.pausedAt;
+			this.updateStationPaused(true);
+		});
+
+		this.socket.on("event:stations.resume", res => {
+			this.timePaused = res.data.timePaused;
+			this.updateStationPaused(false);
+		});
+
+		this.socket.on("event:songs.next", res => {
+			const { currentSong } = res.data;
+
+			this.updateCurrentSong(currentSong || {});
+		});
+	},
+	beforeDestroy() {
+		this.repositionSongInList([]);
+		this.clearStation();
+	},
+	methods: {
+		showTab(tab) {
+			this.tab = tab;
+		},
+		isOwner() {
+			return this.loggedIn && this.userId === this.station.owner;
+		},
+		isAdmin() {
+			return this.loggedIn && this.role === "admin";
+		},
+		isOwnerOrAdmin() {
+			return this.isOwner() || this.isAdmin();
+		},
+		resumeStation() {
+			this.socket.dispatch("stations.resume", this.station._id, res => {
+				if (res.status !== "success")
+					new Toast(`Error: ${res.message}`);
+				else new Toast("Successfully resumed the station.");
+			});
+		},
+		pauseStation() {
+			this.socket.dispatch("stations.pause", this.station._id, res => {
+				if (res.status !== "success")
+					new Toast(`Error: ${res.message}`);
+				else new Toast("Successfully paused the station.");
+			});
+		},
+		skipStation() {
+			this.socket.dispatch(
+				"stations.forceSkip",
+				this.station._id,
+				res => {
+					if (res.status !== "success")
+						new Toast(`Error: ${res.message}`);
+					else
+						new Toast(
+							"Successfully skipped the station's current song."
+						);
+				}
+			);
+		},
+		clearAndRefillStationQueue() {
+			this.socket.dispatch(
+				"stations.clearAndRefillStationQueue",
+				this.station._id,
+				res => {
+					if (res.status !== "success")
+						new Toast({
+							content: `Error: ${res.message}`,
+							timeout: 8000
+						});
+					else new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		stationPlaylist() {
+			this.socket.dispatch(
+				"playlists.getPlaylistForStation",
+				this.station._id,
+				false,
+				res => {
+					if (res.status === "success") {
+						this.editPlaylist(res.data.playlist._id);
+						this.openModal("editPlaylist");
+					} else {
+						new Toast(res.message);
+					}
+				}
+			);
+		},
+		...mapActions("modals/manageStation", [
+			"editStation",
+			"setIncludedPlaylists",
+			"setExcludedPlaylists",
+			"clearStation",
+			"updateSongsList",
+			"repositionSongInList",
+			"updateStationPaused",
+			"updateCurrentSong"
+		]),
+		...mapActions("modalVisibility", ["openModal", "closeModal"]),
+		...mapActions("user/playlists", ["editPlaylist"])
+	}
+};
+</script>
+
+<style lang="scss">
+.manage-station-modal.modal {
+	z-index: 1800;
+	.modal-card {
+		width: 1300px;
+		height: 100%;
+		overflow: auto;
+		.tab > button {
+			width: 100%;
+			margin-bottom: 10px;
+		}
+	}
+}
+</style>
+
+<style lang="scss" scoped>
+.manage-station-modal.modal .modal-card-body .custom-modal-body {
+	display: flex;
+	flex-wrap: wrap;
+	height: 100%;
+
+	.section {
+		display: flex;
+		flex-direction: column;
+		flex-grow: 1;
+		width: auto;
+		padding: 15px !important;
+		margin: 0 10px;
+	}
+
+	.left-section {
+		flex-basis: 50%;
+		height: 100%;
+		overflow-y: auto;
+		flex-grow: 1;
+
+		.tabs-container {
+			.tab-selection {
+				display: flex;
+
+				.button {
+					border-radius: 5px 5px 0 0;
+					border: 0;
+					text-transform: uppercase;
+					font-size: 14px;
+					color: var(--dark-grey-3);
+					background-color: var(--light-grey-2);
+					flex-grow: 1;
+					height: 32px;
+
+					&:not(:first-of-type) {
+						margin-left: 5px;
+					}
+				}
+
+				.selected {
+					background-color: var(--dark-grey-3) !important;
+					color: var(--white) !important;
+				}
+			}
+			.tab {
+				border: 1px solid var(--light-grey-3);
+				padding: 15px;
+				border-radius: 0 0 5px 5px;
+			}
+		}
+	}
+	.right-section {
+		flex-basis: 50%;
+		height: 100%;
+		overflow-y: auto;
+		flex-grow: 1;
+		.section {
+			.queue-title {
+				display: flex;
+				line-height: 30px;
+				.material-icons {
+					margin-left: 5px;
+					margin-bottom: 5px;
+					font-size: 28px;
+					cursor: pointer;
+					&:first-of-type {
+						margin-left: auto;
+					}
+					&.skip-station {
+						color: var(--red);
+					}
+					&.resume-station,
+					&.pause-station {
+						color: var(--primary-color);
+					}
+				}
+			}
+			.currently-playing {
+				margin-bottom: 10px;
+			}
+		}
+	}
+}
+
+@media screen and (max-width: 1100px) {
+	.manage-station-modal.modal .modal-card-body .custom-modal-body {
+		.left-section,
+		.right-section {
+			flex-basis: unset;
+			height: auto;
+		}
+	}
+}
+</style>

+ 19 - 8
frontend/src/pages/Admin/tabs/Stations.vue

@@ -58,8 +58,8 @@
 							/>
 						</td>
 						<td>
-							<a class="button is-info" @click="edit(station)"
-								>Edit</a
+							<a class="button is-info" @click="manage(station)"
+								>Manage</a
 							>
 							<confirm @confirm="removeStation(index)">
 								<a class="button is-danger">Remove</a>
@@ -177,11 +177,16 @@
 			</div>
 		</div>
 
-		<edit-station
-			v-if="modals.editStation"
+		<request-song v-if="modals.requestSong" />
+		<edit-playlist v-if="modals.editPlaylist" />
+		<create-playlist v-if="modals.createPlaylist" />
+		<manage-station
+			v-if="modals.manageStation"
 			:station-id="editingStationId"
 			sector="admin"
 		/>
+		<report v-if="modals.report" />
+		<edit-song v-if="modals.editSong" song-type="songs" sector="admin" />
 	</div>
 </template>
 
@@ -195,7 +200,13 @@ import ws from "@/ws";
 
 export default {
 	components: {
-		EditStation: () => import("@/components/modals/EditStation.vue"),
+		RequestSong: () => import("@/components/modals/RequestSong.vue"),
+		EditPlaylist: () => import("@/components/modals/EditPlaylist.vue"),
+		CreatePlaylist: () => import("@/components/modals/CreatePlaylist.vue"),
+		ManageStation: () =>
+			import("@/components/modals/ManageStation/index.vue"),
+		Report: () => import("@/components/modals/Report.vue"),
+		EditSong: () => import("@/components/modals/EditSong.vue"),
 		UserIdToUsername,
 		Confirm
 	},
@@ -279,9 +290,9 @@ export default {
 				}
 			);
 		},
-		edit(station) {
+		manage(station) {
 			this.editingStationId = station._id;
-			this.openModal("editStation");
+			this.openModal("manageStation");
 		},
 		addGenre() {
 			const genre = this.$refs["new-genre"].value.toLowerCase().trim();
@@ -329,7 +340,7 @@ export default {
 		},
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("admin/stations", [
-			"editStation",
+			"manageStation",
 			"loadStations",
 			"stationRemoved",
 			"stationAdded"

+ 90 - 40
frontend/src/pages/Station/Sidebar/Playlists.vue

@@ -23,9 +23,12 @@
 						<i
 							v-if="
 								station.type === 'community' &&
-									isNotSelected(playlist._id)
+									(userId === station.owner ||
+										role === 'admin' ||
+										station.partyMode) &&
+									!isSelected(playlist._id)
 							"
-							@click="selectPlaylist(playlist._id)"
+							@click="selectPlaylist(playlist)"
 							class="material-icons play-icon"
 							:content="
 								station.partyMode
@@ -38,7 +41,10 @@
 						<i
 							v-if="
 								station.type === 'community' &&
-									!isNotSelected(playlist._id)
+									(userId === station.owner ||
+										role === 'admin' ||
+										station.partyMode) &&
+									isSelected(playlist._id)
 							"
 							@click="deselectPlaylist(playlist._id)"
 							class="material-icons stop-icon"
@@ -92,10 +98,22 @@ export default {
 		};
 	},
 	computed: {
+		currentPlaylists() {
+			if (this.station.type === "community" && this.station.partyMode) {
+				return this.partyPlaylists;
+			}
+			return this.includedPlaylists;
+		},
 		...mapState({
-			station: state => state.station.station,
-			privatePlaylistQueueSelected: state =>
-				state.station.privatePlaylistQueueSelected
+			role: state => state.user.auth.role,
+			userId: state => state.user.auth.userId
+		}),
+		...mapState("station", {
+			station: state => state.station,
+			partyPlaylists: state => state.partyPlaylists,
+			includedPlaylists: state => state.includedPlaylists,
+			excludedPlaylists: state => state.excludedPlaylists,
+			songsList: state => state.songsList
 		}),
 		...mapGetters({
 			socket: "websockets/getSocket"
@@ -177,11 +195,11 @@ export default {
 			this.editPlaylist(id);
 			this.openModal("editPlaylist");
 		},
-		selectPlaylist(id) {
+		selectPlaylist(playlist) {
 			if (this.station.type === "community" && this.station.partyMode) {
-				if (this.isNotSelected(id)) {
-					this.updatePrivatePlaylistQueueSelected(id);
-					this.$parent.$parent.addFirstPrivatePlaylistSongToQueue();
+				if (!this.isSelected(playlist.id)) {
+					this.partyPlaylists.push(playlist);
+					this.addPartyPlaylistSongToQueue();
 					new Toast(
 						"Successfully selected playlist to auto request songs."
 					);
@@ -190,56 +208,88 @@ export default {
 				}
 			} else {
 				this.socket.dispatch(
-					"stations.selectPrivatePlaylist",
+					"stations.includePlaylist",
 					this.station._id,
-					id,
+					playlist._id,
 					res => {
-						if (res.status === "error") {
-							new Toast(res.message);
-						} else {
-							this.station.includedPlaylists.push(id);
-							new Toast(res.message);
-						}
+						new Toast(res.message);
 					}
 				);
 			}
 		},
 		deselectPlaylist(id) {
 			if (this.station.type === "community" && this.station.partyMode) {
-				this.updatePrivatePlaylistQueueSelected(null);
-				new Toast("Successfully deselected playlist.");
+				let selected = false;
+				this.currentPlaylists.forEach((playlist, index) => {
+					if (playlist._id === id) {
+						selected = true;
+						this.partyPlaylists.splice(index, 1);
+					}
+				});
+				if (selected) {
+					new Toast("Successfully deselected playlist.");
+				} else {
+					new Toast("Playlist not selected.");
+				}
 			} else {
 				this.socket.dispatch(
-					"stations.deselectPrivatePlaylist",
+					"stations.removeIncludedPlaylist",
 					this.station._id,
 					id,
 					res => {
-						if (res.status === "error")
-							return new Toast(res.message);
-
-						this.station.includedPlaylists.splice(
-							this.station.includedPlaylists.indexOf(id),
-							1
-						);
-
-						return new Toast(res.message);
+						new Toast(res.message);
 					}
 				);
 			}
 		},
-		isNotSelected(id) {
-			if (this.station.type === "community" && this.station.partyMode) {
-				return this.privatePlaylistQueueSelected !== id;
-			}
+		isSelected(id) {
 			// TODO Also change this once it changes for a station
+			let selected = false;
+			this.currentPlaylists.forEach(playlist => {
+				if (playlist._id === id) selected = true;
+			});
+			return selected;
+		},
+		addPartyPlaylistSongToQueue() {
+			let isInQueue = false;
 			if (
-				this.station &&
-				this.station.includedPlaylists.indexOf(id) !== -1
-			)
-				return false;
-			return true;
+				this.station.type === "community" &&
+				this.station.partyMode === true
+			) {
+				this.songsList.forEach(queueSong => {
+					if (queueSong.requestedBy === this.userId) isInQueue = true;
+				});
+				if (!isInQueue && this.partyPlaylists) {
+					const selectedPlaylist = this.partyPlaylists[
+						Math.floor(Math.random() * this.partyPlaylists.length)
+					];
+					if (
+						selectedPlaylist._id &&
+						selectedPlaylist.songs.length > 0
+					) {
+						const selectedSong =
+							selectedPlaylist.songs[
+								Math.floor(
+									Math.random() *
+										selectedPlaylist.songs.length
+								)
+							];
+						if (selectedSong.youtubeId) {
+							this.socket.dispatch(
+								"stations.addToQueue",
+								this.station._id,
+								selectedSong.youtubeId,
+								data => {
+									if (data.status !== "success")
+										new Toast("Error auto queueing song");
+								}
+							);
+						}
+					}
+				}
+			}
 		},
-		...mapActions("station", ["updatePrivatePlaylistQueueSelected"]),
+		...mapActions("station", ["updatePartyPlaylists"]),
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("user/playlists", ["editPlaylist"])
 	}

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

@@ -43,7 +43,7 @@ import { mapActions, mapState } from "vuex";
 
 import TabQueryHandler from "@/mixins/TabQueryHandler.vue";
 
-import Queue from "./Queue.vue";
+import Queue from "@/components/Queue.vue";
 import Users from "./Users.vue";
 import Playlists from "./Playlists.vue";
 

+ 68 - 103
frontend/src/pages/Station/index.vue

@@ -156,13 +156,13 @@
 								<!-- (Admin) Station Settings Button -->
 								<button
 									class="button is-primary"
-									@click="openModal('editStation')"
+									@click="openModal('manageStation')"
 								>
 									<i class="material-icons icon-with-button"
 										>settings</i
 									>
 									<span class="optional-desktop-only-text">
-										Station settings
+										Manage Station
 									</span>
 								</button>
 							</div>
@@ -561,12 +561,11 @@
 					</div>
 				</div>
 
-				<song-queue v-if="modals.addSongToQueue" />
 				<request-song v-if="modals.requestSong" />
 				<edit-playlist v-if="modals.editPlaylist" />
 				<create-playlist v-if="modals.createPlaylist" />
-				<edit-station
-					v-if="modals.editStation"
+				<manage-station
+					v-if="modals.manageStation"
 					:station-id="station._id"
 					sector="station"
 				/>
@@ -607,8 +606,7 @@
 				<span><b>Local paused</b>: {{ localPaused }}</span>
 				<span><b>No song</b>: {{ noSong }}</span>
 				<span
-					><b>Private playlist queue selected</b>:
-					{{ privatePlaylistQueueSelected }}</span
+					><b>Party playlists selected</b>: {{ partyPlaylists }}</span
 				>
 				<span><b>Station paused</b>: {{ stationPaused }}</span>
 				<span
@@ -652,11 +650,11 @@ export default {
 		ContentLoader,
 		MainHeader,
 		MainFooter,
-		SongQueue: () => import("@/components/modals/AddSongToQueue.vue"),
 		RequestSong: () => import("@/components/modals/RequestSong.vue"),
 		EditPlaylist: () => import("@/components/modals/EditPlaylist.vue"),
 		CreatePlaylist: () => import("@/components/modals/CreatePlaylist.vue"),
-		EditStation: () => import("@/components/modals/EditStation.vue"),
+		ManageStation: () =>
+			import("@/components/modals/ManageStation/index.vue"),
 		Report: () => import("@/components/modals/Report.vue"),
 		Z404,
 		FloatingBox,
@@ -681,7 +679,6 @@ export default {
 			disliked: false,
 			timeBeforePause: 0,
 			skipVotes: 0,
-			automaticallyRequestedYoutubeId: null,
 			systemDifference: 0,
 			attemptsToPlayVideo: 0,
 			canAutoplay: true,
@@ -711,8 +708,9 @@ export default {
 			stationPaused: state => state.stationPaused,
 			localPaused: state => state.localPaused,
 			noSong: state => state.noSong,
-			privatePlaylistQueueSelected: state =>
-				state.privatePlaylistQueueSelected
+			partyPlaylists: state => state.partyPlaylists,
+			includedPlaylists: state => state.includedPlaylists,
+			excludedPlaylists: state => state.excludedPlaylists
 		}),
 		...mapState({
 			loggedIn: state => state.user.auth.loggedIn,
@@ -822,44 +820,6 @@ export default {
 				if (this.playerReady) this.player.pauseVideo();
 				this.updateNoSong(true);
 			}
-
-			let isInQueue = false;
-
-			this.songsList.forEach(queueSong => {
-				if (queueSong.requestedBy === this.userId) isInQueue = true;
-			});
-
-			if (
-				!isInQueue &&
-				this.privatePlaylistQueueSelected &&
-				(this.automaticallyRequestedYoutubeId !==
-					this.currentSong.youtubeId ||
-					!this.currentSong.youtubeId)
-			) {
-				this.addFirstPrivatePlaylistSongToQueue();
-			}
-
-			// if (this.station.type === "official") {
-			// 	this.socket.dispatch(
-			// 		"stations.getQueue",
-			// 		this.station._id,
-			// 		res => {
-			// 			if (res.status === "success") {
-			// 				this.updateSongsList(res.data.queue);
-			// 			}
-			// 		}
-			// 	);
-			// }
-
-			// if (
-			// 	!isInQueue &&
-			// 	this.privatePlaylistQueueSelected &&
-			// 	(this.automaticallyRequestedYoutubeId !==
-			// 		this.currentSong.youtubeId ||
-			// 		!this.currentSong.youtubeId)
-			// ) {
-			// 	this.addFirstPrivatePlaylistSongToQueue();
-			// }
 		});
 
 		this.socket.on("event:stations.pause", res => {
@@ -934,6 +894,8 @@ export default {
 					: null;
 
 			this.updateNextSong(nextSong);
+
+			this.addPartyPlaylistSongToQueue();
 		});
 
 		this.socket.on("event:queue.repositionSong", res => {
@@ -1063,7 +1025,7 @@ export default {
 
 		clearInterval(this.activityWatchVideoDataInterval);
 
-		this.joinStation();
+		this.leaveStation();
 	},
 	methods: {
 		isOwnerOnly() {
@@ -1508,7 +1470,7 @@ export default {
 				}
 			);
 		},
-		addFirstPrivatePlaylistSongToQueue() {
+		addPartyPlaylistSongToQueue() {
 			let isInQueue = false;
 			if (
 				this.station.type === "community" &&
@@ -1517,57 +1479,33 @@ export default {
 				this.songsList.forEach(queueSong => {
 					if (queueSong.requestedBy === this.userId) isInQueue = true;
 				});
-				if (!isInQueue && this.privatePlaylistQueueSelected) {
-					this.socket.dispatch(
-						"playlists.getFirstSong",
-						this.privatePlaylistQueueSelected,
-						res => {
-							if (res.status === "success") {
-								const { song } = res.data;
-								if (song) {
-									if (song.duration < 15 * 60) {
-										this.automaticallyRequestedYoutubeId =
-											song.youtubeId;
-										this.socket.dispatch(
-											"stations.addToQueue",
-											this.station._id,
-											song.youtubeId,
-											data2 => {
-												if (data2.status === "success")
-													this.socket.dispatch(
-														"playlists.moveSongToBottom",
-														this
-															.privatePlaylistQueueSelected,
-														song.youtubeId
-													);
-											}
-										);
-									} else {
-										new Toast(
-											`Top song in playlist was too long to be added.`
-										);
-
-										this.socket.dispatch(
-											"playlists.moveSongToBottom",
-											this.privatePlaylistQueueSelected,
-											song.youtubeId,
-											data3 => {
-												if (data3.status === "success")
-													setTimeout(
-														() =>
-															this.addFirstPrivatePlaylistSongToQueue(),
-														3000
-													);
-											}
-										);
-									}
-								} else
-									new Toast(
-										`Selected playlist has no songs.`
-									);
-							}
+				if (!isInQueue && this.partyPlaylists.length > 0) {
+					const selectedPlaylist = this.partyPlaylists[
+						Math.floor(Math.random() * this.partyPlaylists.length)
+					];
+					if (
+						selectedPlaylist._id &&
+						selectedPlaylist.songs.length > 0
+					) {
+						const selectedSong =
+							selectedPlaylist.songs[
+								Math.floor(
+									Math.random() *
+										selectedPlaylist.songs.length
+								)
+							];
+						if (selectedSong.youtubeId) {
+							this.socket.dispatch(
+								"stations.addToQueue",
+								this.station._id,
+								selectedSong.youtubeId,
+								data => {
+									if (data.status !== "success")
+										new Toast("Error auto queueing song");
+								}
+							);
 						}
-					);
+					}
 				}
 			}
 		},
@@ -1669,6 +1607,30 @@ export default {
 							this.updateNoSong(true);
 						}
 
+						this.socket.dispatch(
+							"stations.getStationIncludedPlaylistsById",
+							this.station._id,
+							res => {
+								if (res.status === "success") {
+									this.setIncludedPlaylists(
+										res.data.playlists
+									);
+								}
+							}
+						);
+
+						this.socket.dispatch(
+							"stations.getStationExcludedPlaylistsById",
+							this.station._id,
+							res => {
+								if (res.status === "success") {
+									this.setExcludedPlaylists(
+										res.data.playlists
+									);
+								}
+							}
+						);
+
 						this.socket.dispatch("stations.getQueue", _id, res => {
 							if (res.status === "success") {
 								this.updateSongsList(res.data.queue);
@@ -1882,6 +1844,7 @@ export default {
 		...mapActions("modalVisibility", ["openModal"]),
 		...mapActions("station", [
 			"joinStation",
+			"leaveStation",
 			"updateUserCount",
 			"updateUsers",
 			"updateCurrentSong",
@@ -1892,7 +1855,9 @@ export default {
 			"updateStationPaused",
 			"updateLocalPaused",
 			"updateNoSong",
-			"updateIfStationIsFavorited"
+			"updateIfStationIsFavorited",
+			"setIncludedPlaylists",
+			"setExcludedPlaylists"
 		]),
 		...mapActions("modals/editSong", ["stopVideo"])
 	}

+ 7 - 3
frontend/src/pages/Team.vue

@@ -160,7 +160,12 @@ export default {
 					name: "Owen Diffey",
 					bio:
 						"Developer, Designer, System Admin and QA Tester. Previously Owner and Project Manager.",
-					projects: ["MusareMeteor", "MusareReact", "MusareNode"],
+					projects: [
+						"MusareMeteor",
+						"MusareReact",
+						"MusareNode",
+						"vue-roaster"
+					],
 					active: "Feb 2016 - present",
 					github: "odiffey",
 					link: "https://diffey.dev",
@@ -195,8 +200,7 @@ export default {
 			previousTeam: [
 				{
 					name: "Akira Laine",
-					bio:
-						"Co-Founder, Leader Developer, Designer and QA Tester.",
+					bio: "Co-Founder, Lead Developer, Designer and QA Tester.",
 					projects: ["MusareMeteor"],
 					active: "Sept 2015 - Feb 2016",
 					github: "darthmeme",

+ 2 - 2
frontend/src/store/index.js

@@ -11,7 +11,7 @@ import station from "./modules/station";
 import admin from "./modules/admin";
 
 import editSongModal from "./modules/modals/editSong";
-import editStationModal from "./modules/modals/editStation";
+import manageStationModal from "./modules/modals/manageStation";
 import editUserModal from "./modules/modals/editUser";
 import editNewsModal from "./modules/modals/editNews";
 import viewPunishmentModal from "./modules/modals/viewPunishment";
@@ -32,7 +32,7 @@ export default new Vuex.Store({
 			namespaced: true,
 			modules: {
 				editSong: editSongModal,
-				editStation: editStationModal,
+				manageStation: manageStationModal,
 				editUser: editUserModal,
 				editNews: editNewsModal,
 				viewPunishment: viewPunishmentModal,

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

@@ -2,14 +2,13 @@
 
 const state = {
 	modals: {
+		manageStation: false,
 		login: false,
 		register: false,
 		createCommunityStation: false,
-		addSongToQueue: false,
 		requestSong: false,
 		editPlaylist: false,
 		createPlaylist: false,
-		editStation: false,
 		report: false,
 		removeAccount: false,
 		editNews: false,

+ 0 - 43
frontend/src/store/modules/modals/editStation.js

@@ -1,43 +0,0 @@
-/* eslint no-param-reassign: 0 */
-
-import Vue from "vue";
-
-export default {
-	namespaced: true,
-	state: {
-		originalStation: {},
-		station: {}
-	},
-	getters: {},
-	actions: {
-		editStation: ({ commit }, station) => commit("editStation", station),
-		setGenres: ({ commit }, genres) => commit("setGenres", genres),
-		setBlacklistedGenres: ({ commit }, blacklistedGenres) =>
-			commit("setBlacklistedGenres", blacklistedGenres),
-		clearStation: ({ commit }) => commit("clearStation")
-	},
-	mutations: {
-		editStation(state, station) {
-			state.originalStation = JSON.parse(JSON.stringify(station));
-			state.station = JSON.parse(JSON.stringify(station));
-		},
-		setGenres(state, genres) {
-			Vue.set(
-				state.station,
-				"genres",
-				JSON.parse(JSON.stringify(genres))
-			);
-		},
-		setBlacklistedGenres(state, blacklistedGenres) {
-			Vue.set(
-				state.station,
-				"blacklistedGenres",
-				JSON.parse(JSON.stringify(blacklistedGenres))
-			);
-		},
-		clearStation(state) {
-			state.originalStation = null;
-			state.station = null;
-		}
-	}
-};

+ 87 - 0
frontend/src/store/modules/modals/manageStation.js

@@ -0,0 +1,87 @@
+/* eslint no-param-reassign: 0 */
+
+export default {
+	namespaced: true,
+	state: {
+		originalStation: {},
+		station: {},
+		includedPlaylists: [],
+		excludedPlaylists: [],
+		songsList: [],
+		stationPaused: true,
+		currentSong: {}
+	},
+	getters: {},
+	actions: {
+		editStation: ({ commit }, station) => {
+			commit("editStation", station);
+		},
+		setIncludedPlaylists: ({ commit }, includedPlaylists) => {
+			commit("setIncludedPlaylists", includedPlaylists);
+		},
+		setExcludedPlaylists: ({ commit }, excludedPlaylists) => {
+			commit("setExcludedPlaylists", excludedPlaylists);
+		},
+		clearStation: ({ commit }) => {
+			commit("clearStation");
+		},
+		updateSongsList: ({ commit }, songsList) => {
+			commit("updateSongsList", songsList);
+		},
+		repositionSongInList: ({ commit }, song) => {
+			commit("repositionSongInList", song);
+		},
+		updateStationPaused: ({ commit }, stationPaused) => {
+			commit("updateStationPaused", stationPaused);
+		},
+		updateCurrentSong: ({ commit }, currentSong) => {
+			commit("updateCurrentSong", currentSong);
+		}
+	},
+	mutations: {
+		editStation(state, station) {
+			state.originalStation = JSON.parse(JSON.stringify(station));
+			state.station = JSON.parse(JSON.stringify(station));
+		},
+		setIncludedPlaylists(state, includedPlaylists) {
+			state.includedPlaylists = JSON.parse(
+				JSON.stringify(includedPlaylists)
+			);
+		},
+		setExcludedPlaylists(state, excludedPlaylists) {
+			state.excludedPlaylists = JSON.parse(
+				JSON.stringify(excludedPlaylists)
+			);
+		},
+		clearStation(state) {
+			state.originalStation = null;
+			state.station = null;
+		},
+		updateSongsList(state, songsList) {
+			state.songsList = songsList;
+		},
+		repositionSongInList(state, song) {
+			if (
+				state.songsList[song.newIndex] &&
+				state.songsList[song.newIndex].youtubeId === song.youtubeId
+			)
+				return;
+
+			const { songsList } = state;
+
+			songsList.splice(
+				song.newIndex,
+				0,
+				songsList.splice(song.oldIndex, 1)[0]
+			);
+
+			state.songsList = songsList;
+		},
+		updateStationPaused(state, stationPaused) {
+			state.stationPaused = stationPaused;
+		},
+		updateCurrentSong(state, currentSong) {
+			state.currentSong = currentSong;
+		}
+	}
+};

+ 27 - 6
frontend/src/store/modules/station.js

@@ -2,7 +2,7 @@
 
 const state = {
 	station: {},
-	privatePlaylistQueueSelected: null,
+	partyPlaylists: [],
 	editing: {},
 	userCount: 0,
 	users: {
@@ -15,7 +15,9 @@ const state = {
 	songsList: [],
 	stationPaused: true,
 	localPaused: false,
-	noSong: true
+	noSong: true,
+	includedPlaylists: [],
+	excludedPlaylists: []
 };
 
 const getters = {};
@@ -24,6 +26,9 @@ const actions = {
 	joinStation: ({ commit }, station) => {
 		commit("joinStation", station);
 	},
+	leaveStation: ({ commit }, station) => {
+		commit("leaveStation", station);
+	},
 	editStation: ({ commit }, station) => {
 		commit("editStation", station);
 	},
@@ -57,11 +62,17 @@ const actions = {
 	updateNoSong: ({ commit }, noSong) => {
 		commit("updateNoSong", noSong);
 	},
-	updatePrivatePlaylistQueueSelected: ({ commit }, status) => {
-		commit("updatePrivatePlaylistQueueSelected", status);
+	updatePartyPlaylists: ({ commit }, playlists) => {
+		commit("updatePartyPlaylists", playlists);
 	},
 	updateIfStationIsFavorited: ({ commit }, { isFavorited }) => {
 		commit("updateIfStationIsFavorited", isFavorited);
+	},
+	setIncludedPlaylists: ({ commit }, includedPlaylists) => {
+		commit("setIncludedPlaylists", includedPlaylists);
+	},
+	setExcludedPlaylists: ({ commit }, excludedPlaylists) => {
+		commit("setExcludedPlaylists", excludedPlaylists);
 	}
 };
 
@@ -69,6 +80,10 @@ const mutations = {
 	joinStation(state, station) {
 		state.station = { ...station };
 	},
+	leaveStation(state) {
+		state.station = {};
+		state.partyPlaylists = [];
+	},
 	editStation(state, station) {
 		state.editing = { ...station };
 	},
@@ -116,11 +131,17 @@ const mutations = {
 	updateNoSong(state, noSong) {
 		state.noSong = noSong;
 	},
-	updatePrivatePlaylistQueueSelected(state, status) {
-		state.privatePlaylistQueueSelected = status;
+	updatePartyPlaylists(state, playlists) {
+		state.partyPlaylists = playlists;
 	},
 	updateIfStationIsFavorited(state, isFavorited) {
 		state.station.isFavorited = isFavorited;
+	},
+	setIncludedPlaylists(state, includedPlaylists) {
+		state.includedPlaylists = JSON.parse(JSON.stringify(includedPlaylists));
+	},
+	setExcludedPlaylists(state, excludedPlaylists) {
+		state.excludedPlaylists = JSON.parse(JSON.stringify(excludedPlaylists));
 	}
 };