Bläddra i källkod

Merge pull request #24 from Musare/staging

Christmas Release
Jonathan 8 år sedan
förälder
incheckning
55d760157b
39 ändrade filer med 2239 tillägg och 1045 borttagningar
  1. 1 0
      backend/config/template.json
  2. 6 1
      backend/index.js
  3. 1 1
      backend/logic/actions/apis.js
  4. 136 17
      backend/logic/actions/news.js
  5. 358 177
      backend/logic/actions/playlists.js
  6. 99 49
      backend/logic/actions/queueSongs.js
  7. 111 68
      backend/logic/actions/reports.js
  8. 2 2
      backend/logic/actions/songs.js
  9. 1 0
      backend/logic/actions/stations.js
  10. 164 54
      backend/logic/actions/users.js
  11. 27 0
      backend/logic/api.js
  12. 7 6
      backend/logic/app.js
  13. 8 2
      backend/logic/cache/index.js
  14. 19 0
      backend/logic/logger.js
  15. 97 17
      backend/logic/playlists.js
  16. 78 16
      backend/logic/songs.js
  17. 105 51
      backend/logic/stations.js
  18. 1 7
      frontend/App.vue
  19. 227 0
      frontend/components/Admin/News.vue
  20. 13 140
      frontend/components/Admin/QueueSongs.vue
  21. 8 4
      frontend/components/Admin/Reports.vue
  22. 12 130
      frontend/components/Admin/Songs.vue
  23. 42 47
      frontend/components/Modals/AddSongToQueue.vue
  24. 23 28
      frontend/components/Modals/CreateCommunityStation.vue
  25. 236 0
      frontend/components/Modals/EditNews.vue
  26. 184 38
      frontend/components/Modals/EditSong.vue
  27. 43 49
      frontend/components/Modals/EditStation.vue
  28. 37 38
      frontend/components/Modals/IssuesModal.vue
  29. 37 0
      frontend/components/Modals/Modal.vue
  30. 3 3
      frontend/components/Modals/Playlists/Edit.vue
  31. 79 84
      frontend/components/Modals/Report.vue
  32. 1 1
      frontend/components/Modals/WhatIsNew.vue
  33. 1 1
      frontend/components/Sidebars/SongsList.vue
  34. 28 7
      frontend/components/Station/Station.vue
  35. 17 1
      frontend/components/pages/Admin.vue
  36. 11 4
      frontend/components/pages/Home.vue
  37. 14 1
      frontend/components/pages/News.vue
  38. 1 0
      frontend/main.js
  39. 1 1
      frontend/nginx.conf

+ 1 - 0
backend/config/template.json

@@ -2,6 +2,7 @@
 	"secret": "",
 	"domain": "",
 	"serverDomain": "",
+  	"serverPort": 8080,
   	"isDocker": true,
 	"apis": {
 		"youtube": {

+ 6 - 1
backend/index.js

@@ -6,6 +6,7 @@ const async = require('async');
 
 const db = require('./logic/db');
 const app = require('./logic/app');
+const api = require('./logic/api');
 const io = require('./logic/io');
 const stations = require('./logic/stations');
 const songs = require('./logic/songs');
@@ -31,7 +32,7 @@ async.waterfall([
 	// setup our MongoDB database
 	(next) => db.init(config.get("mongo").url, next),
 
-	// setup the express server (not used right now, but may be used for OAuth stuff later, or for an API)
+	// setup the express server
 	(next) => app.init(next),
 
 	// setup the socket.io server (all client / server communication is done over this)
@@ -49,6 +50,9 @@ async.waterfall([
 	// setup the playlists
 	(next) => playlists.init(next),
 
+	// setup the API
+	(next) => api.init(next),
+
 	// setup the frontend for local setups
 	(next) => {
 		if (!config.get("isDocker")) {
@@ -63,6 +67,7 @@ async.waterfall([
 	if (err && err !== true) {
 		console.error('An error occurred while initializing the backend server');
 		console.error(err);
+		process.exit();
 	} else {
 		console.info('Backend server has been successfully started');
 	}

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

@@ -44,7 +44,7 @@ module.exports = {
 	},
 
 	joinAdminRoom: hooks.adminRequired((session, page, cb) => {
-		if (page === 'queue' || page === 'songs' || page === 'stations' || page === 'reports') {
+		if (page === 'queue' || page === 'songs' || page === 'stations' || page === 'reports' || page === 'news') {
 			utils.socketJoinRoom(session.socketId, `admin.${page}`);
 		}
 		cb({});

+ 136 - 17
backend/logic/actions/news.js

@@ -1,30 +1,149 @@
 'use strict';
 
+const async = require('async');
+
 const db = require('../db');
+const cache = require('../cache');
+const utils = require('../utils');
+const logger = require('../logger');
+const hooks = require('./hooks');
+
+cache.sub('news.create', news => {
+	utils.socketsFromUser(news.createdBy, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:admin.news.created', news);
+		});
+	});
+});
+
+cache.sub('news.remove', news => {
+	utils.socketsFromUser(news.createdBy, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:admin.news.removed', news);
+		});
+	});
+});
+
+cache.sub('news.update', news => {
+	utils.socketsFromUser(news.createdBy, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:admin.news.updated', news);
+		});
+	});
+});
 
 module.exports = {
 
+	/**
+	 * Gets all news items
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
 	index: (session, cb) => {
-		db.models.news.find({}).sort({ createdAt: 'desc' }).exec((err, news) => {
-			if (err) throw err;
-			else cb({ status: 'success', data: news });
+		async.waterfall([
+			(next) => {
+				db.models.news.find({}).sort({ createdAt: 'desc' }).exec(next);
+			}
+		], (err, news) => {
+			if (err) {
+				logger.log("NEWS_INDEX", "ERROR", `Indexing news failed. "${err.message}"`);
+				return cb({status: 'failure', message: 'Something went wrong.'});
+			}
+			logger.log("NEWS_INDEX", "SUCCESS", `Indexing news successful.`);
+			return cb({ status: 'success', data: news });
 		});
 	},
 
+	/**
+	 * Creates a news item
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Object} data - the object of the news data
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	create: hooks.adminRequired((session, data, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				data.createdBy = userId;
+				data.createdAt = Date.now();
+				db.models.news.create(data, next);
+			}
+		], (err, news) => {
+			if (err) {
+				logger.log("NEWS_CREATE", "ERROR", `Creating news failed. "${err.message}"`);
+				return cb({ 'status': 'failure', 'message': 'Something went wrong' });
+			}
+			cache.pub('news.create', news);
+			logger.log("NEWS_CREATE", "SUCCESS", `Creating news successful.`);
+			return cb({ 'status': 'success', 'message': 'Successfully created News' });
+		});
+	}),
+
+	/**
+	 * Gets the latest news item
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
 	newest: (session, cb) => {
-		// db.models.news.create({
-		// 	title: 'Beta',
-		// 	description: 'Remember to let us know in Discord if you notice anything odd!',
-		// 	upcoming: ['Private Playlists', 'Christmas Magic', 'Reports'],
-		// 	bugs: ['Mobile Responsiveness',	'Station Name Overflow'],
-		// 	improvements: ['No more Meteor Glitches!'],
-		// 	createdAt: Date.now(),
-		// 	createdBy: 'Jonathan (Musare Lead Developer)'
-		// });
-		db.models.news.findOne({}).sort({ createdAt: 'desc' }).exec((err, news) => {
-			if (err) throw err;
-			else cb({ status: 'success', data: news });
-		});
-	}
+		async.waterfall([
+			(next) => {
+				db.models.news.findOne({}).sort({ createdAt: 'desc' }).exec(next);
+			}
+		], (err, news) => {
+			if (err) {
+				logger.log("NEWS_NEWEST", "ERROR", `Getting the latest news failed. "${err.message}"`);
+				return cb({ 'status': 'failure', 'message': 'Something went wrong' });
+			}
+			logger.log("NEWS_NEWEST", "SUCCESS", `Successfully got the latest news.`);
+			return cb({ status: 'success', data: news });
+		});
+	},
+
+	/**
+	 * Removes a news item
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Object} news - the news object
+	 * @param {Function} cb - gets called with the result
+	 */
+	//TODO Pass in an id, not an object
+	//TODO Fix this
+	remove: hooks.adminRequired((session, news, cb, userId) => {
+		db.models.news.remove({ _id: news._id }, err => {
+			if (err) {
+				logger.log("NEWS_REMOVE", "ERROR", `Removing news "${news._id}" failed for user "${userId}". "${err.message}"`);
+				return cb({ 'status': 'failure', 'message': 'Something went wrong' });
+			} else {
+				cache.pub('news.remove', news);
+				logger.log("NEWS_REMOVE", "SUCCESS", `Removing news "${news._id}" successful by user "${userId}".`);
+				return cb({ 'status': 'success', 'message': 'Successfully removed News' });
+			}
+		});
+	}),
+
+	/**
+	 * Removes a news item
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} _id - the news id
+	 * @param {Object} news - the news object
+	 * @param {Function} cb - gets called with the result
+	 */
+	//TODO Fix this
+	update: hooks.adminRequired((session, _id, news, cb, userId) => {
+		db.models.news.update({ _id }, news, { upsert: true }, err => {
+			if (err) {
+				logger.log("NEWS_UPDATE", "ERROR", `Updating news "${_id}" failed for user "${userId}". "${err.message}"`);
+				return cb({ 'status': 'failure', 'message': 'Something went wrong' });
+			} else {
+				cache.pub('news.update', news);
+				logger.log("NEWS_UPDATE", "SUCCESS", `Updating news "${_id}" successful for user "${userId}".`);
+				return cb({ 'status': 'success', 'message': 'Successfully updated News' });
+			}
+		});
+	}),
 
 };

+ 358 - 177
backend/logic/actions/playlists.js

@@ -4,6 +4,7 @@ const db = require('../db');
 const io = require('../io');
 const cache = require('../cache');
 const utils = require('../utils');
+const logger = require('../logger');
 const hooks = require('./hooks');
 const async = require('async');
 const playlists = require('../playlists');
@@ -71,19 +72,61 @@ cache.sub('playlist.updateDisplayName', res => {
 
 let lib = {
 
+	/**
+	 * Gets the first song from a private playlist
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} playlistId - the id of the playlist we are getting the first song from
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	getFirstSong: hooks.loginRequired((session, playlistId, cb, userId) => {
-		playlists.getPlaylist(playlistId, (err, playlist) => {
-			if (err || !playlist || playlist.createdBy !== userId) return cb({ status: 'failure', message: 'Something went wrong when getting the playlist'});
+		async.waterfall([
+			(next) => {
+				playlists.getPlaylist(playlistId, next);
+			},
+
+			(playlist, next) => {
+				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found.');
+				next(null, playlist.songs[0]);
+			}
+		], (err, song) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("PLAYLIST_GET_FIRST_SONG", "ERROR", `Getting the first song of playlist "${playlistId}" failed for user "${userId}". "${error}"`);
+				return cb({ status: 'failure', message: 'Something went wrong when getting the playlist'});
+			}
+			logger.log("PLAYLIST_GET_FIRST_SONG", "SUCCESS", `Successfully got the first song of playlist "${playlistId}" for user "${userId}".`);
 			cb({
 				status: 'success',
-				song: playlist.songs[0]
+				song: song
 			});
 		});
 	}),
 
+	/**
+	 * Gets all playlists for the user requesting it
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	indexForUser: hooks.loginRequired((session, cb, userId) => {
-		db.models.playlist.find({ createdBy: userId }, (err, playlists) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when getting the playlists'});;
+		async.waterfall([
+			(next) => {
+				db.models.playlist.find({ createdBy: userId }, next);
+			}
+		], (err, playlists) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("PLAYLIST_INDEX_FOR_USER", "ERROR", `Indexing playlists for user "${userId}" failed. "${error}"`);
+				return cb({ status: 'failure', message: error});
+			}
+			logger.log("PLAYLIST_INDEX_FOR_USER", "SUCCESS", `Successfully indexed playlists for user "${userId}".`);
 			cb({
 				status: 'success',
 				data: playlists
@@ -91,6 +134,14 @@ let lib = {
 		});
 	}),
 
+	/**
+	 * Creates a new private playlist
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Object} data - the data for the new private playlist
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	create: hooks.loginRequired((session, data, cb, userId) => {
 		async.waterfall([
 
@@ -99,7 +150,7 @@ let lib = {
 			},
 
 			(next) => {
-				const { name, displayName, songs } = data;
+				const { displayName, songs } = data;
 				db.models.playlist.create({
 					_id: utils.generateRandomString(17),//TODO Check if exists
 					displayName,
@@ -110,17 +161,48 @@ let lib = {
 			}
 
 		], (err, playlist) => {
-			console.log(err, playlist);
-			if (err) return cb({ 'status': 'failure', 'message': 'Something went wrong'});
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("PLAYLIST_CREATE", "ERROR", `Creating private playlist failed for user "${userId}". "${error}"`);
+				return cb({ status: 'failure', message: error});
+			}
 			cache.pub('playlist.create', playlist._id);
-			return cb({ 'status': 'success', 'message': 'Successfully created playlist' });
+			logger.log("PLAYLIST_CREATE", "SUCCESS", `Successfully created private playlist for user "${userId}".`);
+			cb({ 'status': 'success', 'message': 'Successfully created playlist' });
 		});
 	}),
 
-	getPlaylist: hooks.loginRequired((session, id, cb, userId) => {
-		playlists.getPlaylist(id, (err, playlist) => {
-			if (err || playlist.createdBy !== userId) return cb({status: 'success', message: 'Playlist not found'});
-			if (err == null) return cb({
+	/**
+	 * Gets a playlist from id
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} playlistId - the id of the playlist we are getting
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	getPlaylist: hooks.loginRequired((session, playlistId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				playlists.getPlaylist(playlistId, next);
+			},
+
+			(playlist, next) => {
+				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
+				next(null, playlist);
+			}
+		], (err, playlist) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("PLAYLIST_GET", "ERROR", `Getting private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
+				return cb({ status: 'failure', message: error});
+			}
+			logger.log("PLAYLIST_GET", "SUCCESS", `Successfully got private playlist "${playlistId}" for user "${userId}".`);
+			console.log(playlist);
+			cb({
 				status: 'success',
 				data: playlist
 			});
@@ -128,29 +210,59 @@ let lib = {
 	}),
 
 	//TODO Remove this
-	update: hooks.loginRequired((session, _id, playlist, cb, userId) => {
-		db.models.playlist.update({ _id, createdBy: userId }, playlist, (err, data) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong.' });
-			playlists.updatePlaylist(_id, (err) => {
-				if (err) return cb({ status: 'failure', message: 'Something went wrong.' });
-				return cb({ status: 'success', message: 'Playlist has been successfully updated', data });
+	/**
+	 * Updates a private playlist
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} playlistId - the id of the playlist we are updating
+	 * @param {Object} playlist - the new private playlist object
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	update: hooks.loginRequired((session, playlistId, playlist, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.playlist.update({ _id: playlistId, createdBy: userId }, playlist, next);
+			},
+
+			(res, next) => {
+				playlists.updatePlaylist(playlistId, next)
+			}
+		], (err, playlist) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("PLAYLIST_UPDATE", "ERROR", `Updating private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
+				return cb({ status: 'failure', message: error});
+			}
+			logger.log("PLAYLIST_UPDATE", "SUCCESS", `Successfully updated private playlist "${playlistId}" for user "${userId}".`);
+			cb({
+				status: 'success',
+				data: playlist
 			});
 		});
 	}),
 
+	/**
+	 * Adds a song to a private playlist
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} songId - the id of the song we are trying to add
+	 * @param {String} playlistId - the id of the playlist we are adding the song to
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	addSongToPlaylist: hooks.loginRequired((session, songId, playlistId, cb, userId) => {
-		console.log(songId);
 		async.waterfall([
 			(next) => {
 				playlists.getPlaylist(playlistId, (err, playlist) => {
 					if (err || !playlist || playlist.createdBy !== userId) return next('Something went wrong when trying to get the playlist');
 
-					let found = false;
-					playlist.songs.forEach((song) => {
-						if (songId === song._id) found = true;
-					});
-					if (found) return next('That song is already in the playlist');
-					return next(null);
+					async.each(playlist.songs, (song, next) => {
+						if (song._id === songId) return next('That song is already in the playlist');
+						next();
+					}, next);
 				});
 			},
 			(next) => {
@@ -170,11 +282,7 @@ let lib = {
 			},
 			(newSong, next) => {
 				db.models.playlist.update({ _id: playlistId }, { $push: { songs: newSong } }, (err) => {
-					if (err) {
-						console.error(err);
-						return next('Failed to add song to playlist');
-					}
-
+					if (err) return next(err);
 					playlists.updatePlaylist(playlistId, (err, playlist) => {
 						next(err, playlist, newSong);
 					});
@@ -182,14 +290,28 @@ let lib = {
 			}
 		],
 		(err, playlist, newSong) => {
-			if (err) return cb({ status: 'error', message: err });
-			else if (playlist.songs) {
-				cache.pub('playlist.addSong', { playlistId: playlist._id, song: newSong, userId: userId });
-				return cb({ status: 'success', message: 'Song has been successfully added to the playlist', data: playlist.songs });
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("PLAYLIST_ADD_SONG", "ERROR", `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
+				return cb({ status: 'failure', message: error});
 			}
+			logger.log("PLAYLIST_ADD_SONG", "SUCCESS", `Successfully added song "${songId}" to private playlist "${playlistId}" for user "${userId}".`);
+			cache.pub('playlist.addSong', { playlistId: playlist._id, song: newSong, userId: userId });
+			return cb({ status: 'success', message: 'Song has been successfully added to the playlist', data: playlist.songs });
 		});
 	}),
-	
+
+	/**
+	 * Adds a set of songs to a private playlist
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} url - the url of the the YouTube playlist
+	 * @param {String} playlistId - the id of the playlist we are adding the set of songs to
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	addSetToPlaylist: hooks.loginRequired((session, url, playlistId, cb, userId) => {
 		async.waterfall([
 			(next) => {
@@ -210,178 +332,237 @@ let lib = {
 				}
 			},
 			(next) => {
-				playlists.getPlaylist(playlistId, (err, playlist) => {
-					if (err || !playlist || playlist.createdBy !== userId) return next('Something went wrong while trying to get the playlist');
+				playlists.getPlaylist(playlistId, next);
+			},
 
-					next(null, playlist);
-				});
+			(playlist, next) => {
+				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found.');
+				next(null, playlist);
 			}
-		],
-		(err, playlist) => {
-			if (err) return cb({ status: 'failure', message: err });
-			else if (playlist.songs) return cb({ status: 'success', message: 'Playlist has been successfully imported.', data: playlist.songs });
+		], (err, playlist) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("PLAYLIST_IMPORT", "ERROR", `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
+				return cb({ status: 'failure', message: error});
+			}
+			logger.log("PLAYLIST_IMPORT", "SUCCESS", `Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${userId}".`);
+			cb({ status: 'success', message: 'Playlist has been successfully imported.', data: playlist.songs });
 		});
 	}),
 
-
+	/**
+	 * Removes a song from a private playlist
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} songId - the id of the song we are removing from the private playlist
+	 * @param {String} playlistId - the id of the playlist we are removing the song from
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	removeSongFromPlaylist: hooks.loginRequired((session, songId, playlistId, cb, userId) => {
-		playlists.getPlaylist(playlistId, (err, playlist) => {
-			if (err || !playlist || playlist.createdBy !== userId) return cb({ status: 'failure', message: 'Something went wrong when getting the playlist'});
+		async.waterfall([
+			(next) => {
+				playlists.getPlaylist(playlistId, next);
+			},
 
-			for (let z = 0; z < playlist.songs.length; z++) {
-				if (playlist.songs[z]._id == songId) playlist.songs.shift(playlist.songs[z]);
-			}
+			(playlist, next) => {
+				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
+				db.models.playlist.update({_id: playlistId}, {$pull: {songs: songId}}, next);
+			},
 
-			db.models.playlist.update({_id: playlistId}, {$pull: {songs: {_id: songId}}}, (err) => {
-				if (err) {
-					console.error(err);
-					return cb({ status: 'failure', message: 'Something went wrong when saving the playlist.'});
-				}
-				playlists.updatePlaylist(playlistId, (err, playlist) => {
-					cache.pub('playlist.removeSong', {playlistId: playlist._id, songId: songId, userId: userId});
-					return cb({ status: 'success', message: 'Song has been successfully removed from playlist', data: playlist.songs });
-				});
-			});
+			(res, next) => {
+				playlists.updatePlaylist(playlistId, next);
+			}
+		], (err, playlist) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("PLAYLIST_REMOVE_SONG", "ERROR", `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
+				return cb({ status: 'failure', message: error});
+			}
+			logger.log("PLAYLIST_REMOVE_SONG", "SUCCESS", `Successfully removed song "${songId}" from private playlist "${playlistId}" for user "${userId}".`);
+			cache.pub('playlist.removeSong', {playlistId: playlist._id, songId: songId, userId: userId});
+			return cb({ status: 'success', message: 'Song has been successfully removed from playlist', data: playlist.songs });
 		});
 	}),
 
-	updateDisplayName: hooks.loginRequired((session, _id, displayName, cb, userId) => {
-		db.models.playlist.update({ _id, createdBy: userId }, { displayName }, (err, res) => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the playlist.'});
-			playlists.updatePlaylist(_id, (err) => {
-				if (err) return cb({ status: 'failure', message: err});
-				cache.pub('playlist.updateDisplayName', {playlistId: _id, displayName: displayName, userId: userId});
-				return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-			})
+	/**
+	 * Updates the displayName of a private playlist
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} playlistId - the id of the playlist we are updating the displayName for
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	updateDisplayName: hooks.loginRequired((session, playlistId, displayName, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.playlist.update({ _id: playlistId, createdBy: userId }, { $set: { displayName } }, next);
+			},
+
+			(res, next) => {
+				playlists.updatePlaylist(playlistId, next);
+			}
+		], (err, playlist) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("PLAYLIST_UPDATE_DISPLAY_NAME", "ERROR", `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
+				return cb({ status: 'failure', message: error});
+			}
+			logger.log("PLAYLIST_UPDATE_DISPLAY_NAME", "SUCCESS", `Successfully updated display name to "${displayName}" for private playlist "${playlistId}" for user "${userId}".`);
+			cache.pub('playlist.updateDisplayName', {playlistId: playlistId, displayName: displayName, userId: userId});
+			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
 		});
 	}),
 
+	/**
+	 * Moves a song to the top of the list in a private playlist
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} playlistId - the id of the playlist we are moving the song to the top from
+	 * @param {String} songId - the id of the song we are moving to the top of the list
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	moveSongToTop: hooks.loginRequired((session, playlistId, songId, cb, userId) => {
-		playlists.getPlaylist(playlistId, (err, playlist) => {
-			if (err || !playlist || playlist.createdBy !== userId) return cb({ status: 'failure', message: 'Something went wrong when getting the playlist'});
-			let found = false;
-			let foundSong;
-			playlist.songs.forEach((song) => {
-				if (song._id === songId) {
-					foundSong = song;
-					found = true;
-				}
-			});
+		async.waterfall([
+			(next) => {
+				playlists.getPlaylist(playlistId, next);
+			},
 
-			if (found) {
+			(playlist, next) => {
+				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
+				async.each(playlist.songs, (song, next) => {
+					if (song._id === songId) return next(song);
+					next();
+				}, (err) => {
+					if (err && err._id) return next(null, err);
+					next('Song not found');
+				});
+			},
+
+			(song, next) => {
 				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {_id: songId}}}, (err) => {
-					console.log(err);
-					if (err) return cb({status: 'failure', message: 'Something went wrong when moving the song'});
-					db.models.playlist.update({_id: playlistId}, {
-						$push: {
-							songs: {
-								$each: [foundSong],
-								$position: 0
-							}
-						}
-					}, (err) => {
-						console.log(err);
-						if (err) return cb({status: 'failure', message: 'Something went wrong when moving the song'});
-						playlists.updatePlaylist(playlistId, (err) => {
-							if (err) return cb({ status: 'failure', message: err});
-							cache.pub('playlist.moveSongToTop', {playlistId, songId, userId: userId});
-							return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-						})
-					});
+					if (err) return next(err);
+					return next(null, song);
 				});
-			} else {
-				return cb({status: 'failure', message: 'Song not found.'});
+			},
+
+			(song, next) => {
+				db.models.playlist.update({_id: playlistId}, {
+					$push: {
+						songs: {
+							$each: [song],
+							$position: 0
+						}
+					}
+				}, next);
+			},
+
+			(res, next) => {
+				playlists.updatePlaylist(playlistId, next);
 			}
+		], (err, playlist) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("PLAYLIST_MOVE_SONG_TO_TOP", "ERROR", `Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
+				return cb({ status: 'failure', message: error});
+			}
+			logger.log("PLAYLIST_MOVE_SONG_TO_TOP", "SUCCESS", `Successfully moved song "${songId}" to the top for private playlist "${playlistId}" for user "${userId}".`);
+			cache.pub('playlist.moveSongToTop', {playlistId, songId, userId: userId});
+			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
 		});
 	}),
 
+	/**
+	 * Moves a song to the bottom of the list in a private playlist
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} playlistId - the id of the playlist we are moving the song to the bottom from
+	 * @param {String} songId - the id of the song we are moving to the bottom of the list
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	moveSongToBottom: hooks.loginRequired((session, playlistId, songId, cb, userId) => {
-		playlists.getPlaylist(playlistId, (err, playlist) => {
-			if (err || !playlist || playlist.createdBy !== userId) return cb({ status: 'failure', message: 'Something went wrong when getting the playlist'});
-			let found = false;
-			let foundSong;
-			playlist.songs.forEach((song) => {
-				if (song._id === songId) {
-					foundSong = song;
-					found = true;
-				}
-			});
+		async.waterfall([
+			(next) => {
+				playlists.getPlaylist(playlistId, next);
+			},
 
-			if (found) {
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {_id: songId}}}, (err) => {
-					console.log(err);
-					if (err) return cb({status: 'failure', message: 'Something went wrong when moving the song'});
-					db.models.playlist.update({_id: playlistId}, {
-						$push: { songs: foundSong }
-					}, (err) => {
-						console.log(err);
-						if (err) return cb({status: 'failure', message: 'Something went wrong when moving the song'});
-						playlists.updatePlaylist(playlistId, (err) => {
-							if (err) return cb({ status: 'failure', message: err });
-							cache.pub('playlist.moveSongToBottom', { playlistId, songId, userId: userId });
-							return cb({ status: 'success', message: 'Playlist has been successfully updated' });
-						})
-					});
+			(playlist, next) => {
+				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
+				async.each(playlist.songs, (song, next) => {
+					if (song._id === songId) return next(song);
+					next();
+				}, (err) => {
+					if (err && err._id) return next(null, err);
+					next('Song not found');
 				});
-			} else return cb({status: 'failure', message: 'Song not found'});
-		});
-	}),
-
-	/*
-
-	promoteSong: hooks.loginRequired((session, playlistId, fromIndex, cb, userId) => {
-		db.models.playlist.findOne({ _id: playlistId }, (err, playlist) => {
-			if (err || !playlist || playlist.createdBy !== userId) return cb({ status: 'failure', message: 'Something went wrong when getting the playlist.'});
-
-			let song = playlist.songs[fromIndex];
-			playlist.songs.splice(fromIndex, 1);
-			playlist.songs.splice((fromIndex + 1), 0, song);
-
-			playlist.save(err => {
-				if (err) {
-					console.error(err);
-					return cb({ status: 'failure', message: 'Something went wrong when saving the playlist.'});
-				}
+			},
 
-				playlists.updatePlaylist(playlistId, (err) => {
-					if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the playlist.'});
-					return cb({ status: 'success', data: playlist.songs });
+			(song, next) => {
+				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {_id: songId}}}, (err) => {
+					if (err) return next(err);
+					return next(null, song);
 				});
+			},
 
-			});
+			(song, next) => {
+				db.models.playlist.update({_id: playlistId}, {
+					$push: {
+						songs: song
+					}
+				}, next);
+			},
+
+			(res, next) => {
+				playlists.updatePlaylist(playlistId, next);
+			}
+		], (err, playlist) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("PLAYLIST_MOVE_SONG_TO_BOTTOM", "ERROR", `Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
+				return cb({ status: 'failure', message: error});
+			}
+			logger.log("PLAYLIST_MOVE_SONG_TO_BOTTOM", "SUCCESS", `Successfully moved song "${songId}" to the bottom for private playlist "${playlistId}" for user "${userId}".`);
+			cache.pub('playlist.moveSongToBottom', {playlistId, songId, userId: userId});
+			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
 		});
 	}),
 
-	demoteSong: hooks.loginRequired((session, playlistId, fromIndex, cb, userId) => {
-		db.models.playlist.findOne({ _id: playlistId }, (err, playlist) => {
-			if (err || !playlist || playlist.createdBy !== userId) return cb({ status: 'failure', message: 'Something went wrong when getting the playlist.'});
-
-			let song = playlist.songs[fromIndex];
-			playlist.songs.splice(fromIndex, 1);
-			playlist.songs.splice((fromIndex - 1), 0, song);
-
-			playlist.save(err => {
-				if (err) {
-					console.error(err);
-					return cb({ status: 'failure', message: 'Something went wrong when saving the playlist.'});
-				}
-
-				playlists.updatePlaylist(playlistId, (err) => {
-					if (err) return cb({ status: 'failure', message: 'Something went wrong when saving the playlist.'});
-					return cb({ status: 'success', data: playlist.songs });
-				});
-
-			});
-		});
-	}),*/
-
-	remove: hooks.loginRequired((session, _id, cb, userId) => {
-		db.models.playlist.remove({ _id, createdBy: userId }).exec(err => {
-			if (err) return cb({ status: 'failure', message: 'Something went wrong when removing the playlist.'});
-			cache.hdel('playlists', _id, () => {
-				cache.pub('playlist.delete', {userId: userId, playlistId: _id});
-				return cb({ status: 'success', message: 'Playlist successfully removed' });
-			});
+	/**
+	 * Removes a song from a private playlist
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} playlistId - the id of the playlist we are moving the song to the top from
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	remove: hooks.loginRequired((session, playlistId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				playlists.deletePlaylist(playlistId, next);
+			}
+		], (err) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("PLAYLIST_REMOVE", "ERROR", `Removing private playlist "${playlistId}" failed for user "${userId}". "${error}"`);
+				return cb({ status: 'failure', message: error});
+			}
+			logger.log("PLAYLIST_REMOVE", "SUCCESS", `Successfully removed private playlist "${playlistId}" for user "${userId}".`);
+			cache.pub('playlist.delete', {userId: userId, playlistId});
+			return cb({ status: 'success', message: 'Playlist successfully removed' });
 		});
 	})
 

+ 99 - 49
backend/logic/actions/queueSongs.js

@@ -2,6 +2,7 @@
 
 const db = require('../db');
 const utils = require('../utils');
+const logger = require('../logger');
 const notifications = require('../notifications');
 const cache = require('../cache');
 const async = require('async');
@@ -21,67 +22,121 @@ cache.sub('queue.removedSong', songId => {
 
 cache.sub('queue.updatedSong', songId => {
 	//TODO Retrieve new Song object
-	utils.emitToRoom('admin.queue', 'event:song.updated', { songId });
+	utils.emitToRoom('admin.queue', 'event:queueSong.updated', { songId });
 });
 
 module.exports = {
 
+	/**
+	 * Gets all queuesongs
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
 	index: hooks.adminRequired((session, cb) => {
-		db.models.queueSong.find({}, (err, songs) => {
-			if (err) throw err;
-			cb(songs);
+		async.waterfall([
+			(next) => {
+				db.models.queueSong.find({}, next);
+			}
+		], (err, songs) => {
+			if (err) {
+				logger.log("QUEUE_INDEX", "ERROR", `Indexing queuesongs failed. "${err.message}"`);
+				return cb({status: 'failure', message: 'Something went wrong.'});
+			}
+			logger.log("QUEUE_INDEX", "SUCCESS", `Indexing queuesongs successful.`);
+			return cb(songs);
 		});
 	}),
 
-	update: hooks.adminRequired((session, _id, updatedSong, cb) => {
-		//TODO Check if id and updatedSong is valid
-		db.models.queueSong.findOne({ _id }, (err, currentSong) => {
-			if (err) console.error(err);
-			// TODO Check if new id, if any, is already in use in queue or on rotation
-			let updated = false;
-			for (let prop in updatedSong) if (updatedSong[prop] !== currentSong[prop]) currentSong[prop] = updatedSong[prop]; updated = true;
-			if (!updated) return cb({ status: 'error', message: 'No properties changed' });
-			else {
-				currentSong.save(err => {
-					if (err) console.error(err);
-					return cb({ status: 'success', message: 'Successfully updated the queued song' });
-				});
+	/**
+	 * Updates a queuesong
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} songId - the id of the queuesong that gets updated
+	 * @param {Object} updatedSong - the object of the updated queueSong
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	update: hooks.adminRequired((session, songId, updatedSong, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.queueSong.findOne({ _id: songId }, next);
+			},
+
+			(song, next) => {
+				if(!song) return next('Song not found');
+				let updated = false;
+				let $set = {};
+				for (let prop in updatedSong) if (updatedSong[prop] !== song[prop]) $set[prop] = updatedSong[prop]; updated = true;
+				if (!updated) return next('No properties changed');
+				db.models.queueSong.update({ _id: songId }, {$set}, next);
+			}
+		], (err) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("QUEUE_UPDATE", "ERROR", `Updating queuesong "${songId}" failed for user ${userId}. "${err.message}"`);
+				return cb({status: 'failure', message: error});
 			}
+			cache.pub('queue.updatedSong', songId);
+			logger.log("QUEUE_UPDATE", "SUCCESS", `User "${userId}" successfully update queuesong "${songId}".`);
+			return cb({status: 'success', message: 'Successfully updated song.'});
 		});
 	}),
 
-	remove: hooks.adminRequired((session, songId, cb) => {
-		db.models.queueSong.remove({ _id: songId }, (err, res) => {
-			if (err) return cb({ status: 'failure', message: err.message });
+	/**
+	 * Removes a queuesong
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} songId - the id of the queuesong that gets removed
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	remove: hooks.adminRequired((session, songId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.queueSong.remove({ _id: songId }, next);
+			}
+		], (err) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("QUEUE_REMOVE", "ERROR", `Removing queuesong "${songId}" failed for user ${userId}. "${err.message}"`);
+				return cb({status: 'failure', message: error});
+			}
 			cache.pub('queue.removedSong', songId);
-			cb({ status: 'success', message: 'Song was removed successfully' });
+			logger.log("QUEUE_REMOVE", "SUCCESS", `User "${userId}" successfully removed queuesong "${songId}".`);
+			return cb({status: 'success', message: 'Successfully updated song.'});
 		});
 	}),
 
+	/**
+	 * Creates a queuesong
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} songId - the id of the song that gets added
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	add: hooks.loginRequired((session, songId, cb, userId) => {
-		//TODO Check if id is valid
-
 		let requestedAt = Date.now();
 
 		async.waterfall([
 			(next) => {
-				db.models.queueSong.findOne({_id: songId}, (err, song) => {
-					if (err) return next('Something went wrong while getting the song from the Database.');
-					if (song) return next('This song is already in the queue.');
-					next();
-				});
+				db.models.queueSong.findOne({_id: songId}, next);
 			},
 
-			(next) => {
-				db.models.song.findOne({_id: songId}, (err, song) => {
-					if (err) return next('Something went wrong while getting the song from the Database.');
-					if (song) return next('This song has already been added.');
-					next();
-				});
+			(song, next) => {
+				if (song) return next('This song is already in the queue.');
+				db.models.song.findOne({_id: songId}, next);
 			},
 
 			// Get YouTube data from id
-			(next) => {
+			(song, next) => {
+				if (song) return next('This song has already been added.');
+				//TODO Add err object as first param of callback
 				utils.getSongFromYouTube(songId, (song) => {
 					song.artists = [];
 					song.genres = [];
@@ -94,32 +149,27 @@ module.exports = {
 				});
 			},
 			(newSong, next) => {
+				//TODO Add err object as first param of callback
 				utils.getSongFromSpotify(newSong, (song) => {
 					next(null, song);
 				});
 			},
 			(newSong, next) => {
 				const song = new db.models.queueSong(newSong);
-
-				// check if song already exists
-
 				song.save(err => {
-
-					if (err) {
-						console.error(err);
-						return next('Failed to add song to database');
-					}
-
-					//stations.getStation(station).playlist.push(newSong);
+					if (err) return next(err);
 					next(null, newSong);
 				});
 			}
-		],
-		(err, newSong) => {
-			if (err) return cb({ status: 'error', message: err });
+		], (err, newSong) => {
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				return cb({status: 'failure', message: error});
+			}
 			cache.pub('queue.newSong', newSong._id);
 			return cb({ status: 'success', message: 'Successfully added that song to the queue' });
 		});
 	})
-
 };

+ 111 - 68
backend/logic/actions/reports.js

@@ -5,8 +5,50 @@ const async = require('async');
 const db = require('../db');
 const cache = require('../cache');
 const utils = require('../utils');
+const logger = require('../logger');
 const hooks = require('./hooks');
 const songs = require('../songs');
+const reportableIssues = [
+	{
+		name: 'Video',
+		reasons: [
+			'Doesn\'t exist',
+			'It\'s private',
+			'It\'s not available in my country'
+		]
+	},
+	{
+		name: 'Title',
+		reasons: [
+			'Incorrect',
+			'Inappropriate'
+		]
+	},
+	{
+		name: 'Duration',
+		reasons: [
+			'Skips too soon',
+			'Skips too late',
+			'Starts too soon',
+			'Skips too late'
+		]
+	},
+	{
+		name: 'Artists',
+		reasons: [
+			'Incorrect',
+			'Inappropriate'
+		]
+	},
+	{
+		name: 'Thumbnail',
+		reasons: [
+			'Incorrect',
+			'Inappropriate',
+			'Doesn\'t exist'
+		]
+	}
+];
 
 cache.sub('report.resolve', reportId => {
 	utils.emitToRoom('admin.reports', 'event:admin.report.resolved', reportId);
@@ -18,95 +60,89 @@ cache.sub('report.create', report => {
 
 module.exports = {
 
+	/**
+	 * Gets all reports
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
 	index: hooks.adminRequired((session, cb) => {
-		db.models.report.find({ resolved: false }).sort({ released: 'desc' }).exec((err, reports) => {
+
+		async.waterfall([
+			(next) => {
+				db.models.report.find({ resolved: false }).sort({ released: 'desc' }).exec(next);
+			}
+		], (err, reports) => {
 			if (err) {
-				console.error(err);
-				cb({ 'status': 'failure', 'message': 'Something went wrong'});
+				logger.log("REPORTS_INDEX", "ERROR", `Indexing reports failed. "${err.message}"`);
+				return cb({ 'status': 'failure', 'message': 'Something went wrong'});
 			}
+			logger.log("REPORTS_INDEX", "SUCCESS", "Indexing reports successful.");
 			cb({ status: 'success', data: reports });
 		});
 	}),
 
-	resolve: hooks.adminRequired((session, _id, cb) => {
-		db.models.report.findOne({ _id }).sort({ released: 'desc' }).exec((err, report) => {
+	/**
+	 * Resolves a report
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} reportId - the id of the report that is getting resolved
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
+	resolve: hooks.adminRequired((session, reportId, cb, userId) => {
+		async.waterfall([
+			(next) => {
+				db.models.report.findOne({ _id: reportId }).exec(next);
+			},
+
+			(report, next) => {
+				if (!report) return next('Report not found.');
+				report.resolved = true;
+				report.save(err => {
+					if (err) next(err.message);
+					else next();
+				});
+			}
+		], (err) => {
 			if (err) {
-				console.error(err);
-				cb({ 'status': 'failure', 'message': 'Something went wrong'});
+				logger.log("REPORTS_RESOLVE", "ERROR", `Resolving report "${reportId}" failed by user "${userId}". Mongo error. "${err.message}"`);
+				return cb({ 'status': 'failure', 'message': 'Something went wrong'});
+			} else {
+				cache.pub('report.resolve', reportId);
+				logger.log("REPORTS_RESOLVE", "SUCCESS", `User "${userId}" resolved report "${reportId}".`);
+				cb({ status: 'success', message: 'Successfully resolved Report' });
 			}
-			report.resolved = true;
-			report.save(err => {
-				if (err) console.error(err);
-				else {
-					cache.pub('report.resolve', _id);
-					cb({ status: 'success', message: 'Successfully resolved Report' });
-				}
-			});
 		});
 	}),
 
+	/**
+	 * Creates a new report
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Object} data - the object of the report data
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	create: hooks.loginRequired((session, data, cb, userId) => {
 		async.waterfall([
 
 			(next) => {
-				songs.getSong(data.songId, (err, song) => {
-					if (err) return next(err);
-					if (!song) return next('Song does not exist in our Database');
-					next();
-				});
+				songs.getSong(data.songId, next);
 			},
 
-			(next) => {
-				let issues = [
-					{
-						name: 'Video',
-						reasons: [
-							'Doesn\'t exist',
-							'It\'s private',
-							'It\'s not available in my country'
-						]
-					},
-					{
-						name: 'Title',
-						reasons: [
-							'Incorrect',
-							'Inappropriate'
-						]
-					},
-					{
-						name: 'Duration',
-						reasons: [
-							'Skips too soon',
-							'Skips too late',
-							'Starts too soon',
-							'Skips too late'
-						]
-					},
-					{
-						name: 'Artists',
-						reasons: [
-							'Incorrect',
-							'Inappropriate'
-						]
-					},
-					{
-						name: 'Thumbnail',
-						reasons: [
-							'Incorrect',
-							'Inappropriate',
-							'Doesn\'t exist'
-						]
-					}
-				];
+			(song, next) => {
+				if (!song) return next('Song not found.');
+
 
 				for (let z = 0; z < data.issues.length; z++) {
-					if (issues.filter(issue => { return issue.name == data.issues[z].name; }).length > 0) {
-						for (let r = 0; r < issues.length; r++) {
-							if (issues[r].reasons.every(reason => data.issues[z].reasons.indexOf(reason) < -1)) {
-								return cb({ 'status': 'failure', 'message': 'Invalid data' });
+					if (reportableIssues.filter(issue => { return issue.name == data.issues[z].name; }).length > 0) {
+						for (let r = 0; r < reportableIssues.length; r++) {
+							if (reportableIssues[r].reasons.every(reason => data.issues[z].reasons.indexOf(reason) < -1)) {
+								return cb({ status: 'failure', message: 'Invalid data' });
 							}
 						}
-					} else return cb({ 'status': 'failure', 'message': 'Invalid data' });
+					} else return cb({ status: 'failure', message: 'Invalid data' });
 				}
 
 				next();
@@ -131,9 +167,16 @@ module.exports = {
 			}
 
 		], (err, report) => {
-			if (err) return cb({ 'status': 'failure', 'message': 'Something went wrong'});
+			if (err) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("REPORTS_CREATE", "ERROR", `Creating report for "${data.songId}" failed by user "${userId}". "${error}"`);
+				return cb({ 'status': 'failure', 'message': 'Something went wrong' });
+			}
 			else {
 				cache.pub('report.create', report);
+				logger.log("REPORTS_CREATE", "SUCCESS", `User "${userId}" created report for "${data.songId}".`);
 				return cb({ 'status': 'success', 'message': 'Successfully created report' });
 			}
 		});

+ 2 - 2
backend/logic/actions/songs.js

@@ -56,12 +56,12 @@ cache.sub('song.undislike', (data) => {
 
 module.exports = {
 
-	index: (session, cb) => {
+	index: hooks.adminRequired((session, cb) => {
 		db.models.song.find({}, (err, songs) => {
 			if (err) throw err;
 			cb(songs);
 		});
-	},
+	}),
 
 	update: hooks.adminRequired((session, songId, song, cb) => {
 		db.models.song.update({ _id: songId }, song, { upsert: true }, (err, updatedSong) => {

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

@@ -482,6 +482,7 @@ module.exports = {
 						});
 					} else cont(song);
 					function cont(song) {
+						song.requestedBy = userId;
 						db.models.station.update({ _id: stationId }, { $push: { queue: song } }, (err) => {
 							if (err) return cb({'status': 'failure', 'message': 'Something went wrong'});
 							stations.updateStation(stationId, (err, station) => {

+ 164 - 54
backend/logic/actions/users.js

@@ -10,6 +10,7 @@ const cache = require('../cache');
 const utils = require('../utils');
 const hooks = require('./hooks');
 const sha256 = require('sha256');
+const logger = require('../logger');
 
 cache.sub('user.updateUsername', user => {
 	utils.socketsFromUser(user._id, sockets => {
@@ -21,6 +22,14 @@ cache.sub('user.updateUsername', user => {
 
 module.exports = {
 
+	/**
+	 * Logs user in
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} identifier - the email of the user
+	 * @param {String} password - the plaintext of the user
+	 * @param {Function} cb - gets called with the result
+	 */
 	login: (session, identifier, password, cb) => {
 
 		identifier = identifier.toLowerCase();
@@ -29,7 +38,7 @@ module.exports = {
 
 			// check if a user with the requested identifier exists
 			(next) => db.models.user.findOne({
-				$or: [{ 'username': identifier }, { 'email.address': identifier }]
+				$or: [{ 'email.address': identifier }]
 			}, next),
 
 			// if the user doesn't exist, respond with a failure
@@ -40,41 +49,43 @@ module.exports = {
 				bcrypt.compare(sha256(password), user.services.password.password, (err, match) => {
 
 					if (err) return next(err);
+					if (!match) return next('Incorrect password');
 
 					// if the passwords match
-					if (match) {
-
-						// store the session in the cache
-						let sessionId = utils.guid();
-						cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
-							if (!err) {
-								//TODO See if it is necessary to add new SID to socket.
-								next(null, { status: 'success', message: 'Login successful', user, SID: sessionId });
-							} else {
-								next(null, { status: 'failure', message: 'Something went wrong' });
-							}
-						});
-					}
-					else {
-						next(null, { status: 'failure', message: 'Incorrect password' });
-					}
+
+					// store the session in the cache
+					let sessionId = utils.guid();
+					cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
+						if (err) return next(err);
+						next(sessionId);
+					});
 				});
 			}
 
-		], (err, payload) => {
-
-			// log this error somewhere
+		], (err, sessionId) => {
 			if (err && err !== true) {
-				if (typeof err === "string") return cb({ status: 'error', message: err });
-				else if (err.message) return cb({ status: 'error', message: err.message });
-				else return cb({ status: 'error', message: 'An error occurred.' });
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("USER_PASSWORD_LOGIN", "ERROR", "Login failed with password for user " + identifier + '. "' + error + '"');
+				return cb({ status: 'failure', message: error });
 			}
-
-			cb(payload);
+			logger.log("USER_PASSWORD_LOGIN", "SUCCESS", "Login successful with password for user " + identifier);
+			cb({ status: 'success', message: 'Login successful', user: {}, SID: sessionId });
 		});
 
 	},
 
+	/**
+	 * Registers a new user
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} username - the username for the new user
+	 * @param {String} email - the email for the new user
+	 * @param {String} password - the plaintext password for the new user
+	 * @param {Object} recaptcha - the recaptcha data
+	 * @param {Function} cb - gets called with the result
+	 */
 	register: function(session, username, email, password, recaptcha, cb) {
 		email = email.toLowerCase();
 		async.waterfall([
@@ -138,51 +149,79 @@ module.exports = {
 			// respond with the new user
 			(newUser, next) => {
 				//TODO Send verification email
-				next(null, { status: 'success', user: newUser })
+				next();
 			}
 
-		], (err, payload) => {
-			// log this error somewhere
+		], (err) => {
 			if (err && err !== true) {
-				if (typeof err === "string") return cb({ status: 'error', message: err });
-				else if (err.message) return cb({ status: 'error', message: err.message });
-				else return cb({ status: 'error', message: 'An error occurred.' });
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("USER_PASSWORD_REGISTER", "ERROR", "Register failed with password for user. " + '"' + error + '"');
+				cb({status: 'failure', message: error});
 			} else {
 				module.exports.login(session, email, password, (result) => {
 					let obj = {status: 'success', message: 'Successfully registered.'};
 					if (result.status === 'success') {
 						obj.SID = result.SID;
 					}
-					cb(obj);
+					logger.log("USER_PASSWORD_REGISTER", "SUCCESS", "Register successful with password for user '" + username + "'.");
+					cb({status: 'success', message: 'Successfully registered.'});
 				});
 			}
 		});
 
 	},
 
+	/**
+	 * Logs out a user
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
 	logout: (session, cb) => {
 
 		cache.hget('sessions', session.sessionId, (err, session) => {
-			if (err || !session) return cb({ 'status': 'failure', message: 'Something went wrong while logging you out.' });
+			if (err || !session) {
+				//TODO Properly return err message
+				logger.log("USER_LOGOUT", "ERROR", "Logout failed. Couldn't get session.");
+				return cb({ 'status': 'failure', message: 'Something went wrong while logging you out.' });
+			}
 
 			cache.hdel('sessions', session.sessionId, (err) => {
-				if (err) return cb({ 'status': 'failure', message: 'Something went wrong while logging you out.' });
+				if (err) {
+					logger.log("USER_LOGOUT", "ERROR", "Logout failed. Failed deleting session from cache.");
+					return cb({ 'status': 'failure', message: 'Something went wrong while logging you out.' });
+				}
+				logger.log("USER_LOGOUT", "SUCCESS", "Logout successful.");
 				return cb({ 'status': 'success', message: 'You have been successfully logged out.' });
 			});
 		});
 
 	},
 
+	/**
+	 * Gets user object from username (only a few properties)
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} username - the username of the user we are trying to find
+	 * @param {Function} cb - gets called with the result
+	 */
 	findByUsername: (session, username, cb) => {
 		db.models.user.find({ username }, (err, account) => {
-			if (err) throw err;
+			if (err) {
+				logger.log("FIND_BY_USERNAME", "ERROR", "Find by username failed for username '" + username + "'. Mongo error.");
+				throw err;
+			}
 			else if (account.length == 0) {
+				logger.log("FIND_BY_USERNAME", "ERROR", "User not found for username '" + username + "'.");
 				return cb({
 					status: 'error',
 					message: 'Username cannot be found'
 				});
 			} else {
 				account = account[0];
+				logger.log("FIND_BY_USERNAME", "SUCCESS", "User found for username '" + username + "'.");
 				return cb({
 					status: 'success',
 					data: {
@@ -202,12 +241,28 @@ module.exports = {
 	},
 
 	//TODO Fix security issues
+	/**
+	 * Gets user info from session
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {Function} cb - gets called with the result
+	 */
 	findBySession: (session, cb) => {
 		cache.hget('sessions', session.sessionId, (err, session) => {
-			if (err) return cb({ 'status': 'error', message: err });
-			if (!session) return cb({ 'status': 'error', message: 'You are not logged in' });
+			if (err) {
+				logger.log("FIND_BY_SESSION", "ERROR", "Failed getting session. Redis error. '" + err + "'.");
+				return cb({ 'status': 'error', message: err.message });
+			}
+			if (!session) {
+				logger.log("FIND_BY_SESSION", "ERROR", "Session not found. Not logged in.");
+				return cb({ 'status': 'error', message: 'You are not logged in' });
+			}
 			db.models.user.findOne({ _id: session.userId }, {username: 1, "email.address": 1}, (err, user) => {
-				if (err) { throw err; } else if (user) {
+				if (err) {
+					logger.log("FIND_BY_SESSION", "ERROR", "User not found. Failed getting user. Mongo error.");
+					throw err;
+				} else if (user) {
+					logger.log("FIND_BY_SESSION", "SUCCESS", "User found. '" + user.username + "'.");
 					return cb({
 						status: 'success',
 						data: user
@@ -218,54 +273,109 @@ module.exports = {
 
 	},
 
+	/**
+	 * Updates a user's username
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} newUsername - the new username
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	updateUsername: hooks.loginRequired((session, newUsername, cb, userId) => {
 		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (err) console.error(err);
-			if (!user) return cb({ status: 'error', message: 'User not found' });
-			if (user.username !== newUsername) {
+			if (err) {
+				logger.log("UPDATE_USERNAME", "ERROR", `Failed getting user. Mongo error. '${err.message}'.`);
+				return cb({ status: 'error', message: 'Something went wrong.' });
+			} else if (!user) {
+				logger.log("UPDATE_USERNAME", "ERROR", `User not found. '${userId}'`);
+				return cb({ status: 'error', message: 'User not found' });
+			} else if (user.username !== newUsername) {
 				if (user.username.toLowerCase() !== newUsername.toLowerCase()) {
 					db.models.user.findOne({ username: new RegExp(`^${newUsername}$`, 'i') }, (err, _user) => {
-						if (err) return cb({ status: 'error', message: err.message });
-						if (_user) return cb({ status: 'failure', message: 'That username is already in use' });
+						if (err) {
+							logger.log("UPDATE_USERNAME", "ERROR", `Failed to get other user with the same username. Mongo error. '${err.message}'`);
+							return cb({ status: 'error', message: err.message });
+						}
+						if (_user) {
+							logger.log("UPDATE_USERNAME", "ERROR", `Username already in use.`);
+							return cb({ status: 'failure', message: 'That username is already in use' });
+						}
 						db.models.user.update({ _id: userId }, { $set: { username: newUsername } }, (err) => {
-							if (err) return cb({ status: 'error', message: err.message });
+							if (err) {
+								logger.log("UPDATE_USERNAME", "ERROR", `Couldn't update user. Mongo error. '${err.message}'`);
+								return cb({ status: 'error', message: err.message });
+							}
 							cache.pub('user.updateUsername', {
 								username: newUsername,
 								_id: userId
 							});
+							logger.log("UPDATE_USERNAME", "SUCCESS", `Updated username. '${userId}' '${newUsername}'`);
 							cb({ status: 'success', message: 'Username updated successfully' });
 						});
 					});
 				} else {
 					db.models.user.update({ _id: userId }, { $set: { username: newUsername } }, (err) => {
-						if (err) return cb({ status: 'error', message: err.message });
+						if (err) {
+							logger.log("UPDATE_USERNAME", "ERROR", `Couldn't update user. Mongo error. '${err.message}'`);
+							return cb({ status: 'error', message: err.message });
+						}
 						cache.pub('user.updateUsername', {
 							username: newUsername,
 							_id: userId
 						});
+						logger.log("UPDATE_USERNAME", "SUCCESS", `Updated username. '${userId}' '${newUsername}'`);
 						cb({ status: 'success', message: 'Username updated successfully' });
 					});
 				}
-			} else cb({ status: 'error', message: 'Your new username cannot be the same as your old username' });
+			} else {
+				logger.log("UPDATE_USERNAME", "ERROR", `New username is the same as the old username. '${newUsername}'`);
+				cb({ status: 'error', message: 'Your new username cannot be the same as your old username' });
+			}
 		});
 	}),
 
+	/**
+	 * Updates a user's email
+	 *
+	 * @param {Object} session - the session object automatically added by socket.io
+	 * @param {String} newEmail - the new email
+	 * @param {Function} cb - gets called with the result
+	 * @param {String} userId - the userId automatically added by hooks
+	 */
 	updateEmail: hooks.loginRequired((session, newEmail, cb, userId) => {
 		newEmail = newEmail.toLowerCase();
 		db.models.user.findOne({ _id: userId }, (err, user) => {
-			if (err) console.error(err);
-			if (!user) return cb({ status: 'error', message: 'User not found.' });
-			if (user.email.address !== newEmail) {
+			if (err) {
+				logger.log("UPDATE_EMAIL", "ERROR", `Failed getting user. Mongo error. '${err.message}'.`);
+				return cb({ status: 'error', message: 'Something went wrong.' });
+			} else if (!user) {
+				logger.log("UPDATE_EMAIL", "ERROR", `User not found. '${userId}'`);
+				return cb({ status: 'error', message: 'User not found.' });
+			} else if (user.email.address !== newEmail) {
 				db.models.user.findOne({"email.address": newEmail}, (err, _user) => {
-					if (err) return cb({ status: 'error', message: err.message });
-					if (_user) return cb({ status: 'failure', message: 'That email is already in use.' });
+					if (err) {
+						logger.log("UPDATE_EMAIL", "ERROR", `Couldn't get other user with new email. Mongo error. '${newEmail}'`);
+						return cb({ status: 'error', message: err.message });
+					} else if (_user) {
+						logger.log("UPDATE_EMAIL", "ERROR", `Email already in use.`);
+						return cb({ status: 'failure', message: 'That email is already in use.' });
+					}
 					db.models.user.update({_id: userId}, {$set: {"email.address": newEmail}}, (err) => {
-						if (err) return cb({ status: 'error', message: err.message });
+						if (err) {
+							logger.log("UPDATE_EMAIL", "ERROR", `Couldn't update user. Mongo error. ${err.message}`);
+							return cb({ status: 'error', message: err.message });
+						}
+						logger.log("UPDATE_EMAIL", "SUCCESS", `Updated email. '${userId}' ${newEmail}'`);
 						cb({ status: 'success', message: 'Email updated successfully.' });
 					});
 				});
-			} else cb({ status: 'error', message: 'Email has not changed. Your new email cannot be the same as your old email.' });
+			} else {
+				logger.log("UPDATE_EMAIL", "ERROR", `New email is the same as the old email.`);
+				cb({
+					status: 'error',
+					message: 'Email has not changed. Your new email cannot be the same as your old email.'
+				});
+			}
 		});
 	})
-
 };

+ 27 - 0
backend/logic/api.js

@@ -0,0 +1,27 @@
+module.exports = {
+	init: (cb) => {
+		const { app } = require('./app.js');
+		const actions = require('./actions');
+
+		app.get('/', (req, res) => {
+			res.json({
+				status: 'success',
+				message: 'Coming Soon'
+			});
+		});
+
+		Object.keys(actions).forEach((namespace) => {
+			Object.keys(actions[namespace]).forEach((action) => {
+				let name = `/${namespace}/${action}`;
+
+				app.get(name, (req, res) => {
+					actions[namespace][action](null, (result) => {
+						if (typeof cb === 'function') return res.json(result);
+					});
+				});
+			})
+		});
+
+		cb();
+	}
+}

+ 7 - 6
backend/logic/app.js

@@ -1,18 +1,17 @@
 'use strict';
 
-// This file contains all the logic for Express
-
 const express = require('express');
 const bodyParser = require('body-parser');
 const cors = require('cors');
 const config = require('config');
 const request = require('request');
-const cache = require('./cache');
-const db = require('./db');
-let utils;
 const OAuth2 = require('oauth').OAuth2;
 
+const api = require('./api');
+const cache = require('./cache');
+const db = require('./db');
 
+let utils;
 
 const lib = {
 
@@ -25,7 +24,7 @@ const lib = {
 
 		let app = lib.app = express();
 
-		lib.server = app.listen(8080);
+		lib.server = app.listen(config.get('serverPort'));
 
 		app.use(bodyParser.json());
 		app.use(bodyParser.urlencoded({ extended: true }));
@@ -134,6 +133,8 @@ const lib = {
 		});
 
 		cb();
+
+
 	}
 };
 

+ 8 - 2
backend/logic/cache/index.js

@@ -69,7 +69,7 @@ const lib = {
 		lib.client.hset(table, key, value, err => {
 			if (cb !== undefined) {
 				if (err) return cb(err);
-				cb(null);
+				cb(null, JSON.parse(value));
 			}
 		});
 	},
@@ -83,9 +83,13 @@ const lib = {
 	 * @param {Boolean} [parseJson=true] - attempt to parse returned data as JSON
 	 */
 	hget: (table, key, cb, parseJson = true) => {
+		if (!key || !table) return cb(null, null);
 		lib.client.hget(table, key, (err, value) => {
 			if (err) return typeof cb === 'function' ? cb(err) : null;
-			if (parseJson) try { value = JSON.parse(value); } catch (e) {}
+			if (parseJson) try {
+				value = JSON.parse(value);
+			} catch (e) {
+			}
 			if (typeof cb === 'function') cb(null, value);
 		});
 	},
@@ -98,6 +102,7 @@ const lib = {
 	 * @param {Function} cb - gets called when the value has been deleted from Redis or when it returned an error
 	 */
 	hdel: (table, key, cb) => {
+		if (!key || !table) return cb(null, null);
 		lib.client.hdel(table, key, (err) => {
 			if (err) return typeof cb === 'function' ? cb(err) : null;
 			if (typeof cb === 'function') cb(null);
@@ -112,6 +117,7 @@ const lib = {
 	 * @param {Boolean} [parseJson=true] - attempts to parse all values as JSON by default
 	 */
 	hgetall: (table, cb, parseJson = true) => {
+		if (!table) return cb(null, null);
 		lib.client.hgetall(table, (err, obj) => {
 			if (err) return typeof cb === 'function' ? cb(err) : null;
 			if (parseJson && obj) Object.keys(obj).forEach((key) => { try { obj[key] = JSON.parse(obj[key]); } catch (e) {} });

+ 19 - 0
backend/logic/logger.js

@@ -0,0 +1,19 @@
+'use strict';
+
+let twoDigits = (num) => {
+	return (num < 10) ? '0' + num : num;
+}
+
+module.exports = {
+	log: function(type, level, message) {
+		let time = new Date();
+		let year = time.getFullYear();
+		let month = time.getMonth() + 1;
+		let day = time.getDate();
+		let hour = time.getHours();
+		let minute = time.getMinutes();
+		let second = time.getSeconds();
+		let timeString = year + '-' + twoDigits(month) + '-' + twoDigits(day) + ' ' + twoDigits(hour) + ':' + twoDigits(minute) + ':' + twoDigits(second);
+		console.log(timeString, level, type, "-", message);
+	}
+};

+ 97 - 17
backend/logic/playlists.js

@@ -6,33 +6,87 @@ const async = require('async');
 
 module.exports = {
 
+	/**
+	 * Initializes the playlists module, and exits if it is unsuccessful
+	 *
+	 * @param {Function} cb - gets called once we're done initializing
+	 */
 	init: cb => {
-		db.models.playlist.find({}, (err, playlists) => {
-			if (!err) {
-				playlists.forEach((playlist) => {
-					cache.hset('playlists', playlist._id, cache.schemas.playlist(playlist));
-				});
+		async.waterfall([
+			(next) => {
+				cache.hgetall('playlists', next);
+			},
+
+			(playlists, next) => {
+				if (!playlists) return next();
+				let playlistIds = Object.keys(playlists);
+				async.each(playlistIds, (playlistId, next) => {
+					db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
+						if (err) next(err);
+						else if (!playlist) {
+							cache.hdel('playlists', playlistId, next);
+						}
+						else next();
+					});
+				}, next);
+			},
+
+			(next) => {
+				db.models.playlist.find({}, next);
+			},
+
+			(playlists, next) => {
+				async.each(playlists, (playlist, next) => {
+					cache.hset('playlists', playlist._id, cache.schemas.playlist(playlist), next);
+				}, next);
+			}
+		], (err) => {
+			if (err) {
+				console.log(`FAILED TO INITIALIZE PLAYLISTS. ABORTING. "${err.message}"`);
+				process.exit();
+			} else {
 				cb();
 			}
 		});
 	},
 
-	getPlaylist: (_id, cb) => {
+	/**
+	 * Gets a playlist by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+	 *
+	 * @param {String} playlistId - the id of the playlist we are trying to get
+	 * @param {Function} cb - gets called once we're done initializing
+	 */
+	getPlaylist: (playlistId, cb) => {
 		async.waterfall([
+			(next) => {
+				cache.hgetall('playlists', next);
+			},
+
+			(playlists, next) => {
+				let playlistIds = Object.keys(playlists);
+				async.each(playlistIds, (playlistId, next) => {
+					db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
+						if (err) next(err);
+						else if (!playlist) {
+							cache.hdel('playlists', playlistId, next);
+						}
+						else next();
+					});
+				}, next);
+			},
 
 			(next) => {
-				cache.hget('playlists', _id, next);
+				cache.hget('playlists', playlistId, next);
 			},
 
 			(playlist, next) => {
 				if (playlist) return next(true, playlist);
-				db.models.playlist.findOne({ _id }, next);
+				db.models.playlist.findOne({ _id: playlistId }, next);
 			},
 
 			(playlist, next) => {
 				if (playlist) {
-					cache.hset('playlists', _id, playlist);
-					next(true, playlist);
+					cache.hset('playlists', playlistId, playlist, next);
 				} else next('Playlist not found');
 			},
 
@@ -42,25 +96,51 @@ module.exports = {
 		});
 	},
 
-	updatePlaylist: (_id, cb) => {
+	/**
+	 * Gets a playlist from id from Mongo and updates the cache with it
+	 *
+	 * @param {String} playlistId - the id of the playlist we are trying to update
+	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
+	 */
+	updatePlaylist: (playlistId, cb) => {
 		async.waterfall([
 
 			(next) => {
-				db.models.playlist.findOne({ _id }, next);
+				db.models.playlist.findOne({ _id: playlistId }, next);
 			},
 
 			(playlist, next) => {
 				if (!playlist) return next('Playlist not found');
-				cache.hset('playlists', _id, playlist, (err) => {
-					if (err) return next(err);
-					return next(null, playlist);
-				});
+				cache.hset('playlists', playlistId, playlist, next);
 			}
 
 		], (err, playlist) => {
 			if (err && err !== true) cb(err);
 			cb(null, playlist);
 		});
-	}
+	},
+
+	/**
+	 * Deletes playlist from id from Mongo and cache
+	 *
+	 * @param {String} playlistId - the id of the playlist we are trying to delete
+	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
+	 */
+	deletePlaylist: (playlistId, cb) => {
+		async.waterfall([
+
+			(next) => {
+				db.models.playlist.remove({ _id: playlistId }, next);
+			},
 
+			(res, next) => {
+				cache.hdel('playlists', playlistId, next);
+			}
+
+		], (err) => {
+			if (err && err !== true) cb(err);
+
+			cb(null);
+		});
+	}
 };

+ 78 - 16
backend/logic/songs.js

@@ -8,35 +8,68 @@ const async = require('async');
 
 module.exports = {
 
+	/**
+	 * Initializes the songs module, and exits if it is unsuccessful
+	 *
+	 * @param {Function} cb - gets called once we're done initializing
+	 */
 	init: cb => {
-		db.models.song.find({}, (err, songs) => {
-			if (!err) {
-				songs.forEach((song) => {
-					cache.hset('songs', song._id, cache.schemas.song(song));
-				});
-				cb();
+		async.waterfall([
+			(next) => {
+				cache.hgetall('songs', next);
+			},
+
+			(songs, next) => {
+				if (!songs) return next();
+				let songIds = Object.keys(songs);
+				async.each(songIds, (songId, next) => {
+					db.models.song.findOne({ _id: songId }, (err, song) => {
+						if (err) next(err);
+						else if (!song) cache.hdel('songs', songId, next);
+						else next();
+					});
+				}, next);
+			},
+
+			(next) => {
+				db.models.song.find({}, next);
+			},
+
+			(songs, next) => {
+				async.each(songs, (song, next) => {
+					cache.hset('songs', song._id, cache.schemas.song(song), next);
+				}, next);
 			}
+		], (err) => {
+			if (err) {
+				console.log(`FAILED TO INITIALIZE SONGS. ABORTING. "${err.message}"`);
+				process.exit();
+			} else cb();
 		});
 	},
 
-	// Attempts to get the song from Reids. If it's not in Redis, get it from Mongo and add it to Redis.
-	getSong: function(_id, cb) {
+	/**
+	 * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
+	 *
+	 * @param {String} songId - the id of the song we are trying to get
+	 * @param {Function} cb - gets called once we're done initializing
+	 */
+	getSong: function(songId, cb) {
 		async.waterfall([
 
 			(next) => {
-				cache.hget('songs', _id, next);
+				cache.hget('songs', songId, next);
 			},
 
 			(song, next) => {
 				if (song) return next(true, song);
 
-				db.models.song.findOne({ _id }, next);
+				db.models.song.findOne({ _id: songId }, next);
 			},
 
 			(song, next) => {
 				if (song) {
-					cache.hset('songs', _id, song);
-					next(true, song);
+					cache.hset('songs', songId, song, next);
 				} else next('Song not found.');
 			},
 
@@ -47,17 +80,23 @@ module.exports = {
 		});
 	},
 
-	updateSong: (_id, cb) => {
+	/**
+	 * Gets a song from id from Mongo and updates the cache with it
+	 *
+	 * @param {String} songId - the id of the song we are trying to update
+	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
+	 */
+	updateSong: (songId, cb) => {
 		async.waterfall([
 
 			(next) => {
-				db.models.song.findOne({ _id }, next);
+				db.models.song.findOne({ _id: songId }, next);
 			},
 
 			(song, next) => {
 				if (!song) return next('Song not found.');
 
-				cache.hset('songs', _id, song, (err) => {
+				cache.hset('songs', songId, song, (err) => {
 					if (err) return next(err);
 					return next(null, song);
 				});
@@ -68,6 +107,29 @@ module.exports = {
 
 			cb(null, song);
 		});
-	}
+	},
 
+	/**
+	 * Deletes song from id from Mongo and cache
+	 *
+	 * @param {String} songId - the id of the song we are trying to delete
+	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
+	 */
+	deleteSong: (songId, cb) => {
+		async.waterfall([
+
+			(next) => {
+				db.models.song.remove({ _id: songId }, next);
+			},
+
+			(next) => {
+				cache.hdel('songs', songId, next);
+			}
+
+		], (err) => {
+			if (err && err !== true) cb(err);
+
+			cb(null);
+		});
+	}
 };

+ 105 - 51
backend/logic/stations.js

@@ -4,10 +4,10 @@ const cache = require('./cache');
 const db = require('./db');
 const io = require('./io');
 const utils = require('./utils');
+const logger = require('./logger');
 const songs = require('./songs');
 const notifications = require('./notifications');
 const async = require('async');
-let skipTimeout = null;
 
 //TEMP
 cache.sub('station.pause', (stationId) => {
@@ -37,61 +37,112 @@ cache.sub('station.newOfficialPlaylist', (stationId) => {
 module.exports = {
 
 	init: function(cb) {
-		let _this = this;
-		//TODO Add async waterfall
-		db.models.station.find({}, (err, stations) => {
-			if (!err) {
-				stations.forEach((station) => {
-					console.info("Initializing Station: " + station._id);
-					_this.initializeStation(station._id);
-				});
-				cb();
+		async.waterfall([
+			(next) => {
+				cache.hgetall('stations', next);
+			},
+
+			(stations, next) => {
+				if (!stations) return next();
+				let stationIds = Object.keys(stations);
+				async.each(stationIds, (stationId, next) => {
+					db.models.station.findOne({_id: stationId}, (err, station) => {
+						if (err) next(err);
+						else if (!station) {
+							cache.hdel('stations', stationId, next);
+						} else next();
+					});
+				}, next);
+			},
+
+			(next) => {
+				db.models.station.find({}, next);
+			},
+
+			(stations, next) => {
+				async.each(stations, (station, next) => {
+					async.waterfall([
+						(next) => {
+							cache.hset('stations', station._id, cache.schemas.station(station), next);
+						},
+
+						(station, next) => {
+							this.initializeStation(station._id, next);
+						}
+					], (err) => {
+						next(err);
+					});
+				}, next);
 			}
+		], (err) => {
+			if (err) {
+				console.log(`FAILED TO INITIALIZE STATIONS. ABORTING. "${err.message}"`);
+				process.exit();
+			} else cb();
 		});
 	},
 
 	initializeStation: function(stationId, cb) {
-		if (typeof cb !== 'function') cb = ()=>{};
-		let _this = this;
-		_this.getStation(stationId, (err, station) => {
-			if (!err) {
-				if (station) {
+		if (typeof cb !== 'function') cb = () => {};
+		async.waterfall([
+			(next) => {
+				this.getStation(stationId, next);
+			},
+
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				if (station.type === 'official') {
 					cache.hget('officialPlaylists', stationId, (err, playlist) => {
-						if (err || !playlist) {
-							_this.calculateOfficialPlaylistList(stationId, station.playlist, ()=>{});
-						}
+						if (err) return next(err);
+						if (playlist) return next(null, station, null);
+						next(null, station, playlist);
 					});
+				} else next(null, station, null);
+			},
 
-					let notification = notifications.subscribe(`stations.nextSong?id=${station._id}`, _this.skipStation(station._id), true);
-					if (!station.paused ) {
-						/*if (!station.startedAt) {
-							station.startedAt = Date.now();
-							station.timePaused = 0;
-							cache.hset('stations', stationId, station);
-						}*/
-						if (station.currentSong) {
-							let timeLeft = ((station.currentSong.duration * 1000) - (Date.now() - station.startedAt - station.timePaused));
-							if (isNaN(timeLeft)) timeLeft = -1;
-							timeLeft = Math.floor(timeLeft);
-							if (station.currentSong.duration * 1000 < timeLeft || timeLeft < 0) {
-								this.skipStation(station._id)((err, station) => {
-									cb(err, station);
-								});
-							} else {
-								notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft);
-								cb(null, station);
-							}
-						} else {
-							_this.skipStation(station._id)((err, station) => {
-								cb(err, station);
-							});
-						}
+			(station, playlist, next) => {
+				if (playlist) {
+					this.calculateOfficialPlaylistList(stationId, station.playlist, () => {
+						next(station);
+					});
+				} else next(station);
+			},
+
+			(station, next) => {
+				if (!station.paused) next(true, station);
+				else {
+					notifications.unschedule(`stations.nextSong?id${station._id}`);
+					next(true, station);
+				}
+			},
+
+			(station, next) => {
+				if (station.currentSong) {
+					let timeLeft = ((station.currentSong.duration * 1000) - (Date.now() - station.startedAt - station.timePaused));
+					if (isNaN(timeLeft)) timeLeft = -1;
+					timeLeft = Math.floor(timeLeft);
+					if (station.currentSong.duration * 1000 < timeLeft || timeLeft < 0) {
+						this.skipStation(station._id)(next);
 					} else {
-						notifications.unschedule(`stations.nextSong?id${station._id}`);
+						notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft, (err) => {
+							next(err, station);
+						});
 						cb(null, station);
 					}
-				} else cb("Station not found");
-			} else cb(err);
+				} else {
+					this.skipStation(station._id)(next);
+				}
+			}
+		], (station, err) => {
+			if (err && err !== true) {
+				let error = 'An error occurred.';
+				if (typeof err === "string") error = err;
+				else if (err.message) error = err.message;
+				logger.log("INITIALIZE_STATION", "ERROR", `Station initialization failed for "${stationId}". "${error}"`);
+			}
+
+			logger.log("INITIALIZE_STATION", "SUCCESS", `Station "${stationId}" initialized.`);
+			cb(err, station);
 		});
 	},
 
@@ -191,14 +242,11 @@ module.exports = {
 
 			(station, next) => {
 				if (!station) return next('Station not found');
-				cache.hset('stations', stationId, station, (err) => {
-					if (err) return next(err);
-					next(null, station);
-				});
+				cache.hset('stations', stationId, station, next);
 			}
 
 		], (err, station) => {
-			if (err && err !== true) cb(err);
+			if (err && err !== true) return cb(err);
 			cb(null, station);
 		});
 	},
@@ -438,7 +486,13 @@ module.exports = {
 									}
 								}
 							}
-
+							console.log(
+								Date.now(),
+								(station) ? station._id : "STATION_NULL",
+								station.currentSong !== null && station.currentSong._id !== undefined,
+								station.currentSong !== null,
+								(station.currentSong) ? station.currentSong._id !== undefined : "CURRENTSONG_NULL"
+							);
 							if (station.currentSong !== null && station.currentSong._id !== undefined) {
 								utils.socketsJoinSongRoom(utils.getRoomSockets(`station.${station._id}`), `song.${station.currentSong._id}`);
 								if (!station.paused) {

+ 1 - 7
frontend/App.vue

@@ -6,7 +6,6 @@
 		<what-is-new></what-is-new>
 		<login-modal v-if='isLoginActive'></login-modal>
 		<register-modal v-if='isRegisterActive'></register-modal>
-		<create-community-station v-if='isCreateCommunityStationActive'></create-community-station>
 	</div>
 </template>
 
@@ -16,7 +15,6 @@
 	import WhatIsNew from './components/Modals/WhatIsNew.vue';
 	import LoginModal from './components/Modals/Login.vue';
 	import RegisterModal from './components/Modals/Register.vue';
-	import CreateCommunityStation from './components/Modals/CreateCommunityStation.vue';
 	import auth from './auth';
 	import io from './io';
 
@@ -39,7 +37,6 @@
 				userId: '',
 				isRegisterActive: false,
 				isLoginActive: false,
-				isCreateCommunityStationActive: false,
 				serverDomain: '',
 				socketConnected: true
 			}
@@ -126,16 +123,13 @@
 					case 'login':
 						this.isLoginActive = !this.isLoginActive;
 						break;
-					case 'createCommunityStation':
-						this.isCreateCommunityStationActive = !this.isCreateCommunityStationActive;
-						break;
 				}
 			},
 			'closeModal': function() {
 				this.$broadcast('closeModal');
 			}
 		},
-		components: { Toast, WhatIsNew, LoginModal, RegisterModal, CreateCommunityStation }
+		components: { Toast, WhatIsNew, LoginModal, RegisterModal }
 	}
 </script>
 

+ 227 - 0
frontend/components/Admin/News.vue

@@ -0,0 +1,227 @@
+<template>
+	<div class='container'>
+		<table class='table is-striped'>
+			<thead>
+				<tr>
+					<td>Title</td>
+					<td>Description</td>
+					<td>Bugs</td>
+					<td>Features</td>
+					<td>Improvements</td>
+					<td>Upcoming</td>
+					<td>Options</td>
+				</tr>
+			</thead>
+			<tbody>
+				<tr v-for='(index, news) in news' track-by='$index'>
+					<td>
+						<strong>{{ news.title }}</strong>
+					</td>
+					<td>{{ news.description }}</td>
+					<td>{{ news.bugs.join(', ') }}</td>
+					<td>{{ news.features.join(', ') }}</td>
+					<td>{{ news.improvements.join(', ') }}</td>
+					<td>{{ news.upcoming.join(', ') }}</td>
+					<td>
+						<button class='button is-primary' @click='editNews(news)'>Edit</button>
+						<button class='button is-danger' @click='removeNews(news)'>Remove</button>
+					</td>
+				</tr>
+			</tbody>
+		</table>
+
+		<div class='card is-fullwidth'>
+			<header class='card-header'>
+				<p class='card-header-title'>Create News</p>
+			</header>
+			<div class='card-content'>
+				<div class='content'>
+
+					<label class='label'>Title & Description</label>
+					<div class='control is-horizontal'>
+						<div class='control is-grouped'>
+							<p class='control is-expanded'>
+								<input class='input' type='text' placeholder='Title' v-model='creating.title'>
+							</p>
+							<p class='control is-expanded'>
+								<input class='input' type='text' placeholder='Short description' v-model='creating.description'>
+							</p>
+						</div>
+					</div>
+
+					<div class="columns">
+						<div class="column">
+							<label class='label'>Bugs</label>
+							<p class='control has-addons'>
+								<input class='input' id='new-bugs' type='text' placeholder='Bug' v-on:keyup.enter='addChange("bugs")'>
+								<a class='button is-info' href='#' @click='addChange("bugs")'>Add</a>
+							</p>
+							<span class='tag is-info' v-for='(index, bug) in creating.bugs' track-by='$index'>
+								{{ bug }}
+								<button class='delete is-info' @click='removeChange("bugs", index)'></button>
+							</span>
+						</div>
+						<div class="column">
+							<label class='label'>Features</label>
+							<p class='control has-addons'>
+								<input class='input' id='new-features' type='text' placeholder='Feature' v-on:keyup.enter='addChange("features")'>
+								<a class='button is-info' href='#' @click='addChange("features")'>Add</a>
+							</p>
+							<span class='tag is-info' v-for='(index, feature) in creating.features' track-by='$index'>
+								{{ feature }}
+								<button class='delete is-info' @click='removeChange("features", index)'></button>
+							</span>
+						</div>
+					</div>
+
+					<div class="columns">
+						<div class="column">
+							<label class='label'>Improvements</label>
+							<p class='control has-addons'>
+								<input class='input' id='new-improvements' type='text' placeholder='Improvement' v-on:keyup.enter='addChange("improvements")'>
+								<a class='button is-info' href='#' @click='addChange("improvements")'>Add</a>
+							</p>
+							<span class='tag is-info' v-for='(index, improvement) in creating.improvements' track-by='$index'>
+								{{ improvement }}
+								<button class='delete is-info' @click='removeChange("improvements", index)'></button>
+							</span>
+						</div>
+						<div class="column">
+							<label class='label'>Upcoming</label>
+							<p class='control has-addons'>
+								<input class='input' id='new-upcoming' type='text' placeholder='Upcoming' v-on:keyup.enter='addChange("upcoming")'>
+								<a class='button is-info' href='#' @click='addChange("upcoming")'>Add</a>
+							</p>
+							<span class='tag is-info' v-for='(index, upcoming) in creating.upcoming' track-by='$index'>
+								{{ upcoming }}
+								<button class='delete is-info' @click='removeChange("upcoming", index)'></button>
+							</span>
+						</div>
+					</div>
+
+				</div>
+			</div>
+			<footer class='card-footer'>
+				<a class='card-footer-item' @click='createNews()' href='#'>Create</a>
+			</footer>
+		</div>
+	</div>
+
+	<edit-news v-if='modals.editNews'></edit-news>
+</template>
+
+<script>
+	import { Toast } from 'vue-roaster';
+	import io from '../../io';
+
+	import EditNews from '../Modals/EditNews.vue';
+
+	export default {
+		components: { EditNews },
+		data() {
+			return {
+				modals: { editNews: false },
+				news: [],
+				creating: {
+					title: '',
+					description: '',
+					bugs: [],
+					features: [],
+					improvements: [],
+					upcoming: []
+				},
+				editing: {}
+			}
+		},
+		methods: {
+			toggleModal: function () {
+				this.modals.editNews = !this.modals.editNews;
+			},
+			createNews: function () {
+				let _this = this;
+
+				let { creating: { bugs, features, improvements, upcoming } } = this;
+
+				if (this.creating.title === '') return Toast.methods.addToast('Field (Title) cannot be empty', 3000);
+				if (this.creating.description === '') return Toast.methods.addToast('Field (Description) cannot be empty', 3000);
+				if (
+					bugs.length <= 0 && features.length <= 0 && 
+					improvements.length <= 0 && upcoming.length <= 0
+				) return Toast.methods.addToast('You must have at least one News Item', 3000);
+
+				_this.socket.emit('news.create', _this.creating, result => {
+					Toast.methods.addToast(result.message, 4000);
+					if (result.status == 'success') _this.creating = {
+						title: '',
+						description: '',
+						bugs: [],
+						features: [],
+						improvements: [],
+						upcoming: []
+					}
+				});
+			},
+			removeNews: function (news) {
+				this.socket.emit('news.remove', news, res => {
+					Toast.methods.addToast(res.message, 8000);
+				});
+			},
+			editNews: function (news) {
+				this.editing = news;
+				this.toggleModal();
+			},
+			updateNews: function (close) {
+				let _this = this;
+				this.socket.emit('news.update', _this.editing._id, _this.editing, res => {
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === 'success') {
+						if (close) _this.toggleModal();
+					}
+				});
+			},
+			addChange: function (type) {
+				let change = $(`#new-${type}`).val().trim();
+
+				if (this.creating[type].indexOf(change) !== -1) return Toast.methods.addToast(`Tag already exists`, 3000);
+
+				if (change) this.creating[type].push(change);
+				else Toast.methods.addToast(`${type} cannot be empty`, 3000);
+			},
+			removeChange: function (type, index) {
+				this.creating[type].splice(index, 1);
+			},
+			init: function () {
+				this.socket.emit('apis.joinAdminRoom', 'news', data => {});
+			}
+		},
+		ready: function () {
+			let _this = this;
+			io.getSocket((socket) => {
+				_this.socket = socket;
+				_this.socket.emit('news.index', result => {
+					_this.news = result.data;
+				});
+				_this.socket.on('event:admin.news.created', news => {
+					_this.news.unshift(news);
+				});
+				_this.socket.on('event:admin.news.removed', news => {
+					_this.news = _this.news.filter(item => item._id !== news._id);
+				});
+				if (_this.socket.connected) _this.init();
+				io.onConnect(() => {
+					_this.init();
+				});
+			});
+		}
+	}
+</script>
+
+<style lang='scss' scoped>
+	.tag:not(:last-child) { margin-right: 5px; }
+
+	td { vertical-align: middle; }
+
+	.is-info:focus { background-color: #0398db; }
+
+	.card-footer-item { color: #03A9F4; }
+</style>

+ 13 - 140
frontend/components/Admin/QueueSongs.vue

@@ -1,5 +1,7 @@
 <template>
 	<div class='container'>
+		<input type='text' class='input' v-model='searchQuery' placeholder='Search for Songs'>
+		<br /><br />
 		<table class='table is-striped'>
 			<thead>
 				<tr>
@@ -13,7 +15,7 @@
 				</tr>
 			</thead>
 			<tbody>
-				<tr v-for='(index, song) in songs' track-by='$index'>
+				<tr v-for='(index, song) in filteredSongs' track-by='$index'>
 					<td>
 						<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
 					</td>
@@ -33,7 +35,7 @@
 			</tbody>
 		</table>
 	</div>
-	<edit-song v-show='isEditActive'></edit-song>
+	<edit-song v-show='modals.editSong'></edit-song>
 </template>
 
 <script>
@@ -46,101 +48,22 @@
 		components: { EditSong },
 		data() {
 			return {
+				searchQuery: '',
 				songs: [],
-				isEditActive: false,
-				editing: {
-					index: 0,
-					song: {}
-				},
-				video: {
-					player: null,
-					paused: false,
-					playerReady: false
-				},
-				timeout: 0
+				modals: { editSong: false }
+			}
+		},
+		computed: {
+			filteredSongs: function () {
+				return this.$eval('songs | filterBy searchQuery');
 			}
 		},
 		methods: {
-			settings: function (type) {
-				let _this = this;
-				switch(type) {
-					case 'stop':
-						_this.video.player.stopVideo();
-						_this.video.paused = true;
-						break;
-					case 'pause':
-						_this.video.player.pauseVideo();
-						_this.video.paused = true;
-						break;
-					case 'play':
-						_this.video.player.playVideo();
-						_this.video.paused = false;
-						break;
-					case 'skipToLast10Secs':
-						_this.video.player.seekTo((_this.editing.song.duration - 10) + _this.editing.song.skipDuration);
-						break;
-				}
-			},
-			changeVolume: function() {
-				let local = this;
-				let volume = $("#volumeSlider").val();
-				localStorage.setItem("volume", volume);
-				local.video.player.setVolume(volume);
-				if (volume > 0) local.video.player.unMute();
-			},
 			toggleModal: function () {
-				this.isEditActive = !this.isEditActive;
-				this.settings('stop');
-			},
-			addTag: function (type) {
-				if (type == 'genres') {
-					let genre = $('#new-genre').val().toLowerCase().trim();
-					if (this.editing.song.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
-					if (genre) {
-						this.editing.song.genres.push(genre);
-						$('#new-genre').val('');
-					} else Toast.methods.addToast('Genre cannot be empty', 3000);
-				} else if (type == 'artists') {
-					let artist = $('#new-artist').val();
-					if (this.editing.song.artists.indexOf(artist) !== -1) return Toast.methods.addToast('Artist already exists', 3000);
-					if ($('#new-artist').val() !== '') {
-						this.editing.song.artists.push(artist);
-						$('#new-artist').val('');
-					} else Toast.methods.addToast('Artist cannot be empty', 3000);
-				}
-			},
-			removeTag: function (type, index) {
-				if (type == 'genres') this.editing.song.genres.splice(index, 1);
-				else if (type == 'artists') this.editing.song.artists.splice(index, 1);
+				this.modals.editSong = !this.modals.editSong;
 			},
 			edit: function (song, index) {
-				if (this.video.player) {
-					this.video.player.loadVideoById(song._id, this.editing.song.skipDuration);
-					let songCopy = {};
-					for (let n in song) {
-						songCopy[n] = song[n];
-					}
-					this.editing = { index, song: songCopy };
-					this.isEditActive = true;
-				}
-			},
-			save: function (song, close) {
-				let _this = this;
-				this.socket.emit('queueSongs.update', song._id, song, function (res) {
-					Toast.methods.addToast(res.message, 4000);
-					if (res.status === 'success') {
-						_this.songs.forEach((lSong) => {
-							if (song._id === lSong._id) {
-								for (let n in song) {
-									lSong[n] = song[n];
-								}
-							}
-						});
-					}
-					if (close) {
-						_this.toggleModal();
-					}
-				});
+				this.$broadcast('editSong', song, index, 'queueSongs');
 			},
 			add: function (song) {
 				this.socket.emit('songs.add', song, res => {
@@ -179,56 +102,6 @@
 					_this.init();
 				});
 			});
-
-			setInterval(() => {
-				if (_this.video.paused === false && _this.playerReady && _this.video.player.getCurrentTime() - _this.editing.song.skipDuration > _this.editing.song.duration) {
-					_this.video.paused = false;
-					_this.video.player.stopVideo();
-				}
-			}, 200);
-
-			this.video.player = new YT.Player('player', {
-				height: 315,
-				width: 560,
-				videoId: this.editing.song._id,
-				playerVars: { controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0 },
-				startSeconds: _this.editing.song.skipDuration,
-				events: {
-					'onReady': () => {
-						let volume = parseInt(localStorage.getItem("volume"));
-						volume = (typeof volume === "number") ? volume : 20;
-						_this.video.player.seekTo(_this.editing.song.skipDuration);
-						_this.video.player.setVolume(volume);
-						if (volume > 0) _this.video.player.unMute();
-						_this.playerReady = true;
-					},
-					'onStateChange': event => {
-						if (event.data === 1) {
-							_this.video.paused = false;
-							let youtubeDuration = _this.video.player.getDuration();
-							youtubeDuration -= _this.editing.song.skipDuration;
-							if (_this.editing.song.duration > youtubeDuration) {
-								this.video.player.stopVideo();
-								_this.video.paused = true;
-								Toast.methods.addToast("Video can't play. Specified duration is bigger than the YouTube song duration.", 4000);
-							} else if (_this.editing.song.duration <= 0) {
-								this.video.player.stopVideo();
-								_this.video.paused = true;
-								Toast.methods.addToast("Video can't play. Specified duration has to be more than 0 seconds.", 4000);
-							}
-
-							if (_this.video.player.getCurrentTime() < _this.editing.song.skipDuration) {
-								_this.video.player.seekTo(10);
-							}
-						} else if (event.data === 2) {
-							this.video.paused = true;
-						}
-					}
-				}
-			});
-			let volume = parseInt(localStorage.getItem("volume"));
-			volume = (typeof volume === "number") ? volume : 20;
-			$("#volumeSlider").val(volume);
 		}
 	}
 </script>

+ 8 - 4
frontend/components/Admin/Reports.vue

@@ -33,7 +33,7 @@
 		</table>
 	</div>
 
-	<issues-modal v-if='isModalActive'></issues-modal>
+	<issues-modal v-if='modals.reportIssues'></issues-modal>
 </template>
 
 <script>
@@ -46,7 +46,9 @@
 		data() {
 			return {
 				reports: [],
-				isModalActive: false
+				modals: {
+					reportIssues: false
+				}
 			}
 		},
 		methods: {
@@ -54,12 +56,14 @@
 				this.socket.emit('apis.joinAdminRoom', 'reports', data => {});
 			},
 			toggleModal: function (report) {
-				this.isModalActive = !this.isModalActive;
-				if (this.isModalActive) this.editing = report;
+				this.modals.reportIssues = !this.modals.reportIssues;
+				if (this.modals.reportIssues) this.editing = report;
 			},
 			resolve: function (reportId) {
+				let _this = this;
 				this.socket.emit('reports.resolve', reportId, res => {
 					Toast.methods.addToast(res.message, 3000);
+					if (res.status == 'success' && this.modals.reportIssues) _this.toggleModal();
 				});
 			}
 		},

+ 12 - 130
frontend/components/Admin/Songs.vue

@@ -1,5 +1,7 @@
 <template>
 	<div class='container'>
+		<input type='text' class='input' v-model='searchQuery' placeholder='Search for Songs'>
+		<br /><br />
 		<table class='table is-striped'>
 			<thead>
 				<tr>
@@ -13,7 +15,7 @@
 				</tr>
 			</thead>
 			<tbody>
-				<tr v-for='(index, song) in songs' track-by='$index'>
+				<tr v-for='(index, song) in filteredSongs' track-by='$index'>
 					<td>
 						<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
 					</td>
@@ -32,7 +34,7 @@
 			</tbody>
 		</table>
 	</div>
-	<edit-song v-show='isEditActive'></edit-song>
+	<edit-song v-show='modals.editSong'></edit-song>
 </template>
 
 <script>
@@ -46,7 +48,7 @@
 		data() {
 			return {
 				songs: [],
-				isEditActive: false,
+				modals: { editSong: false },
 				editing: {
 					index: 0,
 					song: {}
@@ -58,87 +60,17 @@
 				}
 			}
 		},
+		computed: {
+			filteredSongs: function () {
+				return this.$eval('songs | filterBy searchQuery');
+			}
+		},
 		methods: {
-			settings: function (type) {
-				let _this = this;
-				switch(type) {
-					case 'stop':
-						_this.video.player.stopVideo();
-						_this.video.paused = true;
-						break;
-					case 'pause':
-						_this.video.player.pauseVideo();
-						_this.video.paused = true;
-						break;
-					case 'play':
-						_this.video.player.playVideo();
-						_this.video.paused = false;
-						break;
-					case 'skipToLast10Secs':
-						_this.video.player.seekTo((_this.editing.song.duration - 10) + _this.editing.song.skipDuration);
-						break;
-				}
-			},
-			changeVolume: function() {
-				let local = this;
-				let volume = $("#volumeSlider").val();
-				localStorage.setItem("volume", volume);
-				local.video.player.setVolume(volume);
-				if (volume > 0) local.video.player.unMute();
-			},
 			toggleModal: function () {
-				this.isEditActive = !this.isEditActive;
-				this.settings('stop');
-			},
-			addTag: function (type) {
-				if (type == 'genres') {
-					let genre = $('#new-genre').val().toLowerCase().trim();
-					if (this.editing.song.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
-					if (genre) {
-						this.editing.song.genres.push(genre);
-						$('#new-genre').val('');
-					} else Toast.methods.addToast('Genre cannot be empty', 3000);
-				} else if (type == 'artists') {
-					let artist = $('#new-artist').val();
-					if (this.editing.song.artists.indexOf(artist) !== -1) return Toast.methods.addToast('Artist already exists', 3000);
-					if ($('#new-artist').val() !== '') {
-						this.editing.song.artists.push(artist);
-						$('#new-artist').val('');
-					} else Toast.methods.addToast('Artist cannot be empty', 3000);
-				}
-			},
-			removeTag: function (type, index) {
-				if (type == 'genres') this.editing.song.genres.splice(index, 1);
-				else if (type == 'artists') this.editing.song.artists.splice(index, 1);
+				this.modals.editSong = !this.modals.editSong;
 			},
 			edit: function (song, index) {
-				if (this.video.player) {
-					this.video.player.loadVideoById(song._id, this.editing.song.skipDuration);
-					let songCopy = {};
-					for (let n in song) {
-						songCopy[n] = song[n];
-					}
-					this.editing = { index, song: songCopy };
-					this.isEditActive = true;
-				}
-			},
-			save: function (song, close) {
-				let _this = this;
-				this.socket.emit('songs.update', song._id, song, function (res) {
-					Toast.methods.addToast(res.message, 4000);
-					if (res.status === 'success') {
-						_this.songs.forEach((lSong) => {
-							if (song._id === lSong._id) {
-								for (let n in song) {
-									lSong[n] = song[n];
-								}
-							}
-						});
-					}
-					if (close) {
-						_this.toggleModal();
-					}
-				});
+				this.$broadcast('editSong', song, index, 'songs');
 			},
 			remove: function (id, index) {
 				this.socket.emit('songs.remove', id, res => {
@@ -173,56 +105,6 @@
 					_this.init();
 				});
 			});
-
-			setInterval(() => {
-				if (_this.video.paused === false && _this.playerReady && _this.video.player.getCurrentTime() - _this.editing.song.skipDuration > _this.editing.song.duration) {
-					_this.video.paused = false;
-					_this.video.player.stopVideo();
-				}
-			}, 200);
-
-			this.video.player = new YT.Player('player', {
-				height: 315,
-				width: 560,
-				videoId: this.editing.song._id,
-				playerVars: { controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0 },
-				startSeconds: _this.editing.song.skipDuration,
-				events: {
-					'onReady': () => {
-						let volume = parseInt(localStorage.getItem("volume"));
-						volume = (typeof volume === "number") ? volume : 20;
-						_this.video.player.seekTo(_this.editing.song.skipDuration);
-						_this.video.player.setVolume(volume);
-						if (volume > 0) _this.video.player.unMute();
-						_this.playerReady = true;
-					},
-					'onStateChange': event => {
-						if (event.data === 1) {
-							_this.video.paused = false;
-							let youtubeDuration = _this.video.player.getDuration();
-							youtubeDuration -= _this.editing.song.skipDuration;
-							if (_this.editing.song.duration > youtubeDuration) {
-								this.video.player.stopVideo();
-								_this.video.paused = true;
-								Toast.methods.addToast("Video can't play. Specified duration is bigger than the YouTube song duration.", 4000);
-							} else if (_this.editing.song.duration <= 0) {
-								this.video.player.stopVideo();
-								_this.video.paused = true;
-								Toast.methods.addToast("Video can't play. Specified duration has to be more than 0 seconds.", 4000);
-							}
-
-							if (_this.video.player.getCurrentTime() < _this.editing.song.skipDuration) {
-								_this.video.player.seekTo(10);
-							}
-						} else if (event.data === 2) {
-							this.video.paused = true;
-						}
-					}
-				}
-			});
-			let volume = parseInt(localStorage.getItem("volume"));
-			volume = (typeof volume === "number") ? volume : 20;
-			$("#volumeSlider").val(volume);
 		}
 	}
 </script>

+ 42 - 47
frontend/components/Modals/AddSongToQueue.vue

@@ -1,56 +1,50 @@
 <template>
-	<div class="modal is-active">
-		<div class="modal-background"></div>
-		<div class="modal-card">
-			<header class="modal-card-head">
-				<p class="modal-card-title">Add Songs to Station</p>
-				<button class="delete" @click="$parent.modals.addSongToQueue = !$parent.modals.addSongToQueue" ></button>
-			</header>
-			<section class="modal-card-body">
-				<aside class='menu' v-if='$parent.$parent.loggedIn && $parent.type === "community"'>
-					<ul class='menu-list'>
-						<li v-for='playlist in playlists' track-by='$index'>
-							<a href='#' target='_blank' @click='$parent.editPlaylist(playlist._id)'>{{ playlist.displayName }}</a>
-							<div class='controls'>
-								<a href='#' @click='selectPlaylist(playlist._id)' v-if="!isPlaylistSelected(playlist._id)"><i class='material-icons'>panorama_fish_eye</i></a>
-								<a href='#' @click='unSelectPlaylist()' v-if="isPlaylistSelected(playlist._id)"><i class='material-icons'>lens</i></a>
-							</div>
-						</li>
-					</ul>
-					<br />
-				</aside>
-				<div class="control is-grouped">
-					<p class="control is-expanded">
-						<input class="input" type="text" placeholder="YouTube Query" v-model='querySearch' autofocus @keyup.enter='submitQuery()'>
-					</p>
-					<p class="control">
-						<a class="button is-info" @click="submitQuery()" href='#'>
-							Search
+	<modal title='Add Song To Queue'>
+		<div slot='body'>
+			<aside class='menu' v-if='$parent.$parent.loggedIn && $parent.type === "community"'>
+				<ul class='menu-list'>
+					<li v-for='playlist in playlists' track-by='$index'>
+						<a href='#' target='_blank' @click='$parent.editPlaylist(playlist._id)'>{{ playlist.displayName }}</a>
+						<div class='controls'>
+							<a href='#' @click='selectPlaylist(playlist._id)' v-if="!isPlaylistSelected(playlist._id)"><i class='material-icons'>panorama_fish_eye</i></a>
+							<a href='#' @click='unSelectPlaylist()' v-if="isPlaylistSelected(playlist._id)"><i class='material-icons'>lens</i></a>
+						</div>
+					</li>
+				</ul>
+				<br />
+			</aside>
+			<div class="control is-grouped">
+				<p class="control is-expanded">
+					<input class="input" type="text" placeholder="YouTube Query" v-model='querySearch' autofocus @keyup.enter='submitQuery()'>
+				</p>
+				<p class="control">
+					<a class="button is-info" @click="submitQuery()" href='#'>
+						Search
+					</a>
+				</p>
+			</div>
+			<table class="table">
+				<tbody>
+				<tr v-for="result in queryResults">
+					<td>
+						<img :src="result.thumbnail" />
+					</td>
+					<td>{{ result.title }}</td>
+					<td>
+						<a class="button is-success" @click="addSongToQueue(result.id)" href='#'>
+							Add
 						</a>
-					</p>
-				</div>
-				<table class="table">
-					<tbody>
-						<tr v-for="result in queryResults">
-							<td>
-								<img :src="result.thumbnail" />
-							</td>
-							<td>{{ result.title }}</td>
-							<td>
-								<a class="button is-success" @click="addSongToQueue(result.id)" href='#'>
-									Add
-								</a>
-							</td>
-						</tr>
-					</tbody>
-				</table>
-			</section>
+					</td>
+				</tr>
+				</tbody>
+			</table>
 		</div>
-	</div>
+	</modal>
 </template>
 
 <script>
 	import { Toast } from 'vue-roaster';
+	import Modal from './Modal.vue';
 	import io from '../../io';
 	import auth from '../../auth';
 
@@ -137,7 +131,8 @@
 			closeModal: function () {
 				this.$parent.modals.addSongToQueue = !this.$parent.modals.addSongToQueue;
 			}
-		}
+		},
+		components: { Modal }
 	}
 </script>
 

+ 23 - 28
frontend/components/Modals/CreateCommunityStation.vue

@@ -1,38 +1,33 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Create community station</p>
-				<button class='delete' @click='toggleModal()'></button>
-			</header>
-			<section class='modal-card-body'>
-				<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
-				<label class='label'>Unique ID (lowercase, a-z, used in the url)</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Name...' v-model='newCommunity._id' autofocus>
-				</p>
-				<label class='label'>Display Name</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Display name...' v-model='newCommunity.displayName'>
-				</p>
-				<label class='label'>Description</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Description...' v-model='newCommunity.description' @keyup.enter="submitModal()">
-				</p>
-			</section>
-			<footer class='modal-card-foot'>
-				<a class='button is-primary' @click='submitModal()'>Create</a>
-			</footer>
+	<modal title='Create Community Station'>
+		<div slot='body'>
+			<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
+			<label class='label'>Unique ID (lowercase, a-z, used in the url)</label>
+			<p class='control'>
+				<input class='input' type='text' placeholder='Name...' v-model='newCommunity._id' autofocus>
+			</p>
+			<label class='label'>Display Name</label>
+			<p class='control'>
+				<input class='input' type='text' placeholder='Display name...' v-model='newCommunity.displayName'>
+			</p>
+			<label class='label'>Description</label>
+			<p class='control'>
+				<input class='input' type='text' placeholder='Description...' v-model='newCommunity.description' @keyup.enter="submitModal()">
+			</p>
 		</div>
-	</div>
+		<div slot='footer'>
+			<a class='button is-primary' @click='submitModal()'>Create</a>
+		</div>
+	</modal>
 </template>
 
 <script>
 	import { Toast } from 'vue-roaster';
+	import Modal from './Modal.vue';
 	import io from '../../io';
 
 	export default {
+		components: { Modal },
 		data() {
 			return {
 				newCommunity: {
@@ -50,7 +45,7 @@
 		},
 		methods: {
 			toggleModal: function () {
-				this.$dispatch('toggleModal', 'createCommunityStation');
+				this.$parent.modals.createCommunityStation = !this.$parent.modals.createCommunityStation;
 			},
 			submitModal: function () {
 				let _this = this;
@@ -71,7 +66,7 @@
 		},
 		events: {
 			closeModal: function() {
-				this.$dispatch('toggleModal', 'createCommunityStation');
+				this.$parent.modals.createCommunityStation = !this.$parent.modals.createCommunityStation;
 			}
 		}
 	}

+ 236 - 0
frontend/components/Modals/EditNews.vue

@@ -0,0 +1,236 @@
+<template>
+	<modal title='Edit News'>
+		<div slot='body'>
+			<label class='label'>Title</label>
+			<p class='control'>
+				<input class='input' type='text' placeholder='News Title' v-model='$parent.editing.title' autofocus>
+			</p>
+			<label class='label'>Description</label>
+			<p class='control'>
+				<input class='input' type='text' placeholder='News Description' v-model='$parent.editing.description'>
+			</p>
+			<div class="columns">
+				<div class="column">
+					<label class='label'>Bugs</label>
+					<p class='control has-addons'>
+						<input class='input' id='edit-bugs' type='text' placeholder='Bug' v-on:keyup.enter='addChange("bugs")'>
+						<a class='button is-info' href='#' @click='addChange("bugs")'>Add</a>
+					</p>
+					<span class='tag is-info' v-for='(index, bug) in $parent.editing.bugs' track-by='$index'>
+						{{ bug }}
+						<button class='delete is-info' @click='removeChange("bugs", index)'></button>
+					</span>
+				</div>
+				<div class="column">
+					<label class='label'>Features</label>
+					<p class='control has-addons'>
+						<input class='input' id='edit-features' type='text' placeholder='Feature' v-on:keyup.enter='addChange("features")'>
+						<a class='button is-info' href='#' @click='addChange("features")'>Add</a>
+					</p>
+					<span class='tag is-info' v-for='(index, feature) in $parent.editing.features' track-by='$index'>
+						{{ feature }}
+						<button class='delete is-info' @click='removeChange("features", index)'></button>
+					</span>
+				</div>
+			</div>
+
+			<div class="columns">
+				<div class="column">
+					<label class='label'>Improvements</label>
+					<p class='control has-addons'>
+						<input class='input' id='edit-improvements' type='text' placeholder='Improvement' v-on:keyup.enter='addChange("improvements")'>
+						<a class='button is-info' href='#' @click='addChange("improvements")'>Add</a>
+					</p>
+					<span class='tag is-info' v-for='(index, improvement) in $parent.editing.improvements' track-by='$index'>
+						{{ improvement }}
+						<button class='delete is-info' @click='removeChange("improvements", index)'></button>
+					</span>
+				</div>
+				<div class="column">
+					<label class='label'>Upcoming</label>
+					<p class='control has-addons'>
+						<input class='input' id='edit-upcoming' type='text' placeholder='Upcoming' v-on:keyup.enter='addChange("upcoming")'>
+						<a class='button is-info' href='#' @click='addChange("upcoming")'>Add</a>
+					</p>
+					<span class='tag is-info' v-for='(index, upcoming) in $parent.editing.upcoming' track-by='$index'>
+						{{ upcoming }}
+						<button class='delete is-info' @click='removeChange("upcoming", index)'></button>
+					</span>
+				</div>
+			</div>
+		</div>
+		<div slot='footer'>
+			<button class='button is-success' @click='$parent.updateNews(false)'>
+				<i class='material-icons save-changes'>done</i>
+				<span>&nbsp;Save</span>
+			</button>
+			<button class='button is-success' @click='$parent.updateNews(true)'>
+				<i class='material-icons save-changes'>done</i>
+				<span>&nbsp;Save and close</span>
+			</button>
+			<button class='button is-danger' @click='$parent.toggleModal()'>
+				<span>&nbsp;Close</span>
+			</button>
+		</div>
+	</modal>
+</template>
+
+<script>
+	import { Toast } from 'vue-roaster';
+
+	import Modal from './Modal.vue';
+
+	export default {
+		components: { Modal },
+		methods: {
+			addChange: function (type) {
+				let change = $(`#edit-${type}`).val().trim();
+
+				if (this.$parent.editing[type].indexOf(change) !== -1) return Toast.methods.addToast(`Tag already exists`, 3000);
+
+				if (change) this.$parent.editing[type].push(change);
+				else Toast.methods.addToast(`${type} cannot be empty`, 3000);
+			},
+			removeChange: function (type, index) {
+				this.$parent.editing[type].splice(index, 1);
+			},
+		},
+		events: {
+			closeModal: function() {
+				this.$parent.toggleModal();
+			}
+		}
+	}
+</script>
+
+<style type='scss' scoped>
+	input[type=range] {
+		-webkit-appearance: none;
+		width: 100%;
+		margin: 7.3px 0;
+	}
+
+	input[type=range]:focus {
+		outline: none;
+	}
+
+	input[type=range]::-webkit-slider-runnable-track {
+		width: 100%;
+		height: 5.2px;
+		cursor: pointer;
+		box-shadow: 0;
+		background: #c2c0c2;
+		border-radius: 0;
+		border: 0;
+	}
+
+	input[type=range]::-webkit-slider-thumb {
+		box-shadow: 0;
+		border: 0;
+		height: 19px;
+		width: 19px;
+		border-radius: 15px;
+		background: #03a9f4;
+		cursor: pointer;
+		-webkit-appearance: none;
+		margin-top: -6.5px;
+	}
+
+	input[type=range]::-moz-range-track {
+		width: 100%;
+		height: 5.2px;
+		cursor: pointer;
+		box-shadow: 0;
+		background: #c2c0c2;
+		border-radius: 0;
+		border: 0;
+	}
+
+	input[type=range]::-moz-range-thumb {
+		box-shadow: 0;
+		border: 0;
+		height: 19px;
+		width: 19px;
+		border-radius: 15px;
+		background: #03a9f4;
+		cursor: pointer;
+		-webkit-appearance: none;
+		margin-top: -6.5px;
+	}
+
+	input[type=range]::-ms-track {
+		width: 100%;
+		height: 5.2px;
+		cursor: pointer;
+		box-shadow: 0;
+		background: #c2c0c2;
+		border-radius: 1.3px;
+	}
+
+	input[type=range]::-ms-fill-lower {
+		background: #c2c0c2;
+		border: 0;
+		border-radius: 0;
+		box-shadow: 0;
+	}
+
+	input[type=range]::-ms-fill-upper {
+		background: #c2c0c2;
+		border: 0;
+		border-radius: 0;
+		box-shadow: 0;
+	}
+
+	input[type=range]::-ms-thumb {
+		box-shadow: 0;
+		border: 0;
+		height: 15px;
+		width: 15px;
+		border-radius: 15px;
+		background: #03a9f4;
+		cursor: pointer;
+		-webkit-appearance: none;
+		margin-top: 1.5px;
+	}
+
+	.controls {
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+	}
+
+	.artist-genres {
+		display: flex;
+    	justify-content: space-between;
+	}
+
+	#volumeSlider { margin-bottom: 15px; }
+
+	.has-text-centered { padding: 10px; }
+
+	.thumbnail-preview {
+		display: flex;
+		margin: 0 auto 25px auto;
+		max-width: 200px;
+		width: 100%;
+	}
+
+	.modal-card-body, .modal-card-foot { border-top: 0; }
+
+	.label, .checkbox, h5 {
+		font-weight: normal;
+	}
+
+	.video-container {
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		padding: 10px;
+
+		iframe { pointer-events: none; }
+	}
+
+	.save-changes { color: #fff; }
+
+	.tag:not(:last-child) { margin-right: 5px; }
+</style>

+ 184 - 38
frontend/components/Modals/EditSong.vue

@@ -1,43 +1,41 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<section class='modal-card-body'>
-
+	<div>
+		<modal title='Edit Song'>
+			<div slot='body'>
 				<h5 class='has-text-centered'>Video Preview</h5>
 				<div class='video-container'>
 					<div id='player'></div>
 					<div class="controls">
 						<form action="#" class="column is-7-desktop is-4-mobile">
 							<p style="margin-top: 0; position: relative;">
-								<input type="range" id="volumeSlider" min="0" max="100" class="active" v-on:change="$parent.changeVolume()" v-on:input="$parent.changeVolume()">
+								<input type="range" id="volumeSlider" min="0" max="100" class="active" v-on:change="changeVolume()" v-on:input="changeVolume()">
 							</p>
 						</form>
 						<p class='control has-addons'>
-							<button class='button' @click='$parent.settings("pause")' v-if='!$parent.video.paused'>
+							<button class='button' @click='settings("pause")' v-if='!video.paused'>
 								<i class='material-icons'>pause</i>
 							</button>
-							<button class='button' @click='$parent.settings("play")' v-if='$parent.video.paused'>
+							<button class='button' @click='settings("play")' v-if='video.paused'>
 								<i class='material-icons'>play_arrow</i>
 							</button>
-							<button class='button' @click='$parent.settings("stop")'>
+							<button class='button' @click='settings("stop")'>
 								<i class='material-icons'>stop</i>
 							</button>
-							<button class='button' @click='$parent.settings("skipToLast10Secs")'>
+							<button class='button' @click='settings("skipToLast10Secs")'>
 								<i class='material-icons'>fast_forward</i>
 							</button>
 						</p>
 					</div>
 				</div>
 				<h5 class='has-text-centered'>Thumbnail Preview</h5>
-				<img class='thumbnail-preview' :src='$parent.editing.song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
+				<img class='thumbnail-preview' :src='editing.song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
 
 				<div class="control is-horizontal">
 					<div class="control-label">
 						<label class="label">Thumbnail URL</label>
 					</div>
 					<div class="control">
-						<input class='input' type='text' v-model='$parent.editing.song.thumbnail'>
+						<input class='input' type='text' v-model='editing.song.thumbnail'>
 					</div>
 				</div>
 
@@ -45,7 +43,7 @@
 
 				<p class='control'>
 					<label class='checkbox'>
-						<input type='checkbox' v-model='$parent.editing.song.explicit'>
+						<input type='checkbox' v-model='editing.song.explicit'>
 						Explicit
 					</label>
 				</p>
@@ -53,10 +51,10 @@
 				<div class="control is-horizontal">
 					<div class="control is-grouped">
 						<p class='control is-expanded'>
-							<input class='input' type='text' v-model='$parent.editing.song._id'>
+							<input class='input' type='text' v-model='editing.song._id'>
 						</p>
 						<p class='control is-expanded'>
-							<input class='input' type='text' v-model='$parent.editing.song.title' autofocus>
+							<input class='input' type='text' v-model='editing.song.title' autofocus>
 						</p>
 					</div>
 				</div>
@@ -66,66 +64,214 @@
 						<div>
 							<p class='control has-addons'>
 								<input class='input' id='new-artist' type='text' placeholder='Artist'>
-								<button class='button is-info' @click='$parent.addTag("artists")'>Add Artist</button>
+								<button class='button is-info' @click='addTag("artists")'>Add Artist</button>
 							</p>
-							<span class='tag is-info' v-for='(index, artist) in $parent.editing.song.artists' track-by='$index'>
+							<span class='tag is-info' v-for='(index, artist) in editing.song.artists' track-by='$index'>
 								{{ artist }}
-								<button class='delete is-info' @click='$parent.$parent.removeTag("artists", index)'></button>
+								<button class='delete is-info' @click='removeTag("artists", index)'></button>
 							</span>
 						</div>
 						<div>
 							<p class='control has-addons'>
 								<input class='input' id='new-genre' type='text' placeholder='Genre'>
-								<button class='button is-info' @click='$parent.addTag("genres")'>Add Genre</button>
+								<button class='button is-info' @click='addTag("genres")'>Add Genre</button>
 							</p>
-							<span class='tag is-info' v-for='(index, genre) in $parent.editing.song.genres' track-by='$index'>
+							<span class='tag is-info' v-for='(index, genre) in editing.song.genres' track-by='$index'>
 								{{ genre }}
-								<button class='delete is-info' @click='$parent.$parent.removeTag("genres", index)'></button>
+								<button class='delete is-info' @click='removeTag("genres", index)'></button>
 							</span>
 						</div>
 					</div>
 				</div>
 				<label class='label'>Song Duration</label>
 				<p class='control'>
-					<input class='input' type='text' v-model='$parent.editing.song.duration'>
+					<input class='input' type='text' v-model='editing.song.duration'>
 				</p>
 				<label class='label'>Skip Duration</label>
 				<p class='control'>
-					<input class='input' type='text' v-model='$parent.editing.song.skipDuration'>
+					<input class='input' type='text' v-model='editing.song.skipDuration'>
 				</p>
-
-			</section>
-			<footer class='modal-card-foot'>
-				<button class='button is-success' @click='$parent.save($parent.editing.song, false)'>
+			</div>
+			<div slot='footer'>
+				<button class='button is-success' @click='save(editing.song, false)'>
 					<i class='material-icons save-changes'>done</i>
 					<span>&nbsp;Save</span>
 				</button>
-				<button class='button is-success' @click='$parent.save($parent.editing.song, true)'>
+				<button class='button is-success' @click='save(editing.song, true)'>
 					<i class='material-icons save-changes'>done</i>
 					<span>&nbsp;Save and close</span>
 				</button>
 				<button class='button is-danger' @click='$parent.toggleModal()'>
 					<span>&nbsp;Close</span>
 				</button>
-			</footer>
-		</div>
+			</div>
+		</modal>
 	</div>
 </template>
 
 <script>
+	import io from '../../io';
+	import { Toast } from 'vue-roaster';
+	import Modal from './Modal.vue';
+
 	export default {
+		components: { Modal },
+		data() {
+			return {
+				editing: {
+					index: 0,
+					song: {},
+					type: ''
+				},
+				video: {
+					player: null,
+					paused: false,
+					playerReady: false
+				}
+			}
+		},
 		methods: {
-			toggleModal: function () {
-				this.$dispatch('toggleModal', 'login');
+			save: function (song, close) {
+				let _this = this;
+				this.socket.emit(`${_this.editing.type}.update`, song._id, song, res => {
+					Toast.methods.addToast(res.message, 4000);
+					if (res.status === 'success') {
+						_this.$parent.songs.forEach(lSong => {
+							if (song._id === lSong._id) {
+								for (let n in song) {
+									lSong[n] = song[n];
+								}
+							}
+						});
+					}
+					if (close) _this.$parent.toggleModal();
+				});
 			},
-			submitModal: function () {
-				this.$dispatch('login');
-				this.toggleModal();
-			}
+			settings: function (type) {
+				let _this = this;
+				switch(type) {
+					case 'stop':
+						_this.video.player.stopVideo();
+						_this.video.paused = true;
+						break;
+					case 'pause':
+						_this.video.player.pauseVideo();
+						_this.video.paused = true;
+						break;
+					case 'play':
+						_this.video.player.playVideo();
+						_this.video.paused = false;
+						break;
+					case 'skipToLast10Secs':
+						_this.video.player.seekTo((_this.editing.song.duration - 10) + _this.editing.song.skipDuration);
+						break;
+				}
+			},
+			changeVolume: function () {
+				let local = this;
+				let volume = $("#volumeSlider").val();
+				localStorage.setItem("volume", volume);
+				local.video.player.setVolume(volume);
+				if (volume > 0) local.video.player.unMute();
+			},
+			addTag: function (type) {
+				if (type == 'genres') {
+					let genre = $('#new-genre').val().toLowerCase().trim();
+					if (this.editing.song.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
+					if (genre) {
+						this.editing.song.genres.push(genre);
+						$('#new-genre').val('');
+					} else Toast.methods.addToast('Genre cannot be empty', 3000);
+				} else if (type == 'artists') {
+					let artist = $('#new-artist').val();
+					if (this.editing.song.artists.indexOf(artist) !== -1) return Toast.methods.addToast('Artist already exists', 3000);
+					if ($('#new-artist').val() !== '') {
+						this.editing.song.artists.push(artist);
+						$('#new-artist').val('');
+					} else Toast.methods.addToast('Artist cannot be empty', 3000);
+				}
+			},
+			removeTag: function (type, index) {
+				if (type == 'genres') this.editing.song.genres.splice(index, 1);
+				else if (type == 'artists') this.editing.song.artists.splice(index, 1);
+			},
+		},
+		ready: function () {
+
+			let _this = this;
+
+			io.getSocket(socket => {
+				_this.socket = socket;
+			});
+			
+			setInterval(() => {
+				if (_this.video.paused === false && _this.playerReady && _this.video.player.getCurrentTime() - _this.editing.song.skipDuration > _this.editing.song.duration) {
+					_this.video.paused = false;
+					_this.video.player.stopVideo();
+				}
+			}, 200);
+
+			this.video.player = new YT.Player('player', {
+				height: 315,
+				width: 560,
+				videoId: this.editing.song._id,
+				playerVars: { controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0 },
+				startSeconds: _this.editing.song.skipDuration,
+				events: {
+					'onReady': () => {
+						let volume = parseInt(localStorage.getItem("volume"));
+						volume = (typeof volume === "number") ? volume : 20;
+						_this.video.player.seekTo(_this.editing.song.skipDuration);
+						_this.video.player.setVolume(volume);
+						if (volume > 0) _this.video.player.unMute();
+						_this.playerReady = true;
+					},
+					'onStateChange': event => {
+						if (event.data === 1) {
+							_this.video.paused = false;
+							let youtubeDuration = _this.video.player.getDuration();
+							youtubeDuration -= _this.editing.song.skipDuration;
+							if (_this.editing.song.duration > youtubeDuration) {
+								this.video.player.stopVideo();
+								_this.video.paused = true;
+								Toast.methods.addToast("Video can't play. Specified duration is bigger than the YouTube song duration.", 4000);
+							} else if (_this.editing.song.duration <= 0) {
+								this.video.player.stopVideo();
+								_this.video.paused = true;
+								Toast.methods.addToast("Video can't play. Specified duration has to be more than 0 seconds.", 4000);
+							}
+
+							if (_this.video.player.getCurrentTime() < _this.editing.song.skipDuration) {
+								_this.video.player.seekTo(10);
+							}
+						} else if (event.data === 2) {
+							this.video.paused = true;
+						}
+					}
+				}
+			});
+
+			let volume = parseInt(localStorage.getItem("volume"));
+			volume = (typeof volume === "number") ? volume : 20;
+			$("#volumeSlider").val(volume);
+			
 		},
 		events: {
-			closeModal: function() {
-				this.$parent.toggleModal()
+			closeModal: function () {
+				this.$parent.toggleModal();
+			},
+			editSong: function (song, index, type) {
+				this.video.player.loadVideoById(song._id, this.editing.song.skipDuration);
+				let newSong = {};
+				for (let n in song) {
+					newSong[n] = song[n];
+				}
+				this.editing = {
+					index,
+					song: newSong,
+					type
+				};
+				this.$parent.toggleModal();
 			}
 		}
 	}

+ 43 - 49
frontend/components/Modals/EditStation.vue

@@ -1,33 +1,27 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Edit station</p>
-				<button class='delete' @click='$parent.modals.editStation = !$parent.modals.editStation'></button>
-			</header>
-			<section class='modal-card-body'>
-				<label class='label'>Display name</label>
-				<div class='control is-grouped'>
-					<p class='control is-expanded'>
-						<input class='input' type='text' placeholder='Station Display Name' v-model='data.displayName' autofocus>
-					</p>
-					<p class='control'>
-						<a class='button is-info' @click='updateDisplayName()' href='#'>Update</a>
-					</p>
-				</div>
-				<label class='label'>Description</label>
-				<div class='control is-grouped'>
-					<p class='control is-expanded'>
-						<input class='input' type='text' placeholder='Station Display Name' v-model='data.description'>
-					</p>
-					<p class='control'>
-						<a class='button is-info' @click='updateDescription()' href='#'>Update</a>
-					</p>
-				</div>
-				<label class='label'>Privacy</label>
-				<div class='control is-grouped'>
-					<p class='control is-expanded'>
+	<modal title='Edit Station'>
+		<div slot='body'>
+			<label class='label'>Display name</label>
+			<div class='control is-grouped'>
+				<p class='control is-expanded'>
+					<input class='input' type='text' placeholder='Station Display Name' v-model='data.displayName' autofocus>
+				</p>
+				<p class='control'>
+					<a class='button is-info' @click='updateDisplayName()' href='#'>Update</a>
+				</p>
+			</div>
+			<label class='label'>Description</label>
+			<div class='control is-grouped'>
+				<p class='control is-expanded'>
+					<input class='input' type='text' placeholder='Station Display Name' v-model='data.description'>
+				</p>
+				<p class='control'>
+					<a class='button is-info' @click='updateDescription()' href='#'>Update</a>
+				</p>
+			</div>
+			<label class='label'>Privacy</label>
+			<div class='control is-grouped'>
+				<p class='control is-expanded'>
 						<span class='select'>
 							<select v-model='data.privacy'>
 								<option :value='"public"'>Public</option>
@@ -35,29 +29,29 @@
 								<option :value='"private"'>Private</option>
 							</select>
 						</span>
-					</p>
-					<p class='control'>
-						<a class='button is-info' @click='updatePrivacy()' href='#'>Update</a>
-					</p>
-				</div>
-				<div class='control is-grouped' v-if="$parent.type === 'community'">
-					<p class="control is-expanded party-mode-outer">
-						<label class="checkbox party-mode-inner">
-							<input type="checkbox" v-model="data.partyMode">
-							&nbsp;Party mode
-						</label>
-					</p>
-					<p class='control'>
-						<a class='button is-info' @click='updatePartyMode()' href='#'>Update</a>
-					</p>
-				</div>
-			</section>
+				</p>
+				<p class='control'>
+					<a class='button is-info' @click='updatePrivacy()' href='#'>Update</a>
+				</p>
+			</div>
+			<div class='control is-grouped' v-if="$parent.type === 'community'">
+				<p class="control is-expanded party-mode-outer">
+					<label class="checkbox party-mode-inner">
+						<input type="checkbox" v-model="data.partyMode">
+						&nbsp;Party mode
+					</label>
+				</p>
+				<p class='control'>
+					<a class='button is-info' @click='updatePartyMode()' href='#'>Update</a>
+				</p>
+			</div>
 		</div>
-	</div>
+	</modal>
 </template>
 
 <script>
 	import { Toast } from 'vue-roaster';
+	import Modal from './Modal.vue';
 	import io from '../../io';
 
 	export default {
@@ -76,7 +70,6 @@
 				this.socket.emit('stations.updateDisplayName', this.data.stationId, this.data.displayName, res => {
 					if (res.status === 'success') {
 						this.$parent.station.displayName = this.data.displayName;
-						return Toast.methods.addToast(res.message, 4000);
 					}
 					Toast.methods.addToast(res.message, 8000);
 				});
@@ -124,7 +117,8 @@
 			closeModal: function() {
 				this.$parent.modals.editStation = !this.$parent.modals.editStation;
 			}
-		}
+		},
+		components: { Modal }
 	}
 </script>
 

+ 37 - 38
frontend/components/Modals/IssuesModal.vue

@@ -1,41 +1,40 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Report Issues</p>
-				<button class='delete' @click='$parent.toggleModal()'></button>
-			</header>
-			<section class='modal-card-body'>
+	<modal title='Report Issues'>
+		<div slot='body'>
+			<table class='table is-narrow'>
+				<thead>
+					<tr>
+						<td>Issue</td>
+						<td>Reasons</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for='(index, issue) in $parent.editing.issues' track-by='$index'>
+						<td>
+							<span>{{ issue.name }}</span>
+						</td>
+						<td>
+							<span>{{ issue.reasons }}</span>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+		<div slot='footer'>
+			<a class='button is-primary' @click='$parent.resolve($parent.editing._id)' href='#'>
+				<span>Resolve</span>
+			</a>
+			<a class='button is-danger' @click='$parent.toggleModal()' href='#'>
+				<span>Cancel</span>
+			</a>
+		</div>
+	</modal>
+</template>
 
-				<table class='table is-narrow'>
-					<thead>
-						<tr>
-							<td>Issue</td>
-							<td>Reasons</td>
-						</tr>
-					</thead>
-					<tbody>
-						<tr v-for='(index, issue) in $parent.editing.issues' track-by='$index'>
-							<td>
-								<span>{{ issue.name }}</span>
-							</td>
-							<td>
-								<span>{{ issue.reasons }}</span>
-							</td>
-						</tr>
-					</tbody>
-				</table>
+<script>
+	import Modal from './Modal.vue';
 
-			</section>
-			<footer class='modal-card-foot'>
-				<a class='button is-primary' @click='$parent.resolve($parent.editing._id)' href='#'>
-					<span>Resolve</span>
-				</a>
-				<a class='button is-danger' @click='$parent.toggleModal()' href='#'>
-					<span>Cancel</span>
-				</a>
-			</footer>
-		</div>
-	</div>
-</template>
+	export default {
+		components: { Modal }
+	}
+</script>

+ 37 - 0
frontend/components/Modals/Modal.vue

@@ -0,0 +1,37 @@
+<template>
+	<div class='modal is-active'>
+		<div class='modal-background'></div>
+		<div class='modal-card'>
+			<header class='modal-card-head'>
+				<p class='modal-card-title'>{{ title }}</p>
+				<button class='delete' @click='$parent.$parent.modals[this.type] = !$parent.$parent.modals[this.type]'></button>
+			</header>
+			<section class='modal-card-body'>
+				<slot name='body'></slot>
+			</section>
+			<footer class='modal-card-foot' v-if='_slotContents["footer"] != null'>
+				<slot name='footer'></slot>
+			</footer>
+		</div>
+	</div>
+</template>
+
+<script>
+	export default {
+		props: {
+			title: { type: String }
+		},
+		methods: {
+			toCamelCase: str => {
+				return str.toLowerCase()
+					.replace(/[-_]+/g, ' ')
+					.replace(/[^\w\s]/g, '')
+					.replace(/ (.)/g, function($1) { return $1.toUpperCase(); })
+					.replace(/ /g, '');
+			}
+		},
+		ready: function () {
+			this.type = this.toCamelCase(this.title);
+		}
+	}
+</script>

+ 3 - 3
frontend/components/Modals/Playlists/Edit.vue

@@ -13,11 +13,11 @@
 							<a :href='' target='_blank'>{{ song.title }}</a>
 							<div class='controls'>
 								<a href='#' @click='promoteSong(song._id)'>
-									<i class='material-icons' v-if='$index > 0' @click='promoteSong(song._id)'>keyboard_arrow_up</i>
+									<i class='material-icons' v-if='$index > 0'>keyboard_arrow_up</i>
 									<i class='material-icons' style='opacity: 0' v-else>error</i>
 								</a>
 								<a href='#' @click='demoteSong(song._id)'>
-									<i class='material-icons' v-if='playlist.songs.length - 1 !== $index' @click='demoteSong(song._id)'>keyboard_arrow_down</i>
+									<i class='material-icons' v-if='playlist.songs.length - 1 !== $index'>keyboard_arrow_down</i>
 									<i class='material-icons' style='opacity: 0' v-else>error</i>
 								</a>
 								<a href='#' @click='removeSongFromPlaylist(song._id)'><i class='material-icons'>delete</i></a>
@@ -143,8 +143,8 @@
 			removePlaylist: function () {
 				let _this = this;
 				_this.socket.emit('playlists.remove', _this.playlist._id, res => {
+					Toast.methods.addToast(res.message, 3000);
 					if (res.status === 'success') {
-						Toast.methods.addToast(res.message, 3000);
 						_this.$parent.modals.editPlaylist = !_this.$parent.modals.editPlaylist;
 					}
 				});

+ 79 - 84
frontend/components/Modals/Report.vue

@@ -1,107 +1,102 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Report</p>
-				<button class='delete' @click='$parent.modals.report = !$parent.modals.report'></button>
-			</header>
-			<section class='modal-card-body'>
-				<div class='columns song-types'>
-					<div class='column song-type' v-if='$parent.previousSong !== null'>
-						<div class='card is-fullwidth' :class="{ 'is-highlight-active': isPreviousSongActive }" @click="highlight('previousSong')">
-							<header class='card-header'>
-								<p class='card-header-title'>
-									Previous Song
-								</p>
-							</header>
-							<div class='card-content'>
-								<article class='media'>
-									<figure class='media-left'>
-										<p class='image is-64x64'>
-											<img :src='$parent.previousSong.thumbnail' onerror='this.src="/assets/notes-transparent.png"'>
+	<modal title='Report'>
+		<div slot='body'>
+			<div class='columns song-types'>
+				<div class='column song-type' v-if='$parent.previousSong !== null'>
+					<div class='card is-fullwidth' :class="{ 'is-highlight-active': isPreviousSongActive }" @click="highlight('previousSong')">
+						<header class='card-header'>
+							<p class='card-header-title'>
+								Previous Song
+							</p>
+						</header>
+						<div class='card-content'>
+							<article class='media'>
+								<figure class='media-left'>
+									<p class='image is-64x64'>
+										<img :src='$parent.previousSong.thumbnail' onerror='this.src="/assets/notes-transparent.png"'>
+									</p>
+								</figure>
+								<div class='media-content'>
+									<div class='content'>
+										<p>
+											<strong>{{ $parent.previousSong.title }}</strong>
+											<br>
+											<small>{{ $parent.previousSong.artists.split(' ,') }}</small>
 										</p>
-									</figure>
-									<div class='media-content'>
-										<div class='content'>
-											<p>
-												<strong>{{ $parent.previousSong.title }}</strong>
-												<br>
-												<small>{{ $parent.previousSong.artists.split(' ,') }}</small>
-											</p>
-										</div>
 									</div>
-								</article>
-							</div>
-							<a @click=highlight('previousSong') href='#' class='absolute-a'></a>
+								</div>
+							</article>
 						</div>
+						<a @click=highlight('previousSong') href='#' class='absolute-a'></a>
 					</div>
-					<div class='column song-type' v-if='$parent.currentSong !== {}'>
-						<div class='card is-fullwidth'  :class="{ 'is-highlight-active': isCurrentSongActive }" @click="highlight('currentSong')">
-							<header class='card-header'>
-								<p class='card-header-title'>
-									Current Song
-								</p>
-							</header>
-							<div class='card-content'>
-								<article class='media'>
-									<figure class='media-left'>
-										<p class='image is-64x64'>
-											<img :src='$parent.currentSong.thumbnail' onerror='this.src="/assets/notes-transparent.png"'>
+				</div>
+				<div class='column song-type' v-if='$parent.currentSong !== {}'>
+					<div class='card is-fullwidth'  :class="{ 'is-highlight-active': isCurrentSongActive }" @click="highlight('currentSong')">
+						<header class='card-header'>
+							<p class='card-header-title'>
+								Current Song
+							</p>
+						</header>
+						<div class='card-content'>
+							<article class='media'>
+								<figure class='media-left'>
+									<p class='image is-64x64'>
+										<img :src='$parent.currentSong.thumbnail' onerror='this.src="/assets/notes-transparent.png"'>
+									</p>
+								</figure>
+								<div class='media-content'>
+									<div class='content'>
+										<p>
+											<strong>{{ $parent.currentSong.title }}</strong>
+											<br>
+											<small>{{ $parent.currentSong.artists.split(' ,') }}</small>
 										</p>
-									</figure>
-									<div class='media-content'>
-										<div class='content'>
-											<p>
-												<strong>{{ $parent.currentSong.title }}</strong>
-												<br>
-												<small>{{ $parent.currentSong.artists.split(' ,') }}</small>
-											</p>
-										</div>
 									</div>
-								</article>
-							</div>
-							<a @click=highlight('currentSong') href='#' class='absolute-a'></a>
+								</div>
+							</article>
 						</div>
+						<a @click=highlight('currentSong') href='#' class='absolute-a'></a>
 					</div>
 				</div>
-				<div class='edit-report-wrapper'>
-					<div class='columns is-multiline'>
-						<div class='column is-half' v-for='issue in issues'>
-							<label class='label'>{{ issue.name }}</label>
-							<p class='control' v-for='reason in issue.reasons' track-by='$index'>
-								<label class='checkbox'>
-									<input type='checkbox' @click='toggleIssue(issue.name, reason)'>
-									{{ reason }}
-								</label>
-							</p>
-						</div>
-						<div class='column'>
-							<label class='label'>Other</label>
-							<textarea class='textarea' maxlength='400' placeholder='Any other details...' @keyup='updateCharactersRemaining()' v-model='report.description'></textarea>
-							<div class='textarea-counter'>{{ charactersRemaining }}</div>
-						</div>
+			</div>
+			<div class='edit-report-wrapper'>
+				<div class='columns is-multiline'>
+					<div class='column is-half' v-for='issue in issues'>
+						<label class='label'>{{ issue.name }}</label>
+						<p class='control' v-for='reason in issue.reasons' track-by='$index'>
+							<label class='checkbox'>
+								<input type='checkbox' @click='toggleIssue(issue.name, reason)'>
+								{{ reason }}
+							</label>
+						</p>
+					</div>
+					<div class='column'>
+						<label class='label'>Other</label>
+						<textarea class='textarea' maxlength='400' placeholder='Any other details...' @keyup='updateCharactersRemaining()' v-model='report.description'></textarea>
+						<div class='textarea-counter'>{{ charactersRemaining }}</div>
 					</div>
 				</div>
-			</section>
-			<footer class='modal-card-foot'>
-				<a class='button is-success' @click='create()' href='#'>
-					<i class='material-icons save-changes'>done</i>
-					<span>&nbsp;Create</span>
-				</a>
-				<a class='button is-danger' @click='$parent.modals.report = !$parent.modals.report' href='#'>
-					<span>&nbsp;Cancel</span>
-				</a>
-			</footer>
+			</div>
+		</div>
+		<div slot='footer'>
+			<a class='button is-success' @click='create()' href='#'>
+				<i class='material-icons save-changes'>done</i>
+				<span>&nbsp;Create</span>
+			</a>
+			<a class='button is-danger' @click='$parent.modals.report = !$parent.modals.report' href='#'>
+				<span>&nbsp;Cancel</span>
+			</a>
 		</div>
-	</div>
+	</modal>
 </template>
 
 <script>
 	import { Toast } from 'vue-roaster';
+	import Modal from './Modal.vue';
 	import io from '../../io';
 
 	export default {
+		components: { Modal },
 		data() {
 			return {
 				charactersRemaining: 400,

+ 1 - 1
frontend/components/Modals/WhatIsNew.vue

@@ -51,7 +51,7 @@
 		},
 		ready: function () {
 			let _this = this;
-			io.getSocket((socket) => {
+			io.getSocket(true, (socket) => {
 				_this.socket = socket;
 				_this.socket.emit('news.newest', res => {
 					_this.news = res.data;

+ 1 - 1
frontend/components/Sidebars/SongsList.vue

@@ -105,7 +105,7 @@
 	}
 
 	.content p strong {
-		word-break: break-all;
+		word-break: break-word;
 	}
 	
 	.content p small {

+ 28 - 7
frontend/components/Station/Station.vue

@@ -43,9 +43,10 @@
 						<div class="columns is-mobile">
 							<form style="margin-top: 12px; margin-bottom: 0;" action="#" class="column is-7-desktop is-4-mobile">
 								<p class='volume-slider-wrapper'>
-									<i class="material-icons">volume_down</i>
+									<i class="material-icons" @click='toggleMute()' v-if='muted'>volume_mute</i>
+									<i class="material-icons" @click='toggleMute()' v-else>volume_down</i>
 									<input type="range" id="volumeSlider" min="0" max="100" class="active" v-on:change="changeVolume()" v-on:input="changeVolume()">
-									<i class="material-icons">volume_up</i>
+									<i class="material-icons" @click='toggleMaxVolume()'>volume_up</i>
 								</p>
 							</form>
 							<div class="column is-8-mobile is-5-desktop" style="float: right;">
@@ -100,6 +101,7 @@
 				player: undefined,
 				timePaused: 0,
 				paused: false,
+				muted: false,
 				timeElapsed: '0:00',
 				liked: false,
 				disliked: false,
@@ -175,7 +177,7 @@
 			},
 			getTimeElapsed: function() {
 				let local = this;
-				if (local.currentSong) return Date.now2() - local.startedAt - local.timePaused;
+				if (local.currentSong) return Date.currently() - local.startedAt - local.timePaused;
 				else return 0;
 			},
 			playVideo: function() {
@@ -205,14 +207,14 @@
 			},
 			calculateTimeElapsed: function() {
 				let local = this;
-				let currentTime = Date.now2();
+				let currentTime = Date.currently();
 
 				if (local.currentTime !== undefined && local.paused) {
-					local.timePaused += (Date.now2() - local.currentTime);
+					local.timePaused += (Date.currently() - local.currentTime);
 					local.currentTime = undefined;
 				}
 
-				let duration = (Date.now2() - local.startedAt - local.timePaused) / 1000;
+				let duration = (Date.currently() - local.startedAt - local.timePaused) / 1000;
 				let songDuration = local.currentSong.duration;
 				if (songDuration <= duration) local.player.pauseVideo();
 				if ((!local.paused) && duration <= songDuration) local.timeElapsed = local.formatTime(duration);
@@ -270,6 +272,23 @@
 					else Toast.methods.addToast('Successfully paused the station.', 4000);
 				});
 			},
+			toggleMute: function () {
+				if (this.playerReady) {
+					let previousVolume = parseInt(localStorage.getItem("volume"));
+					let volume = this.player.getVolume() <= 0 ? previousVolume : 0;
+					this.muted = !this.muted;
+					$("#volumeSlider").val(volume);
+					this.player.setVolume(volume);
+				}
+			},
+			toggleMaxVolume: function () {
+				if (this.playerReady) {
+					let previousVolume = parseInt(localStorage.getItem("volume"));
+					let volume = this.player.getVolume() <= previousVolume ? 100 : previousVolume;
+					$("#volumeSlider").val(volume);
+					this.player.setVolume(volume);
+				}
+			},
 			toggleLike: function() {
 				let _this = this;
 				if (_this.liked) _this.socket.emit('songs.unlike', _this.currentSong._id, data => {
@@ -381,7 +400,7 @@
 		},
 		ready: function() {
 			let _this = this;
-			Date.now2 = function() {
+			Date.currently = () => {
 				return new Date().getTime() + _this.systemDifference;
 			};
 			_this.stationId = _this.$route.params.id;
@@ -558,6 +577,8 @@
 		align-items: center;
 	}
 
+	.material-icons { cursor: pointer; }
+
 	.stationDisplayName {
 		color: white !important;
 	}

+ 17 - 1
frontend/components/pages/Admin.vue

@@ -27,6 +27,12 @@
 						<span>&nbsp;Reports</span>
 					</a>
 				</li>
+				<li :class='{ "is-active": currentTab == "news" }' @click='showTab("news")'>
+					<a>
+						<i class="material-icons">chrome_reader_mode</i>
+						<span>&nbsp;News</span>
+					</a>
+				</li>
 			</ul>
 		</div>
 
@@ -34,6 +40,7 @@
 		<songs v-if='currentTab == "songs"'></songs>
 		<stations v-if='currentTab == "stations"'></stations>
 		<reports v-if='currentTab == "reports"'></reports>
+		<news v-if='currentTab == "news"'></news>
 	</div>
 </template>
 
@@ -45,9 +52,18 @@
 	import Songs from '../Admin/Songs.vue';
 	import Stations from '../Admin/Stations.vue';
 	import Reports from '../Admin/Reports.vue';
+	import News from '../Admin/News.vue';
 
 	export default {
-		components: { MainHeader, MainFooter, QueueSongs, Songs, Stations, Reports },
+		components: {
+			MainHeader,
+			MainFooter,
+			QueueSongs,
+			Songs,
+			Stations,
+			Reports,
+			News
+		},
 		data() {
 			return {
 				currentTab: 'queueSongs'

+ 11 - 4
frontend/components/pages/Home.vue

@@ -29,7 +29,11 @@
 			</div>
 		</div>
 		<div class="group">
-			<div class="group-title">Community Stations <a @click="toggleModal('createCommunityStation')" v-if="$parent.loggedIn" href='#'><i class="material-icons community-button">add_circle_outline</i></a></div>
+			<div class="group-title">
+				Community Stations&nbsp;
+				<a @click='modals.createCommunityStation = !modals.createCommunityStation' v-if="$parent.loggedIn" href='#'>
+				<i class="material-icons community-button">add_circle_outline</i></a>
+			</div>
 			<div class="card station-card" v-for="station in stations.community" v-link="{ path: '/community/' + station._id }" @click="this.$dispatch('joinStation', station._id)">
 				<div class="card-image">
 					<figure class="image is-square">
@@ -60,25 +64,28 @@
 		</div>
 		<main-footer></main-footer>
 	</div>
+	<create-community-station v-if='modals.createCommunityStation'></create-community-station>
 </template>
 
 <script>
 	import MainHeader from '../MainHeader.vue';
 	import MainFooter from '../MainFooter.vue';
+	import CreateCommunityStation from '../Modals/CreateCommunityStation.vue';
 	import auth from '../../auth';
 	import io from '../../io';
 
 	export default {
 		data() {
 			return {
-				isRegisterActive: false,
-				isLoginActive: false,
 				recaptcha: {
 					key: ''
 				},
 				stations: {
 					official: [],
 					community: []
+				},
+				modals: {
+					createCommunityStation: false
 				}
 			}
 		},
@@ -144,7 +151,7 @@
 				return station.owner === _this.$parent.userId && station.privacy === 'public';
 			}
 		},
-		components: { MainHeader, MainFooter }
+		components: { MainHeader, MainFooter, CreateCommunityStation }
 	}
 </script>
 

+ 14 - 1
frontend/components/pages/News.vue

@@ -53,7 +53,7 @@
 		methods: {
 			formatDate: unix => {
 				return moment(unix).format('DD-MM-YYYY');
-			},
+			}
 		},
 		data() {
 			return {
@@ -67,6 +67,19 @@
 				_this.socket.emit('news.index', res => {
 					_this.news = res.data;
 				});
+				_this.socket.on('event:admin.news.created', news => {
+					_this.news.unshift(news);
+				});
+				_this.socket.on('event:admin.news.updated', news => {
+					for (let n = 0; n < _this.news.length; n++) {
+						if (_this.news[n]._id === news._id) {
+							_this.news.$set(n, news);
+						}
+					}
+				});
+				_this.socket.on('event:admin.news.removed', news => {
+					_this.news = _this.news.filter(item => item._id !== news._id);
+				});
 			});
 		}
 	}

+ 1 - 0
frontend/main.js

@@ -36,6 +36,7 @@ document.onkeydown = event => {
 };
 
 router.beforeEach(transition => {
+	window.location.hash = '';
 	if (window.stationInterval) {
 		clearInterval(window.stationInterval);
 		window.stationInterval = 0;

+ 1 - 1
frontend/nginx.conf

@@ -13,7 +13,7 @@ http {
     keepalive_timeout  65;
 
     server {
-        listen       ${NGINX_PORT};
+        listen       80;
         server_name  localhost;
 
         location / {