Browse Source

Merge pull request #42 from Musare/experimental

Lofig, vue-roaster, EditStation modal, bugs...
Jonathan Graham 5 years ago
parent
commit
880397ea3b
68 changed files with 2496 additions and 2190 deletions
  1. 1 1
      .gitignore
  2. 66 64
      README.md
  3. 3 1
      backend/core.js
  4. 1 1
      backend/index.js
  5. 4 4
      backend/logic/actions/apis.js
  6. 0 1
      backend/logic/actions/hooks/adminRequired.js
  7. 0 1
      backend/logic/actions/hooks/loginRequired.js
  8. 0 1
      backend/logic/actions/hooks/ownerRequired.js
  9. 8 9
      backend/logic/actions/news.js
  10. 53 65
      backend/logic/actions/playlists.js
  11. 6 7
      backend/logic/actions/punishments.js
  12. 37 37
      backend/logic/actions/queueSongs.js
  13. 7 9
      backend/logic/actions/reports.js
  14. 26 34
      backend/logic/actions/songs.js
  15. 62 101
      backend/logic/actions/stations.js
  16. 49 59
      backend/logic/actions/users.js
  17. 1 1
      backend/logic/cache/index.js
  18. 39 30
      backend/logic/db/index.js
  19. 1 1
      backend/logic/discord.js
  20. 8 3
      backend/logic/io.js
  21. 2 1
      backend/logic/notifications.js
  22. 57 27
      backend/logic/stations.js
  23. 33 11
      backend/logic/tasks.js
  24. 14 34
      backend/logic/utils.js
  25. 2 1
      frontend/.eslintrc
  26. 0 16
      frontend/.snyk
  27. 17 8
      frontend/App.vue
  28. 5 5
      frontend/api/auth.js
  29. 0 5
      frontend/components/404.vue
  30. 0 501
      frontend/components/Admin/EditStation.vue
  31. 26 18
      frontend/components/Admin/News.vue
  32. 2 2
      frontend/components/Admin/Punishments.vue
  33. 53 34
      frontend/components/Admin/QueueSongs.vue
  34. 21 9
      frontend/components/Admin/Reports.vue
  35. 46 9
      frontend/components/Admin/Songs.vue
  36. 33 21
      frontend/components/Admin/Stations.vue
  37. 3 3
      frontend/components/MainFooter.vue
  38. 7 9
      frontend/components/MainHeader.vue
  39. 3 3
      frontend/components/Modals/AddSongToPlaylist.vue
  40. 25 13
      frontend/components/Modals/AddSongToQueue.vue
  41. 39 35
      frontend/components/Modals/CreateCommunityStation.vue
  42. 11 4
      frontend/components/Modals/EditNews.vue
  43. 139 98
      frontend/components/Modals/EditSong.vue
  44. 869 174
      frontend/components/Modals/EditStation.vue
  45. 36 30
      frontend/components/Modals/EditUser.vue
  46. 29 6
      frontend/components/Modals/IssuesModal.vue
  47. 37 33
      frontend/components/Modals/Login.vue
  48. 13 11
      frontend/components/Modals/Playlists/Create.vue
  49. 25 22
      frontend/components/Modals/Playlists/Edit.vue
  50. 8 8
      frontend/components/Modals/Register.vue
  51. 2 2
      frontend/components/Modals/Report.vue
  52. 6 3
      frontend/components/Sidebars/Playlist.vue
  53. 7 6
      frontend/components/Sidebars/SongsList.vue
  54. 123 67
      frontend/components/Station/Station.vue
  55. 9 10
      frontend/components/Station/StationHeader.vue
  56. 16 7
      frontend/components/User/ResetPassword.vue
  57. 67 58
      frontend/components/User/Settings.vue
  58. 6 6
      frontend/components/User/Show.vue
  59. 293 287
      frontend/components/pages/Home.vue
  60. 0 0
      frontend/dist/assets/blue_wordmark.png
  61. BIN
      frontend/dist/assets/white_wordmark.png
  62. 2 1
      frontend/dist/config/template.json
  63. 4 1
      frontend/dist/index.tpl.html
  64. 0 0
      frontend/dist/lofig.min.js
  65. 16 12
      frontend/main.js
  66. 1 1
      frontend/package.json
  67. 4 2
      frontend/validation.js
  68. 13 146
      yarn.lock

+ 1 - 1
.gitignore

@@ -13,6 +13,7 @@ startMongo.cmd
 .redis
 *.rdb
 npm-debug.log
+lerna-debug.log
 
 # Backend
 backend/node_modules/
@@ -24,7 +25,6 @@ frontend/bundle-stats.json
 frontend/bundle-report.html
 frontend/node_modules/
 frontend/dist/build/
-!frontend/dist/lofig.min.js
 frontend/dist/index.html
 frontend/dist/config/default.json
 

+ 66 - 64
README.md

@@ -1,5 +1,4 @@
 
-  
 # MusareNode
 
 Based off of the original [Musare](https://github.com/Musare/MusareMeteor), which utilized Meteor.
@@ -34,6 +33,7 @@ The backend is a scalable NodeJS / Redis / MongoDB app. Each backend server hand
 We currently only utilize 1 backend, 1 MongoDB server and 1 Redis server running for production, though it is relatively easy to expand.
 
 ## Requirements
+
 Installing with Docker: (not recommended for Windows users)
 
 - [Docker](https://www.docker.com/)
@@ -57,56 +57,57 @@ Once you've installed the required tools:
 
 3. `cp backend/config/template.json backend/config/default.json`
 
-|Property|Description|
-|--|--|
-|`mode`|Should be either `development` or `production`. No more explanation needed.|
-|`secret`|Whatever you want - used by express's session module.|
-|`domain`|Should be the url where the site will be accessible from,usually `http://localhost` for non-Docker.|
-|`serverDomain`|Should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.|
-|`serverPort`|Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker.|
-|`isDocker`|Self-explanatory. Are you using Docker?|
-|`serverPort`|Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker.|
-|`apis.youtube.key`|Can be obtained by setting up a [YouTube API Key](https://developers.google.com/youtube/v3/getting-started). You need to use the YouTube Data API v3, and create an API key.|
-|`apis.recaptcha.secret`|Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin).|
-|`apis.github`|Can be obtained by setting up a [GitHub OAuth Application](https://github.com/settings/developers). You need to fill in some values to create the OAuth application. The homepage is the homepage of frontend. The authorization callback url is the backend url with `/auth/github/authorize/callback` added at the end. For example `http://localhost:8080/auth/github/authorize/callback`.|
-|`apis.discord.token`|Token for the Discord bot.|
-|`apis.discord.loggingServer`|Server ID of the Discord logging server.|
-|`apis.discord.loggingChannel`|ID of the channel to be used in the Discord logging server.|
-|`apis.mailgun`|Can be obtained by setting up a [Mailgun account](http://www.mailgun.com/), or you can disable it.|
-|`apis.spotify`|Can be obtained by setting up a [Spotify client id](https://developer.spotify.com/dashboard/applications), or you can disable it.|
-|`apis.discogs`|Can be obtained by setting up a [Discogs application](https://www.discogs.com/settings/developers), or you can disable it.|
-|`redis.url`|Should be left alone for Docker, and changed to `redis://localhost:6379/0` for non-Docker.|
-|`redis.password`|Should be the Redis password you either put in your `startRedis.cmd` file for Windows, or `.env` for docker.|
-|`mongo.url`|Needs to have the proper password for the MongoDB musare user, and for non-Docker you need to replace `@musare:27017` with `@localhost:27017`.|
-|`cookie.domain`|Should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.|
-|`cookie.secure`|Should be `true` for SSL connections, and `false` for normal http connections.|
+   |Property|Description|
+   |--|--|
+   |`mode`|Should be either `development` or `production`. No more explanation needed.|
+   |`secret`|Whatever you want - used by express's session module.|
+   |`domain`|Should be the url where the site will be accessible from,usually `http://localhost` for non-Docker.|
+   |`serverDomain`|Should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.|
+   |`serverPort`|Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker.|
+   |`isDocker`|Self-explanatory. Are you using Docker?|
+   |`serverPort`|Should be the port where the backend will listen on, should always be `8080` for Docker, and is recommended for non-Docker.|
+   |`apis.youtube.key`|Can be obtained by setting up a [YouTube API Key](https://developers.google.com/youtube/v3/getting-started). You need to use the YouTube Data API v3, and create an API key.|
+   |`apis.recaptcha.secret`|Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin).|
+   |`apis.github`|Can be obtained by setting up a [GitHub OAuth Application](https://github.com/settings/developers). You need to fill in some values to create the OAuth application. The homepage is the homepage of frontend. The authorization callback url is the backend url with `/auth/github/authorize/callback` added at the end. For example `http://localhost:8080/auth/github/authorize/callback`.|
+   |`apis.discord.token`|Token for the Discord bot.|
+   |`apis.discord.loggingServer`|Server ID of the Discord logging server.|
+   |`apis.discord.loggingChannel`|ID of the channel to be used in the Discord logging server.|
+   |`apis.mailgun`|Can be obtained by setting up a [Mailgun account](http://www.mailgun.com/), or you can disable it.|
+   |`apis.spotify`|Can be obtained by setting up a [Spotify client id](https://developer.spotify.com/dashboard/applications), or you can disable it.|
+   |`apis.discogs`|Can be obtained by setting up a [Discogs application](https://www.discogs.com/settings/developers), or you can disable it.|
+   |`redis.url`|Should be left alone for Docker, and changed to `redis://localhost:6379/0` for non-Docker.|
+   |`redis.password`|Should be the Redis password you either put in your `startRedis.cmd` file for Windows, or `.env` for docker.|
+   |`mongo.url`|Needs to have the proper password for the MongoDB musare user, and for non-Docker you need to replace `@musare:27017` with `@localhost:27017`.|
+   |`cookie.domain`|Should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.|
+   |`cookie.secure`|Should be `true` for SSL connections, and `false` for normal http connections.|
 
 4. `cp frontend/build/config/template.json frontend/build/config/default.json`
 
-|Property|Description|
-|--|--|
-|`serverDomain`|Should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.|
-|`frontendDomain`|Should be the url where the frontend will be accessible from, usually `http://localhost` for docker or `http://localhost:80` for non-Docker.|
-|`frontendPort`|Should be the port where the frontend will be accessible from, should always be port `81` for Docker, and is recommended to be port `80` for non-Docker.|
-|`recaptcha.key`|Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin).|
-|`cookie.domain`|Should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.|
-|`cookie.secure`|Should be `true` for SSL connections, and `false` for normal http connections.|
-|`siteSettings.logo`|Path to the logo image, by default it is `/assets/wordmark.png`.|
-|`siteSettings.siteName`|Should be the name of the site.|
-|`siteSettings.socialLinks`|`github`, `twitter` and `facebook` are set to the official Musare accounts by default, but can be changed.|
+   |Property|Description|
+   |--|--|
+   |`serverDomain`|Should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.|
+   |`frontendDomain`|Should be the url where the frontend will be accessible from, usually `http://localhost` for docker or `http://localhost:80` for non-Docker.|
+   |`frontendPort`|Should be the port where the frontend will be accessible from, should always be port `81` for Docker, and is recommended to be port `80` for non-Docker.|
+   |`recaptcha.key`|Can be obtained by setting up a [ReCaptcha Site (v3)](https://www.google.com/recaptcha/admin).|
+   |`cookie.domain`|Should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.|
+   |`cookie.secure`|Should be `true` for SSL connections, and `false` for normal http connections.|
+   |`siteSettings.logo`|Path to the logo image, by default it is `/assets/wordmark.png`.|
+   |`siteSettings.siteName`|Should be the name of the site.|
+   |`siteSettings.socialLinks`|`github`, `twitter` and `facebook` are set to the official Musare accounts by default, but can be changed.|
 
 5. Simply `cp .env.example .env` to setup your environment variables.
 
 6. To setup [snyk](https://snyk.io/) (which is what we use for our precommit git-hooks), you will need to:
-- Setup an account
-- Go to [settings](https://app.snyk.io/account)
-- Copy the API token and set it as your `SNYK_TOKEN` environment variable.
+
+   - Setup an account
+   - Go to [settings](https://app.snyk.io/account)
+   - Copy the API token and set it as your `SNYK_TOKEN` environment variable.
 
 We use snyk to test our dependencies / dev-dependencies for vulnerabilities.
 
 ### Installing with Docker
 
-_Configuration_
+#### Configuration
 
 To configure docker configure the `.env` file to match your settings in `backend/config/default.json`.  
 The configurable ports will be how you access the services on your machine, or what ports you will need to specify in your nginx files when using proxy_pass. 
@@ -127,20 +128,19 @@ The configurable ports will be how you access the services on your machine, or w
 
       In `.env` set the environment variable of `MONGO_USER_USERNAME` and `MONGO_USER_PASSWORD`.
 
-   2. Start the database (in detached mode), which will generate the correct MongoDB users.
+   3. Start the database (in detached mode), which will generate the correct MongoDB users.
 
       `docker-compose up -d mongo`
 
-
-3) Start redis and the mongo client in the background, as we usually don't need to monitor these for errors
+3. Start redis and the mongo client in the background, as we usually don't need to monitor these for errors
 
    `docker-compose up -d mongoclient redis`
 
-4) Start the backend and frontend in the foreground, so we can watch for errors during development
+4. Start the backend and frontend in the foreground, so we can watch for errors during development
 
    `docker-compose up backend frontend`
 
-5) You should now be able to begin development! The backend is auto reloaded when
+5. You should now be able to begin development! The backend is auto reloaded when
    you make changes and the frontend is auto compiled and live reloaded by webpack
    when you make changes. You should be able to access Musare in your local browser
    at `http://<docker-machine-ip>:8080/` where `<docker-machine-ip>` can be found below:
@@ -150,24 +150,25 @@ The configurable ports will be how you access the services on your machine, or w
    - Docker ToolBox: The output of `docker-machine ip default`
 
 If you are using linting extensions in IDEs/want to run `yarn lint`, you need to install the following locally (outside of Docker):
-```
-yarn global add eslint
-yarn add eslint-config-airbnb-base
-```
+
+   ```bash
+   yarn global add eslint
+   yarn add eslint-config-airbnb-base
+   ```
 
 ### Standard Installation
 
 Steps 1-4 are things you only have to do once. The steps to start servers follow.
 
-1.  In the main folder, create a folder called `.database`
+1. In the main folder, create a folder called `.database`
 
-2.  Create a file called `startMongo.cmd` in the main folder with the contents:
+2. Create a file called `startMongo.cmd` in the main folder with the contents:
 
-        "C:\Program Files\MongoDB\Server\3.2\bin\mongod.exe" --dbpath "D:\Programming\HTML\MusareNode\.database"
+   "C:\Program Files\MongoDB\Server\3.2\bin\mongod.exe" --dbpath "D:\Programming\HTML\MusareNode\.database"
 
-    Make sure to adjust your paths accordingly.
+   Make sure to adjust your paths accordingly.
 
-3.  Set up the MongoDB database
+3. Set up the MongoDB database
 
     1. Start the database by executing the script `startMongo.cmd` you just made
 
@@ -195,21 +196,21 @@ Steps 1-4 are things you only have to do once. The steps to start servers follow
 
        In `startMongo.cmd` add `--auth` at the end of the first line
 
-4.  In the folder where you installed Redis, edit the `redis.windows.conf` file. In there, look for the property `notify-keyspace-events`. Make sure that property is uncommented and has the value `Ex`. It should look like `notify-keyspace-events Ex` when done.
+4. In the folder where you installed Redis, edit the `redis.windows.conf` file. In there, look for the property `notify-keyspace-events`. Make sure that property is uncommented and has the value `Ex`. It should look like `notify-keyspace-events Ex` when done.
 
-5.  Create a file called `startRedis.cmd` in the main folder with the contents:
+5. Create a file called `startRedis.cmd` in the main folder with the contents:
 
-        "D:\Redis\redis-server.exe" "D:\Redis\redis.windows.conf" "--requirepass" "PASSWORD"
+   "D:\Redis\redis-server.exe" "D:\Redis\redis.windows.conf" "--requirepass" "PASSWORD"
 
-    And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
+   And again, make sure that the paths lead to the proper config and executable. Replace `PASSWORD` with your Redis password.
 
 ### Non-docker start servers
 
-**Automatic**
+#### Automatic
 
-1.  If you are on Windows you can run `windows-start.cmd` or just double click the `windows-start.cmd` file and all servers will automatically start up.
+1. If you are on Windows you can run `windows-start.cmd` or just double click the `windows-start.cmd` file and all servers will automatically start up.
 
-**Manual**
+#### Manual
 
 1. Run `startRedis.cmd` and `startMongo.cmd` to start Redis and Mongo.
 
@@ -289,15 +290,15 @@ Run this command in your shell. You will have to do this command for every shell
 You can call Toasts using our custom package, [`vue-roaster`](https://github.com/atjonathan/vue-roaster), using the following code:
 
 ```js
-import { Toast } from "vue-roaster";
-Toast.methods.addToast("", 0);
+import Toast from "vue-roaster";
+new Toast({ content: "", persistant: true });
 ```
 
 ### Set user role
 
 When setting up you will need to grant yourself the admin role, using the following commands:
 
-```
+```bash
 docker-compose exec mongo mongo admin
 
 use musare
@@ -310,6 +311,7 @@ db.users.update({username: "USERNAME"}, {$set: {role: "admin"}})
 We use lerna to add an additional package to either the frontend or the backend.
 
 For example, this is how we would to add the `webpack-bundle-analyser` package as a dev-dependency to the frontend:
-```
+
+```bash
 npx lerna add webpack-bundle-analyser --scope=musare-frontend --dev
 ```

+ 3 - 1
backend/core.js

@@ -2,6 +2,8 @@ const EventEmitter = require('events');
 
 const bus = new EventEmitter();
 
+bus.setMaxListeners(1000);
+
 module.exports = class {
 	constructor(name, moduleManager) {
 		this.name = name;
@@ -69,7 +71,7 @@ module.exports = class {
 	}
 
 	_validateHook() {
-		return Promise.race([this._onInitialize, this._isInitialized]).then(
+		return Promise.race([this._onInitialize(), this._isInitialized()]).then(
 			() => this._isNotLocked()
 		);
 	}

+ 1 - 1
backend/index.js

@@ -66,7 +66,7 @@ class ModuleManager {
 	}
 
 	async printStatus() {
-		try { await Promise.race([this.logger._onInitialize, this.logger._isInitialized]); } catch { return; }
+		try { await Promise.race([this.logger._onInitialize(), this.logger._isInitialized()]); } catch { return; }
 		if (!this.fancyConsole) return;
 		
 		let colors = this.logger.colors;

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

@@ -58,13 +58,13 @@ module.exports = {
 	 * @param artist - an artist for that song
 	 * @param cb
 	 */
-	getSpotifySongs: hooks.adminRequired((session, title, artist, cb, userId) => {
+	getSpotifySongs: hooks.adminRequired((session, title, artist, cb) => {
 		async.waterfall([
 			(next) => {
 				utils.getSongsFromSpotify(title, artist, next);
 			}
 		], (songs) => {
-			logger.success('APIS_GET_SPOTIFY_SONGS', `User "${userId}" got Spotify songs for title "${title}" successfully.`);
+			logger.success('APIS_GET_SPOTIFY_SONGS', `User "${session.userId}" got Spotify songs for title "${title}" successfully.`);
 			cb({status: 'success', songs: songs});
 		});
 	}),
@@ -76,7 +76,7 @@ module.exports = {
 	 * @param query - the query
 	 * @param cb
 	 */
-	searchDiscogs: hooks.adminRequired((session, query, page, cb, userId) => {
+	searchDiscogs: hooks.adminRequired((session, query, page, cb) => {
 		async.waterfall([
 			(next) => {
 				const params = [
@@ -106,7 +106,7 @@ module.exports = {
 				logger.error("APIS_SEARCH_DISCOGS", `Searching discogs failed with query "${query}". "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
-			logger.success('APIS_SEARCH_DISCOGS', `User "${userId}" searched Discogs succesfully for query "${query}".`);
+			logger.success('APIS_SEARCH_DISCOGS', `User "${session.userId}" searched Discogs succesfully for query "${query}".`);
 			cb({status: 'success', results: body.results, pages: body.pagination.pages});
 		});
 	}),

+ 0 - 1
backend/logic/actions/hooks/adminRequired.js

@@ -33,7 +33,6 @@ module.exports = function(next) {
 				return cb({status: 'failure', message: err});
 			}
 			logger.info("ADMIN_REQUIRED", `User "${session.userId}" passed admin required check.`, false);
-			args.push(session.userId);
 			next.apply(null, args);
 		});
 	}

+ 0 - 1
backend/logic/actions/hooks/loginRequired.js

@@ -27,7 +27,6 @@ module.exports = function(next) {
 				return cb({status: 'failure', message: err});
 			}
 			logger.info("LOGIN_REQUIRED", `User "${session.userId}" passed login required check.`, false);
-			args.push(session.userId);
 			next.apply(null, args);
 		});
 	}

+ 0 - 1
backend/logic/actions/hooks/ownerRequired.js

@@ -39,7 +39,6 @@ module.exports = function(next) {
 				return cb({status: 'failure', message: err});
 			}
 			logger.info("OWNER_REQUIRED", `User "${session.userId}" passed owner required check for station "${stationId}"`, false);
-			args.push(session.userId);
 			next.apply(null, args);
 		});
 	}

+ 8 - 9
backend/logic/actions/news.js

@@ -64,12 +64,11 @@ module.exports = {
 	 * @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) => {
+	create: hooks.adminRequired((session, data, cb) => {
 		async.waterfall([
 			(next) => {
-				data.createdBy = userId;
+				data.createdBy = session.userId;
 				data.createdAt = Date.now();
 				db.models.news.create(data, next);
 			}
@@ -116,15 +115,15 @@ module.exports = {
 	 */
 	//TODO Pass in an id, not an object
 	//TODO Fix this
-	remove: hooks.adminRequired((session, news, cb, userId) => {
+	remove: hooks.adminRequired((session, news, cb) => {
 		db.models.news.deleteOne({ _id: news._id }, async err => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("NEWS_REMOVE", `Removing news "${news._id}" failed for user "${userId}". "${err}"`);
+				logger.error("NEWS_REMOVE", `Removing news "${news._id}" failed for user "${session.userId}". "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			} else {
 				cache.pub('news.remove', news);
-				logger.success("NEWS_REMOVE", `Removing news "${news._id}" successful by user "${userId}".`);
+				logger.success("NEWS_REMOVE", `Removing news "${news._id}" successful by user "${session.userId}".`);
 				return cb({ 'status': 'success', 'message': 'Successfully removed News' });
 			}
 		});
@@ -139,15 +138,15 @@ module.exports = {
 	 * @param {Function} cb - gets called with the result
 	 */
 	//TODO Fix this
-	update: hooks.adminRequired((session, _id, news, cb, userId) => {
+	update: hooks.adminRequired((session, _id, news, cb) => {
 		db.models.news.updateOne({ _id }, news, { upsert: true }, async err => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("NEWS_UPDATE", `Updating news "${_id}" failed for user "${userId}". "${err}"`);
+				logger.error("NEWS_UPDATE", `Updating news "${_id}" failed for user "${session.userId}". "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			} else {
 				cache.pub('news.update', news);
-				logger.success("NEWS_UPDATE", `Updating news "${_id}" successful for user "${userId}".`);
+				logger.success("NEWS_UPDATE", `Updating news "${_id}" successful for user "${session.userId}".`);
 				return cb({ 'status': 'success', 'message': 'Successfully updated News' });
 			}
 		});

+ 53 - 65
backend/logic/actions/playlists.js

@@ -80,25 +80,24 @@ let lib = {
 	 * @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) => {
+	getFirstSong: hooks.loginRequired((session, playlistId, cb) => {
 		async.waterfall([
 			(next) => {
 				playlists.getPlaylist(playlistId, next);
 			},
 
 			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found.');
+				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found.');
 				next(null, playlist.songs[0]);
 			}
 		], async (err, song) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("PLAYLIST_GET_FIRST_SONG", `Getting the first song of playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				logger.error("PLAYLIST_GET_FIRST_SONG", `Getting the first song of playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
-			logger.success("PLAYLIST_GET_FIRST_SONG", `Successfully got the first song of playlist "${playlistId}" for user "${userId}".`);
+			logger.success("PLAYLIST_GET_FIRST_SONG", `Successfully got the first song of playlist "${playlistId}" for user "${session.userId}".`);
 			cb({
 				status: 'success',
 				song: song
@@ -111,20 +110,19 @@ let lib = {
 	 *
 	 * @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) => {
+	indexForUser: hooks.loginRequired((session, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.playlist.find({ createdBy: userId }, next);
+				db.models.playlist.find({ createdBy: session.userId }, next);
 			}
 		], async (err, playlists) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("PLAYLIST_INDEX_FOR_USER", `Indexing playlists for user "${userId}" failed. "${err}"`);
+				logger.error("PLAYLIST_INDEX_FOR_USER", `Indexing playlists for user "${session.userId}" failed. "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
-			logger.success("PLAYLIST_INDEX_FOR_USER", `Successfully indexed playlists for user "${userId}".`);
+			logger.success("PLAYLIST_INDEX_FOR_USER", `Successfully indexed playlists for user "${session.userId}".`);
 			cb({
 				status: 'success',
 				data: playlists
@@ -138,9 +136,8 @@ let lib = {
 	 * @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) => {
+	create: hooks.loginRequired((session, data, cb) => {
 		async.waterfall([
 
 			(next) => {
@@ -152,7 +149,7 @@ let lib = {
 				db.models.playlist.create({
 					displayName,
 					songs,
-					createdBy: userId,
+					createdBy: session.userId,
 					createdAt: Date.now()
 				}, next);
 			}
@@ -160,11 +157,11 @@ let lib = {
 		], async (err, playlist) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("PLAYLIST_CREATE", `Creating private playlist failed for user "${userId}". "${err}"`);
+				logger.error("PLAYLIST_CREATE", `Creating private playlist failed for user "${session.userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
 			cache.pub('playlist.create', playlist._id);
-			logger.success("PLAYLIST_CREATE", `Successfully created private playlist for user "${userId}".`);
+			logger.success("PLAYLIST_CREATE", `Successfully created private playlist for user "${session.userId}".`);
 			cb({ status: 'success', message: 'Successfully created playlist', data: {
 				_id: playlist._id
 			} });
@@ -177,25 +174,24 @@ let lib = {
 	 * @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) => {
+	getPlaylist: hooks.loginRequired((session, playlistId, cb) => {
 		async.waterfall([
 			(next) => {
 				playlists.getPlaylist(playlistId, next);
 			},
 
 			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
+				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
 				next(null, playlist);
 			}
 		], async (err, playlist) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("PLAYLIST_GET", `Getting private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				logger.error("PLAYLIST_GET", `Getting private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
-			logger.success("PLAYLIST_GET", `Successfully got private playlist "${playlistId}" for user "${userId}".`);
+			logger.success("PLAYLIST_GET", `Successfully got private playlist "${playlistId}" for user "${session.userId}".`);
 			cb({
 				status: 'success',
 				data: playlist
@@ -211,12 +207,11 @@ let lib = {
 	 * @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) => {
+	update: hooks.loginRequired((session, playlistId, playlist, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.playlist.updateOne({ _id: playlistId, createdBy: userId }, playlist, {runValidators: true}, next);
+				db.models.playlist.updateOne({ _id: playlistId, createdBy: session.userId }, playlist, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -225,10 +220,10 @@ let lib = {
 		], async (err, playlist) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("PLAYLIST_UPDATE", `Updating private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				logger.error("PLAYLIST_UPDATE", `Updating private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
-			logger.success("PLAYLIST_UPDATE", `Successfully updated private playlist "${playlistId}" for user "${userId}".`);
+			logger.success("PLAYLIST_UPDATE", `Successfully updated private playlist "${playlistId}" for user "${session.userId}".`);
 			cb({
 				status: 'success',
 				data: playlist
@@ -243,13 +238,12 @@ let lib = {
 	 * @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) => {
+	addSongToPlaylist: hooks.loginRequired((session, songId, playlistId, cb) => {
 		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');
+					if (err || !playlist || playlist.createdBy !== session.userId) return next('Something went wrong when trying to get the playlist');
 
 					async.each(playlist.songs, (song, next) => {
 						if (song.songId === songId) return next('That song is already in the playlist');
@@ -285,11 +279,11 @@ let lib = {
 		async (err, playlist, newSong) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("PLAYLIST_ADD_SONG", `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				logger.error("PLAYLIST_ADD_SONG", `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			} else {
-				logger.success("PLAYLIST_ADD_SONG", `Successfully added song "${songId}" to private playlist "${playlistId}" for user "${userId}".`);
-				cache.pub('playlist.addSong', { playlistId: playlist._id, song: newSong, userId });
+				logger.success("PLAYLIST_ADD_SONG", `Successfully added song "${songId}" to private playlist "${playlistId}" for user "${session.userId}".`);
+				cache.pub('playlist.addSong', { playlistId: playlist._id, song: newSong, userId: session.userId });
 				return cb({ status: 'success', message: 'Song has been successfully added to the playlist', data: playlist.songs });
 			}
 		});
@@ -302,9 +296,8 @@ let lib = {
 	 * @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) => {
+	addSetToPlaylist: hooks.loginRequired((session, url, playlistId, cb) => {
 		async.waterfall([
 			(next) => {
 				utils.getPlaylistFromYouTube(url, songs => {
@@ -328,16 +321,16 @@ let lib = {
 			},
 
 			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found.');
+				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found.');
 				next(null, playlist);
 			}
 		], async (err, playlist) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("PLAYLIST_IMPORT", `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				logger.error("PLAYLIST_IMPORT", `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			} else {
-				logger.success("PLAYLIST_IMPORT", `Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${userId}".`);
+				logger.success("PLAYLIST_IMPORT", `Successfully imported a YouTube playlist to private playlist "${playlistId}" for user "${session.userId}".`);
 				cb({ status: 'success', message: 'Playlist has been successfully imported.', data: playlist.songs });
 			}
 		});
@@ -350,9 +343,8 @@ let lib = {
 	 * @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) => {
+	removeSongFromPlaylist: hooks.loginRequired((session, songId, playlistId, cb) => {
 		async.waterfall([
 			(next) => {
 				if (!songId || typeof songId !== 'string') return next('Invalid song id.');
@@ -365,7 +357,7 @@ let lib = {
 			},
 
 			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
+				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
 				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId: songId}}}, next);
 			},
 
@@ -375,11 +367,11 @@ let lib = {
 		], async (err, playlist) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("PLAYLIST_REMOVE_SONG", `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				logger.error("PLAYLIST_REMOVE_SONG", `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			} else {
-				logger.success("PLAYLIST_REMOVE_SONG", `Successfully removed song "${songId}" from private playlist "${playlistId}" for user "${userId}".`);
-				cache.pub('playlist.removeSong', { playlistId: playlist._id, songId: songId, userId });
+				logger.success("PLAYLIST_REMOVE_SONG", `Successfully removed song "${songId}" from private playlist "${playlistId}" for user "${session.userId}".`);
+				cache.pub('playlist.removeSong', { playlistId: playlist._id, songId: songId, userId: session.userId });
 				return cb({ status: 'success', message: 'Song has been successfully removed from playlist', data: playlist.songs });
 			}
 		});
@@ -391,12 +383,11 @@ let lib = {
 	 * @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) => {
+	updateDisplayName: hooks.loginRequired((session, playlistId, displayName, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.playlist.updateOne({ _id: playlistId, createdBy: userId }, { $set: { displayName } }, {runValidators: true}, next);
+				db.models.playlist.updateOne({ _id: playlistId, createdBy: session.userId }, { $set: { displayName } }, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -405,11 +396,11 @@ let lib = {
 		], async (err, playlist) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("PLAYLIST_UPDATE_DISPLAY_NAME", `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				logger.error("PLAYLIST_UPDATE_DISPLAY_NAME", `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
-			logger.success("PLAYLIST_UPDATE_DISPLAY_NAME", `Successfully updated display name to "${displayName}" for private playlist "${playlistId}" for user "${userId}".`);
-			cache.pub('playlist.updateDisplayName', {playlistId: playlistId, displayName: displayName, userId: userId});
+			logger.success("PLAYLIST_UPDATE_DISPLAY_NAME", `Successfully updated display name to "${displayName}" for private playlist "${playlistId}" for user "${session.userId}".`);
+			cache.pub('playlist.updateDisplayName', {playlistId: playlistId, displayName: displayName, userId: session.userId});
 			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
 		});
 	}),
@@ -421,16 +412,15 @@ let lib = {
 	 * @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) => {
+	moveSongToTop: hooks.loginRequired((session, playlistId, songId, cb) => {
 		async.waterfall([
 			(next) => {
 				playlists.getPlaylist(playlistId, next);
 			},
 
 			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
+				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
 				async.each(playlist.songs, (song, next) => {
 					if (song.songId === songId) return next(song);
 					next();
@@ -464,11 +454,11 @@ let lib = {
 		], async (err, playlist) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("PLAYLIST_MOVE_SONG_TO_TOP", `Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				logger.error("PLAYLIST_MOVE_SONG_TO_TOP", `Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
-			logger.success("PLAYLIST_MOVE_SONG_TO_TOP", `Successfully moved song "${songId}" to the top for private playlist "${playlistId}" for user "${userId}".`);
-			cache.pub('playlist.moveSongToTop', {playlistId, songId, userId: userId});
+			logger.success("PLAYLIST_MOVE_SONG_TO_TOP", `Successfully moved song "${songId}" to the top for private playlist "${playlistId}" for user "${session.userId}".`);
+			cache.pub('playlist.moveSongToTop', {playlistId, songId, userId: session.userId});
 			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
 		});
 	}),
@@ -480,16 +470,15 @@ let lib = {
 	 * @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) => {
+	moveSongToBottom: hooks.loginRequired((session, playlistId, songId, cb) => {
 		async.waterfall([
 			(next) => {
 				playlists.getPlaylist(playlistId, next);
 			},
 
 			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
+				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
 				async.each(playlist.songs, (song, next) => {
 					if (song.songId === songId) return next(song);
 					next();
@@ -520,11 +509,11 @@ let lib = {
 		], async (err, playlist) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				logger.error("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
-			logger.success("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Successfully moved song "${songId}" to the bottom for private playlist "${playlistId}" for user "${userId}".`);
-			cache.pub('playlist.moveSongToBottom', {playlistId, songId, userId: userId});
+			logger.success("PLAYLIST_MOVE_SONG_TO_BOTTOM", `Successfully moved song "${songId}" to the bottom for private playlist "${playlistId}" for user "${session.userId}".`);
+			cache.pub('playlist.moveSongToBottom', {playlistId, songId, userId: session.userId});
 			return cb({ status: 'success', message: 'Playlist has been successfully updated' });
 		});
 	}),
@@ -535,9 +524,8 @@ let lib = {
 	 * @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) => {
+	remove: hooks.loginRequired((session, playlistId, cb) => {
 		async.waterfall([
 			(next) => {
 				playlists.deletePlaylist(playlistId, next);
@@ -545,11 +533,11 @@ let lib = {
 		], async (err) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("PLAYLIST_REMOVE", `Removing private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				logger.error("PLAYLIST_REMOVE", `Removing private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			}
-			logger.success("PLAYLIST_REMOVE", `Successfully removed private playlist "${playlistId}" for user "${userId}".`);
-			cache.pub('playlist.delete', {userId: userId, playlistId});
+			logger.success("PLAYLIST_REMOVE", `Successfully removed private playlist "${playlistId}" for user "${session.userId}".`);
+			cache.pub('playlist.delete', {userId: session.userId, playlistId});
 			return cb({ status: 'success', message: 'Playlist successfully removed' });
 		});
 	})

+ 6 - 7
backend/logic/actions/punishments.js

@@ -52,13 +52,12 @@ module.exports = {
 	 * @param {String} reason - the reason for the ban
 	 * @param {String} expiresAt - the time the ban expires
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	banIP: hooks.adminRequired((session, value, reason, expiresAt, cb, userId) => {
+	banIP: hooks.adminRequired((session, value, reason, expiresAt, cb) => {
 		async.waterfall([
 			(next) => {
-				if (value === '') return next('You must provide an IP address to ban.');
-				else if (reason === '') return next('You must provide a reason for the ban.');
+				if (!value) return next('You must provide an IP address to ban.');
+				else if (!reason) return next('You must provide a reason for the ban.');
 				else return next();
 			},
 
@@ -101,15 +100,15 @@ module.exports = {
 			},
 
 			(next) => {
-				punishments.addPunishment('banUserIp', value, reason, expiresAt, userId, next)
+				punishments.addPunishment('banUserIp', value, reason, expiresAt, session.userId, next)
 			}
 		], async (err, punishment) => {
 			if (err && err !== true) {
 				err = await utils.getError(err);
-				logger.error("BAN_IP", `User ${userId} failed to ban IP address ${value} with the reason ${reason}. '${err}'`);
+				logger.error("BAN_IP", `User ${session.userId} failed to ban IP address ${value} with the reason ${reason}. '${err}'`);
 				cb({ status: 'failure', message: err });
 			}
-			logger.success("BAN_IP", `User ${userId} has successfully banned IP address ${value} with the reason ${reason}.`);
+			logger.success("BAN_IP", `User ${session.userId} has successfully banned IP address ${value} with the reason ${reason}.`);
 			cache.pub('ip.ban', { ip: value, punishment });
 			return cb({
 				status: 'success',

+ 37 - 37
backend/logic/actions/queueSongs.js

@@ -32,30 +32,24 @@ cache.sub('queue.update', songId => {
 let lib = {
 
 	/**
-	 * Gets all queuesongs
+	 * Returns the length of the queue songs list
 	 *
-	 * @param {Object} session - the session object automatically added by socket.io
-	 * @param {Function} cb - gets called with the result
+	 * @param session
+	 * @param cb
 	 */
-	index: hooks.adminRequired((session, cb) => {
+	length: hooks.adminRequired((session, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.queueSong.find({}, next);
+				db.models.queueSong.countDocuments({}, next);
 			}
-		], async (err, songs) => {
+		], async (err, count) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("QUEUE_INDEX", `Indexing queuesongs failed. "${err}"`);
-				return cb({status: 'failure', message: err});
-			} else {
-				module.exports.getSet(session, 1, result => {
-					logger.success("QUEUE_INDEX", `Indexing queuesongs successful.`);
-					return cb({
-						songs: result,
-						maxLength: songs.length
-					});
-				});
+				logger.error("QUEUE_SONGS_LENGTH", `Failed to get length from queue songs. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
 			}
+			logger.success("QUEUE_SONGS_LENGTH", `Got length from queue songs successfully.`);
+			cb(count);
 		});
 	}),
 
@@ -67,9 +61,18 @@ let lib = {
 	 * @param cb
 	 */
 	getSet: hooks.adminRequired((session, set, cb) => {
-		db.models.queueSong.find({}).limit(50 * set).exec((err, songs) => {
-			if (err) throw err;
-			cb(songs.splice(Math.max(songs.length - 50, 0)));
+		async.waterfall([
+			(next) => {
+				db.models.queueSong.find({}).skip(15 * (set - 1)).limit(15).exec(next);
+			},
+		], async (err, songs) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("QUEUE_SONGS_GET_SET", `Failed to get set from queue songs. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("QUEUE_SONGS_GET_SET", `Got set from queue songs successfully.`);
+			cb(songs);
 		});
 	}),
 
@@ -80,9 +83,8 @@ let lib = {
 	 * @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) => {
+	update: hooks.adminRequired((session, songId, updatedSong, cb) => {
 		async.waterfall([
 			(next) => {
 				db.models.queueSong.findOne({_id: songId}, next);
@@ -99,11 +101,11 @@ let lib = {
 		], async (err) => {
 			if (err) {
 				err = await  utils.getError(err);
-				logger.error("QUEUE_UPDATE", `Updating queuesong "${songId}" failed for user ${userId}. "${err}"`);
+				logger.error("QUEUE_UPDATE", `Updating queuesong "${songId}" failed for user ${session.userId}. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
 			cache.pub('queue.update', songId);
-			logger.success("QUEUE_UPDATE", `User "${userId}" successfully update queuesong "${songId}".`);
+			logger.success("QUEUE_UPDATE", `User "${session.userId}" successfully update queuesong "${songId}".`);
 			return cb({status: 'success', message: 'Successfully updated song.'});
 		});
 	}),
@@ -114,7 +116,6 @@ let lib = {
 	 * @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([
@@ -124,11 +125,11 @@ let lib = {
 		], async (err) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("QUEUE_REMOVE", `Removing queuesong "${songId}" failed for user ${userId}. "${err}"`);
+				logger.error("QUEUE_REMOVE", `Removing queuesong "${songId}" failed for user ${session.userId}. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
 			cache.pub('queue.removedSong', songId);
-			logger.success("QUEUE_REMOVE", `User "${userId}" successfully removed queuesong "${songId}".`);
+			logger.success("QUEUE_REMOVE", `User "${session.userId}" successfully removed queuesong "${songId}".`);
 			return cb({status: 'success', message: 'Successfully updated song.'});
 		});
 	}),
@@ -139,9 +140,8 @@ let lib = {
 	 * @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) => {
+	add: hooks.loginRequired((session, songId, cb) => {
 		let requestedAt = Date.now();
 
 		async.waterfall([
@@ -159,12 +159,13 @@ let lib = {
 				if (song) return next('This song has already been added.');
 				//TODO Add err object as first param of callback
 				utils.getSongFromYouTube(songId, (song) => {
+					song.duration = -1;
 					song.artists = [];
 					song.genres = [];
 					song.skipDuration = 0;
 					song.thumbnail = `${config.get("domain")}/assets/notes.png`;
 					song.explicit = false;
-					song.requestedBy = userId;
+					song.requestedBy = session.userId;
 					song.requestedAt = requestedAt;
 					next(null, song);
 				});
@@ -177,13 +178,13 @@ let lib = {
 			},*/
 			(newSong, next) => {
 				const song = new db.models.queueSong(newSong);
-				song.save((err, song) => {
+				song.save({ validateBeforeSave: false }, (err, song) => {
 					if (err) return next(err);
 					next(null, song);
 				});
 			},
 			(newSong, next) => {
-				db.models.user.findOne({ _id: userId }, (err, user) => {
+				db.models.user.findOne({ _id: session.userId }, (err, user) => {
 					if (err) next(err, newSong);
 					else {
 						user.statistics.songsRequested = user.statistics.songsRequested + 1;
@@ -197,11 +198,11 @@ let lib = {
 		], async (err, newSong) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("QUEUE_ADD", `Adding queuesong "${songId}" failed for user ${userId}. "${err}"`);
+				logger.error("QUEUE_ADD", `Adding queuesong "${songId}" failed for user ${session.userId}. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
 			cache.pub('queue.newSong', newSong._id);
-			logger.success("QUEUE_ADD", `User "${userId}" successfully added queuesong "${songId}".`);
+			logger.success("QUEUE_ADD", `User "${session.userId}" successfully added queuesong "${songId}".`);
 			return cb({ status: 'success', message: 'Successfully added that song to the queue' });
 		});
 	}),
@@ -212,9 +213,8 @@ let lib = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {String} url - the url of the the YouTube playlist
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	addSetToQueue: hooks.loginRequired((session, url, cb, userId) => {
+	addSetToQueue: hooks.loginRequired((session, url, cb) => {
 		async.waterfall([
 			(next) => {
 				utils.getPlaylistFromYouTube(url, songs => {
@@ -236,10 +236,10 @@ let lib = {
 		], async (err) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("QUEUE_IMPORT", `Importing a YouTube playlist to the queue failed for user "${userId}". "${err}"`);
+				logger.error("QUEUE_IMPORT", `Importing a YouTube playlist to the queue failed for user "${session.userId}". "${err}"`);
 				return cb({ status: 'failure', message: err});
 			} else {
-				logger.success("QUEUE_IMPORT", `Successfully imported a YouTube playlist to the queue for user "${userId}".`);
+				logger.success("QUEUE_IMPORT", `Successfully imported a YouTube playlist to the queue for user "${session.userId}".`);
 				cb({ status: 'success', message: 'Playlist has been successfully imported.' });
 			}
 		});

+ 7 - 9
backend/logic/actions/reports.js

@@ -147,9 +147,8 @@ module.exports = {
 	 * @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) => {
+	resolve: hooks.adminRequired((session, reportId, cb) => {
 		async.waterfall([
 			(next) => {
 				db.models.report.findOne({ _id: reportId }).exec(next);
@@ -166,11 +165,11 @@ module.exports = {
 		], async (err) => {
 			if (err) {
 				err = await  utils.getError(err);
-				logger.error("REPORTS_RESOLVE", `Resolving report "${reportId}" failed by user "${userId}". "${err}"`);
+				logger.error("REPORTS_RESOLVE", `Resolving report "${reportId}" failed by user "${session.userId}". "${err}"`);
 				return cb({ 'status': 'failure', 'message': err});
 			} else {
 				cache.pub('report.resolve', reportId);
-				logger.success("REPORTS_RESOLVE", `User "${userId}" resolved report "${reportId}".`);
+				logger.success("REPORTS_RESOLVE", `User "${session.userId}" resolved report "${reportId}".`);
 				cb({ status: 'success', message: 'Successfully resolved Report' });
 			}
 		});
@@ -182,9 +181,8 @@ module.exports = {
 	 * @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) => {
+	create: hooks.loginRequired((session, data, cb) => {
 		async.waterfall([
 
 			(next) => {
@@ -231,7 +229,7 @@ module.exports = {
 			},
 
 			(next) => {
-				data.createdBy = userId;
+				data.createdBy = session.userId;
 				data.createdAt = Date.now();
 				db.models.report.create(data, next);
 			}
@@ -239,11 +237,11 @@ module.exports = {
 		], async (err, report) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("REPORTS_CREATE", `Creating report for "${data.song._id}" failed by user "${userId}". "${err}"`);
+				logger.error("REPORTS_CREATE", `Creating report for "${data.song._id}" failed by user "${session.userId}". "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			} else {
 				cache.pub('report.create', report);
-				logger.success("REPORTS_CREATE", `User "${userId}" created report for "${data.songId}".`);
+				logger.success("REPORTS_CREATE", `User "${session.userId}" created report for "${data.songId}".`);
 				return cb({ 'status': 'success', 'message': 'Successfully created report' });
 			}
 		});

+ 26 - 34
backend/logic/actions/songs.js

@@ -99,8 +99,8 @@ module.exports = {
 	getSet: hooks.adminRequired((session, set, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.song.find({}).limit(15 * set).exec(next);
-			}
+				db.models.song.find({}).skip(15 * (set - 1)).limit(15).exec(next);
+			},
 		], async (err, songs) => {
 			if (err) {
 				err = await utils.getError(err);
@@ -108,9 +108,7 @@ module.exports = {
 				return cb({'status': 'failure', 'message': err});
 			}
 			logger.success("SONGS_GET_SET", `Got set from songs successfully.`);
-			logger.stationIssue(songs.length, true);
-			logger.stationIssue(Math.max(songs.length - 15, 0), true);
-			cb(songs.splice(Math.max(songs.length - 15, 0)));
+			cb(songs);
 		});
 	}),
 
@@ -203,9 +201,8 @@ module.exports = {
 	 * @param session
 	 * @param song - the song object
 	 * @param cb
-	 * @param userId
 	 */
-	add: hooks.adminRequired((session, song, cb, userId) => {
+	add: hooks.adminRequired((session, song, cb) => {
 		async.waterfall([
 			(next) => {
 				db.models.song.findOne({songId: song.songId}, next);
@@ -218,7 +215,7 @@ module.exports = {
 
 			(next) => {
 				const newSong = new db.models.song(song);
-				newSong.acceptedBy = userId;
+				newSong.acceptedBy = session.userId;
 				newSong.acceptedAt = Date.now();
 				newSong.save(next);
 			},
@@ -231,10 +228,10 @@ module.exports = {
 		], async (err) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("SONGS_ADD", `User "${userId}" failed to add song. "${err}"`);
+				logger.error("SONGS_ADD", `User "${session.userId}" failed to add song. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
-			logger.success("SONGS_ADD", `User "${userId}" successfully added song "${song.songId}".`);
+			logger.success("SONGS_ADD", `User "${session.userId}" successfully added song "${song.songId}".`);
 			cache.pub('song.added', song.songId);
 			cb({status: 'success', message: 'Song has been moved from the queue successfully.'});
 		});
@@ -247,9 +244,8 @@ module.exports = {
 	 * @param session
 	 * @param songId - the song id
 	 * @param cb
-	 * @param userId
 	 */
-	like: hooks.loginRequired((session, songId, cb, userId) => {
+	like: hooks.loginRequired((session, songId, cb) => {
 		async.waterfall([
 			(next) => {
 				db.models.song.findOne({songId}, next);
@@ -262,14 +258,14 @@ module.exports = {
 		], async (err, song) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("SONGS_LIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
+				logger.error("SONGS_LIKE", `User "${session.userId}" failed to like song ${songId}. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
 			let oldSongId = songId;
 			songId = song._id;
-			db.models.user.findOne({ _id: userId }, (err, user) => {
+			db.models.user.findOne({ _id: session.userId }, (err, user) => {
 				if (user.liked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already liked this song.' });
-				db.models.user.updateOne({_id: userId}, {$push: {liked: songId}, $pull: {disliked: songId}}, err => {
+				db.models.user.updateOne({_id: session.userId}, {$push: {liked: songId}, $pull: {disliked: songId}}, err => {
 					if (!err) {
 						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
@@ -295,9 +291,8 @@ module.exports = {
 	 * @param session
 	 * @param songId - the song id
 	 * @param cb
-	 * @param userId
 	 */
-	dislike: hooks.loginRequired((session, songId, cb, userId) => {
+	dislike: hooks.loginRequired((session, songId, cb) => {
 		async.waterfall([
 			(next) => {
 				db.models.song.findOne({songId}, next);
@@ -310,14 +305,14 @@ module.exports = {
 		], async (err, song) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("SONGS_DISLIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
+				logger.error("SONGS_DISLIKE", `User "${session.userId}" failed to like song ${songId}. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
 			let oldSongId = songId;
 			songId = song._id;
-			db.models.user.findOne({ _id: userId }, (err, user) => {
+			db.models.user.findOne({ _id: session.userId }, (err, user) => {
 				if (user.disliked.indexOf(songId) !== -1) return cb({ status: 'failure', message: 'You have already disliked this song.' });
-				db.models.user.updateOne({_id: userId}, {$push: {disliked: songId}, $pull: {liked: songId}}, err => {
+				db.models.user.updateOne({_id: session.userId}, {$push: {disliked: songId}, $pull: {liked: songId}}, err => {
 					if (!err) {
 						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
@@ -343,9 +338,8 @@ module.exports = {
 	 * @param session
 	 * @param songId - the song id
 	 * @param cb
-	 * @param userId
 	 */
-	undislike: hooks.loginRequired((session, songId, cb, userId) => {
+	undislike: hooks.loginRequired((session, songId, cb) => {
 		async.waterfall([
 			(next) => {
 				db.models.song.findOne({songId}, next);
@@ -358,17 +352,17 @@ module.exports = {
 		], async (err, song) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("SONGS_UNDISLIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
+				logger.error("SONGS_UNDISLIKE", `User "${session.userId}" failed to like song ${songId}. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
 			let oldSongId = songId;
 			songId = song._id;
-			db.models.user.findOne({_id: userId}, (err, user) => {
+			db.models.user.findOne({_id: session.userId}, (err, user) => {
 				if (user.disliked.indexOf(songId) === -1) return cb({
 					status: 'failure',
 					message: 'You have not disliked this song.'
 				});
-				db.models.user.updateOne({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
+				db.models.user.updateOne({_id: session.userId}, {$pull: {liked: songId, disliked: songId}}, err => {
 					if (!err) {
 						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({
@@ -412,9 +406,8 @@ module.exports = {
 	 * @param session
 	 * @param songId - the song id
 	 * @param cb
-	 * @param userId
 	 */
-	unlike: hooks.loginRequired((session, songId, cb, userId) => {
+	unlike: hooks.loginRequired((session, songId, cb) => {
 		async.waterfall([
 			(next) => {
 				db.models.song.findOne({songId}, next);
@@ -427,14 +420,14 @@ module.exports = {
 		], async (err, song) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("SONGS_UNLIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
+				logger.error("SONGS_UNLIKE", `User "${session.userId}" failed to like song ${songId}. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
 			let oldSongId = songId;
 			songId = song._id;
-			db.models.user.findOne({ _id: userId }, (err, user) => {
+			db.models.user.findOne({ _id: session.userId }, (err, user) => {
 				if (user.liked.indexOf(songId) === -1) return cb({ status: 'failure', message: 'You have not liked this song.' });
-				db.models.user.updateOne({_id: userId}, {$pull: {liked: songId, disliked: songId}}, err => {
+				db.models.user.updateOne({_id: session.userId}, {$pull: {liked: songId, disliked: songId}}, err => {
 					if (!err) {
 						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
@@ -460,9 +453,8 @@ module.exports = {
 	 * @param session
 	 * @param songId - the song id
 	 * @param cb
-	 * @param userId
 	 */
-	getOwnSongRatings: hooks.loginRequired((session, songId, cb, userId) => {
+	getOwnSongRatings: hooks.loginRequired((session, songId, cb) => {
 		async.waterfall([
 			(next) => {
 				db.models.song.findOne({songId}, next);
@@ -475,11 +467,11 @@ module.exports = {
 		], async (err, song) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("SONGS_GET_OWN_RATINGS", `User "${userId}" failed to get ratings for ${songId}. "${err}"`);
+				logger.error("SONGS_GET_OWN_RATINGS", `User "${session.userId}" failed to get ratings for ${songId}. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
 			let newSongId = song._id;
-			db.models.user.findOne({_id: userId}, (err, user) => {
+			db.models.user.findOne({_id: session.userId}, (err, user) => {
 				if (!err && user) {
 					return cb({
 						status: 'success',

+ 62 - 101
backend/logic/actions/stations.js

@@ -214,31 +214,18 @@ module.exports = {
 				next(null, stations);
 			},
 
-			(stations, next) => {
+			(stationsArray, next) => {
 				let resultStations = [];
-				async.each(stations, (station, next) => {
+				async.each(stationsArray, (station, next) => {
 					async.waterfall([
 						(next) => {
-							if (station.privacy === 'public') return next(true);
-							if (!session.sessionId) return next(`Insufficient permissions.`);
-							cache.hget('sessions', session.sessionId, next);
-						},
-
-						(session, next) => {
-							if (!session) return next(`Insufficient permissions.`);
-							db.models.user.findOne({_id: session.userId}, next);
-						},
-
-						(user, next) => {
-							if (!user) return next(`Insufficient permissions.`);
-							if (user.role === 'admin') return next(true);
-							if (station.type === 'official') return next(`Insufficient permissions.`);
-							if (station.owner === session.userId) return next(true);
-							next(`Insufficient permissions.`);
+							stations.canUserViewStation(station, session.userId, (err, exists) => {
+								next(err, exists);
+							});
 						}
-					], (err) => {
+					], (err, exists) => {
 						station.userCount = usersPerStationCount[station._id] || 0;
-						if (err === true) resultStations.push(station);
+						if (exists) resultStations.push(station);
 						next();
 					});
 				}, () => {
@@ -257,30 +244,32 @@ module.exports = {
 	},
 
 	/**
-	 * Finds a station by name
+	 * Verifies that a station exists
 	 *
 	 * @param session
 	 * @param stationName - the station name
 	 * @param cb
 	 */
-	findByName: (session, stationName, cb) => {
+	existsByName: (session, stationName, cb) => {
 		async.waterfall([
 			(next) => {
 				stations.getStationByName(stationName, next);
 			},
 
 			(station, next) => {
-				if (!station) return next('Station not found.');
-				next(null, station);
+				if (!station) return next(null, false);
+				stations.canUserViewStation(station, session.userId, (err, exists) => {
+					next(err, exists);
+				});
 			}
-		], async (err, station) => {
+		], async (err, exists) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("STATIONS_FIND_BY_NAME", `Finding station "${stationName}" failed. "${err}"`);
+				logger.error("STATION_EXISTS_BY_NAME", `Checking if station "${stationName}" exists failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
-			logger.success("STATIONS_FIND_BY_NAME", `Found station "${stationName}" successfully.`, false);
-			cb({status: 'success', data: station});
+			logger.success("STATION_EXISTS_BY_NAME", `Station "${stationName}" exists successfully.`/*, false*/);
+			cb({status: 'success', exists});
 		});
 	},
 
@@ -297,6 +286,14 @@ module.exports = {
 				stations.getStation(stationId, next);
 			},
 
+			(station, next) => {
+				stations.canUserViewStation(station, session.userId, (err, canView) => {
+					if (err) return next(err);
+					if (canView) return next(null, station);
+					return next('Insufficient permissions.');
+				});
+			},
+
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				else if (station.type !== 'official') return next('This is not an official station.');
@@ -339,27 +336,10 @@ module.exports = {
 
 			(station, next) => {
 				if (!station) return next('Station not found.');
-				async.waterfall([
-					(next) => {
-						if (station.privacy !== 'private') return next(true);
-						if (!session.userId) return next('An error occurred while joining the station.');
-						next();
-					},
-
-					(next) => {
-						db.models.user.findOne({_id: session.userId}, next);
-					},
-
-					(user, next) => {
-						if (!user) return next('An error occurred while joining the station.');
-						if (user.role === 'admin') return next(true);
-						if (station.type === 'official') return next('An error occurred while joining the station.');
-						if (station.owner === session.userId) return next(true);
-						next('An error occurred while joining the station.');
-					}
-				], async (err) => {
-					if (err === true) return next(null, station);
-					next(await utils.getError(err));
+				stations.canUserViewStation(station, session.userId, (err, canView) => {
+					if (err) return next(err);
+					if (!canView) next("Not allowed to join station.");
+					else next(null, station);
 				});
 			},
 
@@ -452,9 +432,8 @@ module.exports = {
 	 * @param session
 	 * @param stationId - the station id
 	 * @param cb
-	 * @param userId
 	 */
-	voteSkip: hooks.loginRequired((session, stationId, cb, userId) => {
+	voteSkip: hooks.loginRequired((session, stationId, cb) => {
 		async.waterfall([
 			(next) => {
 				stations.getStation(stationId, next);
@@ -462,20 +441,21 @@ module.exports = {
 
 			(station, next) => {
 				if (!station) return next('Station not found.');
-				utils.canUserBeInStation(station, userId, (canBe) => {
-					if (canBe) return next(null, station);
+				stations.canUserViewStation(station, session.userId, (err, canView) => {
+					if (err) return next(err);
+					if (canView) return next(null, station);
 					return next('Insufficient permissions.');
 				});
 			},
 
 			(station, next) => {
 				if (!station.currentSong) return next('There is currently no song to skip.');
-				if (station.currentSong.skipVotes.indexOf(userId) !== -1) return next('You have already voted to skip this song.');
+				if (station.currentSong.skipVotes.indexOf(session.userId) !== -1) return next('You have already voted to skip this song.');
 				next(null, station);
 			},
 
 			(station, next) => {
-				db.models.station.updateOne({_id: stationId}, {$push: {"currentSong.skipVotes": userId}}, next)
+				db.models.station.updateOne({_id: stationId}, {$push: {"currentSong.skipVotes": session.userId}}, next)
 			},
 
 			(res, next) => {
@@ -580,10 +560,10 @@ module.exports = {
 		], async (err) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("STATIONS_UPDATE_DISPLAY_NAME", `Updating station "${stationId}" displayName to "${newName}" failed. "${err}"`);
+				logger.error("STATIONS_UPDATE_NAME", `Updating station "${stationId}" name to "${newName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
-			logger.success("STATIONS_UPDATE_DISPLAY_NAME", `Updated station "${stationId}" displayName to "${newName}" successfully.`);
+			logger.success("STATIONS_UPDATE_NAME", `Updated station "${stationId}" name to "${newName}" successfully.`);
 			return cb({'status': 'success', 'message': 'Successfully updated the name.'});
 		});
 	}),
@@ -868,9 +848,8 @@ module.exports = {
 	 * @param session
 	 * @param data - the station data
 	 * @param cb
-	 * @param userId
 	 */
-	create: hooks.loginRequired((session, data, cb, userId) => {
+	create: hooks.loginRequired((session, data, cb) => {
 		data.name = data.name.toLowerCase();
 		let blacklist = ["country", "edm", "musare", "hip-hop", "rap", "top-hits", "todays-hits", "old-school", "christmas", "about", "support", "staff", "help", "news", "terms", "privacy", "profile", "c", "community", "tos", "login", "register", "p", "official", "o", "trap", "faq", "team", "donate", "buy", "shop", "forums", "explore", "settings", "admin", "auth", "reset_password"];
 		async.waterfall([
@@ -887,7 +866,7 @@ module.exports = {
 				if (station) return next('A station with that name or display name already exists.');
 				const { name, displayName, description, genres, playlist, type, blacklistedGenres } = data;
 				if (type === 'official') {
-					db.models.user.findOne({_id: userId}, (err, user) => {
+					db.models.user.findOne({_id: session.userId}, (err, user) => {
 						if (err) return next(err);
 						if (!user) return next('User not found.');
 						if (user.role !== 'admin') return next('Admin required.');
@@ -911,7 +890,7 @@ module.exports = {
 						description,
 						type,
 						privacy: 'private',
-						owner: userId,
+						owner: session.userId,
 						queue: [],
 						currentSong: null
 					}, next);
@@ -936,9 +915,8 @@ module.exports = {
 	 * @param stationId - the station id
 	 * @param songId - the song id
 	 * @param cb
-	 * @param userId
 	 */
-	addToQueue: hooks.loginRequired((session, stationId, songId, cb, userId) => {
+	addToQueue: hooks.loginRequired((session, stationId, songId, cb) => {
 		async.waterfall([
 			(next) => {
 				stations.getStation(stationId, next);
@@ -947,8 +925,8 @@ module.exports = {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				if (station.locked) {
-					db.models.user.findOne({ _id: userId }, (err, user) => {
-						if (user.role !== 'admin' && station.owner !== userId) return next('Only owners and admins can add songs to a locked queue.');
+					db.models.user.findOne({ _id: session.userId }, (err, user) => {
+						if (user.role !== 'admin' && station.owner !== session.userId) return next('Only owners and admins can add songs to a locked queue.');
 						else return next(null, station);
 					});
 				} else {
@@ -958,8 +936,9 @@ module.exports = {
 
 			(station, next) => {
 				if (station.type !== 'community') return next('That station is not a community station.');
-				utils.canUserBeInStation(station, userId, (canBe) => {
-					if (canBe) return next(null, station);
+				stations.canUserViewStation(station, session.userId, (err, canView) => {
+					if (err) return next(err);
+					if (canView) return next(null, station);
 					return next('Insufficient permissions.');
 				});
 			},
@@ -991,7 +970,7 @@ module.exports = {
 
 			(song, station, next) => {
 				let queue = station.queue;
-				song.requestedBy = userId;
+				song.requestedBy = session.userId;
 				queue.push(song);
 
 				let totalDuration = 0;
@@ -1060,9 +1039,8 @@ module.exports = {
 	 * @param stationId - the station id
 	 * @param songId - the song id
 	 * @param cb
-	 * @param userId
 	 */
-	removeFromQueue: hooks.ownerRequired((session, stationId, songId, cb, userId) => {
+	removeFromQueue: hooks.ownerRequired((session, stationId, songId, cb) => {
 		async.waterfall([
 			(next) => {
 				if (!songId) return next('Invalid song id.');
@@ -1120,8 +1098,9 @@ module.exports = {
 			},
 
 			(station, next) => {
-				utils.canUserBeInStation(station, session.userId, (canBe) => {
-					if (canBe) return next(null, station);
+				stations.canUserViewStation(station, session.userId, (err, canView) => {
+					if (err) return next(err);
+					if (canView) return next(null, station);
 					return next('Insufficient permissions.');
 				});
 			}
@@ -1143,9 +1122,8 @@ module.exports = {
 	 * @param stationId - the station id
 	 * @param playlistId - the private playlist id
 	 * @param cb
-	 * @param userId
 	 */
-	selectPrivatePlaylist: hooks.ownerRequired((session, stationId, playlistId, cb, userId) => {
+	selectPrivatePlaylist: hooks.ownerRequired((session, stationId, playlistId, cb) => {
 		async.waterfall([
 			(next) => {
 				stations.getStation(stationId, next);
@@ -1181,7 +1159,7 @@ module.exports = {
 		});
 	}),
 
-	favoriteStation: hooks.loginRequired((session, stationId, cb, userId) => {
+	favoriteStation: hooks.loginRequired((session, stationId, cb) => {
 		async.waterfall([
 			(next) => {
 				stations.getStation(stationId, next);
@@ -1189,32 +1167,15 @@ module.exports = {
 
 			(station, next) => {
 				if (!station) return next('Station not found.');
-				async.waterfall([
-					(next) => {
-						if (station.privacy !== 'private') return next(true);
-						if (!session.userId) return next("You're not allowed to favorite this station.");
-						next();
-					},
-
-					(next) => {
-						db.models.user.findOne({ _id: userId }, next);
-					},
-
-					(user, next) => {
-						if (!user) return next("You're not allowed to favorite this station.");
-						if (user.role === 'admin') return next(true);
-						if (station.type === 'official') return next("You're not allowed to favorite this station.");
-						if (station.owner === session.userId) return next(true);
-						next("You're not allowed to favorite this station.");
-					}
-				], (err) => {
-					if (err === true) return next(null);
-					next(utils.getError(err));
+				stations.canUserViewStation(station, session.userId, (err, canView) => {
+					if (err) return next(err);
+					if (canView) return next();
+					return next('Insufficient permissions.');
 				});
 			},
 
 			(next) => {
-				db.models.user.updateOne({ _id: userId }, { $addToSet: { favoriteStations: stationId } }, next);
+				db.models.user.updateOne({ _id: session.userId }, { $addToSet: { favoriteStations: stationId } }, next);
 			},
 
 			(res, next) => {
@@ -1228,15 +1189,15 @@ module.exports = {
 				return cb({'status': 'failure', 'message': err});
 			}
 			logger.success("FAVORITE_STATION", `Favorited station "${stationId}" successfully.`);
-			cache.pub('user.favoritedStation', { userId, stationId });
+			cache.pub('user.favoritedStation', { userId: session.userId, stationId });
 			return cb({'status': 'success', 'message': 'Succesfully favorited station.'});
 		});
 	}),
 
-	unfavoriteStation: hooks.loginRequired((session, stationId, cb, userId) => {
+	unfavoriteStation: hooks.loginRequired((session, stationId, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.user.updateOne({ _id: userId }, { $pull: { favoriteStations: stationId } }, next);
+				db.models.user.updateOne({ _id: session.userId }, { $pull: { favoriteStations: stationId } }, next);
 			},
 
 			(res, next) => {
@@ -1250,7 +1211,7 @@ module.exports = {
 				return cb({'status': 'failure', 'message': err});
 			}
 			logger.success("UNFAVORITE_STATION", `Unfavorited station "${stationId}" successfully.`);
-			cache.pub('user.unfavoritedStation', { userId, stationId });
+			cache.pub('user.unfavoritedStation', { userId: session.userId, stationId });
 			return cb({'status': 'success', 'message': 'Succesfully unfavorited station.'});
 		});
 	}),

+ 49 - 59
backend/logic/actions/users.js

@@ -339,15 +339,15 @@ module.exports = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {String} userId - the id of the user we are trying to delete the sessions of
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} loggedInUser - the logged in userId automatically added by hooks
 	 */
-	removeSessions:  hooks.loginRequired((session, userId, cb, loggedInUser) => {
+	removeSessions:  hooks.loginRequired((session, userId, cb) => {
 
 		async.waterfall([
 
 			(next) => {
-				db.models.user.findOne({ _id: loggedInUser }, (err, user) => {
-					if (user.role !== 'admin' && loggedInUser !== userId) return next('Only admins and the owner of the account can remove their sessions.');
+				db.models.user.findOne({ _id: session.userId }, (err, user) => {
+					if (err) return next(err);
+					if (user.role !== 'admin' && session.userId !== userId) return next('Only admins and the owner of the account can remove their sessions.');
 					else return next();
 				});
 			},
@@ -522,13 +522,12 @@ module.exports = {
 	 * @param {String} updatingUserId - the updating user's id
 	 * @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, updatingUserId, newUsername, cb, userId) => {
+	updateUsername: hooks.loginRequired((session, updatingUserId, newUsername, cb) => {
 		async.waterfall([
 			(next) => {
-				if (updatingUserId === userId) return next(null, true);
-				db.models.user.findOne({_id: userId}, next);
+				if (updatingUserId === session.userId) return next(null, true);
+				db.models.user.findOne({_id: session.userId}, next);
 			},
 
 			(user, next) => {
@@ -578,15 +577,14 @@ module.exports = {
 	 * @param {String} updatingUserId - the updating user's id
 	 * @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(async (session, updatingUserId, newEmail, cb, userId) => {
+	updateEmail: hooks.loginRequired(async (session, updatingUserId, newEmail, cb) => {
 		newEmail = newEmail.toLowerCase();
 		let verificationToken = await utils.generateRandomString(64);
 		async.waterfall([
 			(next) => {
-				if (updatingUserId === userId) return next(null, true);
-				db.models.user.findOne({_id: userId}, next);
+				if (updatingUserId === session.userId) return next(null, true);
+				db.models.user.findOne({_id: session.userId}, next);
 			},
 
 			(user, next) => {
@@ -642,9 +640,8 @@ module.exports = {
 	 * @param {String} updatingUserId - the updating user's id
 	 * @param {String} newRole - the new role
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	updateRole: hooks.adminRequired((session, updatingUserId, newRole, cb, userId) => {
+	updateRole: hooks.adminRequired((session, updatingUserId, newRole, cb) => {
 		newRole = newRole.toLowerCase();
 		async.waterfall([
 
@@ -664,10 +661,10 @@ module.exports = {
 		], async (err) => {
 			if (err && err !== true) {
 				err = await utils.getError(err);
-				logger.error("UPDATE_ROLE", `User "${userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`);
+				logger.error("UPDATE_ROLE", `User "${session.userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
-				logger.success("UPDATE_ROLE", `User "${userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`);
+				logger.success("UPDATE_ROLE", `User "${session.userId}" updated the role of user "${updatingUserId}" to role "${newRole}".`);
 				cb({
 					status: 'success',
 					message: 'Role successfully updated.'
@@ -682,12 +679,11 @@ module.exports = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {String} newPassword - the new password
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	updatePassword: hooks.loginRequired((session, newPassword, cb, userId) => {
+	updatePassword: hooks.loginRequired((session, newPassword, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.user.findOne({_id: userId}, next);
+				db.models.user.findOne({_id: session.userId}, next);
 			},
 
 			(user, next) => {
@@ -710,16 +706,16 @@ module.exports = {
 			},
 
 			(hashedPassword, next) => {
-				db.models.user.updateOne({_id: userId}, {$set: {"services.password.password": hashedPassword}}, next);
+				db.models.user.updateOne({_id: session.userId}, {$set: {"services.password.password": hashedPassword}}, next);
 			}
 		], async (err) => {
 			if (err) {
 				err = await utils.getError(err);
-				logger.error("UPDATE_PASSWORD", `Failed updating user password of user '${userId}'. '${err}'.`);
+				logger.error("UPDATE_PASSWORD", `Failed updating user password of user '${session.userId}'. '${err}'.`);
 				return cb({ status: 'failure', message: err });
 			}
 
-			logger.success("UPDATE_PASSWORD", `User '${userId}' updated their password.`);
+			logger.success("UPDATE_PASSWORD", `User '${session.userId}' updated their password.`);
 			cb({
 				status: 'success',
 				message: 'Password successfully updated.'
@@ -733,13 +729,12 @@ module.exports = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {String} email - the email of the user that requests a password reset
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	requestPassword: hooks.loginRequired(async (session, cb, userId) => {
+	requestPassword: hooks.loginRequired(async (session, cb) => {
 		let code = await utils.generateRandomString(8);
 		async.waterfall([
 			(next) => {
-				db.models.user.findOne({_id: userId}, next);
+				db.models.user.findOne({_id: session.userId}, next);
 			},
 
 			(user, next) => {
@@ -760,10 +755,10 @@ module.exports = {
 		], async (err) => {
 			if (err && err !== true) {
 				err = await utils.getError(err);
-				logger.error("REQUEST_PASSWORD", `UserId '${userId}' failed to request password. '${err}'`);
+				logger.error("REQUEST_PASSWORD", `UserId '${session.userId}' failed to request password. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
-				logger.success("REQUEST_PASSWORD", `UserId '${userId}' successfully requested a password.`);
+				logger.success("REQUEST_PASSWORD", `UserId '${session.userId}' successfully requested a password.`);
 				cb({
 					status: 'success',
 					message: 'Successfully requested password.'
@@ -778,13 +773,12 @@ module.exports = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {String} code - the password code
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	verifyPasswordCode: hooks.loginRequired((session, code, cb, userId) => {
+	verifyPasswordCode: hooks.loginRequired((session, code, cb) => {
 		async.waterfall([
 			(next) => {
 				if (!code || typeof code !== 'string') return next('Invalid code1.');
-				db.models.user.findOne({"services.password.set.code": code, _id: userId}, next);
+				db.models.user.findOne({"services.password.set.code": code, _id: session.userId}, next);
 			},
 
 			(user, next) => {
@@ -814,9 +808,8 @@ module.exports = {
 	 * @param {String} code - the password code
 	 * @param {String} newPassword - the new password code
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	changePasswordWithCode: hooks.loginRequired((session, code, newPassword, cb, userId) => {
+	changePasswordWithCode: hooks.loginRequired((session, code, newPassword, cb) => {
 		async.waterfall([
 			(next) => {
 				if (!code || typeof code !== 'string') return next('Invalid code1.');
@@ -853,7 +846,7 @@ module.exports = {
 				cb({status: 'failure', message: err});
 			} else {
 				logger.success("ADD_PASSWORD_WITH_CODE", `Code '${code}' successfully added password.`);
-				cache.pub('user.linkPassword', userId);
+				cache.pub('user.linkPassword', session.userId);
 				cb({
 					status: 'success',
 					message: 'Successfully added password.'
@@ -867,27 +860,26 @@ module.exports = {
 	 *
 	 * @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
 	 */
-	unlinkPassword: hooks.loginRequired((session, cb, userId) => {
+	unlinkPassword: hooks.loginRequired((session, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.user.findOne({_id: userId}, next);
+				db.models.user.findOne({_id: session.userId}, next);
 			},
 
 			(user, next) => {
 				if (!user) return next('Not logged in.');
 				if (!user.services.github || !user.services.github.id) return next('You can\'t remove password login without having GitHub login.');
-				db.models.user.updateOne({_id: userId}, {$unset: {"services.password": ''}}, next);
+				db.models.user.updateOne({_id: session.userId}, {$unset: {"services.password": ''}}, next);
 			}
 		], async (err) => {
 			if (err && err !== true) {
 				err = await utils.getError(err);
-				logger.error("UNLINK_PASSWORD", `Unlinking password failed for userId '${userId}'. '${err}'`);
+				logger.error("UNLINK_PASSWORD", `Unlinking password failed for userId '${session.userId}'. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
-				logger.success("UNLINK_PASSWORD", `Unlinking password successful for userId '${userId}'.`);
-				cache.pub('user.unlinkPassword', userId);
+				logger.success("UNLINK_PASSWORD", `Unlinking password successful for userId '${session.userId}'.`);
+				cache.pub('user.unlinkPassword', session.userId);
 				cb({
 					status: 'success',
 					message: 'Successfully unlinked password.'
@@ -901,27 +893,26 @@ module.exports = {
 	 *
 	 * @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
 	 */
-	unlinkGitHub: hooks.loginRequired((session, cb, userId) => {
+	unlinkGitHub: hooks.loginRequired((session, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.user.findOne({_id: userId}, next);
+				db.models.user.findOne({_id: session.userId}, next);
 			},
 
 			(user, next) => {
 				if (!user) return next('Not logged in.');
 				if (!user.services.password || !user.services.password.password) return next('You can\'t remove GitHub login without having password login.');
-				db.models.user.updateOne({_id: userId}, {$unset: {"services.github": ''}}, next);
+				db.models.user.updateOne({_id: session.userId}, {$unset: {"services.github": ''}}, next);
 			}
 		], async (err) => {
 			if (err && err !== true) {
 				err = await utils.getError(err);
-				logger.error("UNLINK_GITHUB", `Unlinking GitHub failed for userId '${userId}'. '${err}'`);
+				logger.error("UNLINK_GITHUB", `Unlinking GitHub failed for userId '${session.userId}'. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
-				logger.success("UNLINK_GITHUB", `Unlinking GitHub successful for userId '${userId}'.`);
-				cache.pub('user.unlinkGitHub', userId);
+				logger.success("UNLINK_GITHUB", `Unlinking GitHub successful for userId '${session.userId}'.`);
+				cache.pub('user.unlinkGitHub', session.userId);
 				cb({
 					status: 'success',
 					message: 'Successfully unlinked GitHub.'
@@ -1071,13 +1062,12 @@ module.exports = {
 	 * @param {String} reason - the reason for the ban
 	 * @param {String} expiresAt - the time the ban expires
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
-	banUserById: hooks.adminRequired((session, value, reason, expiresAt, cb, userId) => {
+	banUserById: hooks.adminRequired((session, userId, reason, expiresAt, cb) => {
 		async.waterfall([
 			(next) => {
-				if (value === '') return next('You must provide an IP address to ban.');
-				else if (reason === '') return next('You must provide a reason for the ban.');
+				if (!userId) return next('You must provide a userId to ban.');
+				else if (!reason) return next('You must provide a reason for the ban.');
 				else return next();
 			},
 
@@ -1120,20 +1110,20 @@ module.exports = {
 			},
 
 			(next) => {
-				punishments.addPunishment('banUserId', value, reason, expiresAt, userId, next)
+				punishments.addPunishment('banUserId', userId, reason, expiresAt, userId, next)
 			},
 
 			(punishment, next) => {
-				cache.pub('user.ban', {userId: value, punishment});
+				cache.pub('user.ban', { userId, punishment });
 				next();
 			},
 		], async (err) => {
 			if (err && err !== true) {
 				err = await utils.getError(err);
-				logger.error("BAN_USER_BY_ID", `User ${userId} failed to ban user ${value} with the reason ${reason}. '${err}'`);
+				logger.error("BAN_USER_BY_ID", `User ${session.userId} failed to ban user ${userId} with the reason ${reason}. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
-				logger.success("BAN_USER_BY_ID", `User ${userId} has successfully banned user ${value} with the reason ${reason}.`);
+				logger.success("BAN_USER_BY_ID", `User ${session.userId} has successfully banned user ${userId} with the reason ${reason}.`);
 				cb({
 					status: 'success',
 					message: 'Successfully banned user.'
@@ -1142,10 +1132,10 @@ module.exports = {
 		});
 	}),
 
-	getFavoriteStations: hooks.loginRequired((session, cb, userId) => {
+	getFavoriteStations: hooks.loginRequired((session, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.user.findOne({ _id: userId }, next);
+				db.models.user.findOne({ _id: session.userId }, next);
 			},
 
 			(user, next) => {
@@ -1155,10 +1145,10 @@ module.exports = {
 		], async (err, user) => {
 			if (err && err !== true) {
 				err = await utils.getError(err);
-				logger.error("GET_FAVORITE_STATIONS", `User ${userId} failed to get favorite stations. '${err}'`);
+				logger.error("GET_FAVORITE_STATIONS", `User ${session.userId} failed to get favorite stations. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
-				logger.success("GET_FAVORITE_STATIONS", `User ${userId} got favorite stations.`);
+				logger.success("GET_FAVORITE_STATIONS", `User ${session.userId} got favorite stations.`);
 				cb({
 					status: 'success',
 					favoriteStations: user.favoriteStations

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

@@ -138,7 +138,7 @@ module.exports = class extends coreClass {
 	async hdel(table, key, cb) {
 		try { await this._validateHook(); } catch { return; }
 
-		if (!key || !table) return cb(null, null);
+		if (!key || !table || typeof key !== "string") return cb(null, null);
 		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 
 		this.client.hdel(table, key, (err) => {

+ 39 - 30
backend/logic/db/index.js

@@ -9,8 +9,8 @@ const regex = {
 	azAZ09_: /^[A-Za-z0-9_]+$/,
 	az09_: /^[a-z0-9_]+$/,
 	emailSimple: /^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/,
-	password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[$@$!%*?&])[A-Za-z\d$@$!%*?&]/,
-	ascii: /^[\x00-\x7F]+$/
+	ascii: /^[\x00-\x7F]+$/,
+	custom: regex => new RegExp(`^[${regex}]+$`)
 };
 
 const isLength = (string, min, max) => {
@@ -80,22 +80,24 @@ module.exports = class extends coreClass {
 						this._lockdown();
 					});
 		
-					// this.schemas.user.path('username').validate((username) => {
-					// 	return (isLength(username, 2, 32) && regex.azAZ09_.test(username));
-					// }, 'Invalid username.');
+					// User
+					this.schemas.user.path('username').validate((username) => {
+						return (isLength(username, 2, 32) && regex.custom("a-zA-Z0-9_-").test(username));
+					}, 'Invalid username.');
 		
 					this.schemas.user.path('email.address').validate((email) => {
 						if (!isLength(email, 3, 254)) return false;
 						if (email.indexOf('@') !== email.lastIndexOf('@')) return false;
-						return regex.emailSimple.test(email);
+						return regex.emailSimple.test(email) && regex.ascii.test(email);
 					}, 'Invalid email.');
-		
+
+					// Station
 					this.schemas.station.path('name').validate((id) => {
 						return (isLength(id, 2, 16) && regex.az09_.test(id));
 					}, 'Invalid station name.');
 		
 					this.schemas.station.path('displayName').validate((displayName) => {
-						return (isLength(displayName, 2, 32) && regex.azAZ09_.test(displayName));
+						return (isLength(displayName, 2, 32) && regex.ascii.test(displayName));
 					}, 'Invalid display name.');
 		
 					this.schemas.station.path('description').validate((description) => {
@@ -106,12 +108,14 @@ module.exports = class extends coreClass {
 						}).length === 0;
 					}, 'Invalid display name.');
 		
-		
 					this.schemas.station.path('owner').validate({
-						isAsync: true,
-						validator: (owner, callback) => {
-							this.models.station.countDocuments({ owner: owner }, (err, c) => {
-								callback(!(err || c >= 3))
+						validator: (owner) => {
+							return new Promise((resolve, reject) => {
+								this.models.station.countDocuments({ owner: owner }, (err, c) => {
+									if (err) reject(new Error("A mongo error happened."));
+									else if (c >= 3) reject(new Error("User already has 3 stations."));
+									else resolve();
+								});
 							});
 						},
 						message: 'User already has 3 stations.'
@@ -153,7 +157,9 @@ module.exports = class extends coreClass {
 						return callback(false);
 					}, 'The max amount of songs per user is 3, and only 2 in a row is allowed.');
 					*/
-		
+
+
+					// Song
 					let songTitle = (title) => {
 						return isLength(title, 1, 100);
 					};
@@ -169,29 +175,32 @@ module.exports = class extends coreClass {
 		
 					let songArtists = (artists) => {
 						return artists.filter((artist) => {
-								return (isLength(artist, 1, 32) && regex.ascii.test(artist) && artist !== "NONE");
+								return (isLength(artist, 1, 64) && artist !== "NONE");
 							}).length === artists.length;
 					};
 					this.schemas.song.path('artists').validate(songArtists, 'Invalid artists.');
 					this.schemas.queueSong.path('artists').validate(songArtists, 'Invalid artists.');
 		
-					/*let songGenres = (genres) => {
+					let songGenres = (genres) => {
+						if (genres.length < 1 || genres.length > 16) return false;
 						return genres.filter((genre) => {
-								return (isLength(genre, 1, 16) && regex.azAZ09_.test(genre));
+								return (isLength(genre, 1, 32) && regex.ascii.test(genre));
 							}).length === genres.length;
 					};
 					this.schemas.song.path('genres').validate(songGenres, 'Invalid genres.');
-					this.schemas.queueSong.path('genres').validate(songGenres, 'Invalid genres.');*/
-		
-					this.schemas.song.path('thumbnail').validate((thumbnail) => {
-						return isLength(thumbnail, 8, 256);
-					}, 'Invalid thumbnail.');
-					this.schemas.queueSong.path('thumbnail').validate((thumbnail) => {
-						return isLength(thumbnail, 0, 256);
-					}, 'Invalid thumbnail.');
+					this.schemas.queueSong.path('genres').validate(songGenres, 'Invalid genres.');
 		
+					let songThumbnail = (thumbnail) => {
+						if (!isLength(thumbnail, 1, 256)) return false;
+						if (config.get("cookie.secure") === true) return thumbnail.startsWith("https://");
+						else return thumbnail.startsWith("http://") || thumbnail.startsWith("https://");
+					};
+					this.schemas.song.path('thumbnail').validate(songThumbnail, 'Invalid thumbnail.');
+					this.schemas.queueSong.path('thumbnail').validate(songThumbnail, 'Invalid thumbnail.');
+
+					// Playlist
 					this.schemas.playlist.path('displayName').validate((displayName) => {
-						return (isLength(displayName, 1, 16) && regex.ascii.test(displayName));
+						return (isLength(displayName, 1, 32) && regex.ascii.test(displayName));
 					}, 'Invalid display name.');
 		
 					this.schemas.playlist.path('createdBy').validate((createdBy) => {
@@ -201,14 +210,15 @@ module.exports = class extends coreClass {
 					}, 'Max 10 playlists per user.');
 		
 					this.schemas.playlist.path('songs').validate((songs) => {
-						return songs.length <= 2000;
-					}, 'Max 2000 songs per playlist.');
+						return songs.length <= 5000;
+					}, 'Max 5000 songs per playlist.');
 		
 					this.schemas.playlist.path('songs').validate((songs) => {
 						if (songs.length === 0) return true;
 						return songs[0].duration <= 10800;
 					}, 'Max 3 hours per song.');
 		
+					// Report
 					this.schemas.report.path('description').validate((description) => {
 						return (!description || (isLength(description, 0, 400) && regex.ascii.test(description)));
 					}, 'Invalid description.');
@@ -223,7 +233,6 @@ module.exports = class extends coreClass {
 	}
 
 	passwordValid(password) {
-		if (!isLength(password, 6, 200)) return false;
-		return regex.password.test(password);
+		return isLength(password, 6, 200);
 	}
 }

+ 1 - 1
backend/logic/discord.js

@@ -50,7 +50,7 @@ module.exports = class extends coreClass {
 	async sendAdminAlertMessage(message, color, type, critical, extraFields) {
 		try { await this._validateHook(); } catch { return; }
 
-		const channel = this.client.channels.find("id", this.adminAlertChannelId);
+		const channel = this.client.channels.find(channel => channel.id === this.adminAlertChannelId);
 		if (channel !== null) {
 			let richEmbed = new Discord.RichEmbed();
 			richEmbed.setAuthor(

+ 8 - 3
backend/logic/io.js

@@ -31,9 +31,9 @@ module.exports = class extends coreClass {
 			const SIDname = config.get("cookie.SIDname");
 
 			// TODO: Check every 30s/60s, for all sockets, if they are still allowed to be in the rooms they are in, and on socket at all (permission changing/banning)
-			this.io = socketio(app.server);
+			this._io = socketio(app.server);
 
-			this.io.use(async (socket, next) => {
+			this._io.use(async (socket, next) => {
 				try { await this._validateHook(); } catch { return; }
 
 				let SID;
@@ -95,7 +95,7 @@ module.exports = class extends coreClass {
 				});
 			});
 
-			this.io.on('connection', async socket => {
+			this._io.on('connection', async socket => {
 				try { await this._validateHook(); } catch { return; }
 
 				let sessionInfo = '';
@@ -186,4 +186,9 @@ module.exports = class extends coreClass {
 			resolve();
 		});
 	}
+
+	async io () {
+		try { await this._validateHook(); } catch { return; }
+		return this._io;
+	}
 }

+ 2 - 1
backend/logic/notifications.js

@@ -89,8 +89,9 @@ module.exports = class extends coreClass {
 			});
 
 			this.sub.on('pmessage', (pattern, channel, expiredKey) => {
-				this.logger.stationIssue(`PMESSAGE - Pattern: ${pattern}; Channel: ${channel}; ExpiredKey: ${expiredKey}`);
+				this.logger.stationIssue(`PMESSAGE1 - Pattern: ${pattern}; Channel: ${channel}; ExpiredKey: ${expiredKey}`);
 				subscriptions.forEach((sub) => {
+					this.logger.stationIssue(`PMESSAGE2 - Sub name: ${sub.name}; Calls cb: ${!(sub.name !== expiredKey)}`);
 					if (sub.name !== expiredKey) return;
 					sub.cb();
 				});

+ 57 - 27
backend/logic/stations.js

@@ -92,18 +92,20 @@ module.exports = class extends coreClass {
 				},
 	
 				(stations, next) => {
-					this.setStage(4);
-					async.each(stations, (station, next) => {
+					this.setStage(5);
+					async.each(stations, (station, next2) => {
 						async.waterfall([
 							(next) => {
 								this.cache.hset('stations', station._id, this.cache.schemas.station(station), next);
 							},
 	
 							(station, next) => {
-								this.initializeStation(station._id, next);
+								this.initializeStation(station._id, () => {
+									next()
+								}, true);
 							}
 						], (err) => {
-							next(err);
+							next2(err);
 						});
 					}, next);
 				}
@@ -118,14 +120,14 @@ module.exports = class extends coreClass {
 		});
 	}
 
-	async initializeStation(stationId, cb) {
-		try { await this._validateHook(); } catch { return; }
+	async initializeStation(stationId, cb, bypassValidate = false) {
+		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
 
 		if (typeof cb !== 'function') cb = ()=>{};
 
 		async.waterfall([
 			(next) => {
-				this.getStation(stationId, next);
+				this.getStation(stationId, next, true);
 			},
 			(station, next) => {
 				if (!station) return next('Station not found.');
@@ -139,14 +141,14 @@ module.exports = class extends coreClass {
 					return this.skipStation(station._id)((err, station) => {
 						if (err) return next(err);
 						return next(true, station);
-					});
+					}, true);
 				}
 				let timeLeft = ((station.currentSong.duration * 1000) - (Date.now() - station.startedAt - station.timePaused));
 				if (isNaN(timeLeft)) timeLeft = -1;
 				if (station.currentSong.duration * 1000 < timeLeft || timeLeft < 0) {
 					this.skipStation(station._id)((err, station) => {
 						next(err, station);
-					});
+					}, true);
 				} else {
 					this.notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft, null, station);
 					next(null, station);
@@ -158,12 +160,13 @@ module.exports = class extends coreClass {
 		});
 	}
 
-	async calculateSongForStation(station, cb) {
-		try { await this._validateHook(); } catch { return; }
+	async calculateSongForStation(station, cb, bypassValidate = false) {
+		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
 
 		let songList = [];
 		async.waterfall([
 			(next) => {
+				if (station.genres.length === 0) return next();
 				let genresDone = [];
 				station.genres.forEach((genre) => {
 					this.db.models.song.find({genres: genre}, (err, songs) => {
@@ -203,14 +206,14 @@ module.exports = class extends coreClass {
 			(playlist, next) => {
 				this.calculateOfficialPlaylistList(station._id, playlist, () => {
 					next(null, playlist);
-				});
+				}, true);
 			},
 
 			(playlist, next) => {
 				this.db.models.station.updateOne({_id: station._id}, {$set: {playlist: playlist}}, {runValidators: true}, (err) => {
 					this.updateStation(station._id, () => {
 						next(err, playlist);
-					});
+					}, true);
 				});
 			}
 
@@ -220,8 +223,8 @@ module.exports = class extends coreClass {
 	}
 
 	// Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
-	async getStation(stationId, cb) {
-		try { await this._validateHook(); } catch { return; }
+	async getStation(stationId, cb, bypassValidate = false) {
+		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
 
 		async.waterfall([
 			(next) => {
@@ -277,8 +280,8 @@ module.exports = class extends coreClass {
 		});
 	}
 
-	async updateStation(stationId, cb) {
-		try { await this._validateHook(); } catch { return; }
+	async updateStation(stationId, cb, bypassValidate = false) {
+		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
 
 		async.waterfall([
 
@@ -300,8 +303,8 @@ module.exports = class extends coreClass {
 		});
 	}
 
-	async calculateOfficialPlaylistList(stationId, songList, cb) {
-		try { await this._validateHook(); } catch { return; }
+	async calculateOfficialPlaylistList(stationId, songList, cb, bypassValidate = false) {
+		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
 
 		let lessInfoPlaylist = [];
 		async.each(songList, (song, next) => {
@@ -327,14 +330,15 @@ module.exports = class extends coreClass {
 
 	skipStation(stationId) {
 		this.logger.info("STATION_SKIP", `Skipping station ${stationId}.`, false);
-		return async (cb) => {
-			try { await this._validateHook(); } catch { return; }
+		return async (cb, bypassValidate = false) => {
+			if (!bypassValidate) try { await this._validateHook(); } catch { return; }
+			this.logger.stationIssue(`SKIP_STATION_CB - Station ID: ${stationId}.`);
 
 			if (typeof cb !== 'function') cb = ()=>{};
 
 			async.waterfall([
 				(next) => {
-					this.getStation(stationId, next);
+					this.getStation(stationId, next, true);
 				},
 				(station, next) => {
 					if (!station) return next('Station not found.');
@@ -384,7 +388,7 @@ module.exports = class extends coreClass {
 									return next(null, song, 0, station);
 								});
 							}
-						});
+						}, true);
 					}
 					if (station.type === 'official' && station.playlist.length > 0) {
 						async.doUntil((next) => {
@@ -393,7 +397,7 @@ module.exports = class extends coreClass {
 									if (!err) return next(null, song, station.currentSongIndex + 1);
 									else {
 										station.currentSongIndex++;
-										next(null, null);
+										next(null, null, null);
 									}
 								});
 							} else {
@@ -404,7 +408,7 @@ module.exports = class extends coreClass {
 										station.playlist = newPlaylist;
 										next(null, song, 0);
 									});
-								});
+								}, true);
 							}
 						}, (song, currentSongIndex, next) => {
 							if (!!song) return next(null, true, currentSongIndex);
@@ -451,7 +455,7 @@ module.exports = class extends coreClass {
 							if (station.type === 'community' && station.partyMode === true)
 								this.cache.pub('station.queueUpdate', stationId);
 							next(null, station);
-						});
+						}, true);
 					});
 				},
 			], async (err, station) => {
@@ -498,10 +502,36 @@ module.exports = class extends coreClass {
 					cb(null, station);
 				} else {
 					err = await this.utils.getError(err);
-					logger.error('SKIP_STATION', `Skipping station "${stationId}" failed. "${err}"`);
+					this.logger.error('SKIP_STATION', `Skipping station "${stationId}" failed. "${err}"`);
 					cb(err);
 				}
 			});
 		}
 	}
+
+	async canUserViewStation(station, userId, cb) {
+		try { await this._validateHook(); } catch { return; }
+		async.waterfall([
+			(next) => {
+				if (station.privacy !== 'private') return next(true);
+				if (!userId) return next("Not allowed");
+				next();
+			},
+			
+			(next) => {
+				this.db.models.user.findOne({_id: userId}, next);
+			},
+			
+			(user, next) => {
+				if (!user) return next("Not allowed");
+				if (user.role === 'admin') return next(true);
+				if (station.type === 'official') return next("Not allowed");
+				if (station.owner === userId) return next(true);
+				next("Not allowed");
+			}
+		], async (errOrResult) => {
+			if (errOrResult === true || errOrResult === "Not allowed") return cb(null, (errOrResult === true) ? true : false);
+			cb(await this.utils.getError(errOrResult));
+		});
+	}
 }

+ 33 - 11
backend/logic/tasks.js

@@ -3,6 +3,7 @@
 const coreClass = require("../core");
 
 const async = require("async");
+const fs = require("fs");
 
 let tasks = {};
 
@@ -19,6 +20,7 @@ module.exports = class extends coreClass {
 			//this.createTask("testTask", testTask, 5000, true);
 			this.createTask("stationSkipTask", this.checkStationSkipTask, 1000 * 60 * 30);
 			this.createTask("sessionClearTask", this.sessionClearingTask, 1000 * 60 * 60 * 6);
+			this.createTask("logFileSizeCheckTask", this.logFileSizeCheckTask, 1000 * 60 * 60);
 
 			resolve();
 		});
@@ -53,13 +55,15 @@ module.exports = class extends coreClass {
 		try { await this._validateHook(); } catch { return; }
 
 		if (task.timer) task.timer.pause();
-
-		task.fn(() => {
-			task.lastRan = Date.now();
-			task.timer = new utils.Timer(() => {
-				this.handleTask(task);
-			}, task.timeout, false);
-		});
+		
+		task.fn.apply(this, [
+			() => {
+				task.lastRan = Date.now();
+				task.timer = new this.utils.Timer(() => {
+					this.handleTask(task);
+				}, task.timeout, false);
+			}
+		]);
 	}
 
 	/*testTask(callback) {
@@ -72,8 +76,6 @@ module.exports = class extends coreClass {
 	}*/
 
 	async checkStationSkipTask(callback) {
-		try { await this._validateHook(); } catch { return; }
-
 		this.logger.info("TASK_STATIONS_SKIP_CHECK", `Checking for stations to be skipped.`, false);
 		async.waterfall([
 			(next) => {
@@ -99,8 +101,6 @@ module.exports = class extends coreClass {
 	}
 
 	async sessionClearingTask(callback) {
-		try { await this._validateHook(); } catch { return; }
-	
 		this.logger.info("TASK_SESSION_CLEAR", `Checking for sessions to be cleared.`, false);
 		async.waterfall([
 			(next) => {
@@ -148,4 +148,26 @@ module.exports = class extends coreClass {
 			callback();
 		});
 	}
+
+	async logFileSizeCheckTask(callback) {
+		this.logger.info("TASK_LOG_FILE_SIZE_CHECK", `Checking the size for the log files.`);
+		async.each(
+			["all.log", "debugStation.log", "error.log", "info.log", "success.log"],
+			(fileName, next) => {
+				const stats = fs.statSync(`${__dirname}/../../log/${fileName}`);
+				const mb = stats.size / 1000000;
+				if (mb > 25) return next(true);
+				else next();
+			},
+			(err) => {
+				if (err === true) {
+					this.logger.error("LOGGER_FILE_SIZE_WARNING", "************************************WARNING*************************************");
+					this.logger.error("LOGGER_FILE_SIZE_WARNING", "***************ONE OR MORE LOG FILES APPEAR TO BE MORE THAN 25MB****************");
+					this.logger.error("LOGGER_FILE_SIZE_WARNING", "****MAKE SURE TO REGULARLY CLEAR UP THE LOG FILES, MANUALLY OR AUTOMATICALLY****");
+					this.logger.error("LOGGER_FILE_SIZE_WARNING", "********************************************************************************");
+				}
+				callback();
+			}
+		);
+	}
 }

+ 14 - 34
backend/logic/utils.js

@@ -172,7 +172,8 @@ module.exports = class extends coreClass {
 	async socketFromSession(socketId) {
 		try { await this._validateHook(); } catch { return; }
 
-		let ns = this.io.io.of("/");
+		let io = await this.io.io();
+		let ns = io.of("/");
 		if (ns) {
 			return ns.connected[socketId];
 		}
@@ -181,7 +182,8 @@ module.exports = class extends coreClass {
 	async socketsFromSessionId(sessionId, cb) {
 		try { await this._validateHook(); } catch { return; }
 
-		let ns = this.io.io.of("/");
+		let io = await this.io.io();
+		let ns = io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
@@ -197,7 +199,8 @@ module.exports = class extends coreClass {
 	async socketsFromUser(userId, cb) {
 		try { await this._validateHook(); } catch { return; }
 
-		let ns = this.io.io.of("/");
+		let io = await this.io.io();
+		let ns = io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
@@ -215,7 +218,8 @@ module.exports = class extends coreClass {
 	async socketsFromIP(ip, cb) {
 		try { await this._validateHook(); } catch { return; }
 
-		let ns = this.io.io.of("/");
+		let io = await this.io.io();
+		let ns = io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
@@ -233,7 +237,8 @@ module.exports = class extends coreClass {
 	async socketsFromUserWithoutCache(userId, cb) {
 		try { await this._validateHook(); } catch { return; }
 
-		let ns = this.io.io.of("/");
+		let io = await this.io.io();
+		let ns = io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
@@ -306,7 +311,8 @@ module.exports = class extends coreClass {
 	async emitToRoom(room, ...args) {
 		try { await this._validateHook(); } catch { return; }
 
-		let sockets = this.io.io.sockets.sockets;
+		let io = await this.io.io();
+		let sockets = io.sockets.sockets;
 		for (let id in sockets) {
 			let socket = sockets[id];
 			if (socket.rooms[room]) {
@@ -318,7 +324,8 @@ module.exports = class extends coreClass {
 	async getRoomSockets(room) {
 		try { await this._validateHook(); } catch { return; }
 
-		let sockets = this.io.io.sockets.sockets;
+		let io = await this.io.io();
+		let sockets = io.sockets.sockets;
 		let roomSockets = [];
 		for (let id in sockets) {
 			let socket = sockets[id];
@@ -555,31 +562,4 @@ module.exports = class extends coreClass {
 		}
 		return error;
 	}
-
-	async canUserBeInStation(station, userId, cb) {
-		try { await this._validateHook(); } catch { return; }
-
-		async.waterfall([
-			(next) => {
-				if (station.privacy !== 'private') return next(true);
-				if (!userId) return next(false);
-				next();
-			},
-
-			(next) => {
-				this.db.models.user.findOne({_id: userId}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next(false);
-				if (user.role === 'admin') return next(true);
-				if (station.type === 'official') return next(false);
-				if (station.owner === userId) return next(true);
-				next(false);
-			}
-		], (err) => {
-			if (err === true) return cb(true);
-			return cb(false);
-		});
-	}
 }

+ 2 - 1
frontend/.eslintrc

@@ -29,6 +29,7 @@
 		"no-underscore-dangle": 0,
 		"radix": 0,
 		"no-multi-assign": 0,
-		"no-shadow": 0
+		"no-shadow": 0,
+		"no-new": 0
 	}
 }

+ 0 - 16
frontend/.snyk

@@ -2,20 +2,4 @@
 version: v1.13.5
 # ignores vulnerabilities until expiry date; change duration by modifying expiry date
 ignore:
-  'npm:vue:20170401':
-    - vue-roaster > vue:
-        reason: temp
-        expires: '2019-09-04T02:07:16.079Z'
-  'npm:vue:20170829':
-    - vue-roaster > vue:
-        reason: temp
-        expires: '2019-09-04T02:07:16.079Z'
-  'npm:vue:20180222':
-    - vue-roaster > vue:
-        reason: temp
-        expires: '2019-09-04T02:07:16.079Z'
-  'npm:vue:20180802':
-    - vue-roaster > vue:
-        reason: temp
-        expires: '2019-09-04T02:07:16.079Z'
 patch: {}

+ 17 - 8
frontend/App.vue

@@ -7,7 +7,6 @@
 			</h1>
 			<!-- should be a persistant toast -->
 			<router-view />
-			<toast />
 			<what-is-new />
 			<mobile-alert />
 			<login-modal v-if="modals.header.login" />
@@ -19,7 +18,7 @@
 <script>
 import { mapState, mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 
 import Banned from "./components/pages/Banned.vue";
 import WhatIsNew from "./components/Modals/WhatIsNew.vue";
@@ -65,6 +64,7 @@ export default {
 			this.$router.go(localStorage.getItem("github_redirect"));
 			localStorage.removeItem("github_redirect");
 		}
+
 		io.onConnect(true, () => {
 			this.socketConnected = true;
 		});
@@ -74,9 +74,11 @@ export default {
 		io.onDisconnect(true, () => {
 			this.socketConnected = false;
 		});
-		lofig.get("serverDomain", res => {
-			this.serverDomain = res;
+
+		lofig.get("serverDomain").then(serverDomain => {
+			this.serverDomain = serverDomain;
 		});
+
 		this.$router.onReady(() => {
 			if (this.$route.query.err) {
 				let { err } = this.$route.query;
@@ -84,7 +86,7 @@ export default {
 					.replace(new RegExp("<", "g"), "&lt;")
 					.replace(new RegExp(">", "g"), "&gt;");
 				this.$router.push({ query: {} });
-				Toast.methods.addToast(err, 20000);
+				new Toast({ content: err, timeout: 20000 });
 			}
 			if (this.$route.query.msg) {
 				let { msg } = this.$route.query;
@@ -92,7 +94,7 @@ export default {
 					.replace(new RegExp("<", "g"), "&lt;")
 					.replace(new RegExp(">", "g"), "&gt;");
 				this.$router.push({ query: {} });
-				Toast.methods.addToast(msg, 20000);
+				new Toast({ content: msg, timeout: 20000 });
 			}
 		});
 		io.getSocket(true, socket => {
@@ -102,7 +104,6 @@ export default {
 		});
 	},
 	components: {
-		Toast,
 		WhatIsNew,
 		MobileAlert,
 		LoginModal,
@@ -115,8 +116,16 @@ export default {
 <style lang="scss">
 @import "styles/global.scss";
 
-#toast-container {
+#toasts-container {
 	z-index: 10000 !important;
+
+	.toast {
+		font-weight: 600;
+	}
+}
+
+.toast:not(:first-of-type) {
+	margin-top: 5px;
 }
 
 html {

+ 5 - 5
frontend/api/auth.js

@@ -1,4 +1,4 @@
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import io from "../io";
 
 // when Vuex needs to interact with socket.io
@@ -18,7 +18,7 @@ export default {
 					res => {
 						if (res.status === "success") {
 							if (res.SID) {
-								return lofig.get("cookie", cookie => {
+								return lofig.get("cookie").then(cookie => {
 									const date = new Date();
 									date.setTime(
 										new Date().getTime() +
@@ -52,7 +52,7 @@ export default {
 			io.getSocket(socket => {
 				socket.emit("users.login", email, password, res => {
 					if (res.status === "success") {
-						return lofig.get("cookie", cookie => {
+						return lofig.get("cookie").then(cookie => {
 							const date = new Date();
 							date.setTime(
 								new Date().getTime() +
@@ -79,12 +79,12 @@ export default {
 			io.getSocket(socket => {
 				socket.emit("users.logout", result => {
 					if (result.status === "success") {
-						return lofig.get("cookie", cookie => {
+						return lofig.get("cookie").then(cookie => {
 							document.cookie = `${cookie.SIDname}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
 							return window.location.reload();
 						});
 					}
-					Toast.methods.addToast(result.message, 4000);
+					new Toast({ content: result.message, timeout: 4000 });
 					return reject(new Error(result.message));
 				});
 			});

+ 0 - 5
frontend/components/404.vue

@@ -12,11 +12,6 @@
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
-* {
-	margin: 0;
-	padding: 0;
-}
-
 .wrapper {
 	height: 100vh;
 	display: flex;

+ 0 - 501
frontend/components/Admin/EditStation.vue

@@ -1,501 +0,0 @@
-<template>
-	<modal title="Edit Station">
-		<template v-slot:body>
-			<label class="label">Name</label>
-			<p class="control">
-				<input
-					v-model="editing.name"
-					class="input"
-					type="text"
-					placeholder="Station Name"
-				/>
-			</p>
-			<label class="label">Display name</label>
-			<p class="control">
-				<input
-					v-model="editing.displayName"
-					class="input"
-					type="text"
-					placeholder="Station Display Name"
-				/>
-			</p>
-			<label class="label">Description</label>
-			<p class="control">
-				<input
-					v-model="editing.description"
-					class="input"
-					type="text"
-					placeholder="Station Description"
-				/>
-			</p>
-			<label class="label">Privacy</label>
-			<p class="control">
-				<span class="select">
-					<select v-model="editing.privacy">
-						<option value="public">Public</option>
-						<option value="unlisted">Unlisted</option>
-						<option value="private">Private</option>
-					</select>
-				</span>
-			</p>
-			<br />
-			<p class="control" v-if="station.type === 'community'">
-				<label class="checkbox party-mode-inner">
-					<input v-model="editing.partyMode" type="checkbox" />
-					&nbsp;Party mode
-				</label>
-			</p>
-			<small v-if="station.type === 'community'"
-				>With party mode enabled, people can add songs to a queue that
-				plays. With party mode disabled you can play a private playlist
-				on loop.</small
-			>
-			<br />
-			<div v-if="station.type === 'community' && station.partyMode">
-				<br />
-				<br />
-				<label class="label">Queue lock</label>
-				<small v-if="station.partyMode"
-					>With the queue locked, only owners (you) can add songs to
-					the queue.</small
-				>
-				<br />
-				<button
-					v-if="!station.locked"
-					class="button is-danger"
-					@click="$parent.toggleLock()"
-				>
-					Lock the queue
-				</button>
-				<button
-					v-if="station.locked"
-					class="button is-success"
-					@click="$parent.toggleLock()"
-				>
-					Unlock the queue
-				</button>
-			</div>
-			<div
-				v-if="station.type === 'official'"
-				class="control is-grouped genre-wrapper"
-			>
-				<div class="sector">
-					<p class="control has-addons">
-						<input
-							id="new-genre-edit"
-							class="input"
-							type="text"
-							placeholder="Genre"
-							@keyup.enter="addGenre()"
-						/>
-						<a class="button is-info" href="#" @click="addGenre()"
-							>Add genre</a
-						>
-					</p>
-					<span
-						v-for="(genre, index) in editing.genres"
-						:key="index"
-						class="tag is-info"
-					>
-						{{ genre }}
-						<button
-							class="delete is-info"
-							@click="removeGenre(index)"
-						/>
-					</span>
-				</div>
-				<div class="sector">
-					<p class="control has-addons">
-						<input
-							id="new-blacklisted-genre-edit"
-							class="input"
-							type="text"
-							placeholder="Blacklisted Genre"
-							@keyup.enter="addBlacklistedGenre()"
-						/>
-						<a
-							class="button is-info"
-							href="#"
-							@click="addBlacklistedGenre()"
-							>Add blacklisted genre</a
-						>
-					</p>
-					<span
-						v-for="(genre, index) in editing.blacklistedGenres"
-						:key="index"
-						class="tag is-info"
-					>
-						{{ genre }}
-						<button
-							class="delete is-info"
-							@click="removeBlacklistedGenre(index)"
-						/>
-					</span>
-				</div>
-			</div>
-		</template>
-		<template v-slot:footer>
-			<button class="button is-success" v-on:click="update()">
-				Update Settings
-			</button>
-			<button
-				v-if="station.type === 'community'"
-				class="button is-danger"
-				@click="deleteStation()"
-			>
-				Delete station
-			</button>
-		</template>
-	</modal>
-</template>
-
-<script>
-import { mapState, mapActions } from "vuex";
-import { Toast } from "vue-roaster";
-
-import Modal from "../Modals/Modal.vue";
-import io from "../../io";
-import validation from "../../validation";
-
-export default {
-	computed: mapState("admin/stations", {
-		station: state => state.station,
-		editing: state => state.editing
-	}),
-	mounted() {
-		io.getSocket(socket => {
-			this.socket = socket;
-			return socket;
-		});
-	},
-	methods: {
-		update() {
-			if (this.station.name !== this.editing.name) this.updateName();
-			if (this.station.displayName !== this.editing.displayName)
-				this.updateDisplayName();
-			if (this.station.description !== this.editing.description)
-				this.updateDescription();
-			if (this.station.privacy !== this.editing.privacy)
-				this.updatePrivacy();
-			if (this.station.partyMode !== this.editing.partyMode)
-				this.updatePartyMode();
-			if (
-				this.station.genres.toString() !==
-				this.editing.genres.toString()
-			)
-				this.updateGenres();
-			if (
-				this.station.blacklistedGenres.toString() !==
-				this.editing.blacklistedGenres.toString()
-			)
-				this.updateBlacklistedGenres();
-		},
-		updateName() {
-			const { name } = this.editing;
-			if (!validation.isLength(name, 2, 16))
-				return Toast.methods.addToast(
-					"Name must have between 2 and 16 characters.",
-					8000
-				);
-			if (!validation.regex.az09_.test(name))
-				return Toast.methods.addToast(
-					"Invalid name format. Allowed characters: a-z, 0-9 and _.",
-					8000
-				);
-
-			return this.socket.emit(
-				"stations.updateName",
-				this.editing._id,
-				name,
-				res => {
-					if (res.status === "success") {
-						if (this.station) this.station.name = name;
-						this.$parent.stations.forEach((station, index) => {
-							if (station._id === this.editing._id) {
-								this.$parent.stations[index].name = name;
-								return name;
-							}
-
-							return false;
-						});
-					}
-					Toast.methods.addToast(res.message, 8000);
-				}
-			);
-		},
-		updateDisplayName() {
-			const { displayName } = this.editing;
-			if (!validation.isLength(displayName, 2, 32))
-				return Toast.methods.addToast(
-					"Display name must have between 2 and 32 characters.",
-					8000
-				);
-			if (!validation.regex.azAZ09_.test(displayName))
-				return Toast.methods.addToast(
-					"Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.",
-					8000
-				);
-
-			return this.socket.emit(
-				"stations.updateDisplayName",
-				this.editing._id,
-				displayName,
-				res => {
-					if (res.status === "success") {
-						if (this.station) {
-							this.station.displayName = displayName;
-							return displayName;
-						}
-						this.$parent.stations.forEach((station, index) => {
-							if (station._id === this.editing._id) {
-								this.$parent.stations[
-									index
-								].displayName = displayName;
-								return displayName;
-							}
-
-							return false;
-						});
-					}
-
-					return Toast.methods.addToast(res.message, 8000);
-				}
-			);
-		},
-		updateDescription() {
-			const { description } = this.editing;
-			if (!validation.isLength(description, 2, 200))
-				return Toast.methods.addToast(
-					"Description must have between 2 and 200 characters.",
-					8000
-				);
-			let characters = description.split("");
-			characters = characters.filter(character => {
-				return character.charCodeAt(0) === 21328;
-			});
-			if (characters.length !== 0)
-				return Toast.methods.addToast(
-					"Invalid description format. Swastika's are not allowed.",
-					8000
-				);
-
-			return this.socket.emit(
-				"stations.updateDescription",
-				this.editing._id,
-				description,
-				res => {
-					if (res.status === "success") {
-						if (this.station) {
-							this.station.description = description;
-							return description;
-						}
-						this.$parent.stations.forEach((station, index) => {
-							if (station._id === this.editing._id) {
-								this.$parent.stations[
-									index
-								].description = description;
-								return description;
-							}
-
-							return false;
-						});
-
-						return Toast.methods.addToast(res.message, 4000);
-					}
-
-					return Toast.methods.addToast(res.message, 8000);
-				}
-			);
-		},
-		updatePrivacy() {
-			this.socket.emit(
-				"stations.updatePrivacy",
-				this.editing._id,
-				this.editing.privacy,
-				res => {
-					if (res.status === "success") {
-						if (this.station)
-							this.station.privacy = this.editing.privacy;
-						else {
-							this.$parent.stations.forEach((station, index) => {
-								if (station._id === this.editing._id) {
-									this.$parent.stations[
-										index
-									].privacy = this.editing.privacy;
-									return this.editing.privacy;
-								}
-
-								return false;
-							});
-						}
-						return Toast.methods.addToast(res.message, 4000);
-					}
-
-					return Toast.methods.addToast(res.message, 8000);
-				}
-			);
-		},
-		updateGenres() {
-			this.socket.emit(
-				"stations.updateGenres",
-				this.editing._id,
-				this.editing.genres,
-				res => {
-					if (res.status === "success") {
-						const genres = JSON.parse(
-							JSON.stringify(this.editing.genres)
-						);
-						if (this.station) this.station.genres = genres;
-						this.$parent.stations.forEach((station, index) => {
-							if (station._id === this.editing._id) {
-								this.$parent.stations[index].genres = genres;
-								return genres;
-							}
-
-							return false;
-						});
-						return Toast.methods.addToast(res.message, 4000);
-					}
-
-					return Toast.methods.addToast(res.message, 8000);
-				}
-			);
-		},
-		updateBlacklistedGenres() {
-			this.socket.emit(
-				"stations.updateBlacklistedGenres",
-				this.editing._id,
-				this.editing.blacklistedGenres,
-				res => {
-					if (res.status === "success") {
-						const blacklistedGenres = JSON.parse(
-							JSON.stringify(this.editing.blacklistedGenres)
-						);
-						if (this.station)
-							this.station.blacklistedGenres = blacklistedGenres;
-						this.$parent.stations.forEach((station, index) => {
-							if (station._id === this.editing._id) {
-								this.$parent.stations[
-									index
-								].blacklistedGenres = blacklistedGenres;
-								return blacklistedGenres;
-							}
-
-							return false;
-						});
-						return Toast.methods.addToast(res.message, 4000);
-					}
-
-					return Toast.methods.addToast(res.message, 8000);
-				}
-			);
-		},
-		updatePartyMode() {
-			this.socket.emit(
-				"stations.updatePartyMode",
-				this.editing._id,
-				this.editing.partyMode,
-				res => {
-					if (res.status === "success") {
-						if (this.station)
-							this.station.partyMode = this.editing.partyMode;
-						this.$parent.stations.forEach((station, index) => {
-							if (station._id === this.editing._id) {
-								this.$parent.stations[
-									index
-								].partyMode = this.editing.partyMode;
-								return this.editing.partyMode;
-							}
-
-							return false;
-						});
-
-						return Toast.methods.addToast(res.message, 4000);
-					}
-
-					return Toast.methods.addToast(res.message, 8000);
-				}
-			);
-		},
-		addGenre() {
-			const genre = document
-				.getElementById(`new-genre-edit`)
-				.value.toLowerCase()
-				.trim();
-
-			if (this.editing.genres.indexOf(genre) !== -1)
-				return Toast.methods.addToast("Genre already exists", 3000);
-			if (genre) {
-				this.editing.genres.push(genre);
-				document.getElementById(`new-genre`).value = "";
-				return true;
-			}
-			return Toast.methods.addToast("Genre cannot be empty", 3000);
-		},
-		removeGenre(index) {
-			this.editing.genres.splice(index, 1);
-		},
-		addBlacklistedGenre() {
-			const genre = document
-				.getElementById(`new-blacklisted-genre-edit`)
-				.value.toLowerCase()
-				.trim();
-			if (this.editing.blacklistedGenres.indexOf(genre) !== -1)
-				return Toast.methods.addToast("Genre already exists", 3000);
-
-			if (genre) {
-				this.editing.blacklistedGenres.push(genre);
-				document.getElementById(`new-blacklisted-genre`).value = "";
-				return true;
-			}
-			return Toast.methods.addToast("Genre cannot be empty", 3000);
-		},
-		removeBlacklistedGenre(index) {
-			this.editing.blacklistedGenres.splice(index, 1);
-		},
-		deleteStation() {
-			this.socket.emit("stations.remove", this.editing._id, res => {
-				if (res.status === "success")
-					this.closeModal({
-						sector: "station",
-						modal: "editStation"
-					});
-				return Toast.methods.addToast(res.message, 8000);
-			});
-		},
-		...mapActions("modals", ["closeModal"])
-	},
-	components: { Modal }
-};
-</script>
-
-<style lang="scss" scoped>
-@import "styles/global.scss";
-
-.controls {
-	display: flex;
-
-	a {
-		display: flex;
-		align-items: center;
-	}
-}
-
-.table {
-	margin-bottom: 0;
-}
-
-h5 {
-	padding: 20px 0;
-}
-
-.party-mode-inner,
-.party-mode-outer {
-	display: flex;
-	align-items: center;
-}
-
-.select:after {
-	border-color: $primary-color;
-}
-</style>

+ 26 - 18
frontend/components/Admin/News.vue

@@ -215,7 +215,7 @@
 <script>
 import { mapActions, mapState } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import io from "../../io";
 
 import EditNews from "../Modals/EditNews.vue";
@@ -267,28 +267,28 @@ export default {
 			} = this;
 
 			if (this.creating.title === "")
-				return Toast.methods.addToast(
-					"Field (Title) cannot be empty",
-					3000
-				);
+				return new Toast({
+					content: "Field (Title) cannot be empty",
+					timeout: 3000
+				});
 			if (this.creating.description === "")
-				return Toast.methods.addToast(
-					"Field (Description) cannot be empty",
-					3000
-				);
+				return new Toast({
+					content: "Field (Description) cannot be empty",
+					timeout: 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
-				);
+				return new Toast({
+					content: "You must have at least one News Item",
+					timeout: 3000
+				});
 
 			return this.socket.emit("news.create", this.creating, result => {
-				Toast.methods.addToast(result.message, 4000);
+				new Toast(result.message, 4000);
 				if (result.status === "success")
 					this.creating = {
 						title: "",
@@ -301,8 +301,10 @@ export default {
 			});
 		},
 		removeNews(news) {
-			this.socket.emit("news.remove", news, res =>
-				Toast.methods.addToast(res.message, 8000)
+			this.socket.emit(
+				"news.remove",
+				news,
+				res => new Toast({ content: res.message, timeout: 8000 })
 			);
 		},
 		editNewsClick(news) {
@@ -313,14 +315,20 @@ export default {
 			const change = document.getElementById(`new-${type}`).value.trim();
 
 			if (this.creating[type].indexOf(change) !== -1)
-				return Toast.methods.addToast(`Tag already exists`, 3000);
+				return new Toast({
+					content: `Tag already exists`,
+					timeout: 3000
+				});
 
 			if (change) {
 				document.getElementById(`new-${type}`).value = "";
 				this.creating[type].push(change);
 				return true;
 			}
-			return Toast.methods.addToast(`${type} cannot be empty`, 3000);
+			return new Toast({
+				content: `${type} cannot be empty`,
+				timeout: 3000
+			});
 		},
 		removeChange(type, index) {
 			this.creating[type].splice(index, 1);

+ 2 - 2
frontend/components/Admin/Punishments.vue

@@ -113,7 +113,7 @@
 
 <script>
 import { mapState, mapActions } from "vuex";
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 
 import ViewPunishment from "../Modals/ViewPunishment.vue";
 import io from "../../io";
@@ -149,7 +149,7 @@ export default {
 				this.ipBan.reason,
 				this.ipBan.expiresAt,
 				res => {
-					Toast.methods.addToast(res.message, 6000);
+					new Toast({ content: res.message, timeout: 6000 });
 				}
 			);
 		},

+ 53 - 34
frontend/components/Admin/QueueSongs.vue

@@ -1,13 +1,25 @@
 <template>
 	<div>
 		<metadata title="Admin | Queue songs" />
-		<div class="container">
+		<div class="container" v-scroll="handleScroll">
+			<p>
+				<span>Sets loaded: {{ position - 1 }} / {{ maxPosition }}</span>
+				<br />
+				<span>Loaded songs: {{ this.songs.length }}</span>
+			</p>
 			<input
 				v-model="searchQuery"
 				type="text"
 				class="input"
 				placeholder="Search for Songs"
 			/>
+			<button
+				v-if="!loadAllSongs"
+				class="button is-primary"
+				@click="loadAll()"
+			>
+				Load all
+			</button>
 			<br />
 			<br />
 			<table class="table is-striped">
@@ -79,24 +91,6 @@
 				</tbody>
 			</table>
 		</div>
-		<nav class="pagination">
-			<a
-				v-if="position > 1"
-				class="button"
-				href="#"
-				@click="getSet(position - 1)"
-			>
-				<i class="material-icons">navigate_before</i>
-			</a>
-			<a
-				v-if="maxPosition > position"
-				class="button"
-				href="#"
-				@click="getSet(position + 1)"
-			>
-				<i class="material-icons">navigate_next</i>
-			</a>
-		</nav>
 		<edit-song v-if="modals.editSong" />
 	</div>
 </template>
@@ -104,7 +98,7 @@
 <script>
 import { mapState, mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 
 import EditSong from "../Modals/EditSong.vue";
 import UserIdToUsername from "../UserIdToUsername.vue";
@@ -118,7 +112,9 @@ export default {
 			position: 1,
 			maxPosition: 1,
 			searchQuery: "",
-			songs: []
+			songs: [],
+			gettingSet: false,
+			loadAllSongs: false
 		};
 	},
 	computed: {
@@ -141,12 +137,6 @@ export default {
 	//   }
 	// },
 	methods: {
-		getSet(position) {
-			this.socket.emit("queueSongs.getSet", position, data => {
-				this.songs = data;
-				this.position = position;
-			});
-		},
 		edit(song, index) {
 			const newSong = {};
 			Object.keys(song).forEach(n => {
@@ -159,21 +149,50 @@ export default {
 		add(song) {
 			this.socket.emit("songs.add", song, res => {
 				if (res.status === "success")
-					Toast.methods.addToast(res.message, 2000);
-				else Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 2000 });
+				else new Toast({ content: res.message, timeout: 4000 });
 			});
 		},
 		remove(id) {
 			this.socket.emit("queueSongs.remove", id, res => {
 				if (res.status === "success")
-					Toast.methods.addToast(res.message, 2000);
-				else Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 2000 });
+				else new Toast({ content: res.message, timeout: 4000 });
+			});
+		},
+		getSet() {
+			if (this.gettingSet) return;
+			if (this.position > this.maxPosition) return;
+			this.gettingSet = true;
+			this.socket.emit("queueSongs.getSet", this.position, data => {
+				data.forEach(song => {
+					this.songs.push(song);
+				});
+				this.position += 1;
+				this.gettingSet = false;
+				if (this.loadAllSongs && this.maxPosition > this.position - 1)
+					setTimeout(() => {
+						this.getSet();
+					}, 500);
 			});
 		},
+		handleScroll() {
+			if (this.loadAllSongs) return false;
+			if (window.scrollY + 50 >= window.scrollMaxY) this.getSet();
+
+			return this.maxPosition === this.position;
+		},
+		loadAll() {
+			this.loadAllSongs = true;
+			this.getSet();
+		},
 		init() {
-			this.socket.emit("queueSongs.index", data => {
-				this.songs = data.songs;
-				this.maxPosition = Math.round(data.maxLength / 50);
+			if (this.songs.length > 0)
+				this.position = Math.ceil(this.songs.length / 15) + 1;
+
+			this.socket.emit("queueSongs.length", length => {
+				this.maxPosition = Math.ceil(length / 15);
+				this.getSet();
 			});
 			this.socket.emit("apis.joinAdminRoom", "queue", () => {});
 		},

+ 21 - 9
frontend/components/Admin/Reports.vue

@@ -6,8 +6,8 @@
 				<thead>
 					<tr>
 						<td>Song ID</td>
-						<td>Created By</td>
-						<td>Created At</td>
+						<td>Author</td>
+						<td>Time of report</td>
 						<td>Description</td>
 						<td>Options</td>
 					</tr>
@@ -28,7 +28,13 @@
 							/>
 						</td>
 						<td>
-							<span>{{ report.createdAt }}</span>
+							<span :title="report.createdAt">{{
+								formatDistance(
+									new Date(report.createdAt),
+									new Date(),
+									{ addSuffix: true }
+								)
+							}}</span>
 						</td>
 						<td>
 							<span>{{ report.description }}</span>
@@ -58,8 +64,9 @@
 
 <script>
 import { mapState, mapActions } from "vuex";
+import { formatDistance } from "date-fns";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import io from "../../io";
 
 import IssuesModal from "../Modals/IssuesModal.vue";
@@ -76,17 +83,21 @@ export default {
 		io.getSocket(socket => {
 			this.socket = socket;
 			if (this.socket.connected) this.init();
+
 			this.socket.emit("reports.index", res => {
 				this.reports = res.data;
 			});
+
 			this.socket.on("event:admin.report.resolved", reportId => {
 				this.reports = this.reports.filter(report => {
 					return report._id !== reportId;
 				});
 			});
+
 			this.socket.on("event:admin.report.created", report => {
 				this.reports.push(report);
 			});
+
 			io.onConnect(() => {
 				this.init();
 			});
@@ -96,10 +107,10 @@ export default {
 			this.socket.emit("reports.findOne", this.$route.query.id, res => {
 				if (res.status === "success") this.view(res.data);
 				else
-					Toast.methods.addToast(
-						"Report with that ID not found",
-						3000
-					);
+					new Toast({
+						content: "Report with that ID not found",
+						timeout: 3000
+					});
 			});
 		}
 	},
@@ -109,6 +120,7 @@ export default {
 		})
 	},
 	methods: {
+		formatDistance,
 		init() {
 			this.socket.emit("apis.joinAdminRoom", "reports", () => {});
 		},
@@ -118,7 +130,7 @@ export default {
 		},
 		resolve(reportId) {
 			this.socket.emit("reports.resolve", reportId, res => {
-				Toast.methods.addToast(res.message, 3000);
+				new Toast({ content: res.message, timeout: 3000 });
 				if (res.status === "success" && this.modals.viewReport)
 					this.closeModal({
 						sector: "admin",

+ 46 - 9
frontend/components/Admin/Songs.vue

@@ -1,13 +1,25 @@
 <template>
 	<div>
 		<metadata title="Admin | Songs" />
-		<div class="container">
+		<div class="container" v-scroll="handleScroll">
+			<p>
+				<span>Sets loaded: {{ position - 1 }} / {{ maxPosition }}</span>
+				<br />
+				<span>Loaded songs: {{ this.songs.length }}</span>
+			</p>
 			<input
 				v-model="searchQuery"
 				type="text"
 				class="input"
 				placeholder="Search for Songs"
 			/>
+			<button
+				v-if="!loadAllSongs"
+				class="button is-primary"
+				@click="loadAll()"
+			>
+				Load all
+			</button>
 			<br />
 			<br />
 			<table class="table is-striped">
@@ -90,7 +102,7 @@
 <script>
 import { mapState, mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 
 import EditSong from "../Modals/EditSong.vue";
 import UserIdToUsername from "../UserIdToUsername.vue";
@@ -107,7 +119,9 @@ export default {
 			editing: {
 				index: 0,
 				song: {}
-			}
+			},
+			gettingSet: false,
+			loadAllSongs: false
 		};
 	},
 	computed: {
@@ -139,20 +153,40 @@ export default {
 		remove(id) {
 			this.socket.emit("songs.remove", id, res => {
 				if (res.status === "success")
-					Toast.methods.addToast(res.message, 4000);
-				else Toast.methods.addToast(res.message, 8000);
+					new Toast({ content: res.message, timeout: 4000 });
+				else new Toast({ content: res.message, timeout: 8000 });
 			});
 		},
 		getSet() {
+			if (this.gettingSet) return;
+			if (this.position > this.maxPosition) return;
+			this.gettingSet = true;
 			this.socket.emit("songs.getSet", this.position, data => {
 				data.forEach(song => {
 					this.addSong(song);
 				});
 				this.position += 1;
-				if (this.maxPosition > this.position - 1) this.getSet();
+				this.gettingSet = false;
+				if (this.loadAllSongs && this.maxPosition > this.position - 1)
+					setTimeout(() => {
+						this.getSet();
+					}, 500);
 			});
 		},
+		handleScroll() {
+			if (this.loadAllSongs) return false;
+			if (window.scrollY + 50 >= window.scrollMaxY) this.getSet();
+
+			return this.maxPosition === this.position;
+		},
+		loadAll() {
+			this.loadAllSongs = true;
+			this.getSet();
+		},
 		init() {
+			if (this.songs.length > 0)
+				this.position = Math.ceil(this.songs.length / 15) + 1;
+
 			this.socket.emit("songs.length", length => {
 				this.maxPosition = Math.ceil(length / 15);
 				this.getSet();
@@ -189,13 +223,16 @@ export default {
 			});
 		});
 
-		if (this.$route.query.id) {
-			this.socket.emit("songs.getSong", this.$route.query.id, res => {
+		if (this.$route.query.songId) {
+			this.socket.emit("songs.getSong", this.$route.query.songId, res => {
 				if (res.status === "success") {
 					this.edit(res.data);
 					this.closeModal({ sector: "admin", modal: "viewReport" });
 				} else
-					Toast.methods.addToast("Song with that ID not found", 3000);
+					new Toast({
+						content: "Song with that ID not found",
+						timeout: 3000
+					});
 			});
 		}
 	}

+ 33 - 21
frontend/components/Admin/Stations.vue

@@ -177,17 +177,17 @@
 			</div>
 		</div>
 
-		<edit-station v-if="modals.editStation" />
+		<edit-station v-if="modals.editStation" store="admin/stations" />
 	</div>
 </template>
 
 <script>
 import { mapState, mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import io from "../../io";
 
-import EditStation from "./EditStation.vue";
+import EditStation from "../Modals/EditStation.vue";
 import UserIdToUsername from "../UserIdToUsername.vue";
 
 export default {
@@ -219,20 +219,20 @@ export default {
 			} = this;
 
 			if (name === undefined)
-				return Toast.methods.addToast(
-					"Field (Name) cannot be empty",
-					3000
-				);
+				return new Toast({
+					content: "Field (Name) cannot be empty",
+					timeout: 3000
+				});
 			if (displayName === undefined)
-				return Toast.methods.addToast(
-					"Field (Display Name) cannot be empty",
-					3000
-				);
+				return new Toast({
+					content: "Field (Display Name) cannot be empty",
+					timeout: 3000
+				});
 			if (description === undefined)
-				return Toast.methods.addToast(
-					"Field (Description) cannot be empty",
-					3000
-				);
+				return new Toast({
+					content: "Field (Description) cannot be empty",
+					timeout: 3000
+				});
 
 			return this.socket.emit(
 				"stations.create",
@@ -245,7 +245,7 @@ export default {
 					blacklistedGenres
 				},
 				result => {
-					Toast.methods.addToast(result.message, 3000);
+					new Toast({ content: result.message, timeout: 3000 });
 					if (result.status === "success")
 						this.newStation = {
 							genres: [],
@@ -259,7 +259,7 @@ export default {
 				"stations.remove",
 				this.stations[index]._id,
 				res => {
-					Toast.methods.addToast(res.message, 3000);
+					new Toast({ content: res.message, timeout: 3000 });
 				}
 			);
 		},
@@ -286,13 +286,19 @@ export default {
 				.value.toLowerCase()
 				.trim();
 			if (this.newStation.genres.indexOf(genre) !== -1)
-				return Toast.methods.addToast("Genre already exists", 3000);
+				return new Toast({
+					content: "Genre already exists",
+					timeout: 3000
+				});
 			if (genre) {
 				this.newStation.genres.push(genre);
 				document.getElementById(`new-genre`).value = "";
 				return true;
 			}
-			return Toast.methods.addToast("Genre cannot be empty", 3000);
+			return new Toast({
+				content: "Genre cannot be empty",
+				timeout: 3000
+			});
 		},
 		removeGenre(index) {
 			this.newStation.genres.splice(index, 1);
@@ -303,14 +309,20 @@ export default {
 				.value.toLowerCase()
 				.trim();
 			if (this.newStation.blacklistedGenres.indexOf(genre) !== -1)
-				return Toast.methods.addToast("Genre already exists", 3000);
+				return new Toast({
+					content: "Genre already exists",
+					timeout: 3000
+				});
 
 			if (genre) {
 				this.newStation.blacklistedGenres.push(genre);
 				document.getElementById(`new-blacklisted-genre`).value = "";
 				return true;
 			}
-			return Toast.methods.addToast("Genre cannot be empty", 3000);
+			return new Toast({
+				content: "Genre cannot be empty",
+				timeout: 3000
+			});
 		},
 		removeBlacklistedGenre(index) {
 			this.newStation.blacklistedGenres.splice(index, 1);

+ 3 - 3
frontend/components/MainFooter.vue

@@ -39,7 +39,7 @@
 				<a href="/"
 					><img
 						class="musareFooterLogo"
-						src="/assets/wordmark.png"
+						src="/assets/blue_wordmark.png"
 						alt="Musare"
 				/></a>
 				<p class="footerLinks">
@@ -74,8 +74,8 @@ export default {
 		};
 	},
 	mounted() {
-		lofig.get("siteSettings.socialLinks", res => {
-			this.socialLinks = res;
+		lofig.get("siteSettings.socialLinks").then(socialLinks => {
+			this.socialLinks = socialLinks;
 		});
 	}
 };

+ 7 - 9
frontend/components/MainHeader.vue

@@ -3,7 +3,7 @@
 		<div class="nav-left">
 			<router-link class="nav-item is-brand" to="/">
 				<img
-					:src="`${this.siteSettings.logo}`"
+					:src="`${this.siteSettings.logo_white}`"
 					:alt="`${this.siteSettings.siteName}` || `Musare`"
 				/>
 			</router-link>
@@ -85,13 +85,12 @@ export default {
 		};
 	},
 	mounted() {
-		lofig.get("frontendDomain", res => {
-			this.frontendDomain = res;
-			return res;
+		lofig.get("frontendDomain").then(frontendDomain => {
+			this.frontendDomain = frontendDomain;
 		});
-		lofig.get("siteSettings", res => {
-			this.siteSettings = res;
-			return res;
+
+		lofig.get("siteSettings").then(siteSettings => {
+			this.siteSettings = siteSettings;
 		});
 	},
 	computed: mapState({
@@ -143,12 +142,11 @@ export default {
 		font-size: 2.1rem !important;
 		line-height: 38px !important;
 		padding: 0 20px;
-		color: $white;
 		font-family: Pacifico, cursive;
-		filter: brightness(0) invert(1);
 
 		img {
 			max-height: 38px;
+			color: $musareBlue;
 		}
 	}
 

+ 3 - 3
frontend/components/Modals/AddSongToPlaylist.vue

@@ -40,7 +40,7 @@
 <script>
 import { mapState } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import Modal from "./Modal.vue";
 import io from "../../io";
 
@@ -81,7 +81,7 @@ export default {
 				this.currentSong.songId,
 				playlistId,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 					if (res.status === "success") {
 						this.playlists[playlistId].songs.push(this.song);
 					}
@@ -95,7 +95,7 @@ export default {
 				this.songId,
 				playlistId,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 					if (res.status === "success") {
 						this.playlists[playlistId].songs.forEach(
 							(song, index) => {

+ 25 - 13
frontend/components/Modals/AddSongToQueue.vue

@@ -94,7 +94,7 @@
 <script>
 import { mapState, mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import Modal from "./Modal.vue";
 import io from "../../io";
 
@@ -138,31 +138,43 @@ export default {
 					songId,
 					data => {
 						if (data.status !== "success")
-							Toast.methods.addToast(
-								`Error: ${data.message}`,
-								8000
-							);
-						else Toast.methods.addToast(`${data.message}`, 4000);
+							new Toast({
+								content: `Error: ${data.message}`,
+								timeout: 8000
+							});
+						else
+							new Toast({
+								content: `${data.message}`,
+								timeout: 4000
+							});
 					}
 				);
 			} else {
 				this.socket.emit("queueSongs.add", songId, data => {
 					if (data.status !== "success")
-						Toast.methods.addToast(`Error: ${data.message}`, 8000);
-					else Toast.methods.addToast(`${data.message}`, 4000);
+						new Toast({
+							content: `Error: ${data.message}`,
+							timeout: 8000
+						});
+					else
+						new Toast({
+							content: `${data.message}`,
+							timeout: 4000
+						});
 				});
 			}
 		},
 		importPlaylist() {
-			Toast.methods.addToast(
-				"Starting to import your playlist. This can take some time to do.",
-				4000
-			);
+			new Toast({
+				content:
+					"Starting to import your playlist. This can take some time to do.",
+				timeout: 4000
+			});
 			this.socket.emit(
 				"queueSongs.addSetToQueue",
 				this.importQuery,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 				}
 			);
 		},

+ 39 - 35
frontend/components/Modals/CreateCommunityStation.vue

@@ -41,7 +41,7 @@
 <script>
 import { mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import Modal from "./Modal.vue";
 import io from "../../io";
 import validation from "../../validation";
@@ -67,39 +67,43 @@ export default {
 			const { name, displayName, description } = this.newCommunity;
 
 			if (!name || !displayName || !description)
-				return Toast.methods.addToast(
-					"Please fill in all fields",
-					8000
-				);
+				return new Toast({
+					content: "Please fill in all fields",
+					timeout: 8000
+				});
 
 			if (!validation.isLength(name, 2, 16))
-				return Toast.methods.addToast(
-					"Name must have between 2 and 16 characters.",
-					8000
-				);
+				return new Toast({
+					content: "Name must have between 2 and 16 characters.",
+					timeout: 8000
+				});
 
 			if (!validation.regex.az09_.test(name))
-				return Toast.methods.addToast(
-					"Invalid name format. Allowed characters: a-z, 0-9 and _.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Invalid name format. Allowed characters: a-z, 0-9 and _.",
+					timeout: 8000
+				});
 
 			if (!validation.isLength(displayName, 2, 32))
-				return Toast.methods.addToast(
-					"Display name must have between 2 and 32 characters.",
-					8000
-				);
-			if (!validation.regex.azAZ09_.test(displayName))
-				return Toast.methods.addToast(
-					"Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Display name must have between 2 and 32 characters.",
+					timeout: 8000
+				});
+			if (!validation.regex.ascii.test(displayName))
+				return new Toast({
+					content:
+						"Invalid display name format. Only ASCII characters are allowed.",
+					timeout: 8000
+				});
 
 			if (!validation.isLength(description, 2, 200))
-				return Toast.methods.addToast(
-					"Description must have between 2 and 200 characters.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Description must have between 2 and 200 characters.",
+					timeout: 8000
+				});
 
 			let characters = description.split("");
 
@@ -108,10 +112,10 @@ export default {
 			});
 
 			if (characters.length !== 0)
-				return Toast.methods.addToast(
-					"Invalid description format. Swastika's are not allowed.",
-					8000
-				);
+				return new Toast({
+					content: "Invalid description format.",
+					timeout: 8000
+				});
 
 			return this.socket.emit(
 				"stations.create",
@@ -123,15 +127,15 @@ export default {
 				},
 				res => {
 					if (res.status === "success") {
-						Toast.methods.addToast(
-							`You have added the station successfully`,
-							4000
-						);
+						new Toast({
+							content: `You have added the station successfully`,
+							timeout: 4000
+						});
 						this.closeModal({
 							sector: "home",
 							modal: "createCommunityStation"
 						});
-					} else Toast.methods.addToast(res.message, 4000);
+					} else new Toast({ content: res.message, timeout: 4000 });
 				}
 			);
 		},

+ 11 - 4
frontend/components/Modals/EditNews.vue

@@ -169,7 +169,7 @@
 <script>
 import { mapActions, mapState } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import io from "../../io";
 
 import Modal from "./Modal.vue";
@@ -186,10 +186,17 @@ export default {
 			const change = document.getElementById(`edit-${type}`).value.trim();
 
 			if (this.editing[type].indexOf(change) !== -1)
-				return Toast.methods.addToast(`Tag already exists`, 3000);
+				return new Toast({
+					content: `Tag already exists`,
+					timeout: 3000
+				});
 
 			if (change) this.addChange({ type, change });
-			else Toast.methods.addToast(`${type} cannot be empty`, 3000);
+			else
+				new Toast({
+					content: `${type} cannot be empty`,
+					timeout: 3000
+				});
 
 			document.getElementById(`edit-${type}`).value = "";
 			return true;
@@ -203,7 +210,7 @@ export default {
 				this.editing._id,
 				this.editing,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 					if (res.status === "success") {
 						if (close)
 							this.closeModal({

+ 139 - 98
frontend/components/Modals/EditSong.vue

@@ -84,20 +84,24 @@
 										class="button album-get-button"
 										v-on:click="getAlbumData('title')"
 									>
-										<i class="material-icons album-get-icon"
-											>album</i
-										>
+										<i class="material-icons">album</i>
 									</button>
 								</p>
 							</div>
 							<div class="duration-container">
 								<label class="label">Duration</label>
-								<p class="control">
+								<p class="control has-addons">
 									<input
 										class="input"
 										type="text"
-										v-model="editing.song.duration"
+										v-model.number="editing.song.duration"
 									/>
+									<button
+										class="button duration-fill-button"
+										v-on:click="fillDuration()"
+									>
+										<i class="material-icons">sync</i>
+									</button>
 								</p>
 							</div>
 							<div class="skip-duration-container">
@@ -106,7 +110,9 @@
 									<input
 										class="input"
 										type="text"
-										v-model="editing.song.skipDuration"
+										v-model.number="
+											editing.song.skipDuration
+										"
 									/>
 								</p>
 							</div>
@@ -124,9 +130,7 @@
 										class="button album-get-button"
 										v-on:click="getAlbumData('albumArt')"
 									>
-										<i class="material-icons album-get-icon"
-											>album</i
-										>
+										<i class="material-icons">album</i>
 									</button>
 								</p>
 							</div>
@@ -148,9 +152,7 @@
 										class="button album-get-button"
 										v-on:click="getAlbumData('artists')"
 									>
-										<i class="material-icons album-get-icon"
-											>album</i
-										>
+										<i class="material-icons">album</i>
 									</button>
 									<button
 										class="button is-info add-button"
@@ -224,9 +226,7 @@
 										class="button album-get-button"
 										v-on:click="getAlbumData('genres')"
 									>
-										<i class="material-icons album-get-icon"
-											>album</i
-										>
+										<i class="material-icons">album</i>
 									</button>
 									<button
 										class="button is-info add-button"
@@ -532,7 +532,7 @@
 
 <script>
 import { mapState, mapActions } from "vuex";
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 
 import io from "../../io";
 import validation from "../../validation";
@@ -618,59 +618,61 @@ export default {
 			modals: state => state.modals.admin
 		})
 	},
+	watch: {
+		/* eslint-disable */
+		"editing.song.duration": function() {
+			this.drawCanvas();
+		},
+		"editing.song.skipDuration": function() {
+			this.drawCanvas();
+		}
+		/* eslint-enable */
+	},
 	methods: {
 		save(songToCopy, close) {
 			const song = JSON.parse(JSON.stringify(songToCopy));
 
 			if (!song.title)
-				return Toast.methods.addToast(
-					"Please fill in all fields",
-					8000
-				);
+				return new Toast({
+					content: "Please fill in all fields",
+					timeout: 8000
+				});
 			if (!song.thumbnail)
-				return Toast.methods.addToast(
-					"Please fill in all fields",
-					8000
-				);
+				return new Toast({
+					content: "Please fill in all fields",
+					timeout: 8000
+				});
 
 			// Duration
 			if (
 				Number(song.skipDuration) + Number(song.duration) >
 				this.youtubeVideoDuration
 			) {
-				return Toast.methods.addToast(
-					"Duration can't be higher than the length of the video",
-					8000
-				);
+				return new Toast({
+					content:
+						"Duration can't be higher than the length of the video",
+					timeout: 8000
+				});
 			}
 
 			// Title
 			if (!validation.isLength(song.title, 1, 100))
-				return Toast.methods.addToast(
-					"Title must have between 1 and 100 characters.",
-					8000
-				);
-			/* if (!validation.regex.ascii.test(song.title))
-				return Toast.methods.addToast(
-					"Invalid title format. Only ascii characters are allowed.",
-					8000
-				); */
+				return new Toast({
+					content: "Title must have between 1 and 100 characters.",
+					timeout: 8000
+				});
 
 			// Artists
 			if (song.artists.length < 1 || song.artists.length > 10)
-				return Toast.methods.addToast(
-					"Invalid artists. You must have at least 1 artist and a maximum of 10 artists.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Invalid artists. You must have at least 1 artist and a maximum of 10 artists.",
+					timeout: 8000
+				});
 			let error;
 			song.artists.forEach(artist => {
-				if (!validation.isLength(artist, 1, 32)) {
-					error = "Artist must have between 1 and 32 characters.";
-					return error;
-				}
-				if (!validation.regex.ascii.test(artist)) {
-					error =
-						"Invalid artist format. Only ascii characters are allowed.";
+				if (!validation.isLength(artist, 1, 64)) {
+					error = "Artist must have between 1 and 64 characters.";
 					return error;
 				}
 				if (artist === "NONE") {
@@ -681,16 +683,16 @@ export default {
 
 				return false;
 			});
-			if (error) return Toast.methods.addToast(error, 8000);
+			if (error) return new Toast({ content: error, timeout: 8000 });
 
 			// Genres
-			/* error = undefined;
+			error = undefined;
 			song.genres.forEach(genre => {
-				if (!validation.isLength(genre, 1, 16)) {
-					error = "Genre must have between 1 and 16 characters.";
+				if (!validation.isLength(genre, 1, 32)) {
+					error = "Genre must have between 1 and 32 characters.";
 					return error;
 				}
-				if (!validation.regex.azAZ09_.test(genre)) {
+				if (!validation.regex.ascii.test(genre)) {
 					error =
 						"Invalid genre format. Only ascii characters are allowed.";
 					return error;
@@ -698,19 +700,22 @@ export default {
 
 				return false;
 			});
-			if (error) return Toast.methods.addToast(error, 8000); */
+			if (song.genres.length < 1 || song.genres.length > 16)
+				error = "You must have between 1 and 16 genres.";
+			if (error) return new Toast({ content: error, timeout: 8000 });
 
 			// Thumbnail
-			if (!validation.isLength(song.thumbnail, 8, 256))
-				return Toast.methods.addToast(
-					"Thumbnail must have between 8 and 256 characters.",
-					8000
-				);
+			if (!validation.isLength(song.thumbnail, 1, 256))
+				return new Toast({
+					content:
+						"Thumbnail must have between 8 and 256 characters.",
+					timeout: 8000
+				});
 			if (this.useHTTPS && song.thumbnail.indexOf("https://") !== 0) {
-				return Toast.methods.addToast(
-					'Thumbnail must start with "https://".',
-					8000
-				);
+				return new Toast({
+					content: 'Thumbnail must start with "https://".',
+					timeout: 8000
+				});
 			}
 
 			if (
@@ -718,10 +723,10 @@ export default {
 				(song.thumbnail.indexOf("http://") !== 0 &&
 					song.thumbnail.indexOf("https://") !== 0)
 			) {
-				return Toast.methods.addToast(
-					'Thumbnail must start with "http://".',
-					8000
-				);
+				return new Toast({
+					content: 'Thumbnail must start with "http://".',
+					timeout: 8000
+				});
 			}
 
 			return this.socket.emit(
@@ -729,7 +734,7 @@ export default {
 				song._id,
 				song,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 					if (res.status === "success") {
 						this.songs.forEach(originalSong => {
 							const updatedSong = song;
@@ -761,7 +766,7 @@ export default {
 					.then(data => {
 						apiResult.album.artists = [];
 						apiResult.album.artistIds = [];
-						const artistRegex = new RegExp(" \\([0-9]\\)$");
+						const artistRegex = new RegExp(" \\([0-9]+\\)$");
 
 						apiResult.dataQuality = data.data_quality;
 						data.artists.forEach(artist => {
@@ -781,6 +786,10 @@ export default {
 					});
 			}
 		},
+		fillDuration() {
+			this.editing.song.duration =
+				this.youtubeVideoDuration - this.editing.song.skipDuration;
+		},
 		getAlbumData(type) {
 			if (!this.editing.song.discogs) return;
 			if (type === "title")
@@ -796,12 +805,16 @@ export default {
 			if (type === "genres")
 				this.updateSongField({
 					field: "genres",
-					value: this.editing.song.discogs.album.genres
+					value: JSON.parse(
+						JSON.stringify(this.editing.song.discogs.album.genres)
+					)
 				});
 			if (type === "artists")
 				this.updateSongField({
 					field: "artists",
-					value: this.editing.song.discogs.album.artists
+					value: JSON.parse(
+						JSON.stringify(this.editing.song.discogs.album.artists)
+					)
 				});
 		},
 		searchDiscogsForPage(page) {
@@ -810,15 +823,15 @@ export default {
 			this.socket.emit("apis.searchDiscogs", query, page, res => {
 				if (res.status === "success") {
 					if (page === 1)
-						Toast.methods.addToast(
-							`Successfully searched. Got ${res.results.length} results.`,
-							4000
-						);
+						new Toast({
+							content: `Successfully searched. Got ${res.results.length} results.`,
+							timeout: 4000
+						});
 					else
-						Toast.methods.addToast(
-							`Successfully got ${res.results.length} more results.`,
-							4000
-						);
+						new Toast({
+							content: `Successfully got ${res.results.length} more results.`,
+							timeout: 4000
+						});
 
 					if (page === 1) {
 						this.discogs.apiResults = [];
@@ -850,7 +863,7 @@ export default {
 
 					this.discogs.page = page;
 					this.discogs.disableLoadMore = false;
-				} else Toast.methods.addToast(res.message, 8000);
+				} else new Toast({ content: res.message, timeout: 8000 });
 			});
 		},
 		loadNextDiscogsPage() {
@@ -937,6 +950,7 @@ export default {
 					this.pauseVideo(false);
 					break;
 				case "skipToLast10Secs":
+					if (this.video.paused) this.pauseVideo(false);
 					this.video.player.seekTo(
 						this.editing.song.duration -
 							10 +
@@ -958,28 +972,37 @@ export default {
 					.value.toLowerCase()
 					.trim();
 				if (this.editing.song.genres.indexOf(genre) !== -1)
-					return Toast.methods.addToast("Genre already exists", 3000);
+					return new Toast({
+						content: "Genre already exists",
+						timeout: 3000
+					});
 				if (genre) {
 					this.editing.song.genres.push(genre);
 					document.getElementById("new-genre").value = "";
 					return false;
 				}
 
-				return Toast.methods.addToast("Genre cannot be empty", 3000);
+				return new Toast({
+					content: "Genre cannot be empty",
+					timeout: 3000
+				});
 			}
 			if (type === "artists") {
 				const artist = document.getElementById("new-artist").value;
 				if (this.editing.song.artists.indexOf(artist) !== -1)
-					return Toast.methods.addToast(
-						"Artist already exists",
-						3000
-					);
+					return new Toast({
+						content: "Artist already exists",
+						timeout: 3000
+					});
 				if (document.getElementById("new-artist").value !== "") {
 					this.editing.song.artists.push(artist);
 					document.getElementById("new-artist").value = "";
 					return false;
 				}
-				return Toast.methods.addToast("Artist cannot be empty", 3000);
+				return new Toast({
+					content: "Artist cannot be empty",
+					timeout: 3000
+				});
 			}
 
 			return false;
@@ -999,7 +1022,7 @@ export default {
 			const duration = Number(this.editing.song.duration);
 			const afterDuration = videoDuration - (skipDuration + duration);
 
-			const width = 560;
+			const width = 530;
 
 			const currentTime = this.video.player.getCurrentTime();
 
@@ -1135,8 +1158,8 @@ export default {
 
 		this.discogsQuery = this.editing.song.title;
 
-		lofig.get("cookie.secure", res => {
-			this.useHTTPS = res;
+		lofig.get("cookie.secure").then(useHTTPS => {
+			this.useHTTPS = useHTTPS;
 		});
 
 		io.getSocket(socket => {
@@ -1145,6 +1168,7 @@ export default {
 
 		this.interval = setInterval(() => {
 			if (
+				this.editing.song.duration !== -1 &&
 				this.video.paused === false &&
 				this.playerReady &&
 				this.video.player.getCurrentTime() -
@@ -1153,6 +1177,7 @@ export default {
 			) {
 				this.video.paused = false;
 				this.video.player.stopVideo();
+				this.drawCanvas();
 			}
 			if (this.playerReady) {
 				this.youtubeVideoCurrentTime = this.video.player
@@ -1190,6 +1215,8 @@ export default {
 					this.drawCanvas();
 				},
 				onStateChange: event => {
+					this.drawCanvas();
+
 					if (event.data === 1) {
 						if (!this.video.autoPlayed) {
 							this.video.autoPlayed = true;
@@ -1200,22 +1227,28 @@ export default {
 						let youtubeDuration = this.video.player.getDuration();
 						this.youtubeVideoDuration = youtubeDuration;
 						this.youtubeVideoNote = "";
+
+						if (this.editing.song.duration === -1)
+							this.editing.song.duration = youtubeDuration;
+
 						youtubeDuration -= this.editing.song.skipDuration;
 						if (this.editing.song.duration > youtubeDuration + 1) {
 							this.video.player.stopVideo();
 							this.video.paused = true;
-							return Toast.methods.addToast(
-								"Video can't play. Specified duration is bigger than the YouTube song duration.",
-								4000
-							);
+							return new Toast({
+								content:
+									"Video can't play. Specified duration is bigger than the YouTube song duration.",
+								timeout: 4000
+							});
 						}
 						if (this.editing.song.duration <= 0) {
 							this.video.player.stopVideo();
 							this.video.paused = true;
-							return Toast.methods.addToast(
-								"Video can't play. Specified duration has to be more than 0 seconds.",
-								4000
-							);
+							return new Toast({
+								content:
+									"Video can't play. Specified duration has to be more than 0 seconds.",
+								timeout: 4000
+							});
 						}
 
 						if (
@@ -1404,6 +1437,14 @@ export default {
 			border-width: 0;
 		}
 
+		.duration-fill-button {
+			background-color: $red;
+			color: $white;
+			width: 32px;
+			text-align: center;
+			border-width: 0;
+		}
+
 		.add-button {
 			background-color: $musareBlue !important;
 			width: 32px;

+ 869 - 174
frontend/components/Modals/EditStation.vue

@@ -1,79 +1,280 @@
 <template>
-	<modal title="Edit Station">
+	<modal title="Edit Station" class="edit-station-modal">
 		<template v-slot:body>
-			<label class="label">Name</label>
-			<p class="control">
-				<input
-					v-model="editing.name"
-					class="input"
-					type="text"
-					placeholder="Station Name"
-				/>
-			</p>
-			<label class="label">Display name</label>
-			<p class="control">
-				<input
-					v-model="editing.displayName"
-					class="input"
-					type="text"
-					placeholder="Station Display Name"
-				/>
-			</p>
-			<label class="label">Description</label>
-			<p class="control">
-				<input
-					v-model="editing.description"
-					class="input"
-					type="text"
-					placeholder="Station Description"
-				/>
-			</p>
-			<label class="label">Privacy</label>
-			<p class="control">
-				<span class="select">
-					<select v-model="editing.privacy">
-						<option value="public">Public</option>
-						<option value="unlisted">Unlisted</option>
-						<option value="private">Private</option>
-					</select>
-				</span>
-			</p>
-			<br />
-			<p class="control">
-				<label class="checkbox party-mode-inner">
-					<input v-model="editing.partyMode" type="checkbox" />
-					&nbsp;Party mode
-				</label>
-			</p>
-			<small
-				>With party mode enabled, people can add songs to a queue that
-				plays. With party mode disabled you can play a private playlist
-				on loop.</small
-			>
-			<br />
-			<div v-if="station.partyMode">
-				<br />
-				<br />
-				<label class="label">Queue lock</label>
-				<small v-if="station.partyMode"
-					>With the queue locked, only owners (you) can add songs to
-					the queue.</small
-				>
-				<br />
-				<button
-					v-if="!station.locked"
-					class="button is-danger"
-					@click="$parent.toggleLock()"
-				>
-					Lock the queue
-				</button>
-				<button
-					v-if="station.locked"
-					class="button is-success"
-					@click="$parent.toggleLock()"
+			<div class="section left-section">
+				<div class="col col-2">
+					<div>
+						<label class="label">Name</label>
+						<p class="control">
+							<input
+								class="input"
+								type="text"
+								v-model="editing.name"
+							/>
+						</p>
+					</div>
+					<div>
+						<label class="label">Display name</label>
+						<p class="control">
+							<input
+								class="input"
+								type="text"
+								v-model="editing.displayName"
+							/>
+						</p>
+					</div>
+				</div>
+				<div class="col col-1">
+					<div>
+						<label class="label">Description</label>
+						<p class="control">
+							<input
+								class="input"
+								type="text"
+								v-model="editing.description"
+							/>
+						</p>
+					</div>
+				</div>
+				<div class="col col-2" v-if="editing.genres">
+					<div>
+						<label class="label">Genre(s)</label>
+						<p class="control has-addons">
+							<input
+								class="input"
+								type="text"
+								id="new-genre"
+								v-model="genreInputValue"
+								v-on:blur="blurGenreInput()"
+								v-on:focus="focusGenreInput()"
+								v-on:keydown="keydownGenreInput()"
+								v-on:keyup.enter="addTag('genres')"
+							/>
+							<button
+								class="button is-info add-button blue"
+								v-on:click="addTag('genres')"
+							>
+								<i class="material-icons">add</i>
+							</button>
+						</p>
+						<div
+							class="autosuggest-container"
+							v-if="
+								(genreInputFocussed ||
+									genreAutosuggestContainerFocussed) &&
+									genreAutosuggestItems.length > 0
+							"
+							@mouseover="focusGenreContainer()"
+							@mouseleave="blurGenreContainer()"
+						>
+							<span
+								class="autosuggest-item"
+								tabindex="0"
+								v-on:click="selectGenreAutosuggest(item)"
+								v-for="(item, index) in genreAutosuggestItems"
+								:key="index"
+								>{{ item }}</span
+							>
+						</div>
+						<div class="list-container">
+							<div
+								class="list-item"
+								v-for="(genre, index) in editing.genres"
+								:key="index"
+							>
+								<div
+									class="list-item-circle blue"
+									v-on:click="removeTag('genres', index)"
+								>
+									<i class="material-icons">close</i>
+								</div>
+								<p>{{ genre }}</p>
+							</div>
+						</div>
+					</div>
+					<div>
+						<label class="label">Blacklist genre(s)</label>
+						<p class="control has-addons">
+							<input
+								class="input"
+								type="text"
+								v-model="blacklistGenreInputValue"
+								v-on:blur="blurBlacklistGenreInput()"
+								v-on:focus="focusBlacklistGenreInput()"
+								v-on:keydown="keydownBlacklistGenreInput()"
+								v-on:keyup.enter="addTag('blacklist-genres')"
+							/>
+							<button
+								class="button is-info add-button red"
+								v-on:click="addTag('blacklist-genres')"
+							>
+								<i class="material-icons">add</i>
+							</button>
+						</p>
+						<div
+							class="autosuggest-container"
+							v-if="
+								(blacklistGenreInputFocussed ||
+									blacklistGenreAutosuggestContainerFocussed) &&
+									blacklistGenreAutosuggestItems.length > 0
+							"
+							@mouseover="focusBlacklistGenreContainer()"
+							@mouseleave="blurBlacklistGenreContainer()"
+						>
+							<span
+								class="autosuggest-item"
+								tabindex="0"
+								v-on:click="
+									selectBlacklistGenreAutosuggest(item)
+								"
+								v-for="(item,
+								index) in blacklistGenreAutosuggestItems"
+								:key="index"
+								>{{ item }}</span
+							>
+						</div>
+						<div class="list-container">
+							<div
+								class="list-item"
+								v-for="(genre,
+								index) in editing.blacklistedGenres"
+								:key="index"
+							>
+								<div
+									class="list-item-circle red"
+									v-on:click="
+										removeTag('blacklist-genres', index)
+									"
+								>
+									<i class="material-icons">close</i>
+								</div>
+								<p>{{ genre }}</p>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+			<div class="section right-section">
+				<div>
+					<label class="label">Privacy</label>
+					<div
+						@mouseenter="privacyDropdownActive = true"
+						@mouseleave="privacyDropdownActive = false"
+						class="button-wrapper"
+					>
+						<button
+							v-bind:class="{
+								green: true,
+								current: editing.privacy === 'public'
+							}"
+							v-if="
+								privacyDropdownActive ||
+									editing.privacy === 'public'
+							"
+							@click="updatePrivacyLocal('public')"
+						>
+							<i class="material-icons">public</i>
+							Public
+						</button>
+						<button
+							v-bind:class="{
+								orange: true,
+								current: editing.privacy === 'unlisted'
+							}"
+							v-if="
+								privacyDropdownActive ||
+									editing.privacy === 'unlisted'
+							"
+							@click="updatePrivacyLocal('unlisted')"
+						>
+							<i class="material-icons">link</i>
+							Unlisted
+						</button>
+						<button
+							v-bind:class="{
+								red: true,
+								current: editing.privacy === 'private'
+							}"
+							v-if="
+								privacyDropdownActive ||
+									editing.privacy === 'private'
+							"
+							@click="updatePrivacyLocal('private')"
+						>
+							<i class="material-icons">lock</i>
+							Private
+						</button>
+					</div>
+				</div>
+				<div v-if="editing.type === 'community'">
+					<label class="label">Mode</label>
+					<div
+						@mouseenter="modeDropdownActive = true"
+						@mouseleave="modeDropdownActive = false"
+						class="button-wrapper"
+					>
+						<button
+							v-bind:class="{
+								blue: true,
+								current: editing.partyMode === false
+							}"
+							v-if="modeDropdownActive || !editing.partyMode"
+							@click="updatePartyModeLocal(false)"
+						>
+							<i class="material-icons">playlist_play</i>
+							Playlist
+						</button>
+						<button
+							v-bind:class="{
+								yellow: true,
+								current: editing.partyMode === true
+							}"
+							v-if="
+								modeDropdownActive || editing.partyMode === true
+							"
+							@click="updatePartyModeLocal(true)"
+						>
+							<i class="material-icons">emoji_people</i>
+							Party
+						</button>
+					</div>
+				</div>
+				<div
+					v-if="
+						editing.type === 'community' &&
+							editing.partyMode === true
+					"
 				>
-					Unlock the queue
-				</button>
+					<label class="label">Queue lock</label>
+					<div
+						@mouseenter="queueLockDropdownActive = true"
+						@mouseleave="queueLockDropdownActive = false"
+						class="button-wrapper"
+					>
+						<button
+							v-bind:class="{
+								green: true,
+								current: editing.locked
+							}"
+							v-if="queueLockDropdownActive || editing.locked"
+							@click="updateQueueLockLocal(true)"
+						>
+							<i class="material-icons">lock</i>
+							On
+						</button>
+						<button
+							v-bind:class="{
+								red: true,
+								current: !editing.locked
+							}"
+							v-if="queueLockDropdownActive || !editing.locked"
+							@click="updateQueueLockLocal(false)"
+						>
+							<i class="material-icons">lock_open</i>
+							Off
+						</button>
+					</div>
+				</div>
 			</div>
 		</template>
 		<template v-slot:footer>
@@ -92,17 +293,23 @@
 </template>
 
 <script>
-import { mapState } from "vuex";
+import { mapState, mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import Modal from "./Modal.vue";
 import io from "../../io";
 import validation from "../../validation";
 
 export default {
-	computed: mapState("station", {
-		station: state => state.station,
-		editing: state => state.editing
+	computed: mapState({
+		editing(state) {
+			return this.$props.store.split("/").reduce((a, v) => a[v], state)
+				.editing;
+		},
+		station(state) {
+			return this.$props.store.split("/").reduce((a, v) => a[v], state)
+				.station;
+		}
 	}),
 	mounted() {
 		io.getSocket(socket => {
@@ -110,6 +317,56 @@ export default {
 			return socket;
 		});
 	},
+	data() {
+		return {
+			genreInputValue: "",
+			genreInputFocussed: false,
+			genreAutosuggestContainerFocussed: false,
+			keydownGenreInputTimeout: 0,
+			genreAutosuggestItems: [],
+			blacklistGenreInputValue: "",
+			blacklistGenreInputFocussed: false,
+			blacklistGenreAutosuggestContainerFocussed: false,
+			blacklistKeydownGenreInputTimeout: 0,
+			blacklistGenreAutosuggestItems: [],
+			privacyDropdownActive: false,
+			modeDropdownActive: false,
+			queueLockDropdownActive: false,
+			genres: [
+				"Blues",
+				"Country",
+				"Disco",
+				"Funk",
+				"Hip-Hop",
+				"Jazz",
+				"Metal",
+				"Oldies",
+				"Other",
+				"Pop",
+				"Rap",
+				"Reggae",
+				"Rock",
+				"Techno",
+				"Trance",
+				"Classical",
+				"Instrumental",
+				"House",
+				"Electronic",
+				"Christian Rap",
+				"Lo-Fi",
+				"Musical",
+				"Rock 'n' Roll",
+				"Opera",
+				"Drum & Bass",
+				"Club-House",
+				"Indie",
+				"Heavy Metal",
+				"Christian rock",
+				"Dubstep"
+			]
+		};
+	},
+	props: ["store"],
 	methods: {
 		update() {
 			if (this.station.name !== this.editing.name) this.updateName();
@@ -119,21 +376,43 @@ export default {
 				this.updateDescription();
 			if (this.station.privacy !== this.editing.privacy)
 				this.updatePrivacy();
-			if (this.station.partyMode !== this.editing.partyMode)
+			if (
+				this.station.type === "community" &&
+				this.station.partyMode !== this.editing.partyMode
+			)
 				this.updatePartyMode();
+			if (
+				this.station.type === "community" &&
+				this.editing.partyMode &&
+				this.station.locked !== this.editing.locked
+			)
+				this.updateQueueLock();
+			if (this.$props.store !== "station") {
+				if (
+					this.station.genres.toString() !==
+					this.editing.genres.toString()
+				)
+					this.updateGenres();
+				if (
+					this.station.blacklistedGenres.toString() !==
+					this.editing.blacklistedGenres.toString()
+				)
+					this.updateBlacklistedGenres();
+			}
 		},
 		updateName() {
 			const { name } = this.editing;
 			if (!validation.isLength(name, 2, 16))
-				return Toast.methods.addToast(
-					"Name must have between 2 and 16 characters.",
-					8000
-				);
+				return new Toast({
+					content: "Name must have between 2 and 16 characters.",
+					timeout: 8000
+				});
 			if (!validation.regex.az09_.test(name))
-				return Toast.methods.addToast(
-					"Invalid name format. Allowed characters: a-z, 0-9 and _.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Invalid name format. Allowed characters: a-z, 0-9 and _.",
+					timeout: 8000
+				});
 
 			return this.socket.emit(
 				"stations.updateName",
@@ -141,37 +420,37 @@ export default {
 				name,
 				res => {
 					if (res.status === "success") {
-						if (this.station) {
-							this.station.name = name;
-							return name;
-						}
-
-						this.$parent.stations.forEach((station, index) => {
-							if (station._id === this.editing._id) {
-								this.$parent.stations[index].name = name;
-								return name;
-							}
+						if (this.station) this.station.name = name;
+						else {
+							this.$parent.stations.forEach((station, index) => {
+								if (station._id === this.editing._id) {
+									this.$parent.stations[index].name = name;
+									return name;
+								}
 
-							return false;
-						});
+								return false;
+							});
+						}
 					}
 
-					return Toast.methods.addToast(res.message, 8000);
+					new Toast({ content: res.message, timeout: 8000 });
 				}
 			);
 		},
 		updateDisplayName() {
 			const { displayName } = this.editing;
 			if (!validation.isLength(displayName, 2, 32))
-				return Toast.methods.addToast(
-					"Display name must have between 2 and 32 characters.",
-					8000
-				);
-			if (!validation.regex.azAZ09_.test(displayName))
-				return Toast.methods.addToast(
-					"Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Display name must have between 2 and 32 characters.",
+					timeout: 8000
+				});
+			if (!validation.regex.ascii.test(displayName))
+				return new Toast({
+					content:
+						"Invalid display name format. Only ASCII characters are allowed.",
+					timeout: 8000
+				});
 
 			return this.socket.emit(
 				"stations.updateDisplayName",
@@ -183,36 +462,40 @@ export default {
 							this.station.displayName = displayName;
 						else {
 							this.$parent.stations.forEach((station, index) => {
-								if (station._id === this.editing._id)
+								if (station._id === this.editing._id) {
 									this.$parent.stations[
 										index
 									].displayName = displayName;
-								return displayName;
+									return displayName;
+								}
+
+								return false;
 							});
 						}
 					}
-					Toast.methods.addToast(res.message, 8000);
+
+					new Toast({ content: res.message, timeout: 8000 });
 				}
 			);
 		},
 		updateDescription() {
 			const { description } = this.editing;
 			if (!validation.isLength(description, 2, 200))
-				return Toast.methods.addToast(
-					"Description must have between 2 and 200 characters.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Description must have between 2 and 200 characters.",
+					timeout: 8000
+				});
 
 			let characters = description.split("");
 			characters = characters.filter(character => {
 				return character.charCodeAt(0) === 21328;
 			});
-
 			if (characters.length !== 0)
-				return Toast.methods.addToast(
-					"Invalid description format. Swastika's are not allowed.",
-					8000
-				);
+				return new Toast({
+					content: "Invalid description format.",
+					timeout: 8000
+				});
 
 			return this.socket.emit(
 				"stations.updateDescription",
@@ -220,126 +503,538 @@ export default {
 				description,
 				res => {
 					if (res.status === "success") {
-						if (this.station) {
+						if (this.station)
 							this.station.description = description;
-							return description;
-						}
+						else {
+							this.$parent.stations.forEach((station, index) => {
+								if (station._id === this.editing._id) {
+									this.$parent.stations[
+										index
+									].description = description;
+									return description;
+								}
 
-						this.$parent.stations.forEach((station, index) => {
-							if (station._id === this.editing._id) {
-								this.$parent.stations[
-									index
-								].description = description;
-								return description;
-							}
+								return false;
+							});
+						}
 
-							return false;
+						return new Toast({
+							content: res.message,
+							timeout: 4000
 						});
-
-						return Toast.methods.addToast(res.message, 4000);
 					}
 
-					return Toast.methods.addToast(res.message, 8000);
+					return new Toast({ content: res.message, timeout: 8000 });
 				}
 			);
 		},
+		updatePrivacyLocal(privacy) {
+			if (this.editing.privacy === privacy) return;
+			this.editing.privacy = privacy;
+			this.privacyDropdownActive = false;
+		},
 		updatePrivacy() {
-			return this.socket.emit(
+			this.socket.emit(
 				"stations.updatePrivacy",
 				this.editing._id,
 				this.editing.privacy,
 				res => {
 					if (res.status === "success") {
-						if (this.station) {
+						if (this.station)
 							this.station.privacy = this.editing.privacy;
-							return this.editing.privacy;
+						else {
+							this.$parent.stations.forEach((station, index) => {
+								if (station._id === this.editing._id) {
+									this.$parent.stations[
+										index
+									].privacy = this.editing.privacy;
+									return this.editing.privacy;
+								}
+
+								return false;
+							});
 						}
+						return new Toast({
+							content: res.message,
+							timeout: 4000
+						});
+					}
 
+					return new Toast({ content: res.message, timeout: 8000 });
+				}
+			);
+		},
+		updateGenres() {
+			this.socket.emit(
+				"stations.updateGenres",
+				this.editing._id,
+				this.editing.genres,
+				res => {
+					if (res.status === "success") {
+						const genres = JSON.parse(
+							JSON.stringify(this.editing.genres)
+						);
+						if (this.station) this.station.genres = genres;
 						this.$parent.stations.forEach((station, index) => {
 							if (station._id === this.editing._id) {
-								this.$parent.stations[
-									index
-								].privacy = this.editing.privacy;
-								return this.editing.privacy;
+								this.$parent.stations[index].genres = genres;
+								return genres;
 							}
 
 							return false;
 						});
 
-						return Toast.methods.addToast(res.message, 4000);
+						return new Toast({
+							content: res.message,
+							timeout: 4000
+						});
 					}
 
-					return Toast.methods.addToast(res.message, 8000);
+					return new Toast({ content: res.message, timeout: 8000 });
 				}
 			);
 		},
-		updatePartyMode() {
-			return this.socket.emit(
-				"stations.updatePartyMode",
+		updateBlacklistedGenres() {
+			this.socket.emit(
+				"stations.updateBlacklistedGenres",
 				this.editing._id,
-				this.editing.partyMode,
+				this.editing.blacklistedGenres,
 				res => {
 					if (res.status === "success") {
-						if (this.station) {
-							this.station.partyMode = this.editing.partyMode;
-							return this.editing.partyMode;
-						}
-
+						const blacklistedGenres = JSON.parse(
+							JSON.stringify(this.editing.blacklistedGenres)
+						);
+						if (this.station)
+							this.station.blacklistedGenres = blacklistedGenres;
 						this.$parent.stations.forEach((station, index) => {
 							if (station._id === this.editing._id) {
 								this.$parent.stations[
 									index
-								].partyMode = this.editing.partyMode;
-								return this.editing.partyMode;
+								].blacklistedGenres = blacklistedGenres;
+								return blacklistedGenres;
 							}
 
 							return false;
 						});
+						return new Toast({
+							content: res.message,
+							timeout: 4000
+						});
+					}
 
-						return Toast.methods.addToast(res.message, 4000);
+					return new Toast({ content: res.message, timeout: 8000 });
+				}
+			);
+		},
+		updatePartyModeLocal(partyMode) {
+			if (this.editing.partyMode === partyMode) return;
+			this.editing.partyMode = partyMode;
+			this.modeDropdownActive = false;
+		},
+		updatePartyMode() {
+			this.socket.emit(
+				"stations.updatePartyMode",
+				this.editing._id,
+				this.editing.partyMode,
+				res => {
+					if (res.status === "success") {
+						if (this.station)
+							this.station.partyMode = this.editing.partyMode;
+						// if (this.station)
+						// 	this.station.partyMode = this.editing.partyMode;
+						// this.$parent.stations.forEach((station, index) => {
+						// 	if (station._id === this.editing._id) {
+						// 		this.$parent.stations[
+						// 			index
+						// 		].partyMode = this.editing.partyMode;
+						// 		return this.editing.partyMode;
+						// 	}
+
+						// 	return false;
+						// });
+
+						return new Toast({
+							content: res.message,
+							timeout: 4000
+						});
 					}
 
-					return Toast.methods.addToast(res.message, 8000);
+					return new Toast({ content: res.message, timeout: 8000 });
 				}
 			);
 		},
+		updateQueueLockLocal(locked) {
+			if (this.editing.locked === locked) return;
+			this.editing.locked = locked;
+			this.queueLockDropdownActive = false;
+		},
+		updateQueueLock() {
+			this.socket.emit("stations.toggleLock", this.editing._id, res => {
+				console.log(res);
+				if (res.status === "success") {
+					if (this.station) this.station.locked = res.data;
+					return new Toast({
+						content: `Toggled queue lock succesfully to ${res.data}`,
+						timeout: 4000
+					});
+				}
+				return new Toast({
+					content: "Failed to toggle queue lock.",
+					timeout: 8000
+				});
+			});
+		},
 		deleteStation() {
 			this.socket.emit("stations.remove", this.editing._id, res => {
-				Toast.methods.addToast(res.message, 8000);
+				if (res.status === "success")
+					this.closeModal({
+						sector: "station",
+						modal: "editStation"
+					});
+				return new Toast({ content: res.message, timeout: 8000 });
 			});
-		}
+		},
+		blurGenreInput() {
+			this.genreInputFocussed = false;
+		},
+		focusGenreInput() {
+			this.genreInputFocussed = true;
+		},
+		keydownGenreInput() {
+			clearTimeout(this.keydownGenreInputTimeout);
+			this.keydownGenreInputTimeout = setTimeout(() => {
+				if (this.genreInputValue.length > 1) {
+					this.genreAutosuggestItems = this.genres.filter(genre => {
+						return genre
+							.toLowerCase()
+							.startsWith(this.genreInputValue.toLowerCase());
+					});
+				} else this.genreAutosuggestItems = [];
+			}, 1000);
+		},
+		focusGenreContainer() {
+			this.genreAutosuggestContainerFocussed = true;
+		},
+		blurGenreContainer() {
+			this.genreAutosuggestContainerFocussed = false;
+		},
+		selectGenreAutosuggest(value) {
+			this.genreInputValue = value;
+		},
+		blurBlacklistGenreInput() {
+			this.blacklistGenreInputFocussed = false;
+		},
+		focusBlacklistGenreInput() {
+			this.blacklistGenreInputFocussed = true;
+		},
+		keydownBlacklistGenreInput() {
+			clearTimeout(this.keydownBlacklistGenreInputTimeout);
+			this.keydownBlacklistGenreInputTimeout = setTimeout(() => {
+				if (this.blacklistGenreInputValue.length > 1) {
+					this.blacklistGenreAutosuggestItems = this.genres.filter(
+						genre => {
+							return genre
+								.toLowerCase()
+								.startsWith(
+									this.blacklistGenreInputValue.toLowerCase()
+								);
+						}
+					);
+				} else this.blacklistGenreAutosuggestItems = [];
+			}, 1000);
+		},
+		focusBlacklistGenreContainer() {
+			this.blacklistGenreAutosuggestContainerFocussed = true;
+		},
+		blurBlacklistGenreContainer() {
+			this.blacklistGenreAutosuggestContainerFocussed = false;
+		},
+		selectBlacklistGenreAutosuggest(value) {
+			this.blacklistGenreInputValue = value;
+		},
+		addTag(type) {
+			if (type === "genres") {
+				const genre = this.genreInputValue.toLowerCase().trim();
+				if (this.editing.genres.indexOf(genre) !== -1)
+					return new Toast({
+						content: "Genre already exists",
+						timeout: 3000
+					});
+				if (genre) {
+					this.editing.genres.push(genre);
+					this.genreInputValue = "";
+					return false;
+				}
+
+				return new Toast({
+					content: "Genre cannot be empty",
+					timeout: 3000
+				});
+			}
+			if (type === "blacklist-genres") {
+				const genre = this.blacklistGenreInputValue
+					.toLowerCase()
+					.trim();
+				if (this.editing.blacklistedGenres.indexOf(genre) !== -1)
+					return new Toast({
+						content: "Blacklist genre already exists",
+						timeout: 3000
+					});
+				if (genre) {
+					this.editing.blacklistedGenres.push(genre);
+					this.blacklistGenreInputValue = "";
+					return false;
+				}
+
+				return new Toast({
+					content: "Blacklist genre cannot be empty",
+					timeout: 3000
+				});
+			}
+
+			return false;
+		},
+		removeTag(type, index) {
+			if (type === "genres") this.editing.genres.splice(index, 1);
+			else if (type === "blacklist-genres")
+				this.editing.blacklistedGenres.splice(index, 1);
+		},
+		...mapActions("modals", ["closeModal"])
 	},
 	components: { Modal }
 };
 </script>
 
+<style lang="scss">
+.edit-station-modal {
+	.modal-card-title {
+		text-align: center;
+		margin-left: 24px;
+	}
+
+	.modal-card {
+		width: 800px;
+		height: 550px;
+
+		.modal-card-body {
+			padding: 16px;
+			display: flex;
+		}
+	}
+}
+</style>
+
 <style lang="scss" scoped>
 @import "styles/global.scss";
 
-.controls {
-	display: flex;
+.section {
+	border: 1px solid #a3e0ff;
+	background-color: #f4f4f4;
+	border-radius: 5px;
+	padding: 16px;
+}
 
-	a {
-		display: flex;
-		align-items: center;
+.left-section {
+	width: 595px;
+	display: grid;
+	gap: 16px;
+	grid-template-rows: min-content min-content auto;
+
+	.control {
+		input {
+			width: 100%;
+		}
+
+		.add-button {
+			width: 32px;
+
+			&.blue {
+				background-color: $musareBlue !important;
+			}
+
+			&.red {
+				background-color: $red !important;
+			}
+
+			i {
+				font-size: 32px;
+			}
+		}
+	}
+
+	.col {
+		> div {
+			position: relative;
+		}
+	}
+
+	.list-item-circle {
+		width: 16px;
+		height: 16px;
+		border-radius: 8px;
+		cursor: pointer;
+		margin-right: 8px;
+		float: left;
+		-webkit-touch-callout: none;
+		-webkit-user-select: none;
+		-khtml-user-select: none;
+		-moz-user-select: none;
+		-ms-user-select: none;
+		user-select: none;
+
+		&.blue {
+			background-color: $musareBlue;
+
+			i {
+				color: $musareBlue;
+			}
+		}
+
+		&.red {
+			background-color: $red;
+
+			i {
+				color: $red;
+			}
+		}
+
+		i {
+			font-size: 14px;
+			margin-left: 1px;
+		}
+	}
+
+	.list-item-circle:hover,
+	.list-item-circle:focus {
+		i {
+			color: white;
+		}
+	}
+
+	.list-item > p {
+		line-height: 16px;
+		word-wrap: break-word;
+		width: calc(100% - 24px);
+		left: 24px;
+		float: left;
+		margin-bottom: 8px;
+	}
+
+	.list-item:last-child > p {
+		margin-bottom: 0;
+	}
+
+	.autosuggest-container {
+		position: absolute;
+		background: white;
+		width: calc(100% + 1px);
+		top: 57px;
+		z-index: 200;
+		overflow: auto;
+		max-height: 100%;
+		clear: both;
+
+		.autosuggest-item {
+			padding: 8px;
+			display: block;
+			border: 1px solid #dbdbdb;
+			margin-top: -1px;
+			line-height: 16px;
+			cursor: pointer;
+			-webkit-user-select: none;
+			-ms-user-select: none;
+			-moz-user-select: none;
+			user-select: none;
+		}
+
+		.autosuggest-item:hover,
+		.autosuggest-item:focus {
+			background-color: #eee;
+		}
+
+		.autosuggest-item:first-child {
+			border-top: none;
+		}
+
+		.autosuggest-item:last-child {
+			border-radius: 0 0 3px 3px;
+		}
 	}
 }
 
-.table {
-	margin-bottom: 0;
+.right-section {
+	width: 157px;
+	margin-left: 16px;
+	display: grid;
+	gap: 16px;
+	grid-template-rows: min-content min-content min-content;
+
+	.button-wrapper {
+		display: flex;
+		flex-direction: column;
+	}
+
+	button {
+		width: 100%;
+		height: 36px;
+		border: 0;
+		border-radius: 10px;
+		font-size: 18px;
+		color: white;
+		box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25);
+		display: block;
+		text-align: center;
+		justify-content: center;
+		display: inline-flex;
+		-ms-flex-align: center;
+		align-items: center;
+		-moz-user-select: none;
+		user-select: none;
+		cursor: pointer;
+		margin-bottom: 16px;
+		padding: 0;
+
+		&.current {
+			order: -1;
+		}
+
+		&.red {
+			background-color: $red;
+		}
+
+		&.green {
+			background-color: $green;
+		}
+
+		&.blue {
+			background-color: $musareBlue;
+		}
+
+		&.orange {
+			background-color: $light-orange;
+		}
+
+		&.yellow {
+			background-color: $yellow;
+		}
+
+		i {
+			font-size: 20px;
+			margin-right: 4px;
+		}
+	}
 }
 
-h5 {
-	padding: 20px 0;
+.col {
+	display: grid;
+	grid-column-gap: 16px;
 }
 
-.party-mode-inner,
-.party-mode-outer {
-	display: flex;
-	align-items: center;
+.col-1 {
+	grid-template-columns: auto;
 }
 
-.select:after {
-	border-color: $primary-color;
+.col-2 {
+	grid-template-columns: auto auto;
 }
 </style>

+ 36 - 30
frontend/components/Modals/EditUser.vue

@@ -92,7 +92,7 @@
 <script>
 import { mapState, mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import io from "../../io";
 import Modal from "./Modal.vue";
 import validation from "../../validation";
@@ -118,44 +118,49 @@ export default {
 		updateUsername() {
 			const { username } = this.editing;
 			if (!validation.isLength(username, 2, 32))
-				return Toast.methods.addToast(
-					"Username must have between 2 and 32 characters.",
-					8000
-				);
-			if (!validation.regex.azAZ09_.test(username))
-				return Toast.methods.addToast(
-					"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _.",
-					8000
-				);
+				return new Toast({
+					content: "Username must have between 2 and 32 characters.",
+					timeout: 8000
+				});
+			if (!validation.regex.custom("a-zA-Z0-9_-").test(username))
+				return new Toast({
+					content:
+						"Invalid username format. Allowed characters: a-z, A-Z, 0-9, _ and -.",
+					timeout: 8000
+				});
 
 			return this.socket.emit(
 				`users.updateUsername`,
 				this.editing._id,
 				username,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 				}
 			);
 		},
 		updateEmail() {
-			const { email } = this.editing;
+			const email = this.editing.email.address;
 			if (!validation.isLength(email, 3, 254))
-				return Toast.methods.addToast(
-					"Email must have between 3 and 254 characters.",
-					8000
-				);
+				return new Toast({
+					content: "Email must have between 3 and 254 characters.",
+					timeout: 8000
+				});
 			if (
 				email.indexOf("@") !== email.lastIndexOf("@") ||
-				!validation.regex.emailSimple.test(email)
+				!validation.regex.emailSimple.test(email) ||
+				!validation.regex.ascii.test(email)
 			)
-				return Toast.methods.addToast("Invalid email format.", 8000);
+				return new Toast({
+					content: "Invalid email format.",
+					timeout: 8000
+				});
 
 			return this.socket.emit(
 				`users.updateEmail`,
 				this.editing._id,
 				email,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 				}
 			);
 		},
@@ -165,7 +170,7 @@ export default {
 				this.editing._id,
 				this.editing.role,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 					if (
 						res.status === "success" &&
 						this.editing.role === "default" &&
@@ -178,15 +183,16 @@ export default {
 		banUser() {
 			const { reason } = this.ban;
 			if (!validation.isLength(reason, 1, 64))
-				return Toast.methods.addToast(
-					"Reason must have between 1 and 64 characters.",
-					8000
-				);
+				return new Toast({
+					content: "Reason must have between 1 and 64 characters.",
+					timeout: 8000
+				});
 			if (!validation.regex.ascii.test(reason))
-				return Toast.methods.addToast(
-					"Invalid reason format. Only ascii characters are allowed.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Invalid reason format. Only ascii characters are allowed.",
+					timeout: 8000
+				});
 
 			return this.socket.emit(
 				`users.banUserById`,
@@ -194,13 +200,13 @@ export default {
 				this.ban.reason,
 				this.ban.expiresAt,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 				}
 			);
 		},
 		removeSessions() {
 			this.socket.emit(`users.removeSessions`, this.editing._id, res => {
-				Toast.methods.addToast(res.message, 4000);
+				new Toast({ content: res.message, timeout: 4000 });
 			});
 		},
 		...mapActions("modals", ["closeModal"])

+ 29 - 6
frontend/components/Modals/IssuesModal.vue

@@ -15,13 +15,26 @@
 			<article class="message">
 				<div class="message-body">
 					<strong>Song ID:</strong>
-					{{ report.songId }}
+					{{ report.song.songId }} / {{ report.song._id }}
 					<br />
-					<strong>Created By:</strong>
-					{{ report.createdBy }}
+					<strong>Author:</strong>
+					<user-id-to-username
+						:userId="report.createdBy"
+						:alt="report.createdBy"
+					/>
 					<br />
-					<strong>Created At:</strong>
-					{{ report.createdAt }}
+					<strong>Time of report:</strong>
+					<span :title="report.createdAt">
+						{{
+							formatDistance(
+								new Date(report.createdAt),
+								new Date(),
+								{
+									addSuffix: true
+								}
+							)
+						}}
+					</span>
 					<br />
 					<span v-if="report.description">
 						<strong>Description:</strong>
@@ -56,6 +69,13 @@
 			>
 				<span>Resolve</span>
 			</a>
+			<a
+				class="button is-primary"
+				:href="`/admin/songs?songId=${report.song.songId}`"
+				target="_blank"
+			>
+				<span>Go to song</span>
+			</a>
 			<a
 				class="button is-danger"
 				@click="
@@ -74,7 +94,9 @@
 
 <script>
 import { mapActions, mapState } from "vuex";
+import { formatDistance } from "date-fns";
 
+import UserIdToUsername from "../UserIdToUsername.vue";
 import Modal from "./Modal.vue";
 
 export default {
@@ -89,9 +111,10 @@ export default {
 		}
 	},
 	methods: {
+		formatDistance,
 		...mapActions("modals", ["closeModal"])
 	},
-	components: { Modal }
+	components: { Modal, UserIdToUsername }
 };
 </script>
 

+ 37 - 33
frontend/components/Modals/Login.vue

@@ -26,37 +26,39 @@
 			</header>
 			<section class="modal-card-body">
 				<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
-				<label class="label">Email</label>
-				<p class="control">
-					<input
-						v-model="email"
-						class="input"
-						type="text"
-						placeholder="Email..."
-					/>
-				</p>
-				<label class="label">Password</label>
-				<p class="control">
-					<input
-						v-model="password"
-						class="input"
-						type="password"
-						placeholder="Password..."
-						@keypress="$parent.submitOnEnter(submitModal, $event)"
-					/>
-				</p>
-				<p>
-					By logging in/registering you agree to our
-					<router-link to="/terms"> Terms of Service </router-link
-					>&nbsp;and
-					<router-link to="/privacy"> Privacy Policy </router-link>.
-				</p>
+				<form>
+					<label class="label">Email</label>
+					<p class="control">
+						<input
+							v-model="email"
+							class="input"
+							type="email"
+							placeholder="Email..."
+						/>
+					</p>
+					<label class="label">Password</label>
+					<p class="control">
+						<input
+							v-model="password"
+							class="input"
+							type="password"
+							placeholder="Password..."
+							@keypress="
+								$parent.submitOnEnter(submitModal, $event)
+							"
+						/>
+					</p>
+					<p>
+						By logging in/registering you agree to our
+						<router-link to="/terms"> Terms of Service </router-link
+						>&nbsp;and
+						<router-link to="/privacy"> Privacy Policy </router-link
+						>.
+					</p>
+				</form>
 			</section>
 			<footer class="modal-card-foot">
-				<a
-					class="button is-primary"
-					href="#"
-					@click="submitModal('login')"
+				<a class="button is-primary" href="#" @click="submitModal()"
 					>Submit</a
 				>
 				<a
@@ -80,7 +82,7 @@
 <script>
 import { mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 
 export default {
 	data() {
@@ -99,7 +101,9 @@ export default {
 				.then(res => {
 					if (res.status === "success") window.location.reload();
 				})
-				.catch(err => Toast.methods.addToast(err.message, 5000));
+				.catch(
+					err => new Toast({ content: err.message, timeout: 5000 })
+				);
 		},
 		resetPassword() {
 			this.closeModal({ sector: "header", modal: "login" });
@@ -112,8 +116,8 @@ export default {
 		...mapActions("user/auth", ["login"])
 	},
 	mounted() {
-		lofig.get("serverDomain", res => {
-			this.serverDomain = res;
+		lofig.get("serverDomain").then(serverDomain => {
+			this.serverDomain = serverDomain;
 		});
 	}
 };

+ 13 - 11
frontend/components/Modals/Playlists/Create.vue

@@ -23,7 +23,7 @@
 <script>
 import { mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import Modal from "../Modal.vue";
 import io from "../../../io";
 import validation from "../../../validation";
@@ -47,18 +47,20 @@ export default {
 		createPlaylist() {
 			const { displayName } = this.playlist;
 			if (!validation.isLength(displayName, 2, 32))
-				return Toast.methods.addToast(
-					"Display name must have between 2 and 32 characters.",
-					8000
-				);
-			if (!validation.regex.azAZ09_.test(displayName))
-				return Toast.methods.addToast(
-					"Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Display name must have between 2 and 32 characters.",
+					timeout: 8000
+				});
+			if (!validation.regex.ascii.test(displayName))
+				return new Toast({
+					content:
+						"Invalid display name format. Only ASCII characters are allowed.",
+					timeout: 8000
+				});
 
 			return this.socket.emit("playlists.create", this.playlist, res => {
-				Toast.methods.addToast(res.message, 3000);
+				new Toast({ content: res.message, timeout: 3000 });
 
 				if (res.status === "success") {
 					this.closeModal({

+ 25 - 22
frontend/components/Modals/Playlists/Edit.vue

@@ -137,7 +137,7 @@
 <script>
 import { mapState, mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import Modal from "../Modal.vue";
 import io from "../../../io";
 import validation from "../../../validation";
@@ -276,7 +276,7 @@ export default {
 						});
 					}
 				} else if (res.status === "error")
-					Toast.methods.addToast(res.message, 3000);
+					new Toast({ content: res.message, timeout: 3000 });
 			});
 		},
 		addSongToPlaylist(id) {
@@ -285,15 +285,16 @@ export default {
 				id,
 				this.playlist._id,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 				}
 			);
 		},
 		importPlaylist() {
-			Toast.methods.addToast(
-				"Starting to import your playlist. This can take some time to do.",
-				4000
-			);
+			new Toast({
+				content:
+					"Starting to import your playlist. This can take some time to do.",
+				timeout: 4000
+			});
 			this.socket.emit(
 				"playlists.addSetToPlaylist",
 				this.importQuery,
@@ -301,7 +302,7 @@ export default {
 				res => {
 					if (res.status === "success")
 						this.playlist.songs = res.data;
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 				}
 			);
 		},
@@ -311,35 +312,37 @@ export default {
 				id,
 				this.playlist._id,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 				}
 			);
 		},
 		renamePlaylist() {
 			const { displayName } = this.playlist;
 			if (!validation.isLength(displayName, 2, 32))
-				return Toast.methods.addToast(
-					"Display name must have between 2 and 32 characters.",
-					8000
-				);
-			if (!validation.regex.azAZ09_.test(displayName))
-				return Toast.methods.addToast(
-					"Invalid display name format. Allowed characters: a-z, A-Z, 0-9 and _.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Display name must have between 2 and 32 characters.",
+					timeout: 8000
+				});
+			if (!validation.regex.ascii.test(displayName))
+				return new Toast({
+					content:
+						"Invalid display name format. Only ASCII characters are allowed.",
+					timeout: 8000
+				});
 
 			return this.socket.emit(
 				"playlists.updateDisplayName",
 				this.playlist._id,
 				this.playlist.displayName,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 				}
 			);
 		},
 		removePlaylist() {
 			this.socket.emit("playlists.remove", this.playlist._id, res => {
-				Toast.methods.addToast(res.message, 3000);
+				new Toast({ content: res.message, timeout: 3000 });
 				if (res.status === "success") {
 					this.closeModal();
 				}
@@ -351,7 +354,7 @@ export default {
 				this.playlist._id,
 				songId,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 				}
 			);
 		},
@@ -361,7 +364,7 @@ export default {
 				this.playlist._id,
 				songId,
 				res => {
-					Toast.methods.addToast(res.message, 4000);
+					new Toast({ content: res.message, timeout: 4000 });
 				}
 			);
 		},

+ 8 - 8
frontend/components/Modals/Register.vue

@@ -31,7 +31,7 @@
 					<input
 						v-model="email"
 						class="input"
-						type="text"
+						type="email"
 						placeholder="Email..."
 						autofocus
 					/>
@@ -84,7 +84,7 @@
 <script>
 import { mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 
 export default {
 	data() {
@@ -100,11 +100,11 @@ export default {
 		};
 	},
 	mounted() {
-		lofig.get("serverDomain", res => {
-			this.serverDomain = res;
+		lofig.get("serverDomain").then(serverDomain => {
+			this.serverDomain = serverDomain;
 		});
 
-		lofig.get("recaptcha", obj => {
+		lofig.get("recaptcha").then(obj => {
 			this.recaptcha.key = obj.key;
 
 			const recaptchaScript = document.createElement("script");
@@ -127,8 +127,6 @@ export default {
 	},
 	methods: {
 		submitModal() {
-			console.log(this.recaptcha.token);
-
 			this.register({
 				username: this.username,
 				email: this.email,
@@ -138,7 +136,9 @@ export default {
 				.then(res => {
 					if (res.status === "success") window.location.reload();
 				})
-				.catch(err => Toast.methods.addToast(err.message, 5000));
+				.catch(
+					err => new Toast({ content: err.message, timeout: 5000 })
+				);
 		},
 		githubRedirect() {
 			localStorage.setItem("github_redirect", this.$route.path);

+ 2 - 2
frontend/components/Modals/Report.vue

@@ -151,7 +151,7 @@
 <script>
 import { mapState, mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import Modal from "./Modal.vue";
 import io from "../../io";
 
@@ -222,7 +222,7 @@ export default {
 		create() {
 			console.log(this.report);
 			this.socket.emit("reports.create", this.report, res => {
-				Toast.methods.addToast(res.message, 4000);
+				new Toast({ content: res.message, timeout: 4000 });
 				if (res.status === "success")
 					this.closeModal({
 						sector: "station",

+ 6 - 3
frontend/components/Sidebars/Playlist.vue

@@ -48,7 +48,7 @@
 <script>
 import { mapState, mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import io from "../../io";
 
 export default {
@@ -77,8 +77,11 @@ export default {
 				id,
 				res => {
 					if (res.status === "failure")
-						return Toast.methods.addToast(res.message, 8000);
-					return Toast.methods.addToast(res.message, 4000);
+						return new Toast({
+							content: res.message,
+							timeout: 8000
+						});
+					return new Toast({ content: res.message, timeout: 4000 });
 				}
 			);
 		},

+ 7 - 6
frontend/components/Sidebars/SongsList.vue

@@ -128,7 +128,7 @@
 <script>
 import { mapState, mapActions } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 
 import UserIdToUsername from "../UserIdToUsername.vue";
 
@@ -161,11 +161,12 @@ export default {
 				songId,
 				res => {
 					if (res.status === "success") {
-						Toast.methods.addToast(
-							"Successfully removed song from the queue.",
-							4000
-						);
-					} else Toast.methods.addToast(res.message, 8000);
+						new Toast({
+							content:
+								"Successfully removed song from the queue.",
+							timeout: 4000
+						});
+					} else new Toast({ content: res.message, timeout: 8000 });
 				}
 			);
 		},

+ 123 - 67
frontend/components/Station/Station.vue

@@ -1,14 +1,18 @@
 <template>
 	<div>
-		<metadata v-bind:title="`${station.displayName}`" />
+		<metadata
+			v-if="exists && !loading"
+			v-bind:title="`${station.displayName}`"
+		/>
+		<metadata v-else-if="!exists && !loading" v-bind:title="`Not found`" />
 
-		<station-header />
+		<station-header v-if="exists" />
 
 		<song-queue v-if="modals.addSongToQueue" />
 		<add-to-playlist v-if="modals.addSongToPlaylist" />
 		<edit-playlist v-if="modals.editPlaylist" />
 		<create-playlist v-if="modals.createPlaylist" />
-		<edit-station v-show="modals.editStation" />
+		<edit-station v-show="modals.editStation" store="station" />
 		<report v-if="modals.report" />
 
 		<transition name="slide">
@@ -418,7 +422,7 @@
 
 <script>
 import { mapState, mapActions } from "vuex";
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 
 import StationHeader from "./StationHeader.vue";
 
@@ -453,7 +457,9 @@ export default {
 			systemDifference: 0,
 			attemptsToPlayVideo: 0,
 			canAutoplay: true,
-			lastTimeRequestedIfCanAutoplay: 0
+			lastTimeRequestedIfCanAutoplay: 0,
+			seeking: false,
+			playbackRate: 1
 		};
 	},
 	computed: {
@@ -487,11 +493,12 @@ export default {
 				songId,
 				res => {
 					if (res.status === "success") {
-						Toast.methods.addToast(
-							"Successfully removed song from the queue.",
-							4000
-						);
-					} else Toast.methods.addToast(res.message, 8000);
+						new Toast({
+							content:
+								"Successfully removed song from the queue.",
+							timeout: 4000
+						});
+					} else new Toast({ content: res.message, timeout: 8000 });
 				}
 			);
 		},
@@ -541,7 +548,7 @@ export default {
 						},
 						onStateChange: event => {
 							if (
-								event.data === 1 &&
+								event.data === window.YT.PlayerState.PLAYING &&
 								this.videoLoading === true
 							) {
 								this.videoLoading = false;
@@ -551,15 +558,23 @@ export default {
 									true
 								);
 								if (this.paused) this.player.pauseVideo();
-							} else if (event.data === 1 && this.paused) {
+							} else if (
+								event.data === window.YT.PlayerState.PLAYING &&
+								this.paused
+							) {
 								this.player.seekTo(
 									this.timeBeforePause / 1000,
 									true
 								);
 								this.player.pauseVideo();
+							} else if (
+								event.data === window.YT.PlayerState.PLAYING &&
+								this.seeking === true
+							) {
+								this.seeking = false;
 							}
 							if (
-								event.data === 2 &&
+								event.data === window.YT.PlayerState.PAUSED &&
 								!this.paused &&
 								!this.noSong &&
 								this.player.getDuration() / 1000 <
@@ -667,29 +682,53 @@ export default {
 				const currentPlayerTime = this.player.getCurrentTime() * 1000;
 
 				const difference = timeElapsed - currentPlayerTime;
-				// console.log(difference123);
-				if (difference < -200) {
+				// console.log(difference);
+
+				let playbackRate = 1;
+
+				if (difference < -2000) {
+					if (!this.seeking) {
+						this.seeking = true;
+						this.player.seekTo(
+							this.getTimeElapsed() / 1000 +
+								this.currentSong.skipDuration
+						);
+					}
+				} else if (difference < -200) {
 					// console.log("Difference0.8");
-					this.player.setPlaybackRate(0.8);
+					playbackRate = 0.8;
 				} else if (difference < -50) {
 					// console.log("Difference0.9");
-					this.player.setPlaybackRate(0.9);
+					playbackRate = 0.9;
 				} else if (difference < -25) {
 					// console.log("Difference0.99");
-					this.player.setPlaybackRate(0.99);
+					playbackRate = 0.95;
+				} else if (difference > 2000) {
+					if (!this.seeking) {
+						this.seeking = true;
+						this.player.seekTo(
+							this.getTimeElapsed() / 1000 +
+								this.currentSong.skipDuration
+						);
+					}
 				} else if (difference > 200) {
 					// console.log("Difference1.2");
-					this.player.setPlaybackRate(1.2);
+					playbackRate = 1.2;
 				} else if (difference > 50) {
 					// console.log("Difference1.1");
-					this.player.setPlaybackRate(1.1);
+					playbackRate = 1.1;
 				} else if (difference > 25) {
 					// console.log("Difference1.01");
-					this.player.setPlaybackRate(1.01);
+					playbackRate = 1.05;
 				} else if (this.player.getPlaybackRate !== 1.0) {
 					// console.log("NDifference1.0");
 					this.player.setPlaybackRate(1.0);
 				}
+
+				if (this.playbackRate !== playbackRate) {
+					this.player.setPlaybackRate(playbackRate);
+					this.playbackRate = playbackRate;
+				}
 			}
 
 			/* if (this.currentTime !== undefined && this.paused) {
@@ -711,11 +750,11 @@ export default {
 		toggleLock() {
 			window.socket.emit("stations.toggleLock", this.station._id, res => {
 				if (res.status === "success") {
-					Toast.methods.addToast(
-						"Successfully toggled the queue lock.",
-						4000
-					);
-				} else Toast.methods.addToast(res.message, 8000);
+					new Toast({
+						content: "Successfully toggled the queue lock.",
+						timeout: 4000
+					});
+				} else new Toast({ content: res.message, timeout: 8000 });
 			});
 		},
 		changeVolume() {
@@ -752,45 +791,58 @@ export default {
 		skipStation() {
 			this.socket.emit("stations.forceSkip", this.station._id, data => {
 				if (data.status !== "success")
-					Toast.methods.addToast(`Error: ${data.message}`, 8000);
+					new Toast({
+						content: `Error: ${data.message}`,
+						timeout: 8000
+					});
 				else
-					Toast.methods.addToast(
-						"Successfully skipped the station's current song.",
-						4000
-					);
+					new Toast({
+						content:
+							"Successfully skipped the station's current song.",
+						timeout: 4000
+					});
 			});
 		},
 		voteSkipStation() {
 			this.socket.emit("stations.voteSkip", this.station._id, data => {
 				if (data.status !== "success")
-					Toast.methods.addToast(`Error: ${data.message}`, 8000);
+					new Toast({
+						content: `Error: ${data.message}`,
+						timeout: 8000
+					});
 				else
-					Toast.methods.addToast(
-						"Successfully voted to skip the current song.",
-						4000
-					);
+					new Toast({
+						content: "Successfully voted to skip the current song.",
+						timeout: 4000
+					});
 			});
 		},
 		resumeStation() {
 			this.socket.emit("stations.resume", this.station._id, data => {
 				if (data.status !== "success")
-					Toast.methods.addToast(`Error: ${data.message}`, 8000);
+					new Toast({
+						content: `Error: ${data.message}`,
+						timeout: 8000
+					});
 				else
-					Toast.methods.addToast(
-						"Successfully resumed the station.",
-						4000
-					);
+					new Toast({
+						content: "Successfully resumed the station.",
+						timeout: 4000
+					});
 			});
 		},
 		pauseStation() {
 			this.socket.emit("stations.pause", this.station._id, data => {
 				if (data.status !== "success")
-					Toast.methods.addToast(`Error: ${data.message}`, 8000);
+					new Toast({
+						content: `Error: ${data.message}`,
+						timeout: 8000
+					});
 				else
-					Toast.methods.addToast(
-						"Successfully paused the station.",
-						4000
-					);
+					new Toast({
+						content: "Successfully paused the station.",
+						timeout: 4000
+					});
 			});
 		},
 		toggleMute() {
@@ -828,10 +880,10 @@ export default {
 					this.currentSong.songId,
 					data => {
 						if (data.status !== "success")
-							Toast.methods.addToast(
-								`Error: ${data.message}`,
-								8000
-							);
+							new Toast({
+								content: `Error: ${data.message}`,
+								timeout: 8000
+							});
 					}
 				);
 			else
@@ -840,10 +892,10 @@ export default {
 					this.currentSong.songId,
 					data => {
 						if (data.status !== "success")
-							Toast.methods.addToast(
-								`Error: ${data.message}`,
-								8000
-							);
+							new Toast({
+								content: `Error: ${data.message}`,
+								timeout: 8000
+							});
 					}
 				);
 		},
@@ -854,10 +906,10 @@ export default {
 					this.currentSong.songId,
 					data => {
 						if (data.status !== "success")
-							Toast.methods.addToast(
-								`Error: ${data.message}`,
-								8000
-							);
+							new Toast({
+								content: `Error: ${data.message}`,
+								timeout: 8000
+							});
 					}
 				);
 
@@ -866,7 +918,10 @@ export default {
 				this.currentSong.songId,
 				data => {
 					if (data.status !== "success")
-						Toast.methods.addToast(`Error: ${data.message}`, 8000);
+						new Toast({
+							content: `Error: ${data.message}`,
+							timeout: 8000
+						});
 				}
 			);
 		},
@@ -907,10 +962,10 @@ export default {
 										}
 									);
 								} else {
-									Toast.methods.addToast(
-										`Top song in playlist was too long to be added.`,
-										3000
-									);
+									new Toast({
+										content: `Top song in playlist was too long to be added.`,
+										timeout: 3000
+									});
 									this.socket.emit(
 										"playlists.moveSongToBottom",
 										this.privatePlaylistQueueSelected,
@@ -1010,6 +1065,9 @@ export default {
 						}
 						this.systemDifference = difference;
 					});
+				} else {
+					this.loading = false;
+					this.exists = false;
 				}
 			});
 		},
@@ -1040,12 +1098,10 @@ export default {
 			io.removeAllListeners();
 			if (this.socket.connected) this.join();
 			io.onConnect(this.join);
-			this.socket.emit("stations.findByName", this.stationName, res => {
-				if (res.status === "failure") {
+			this.socket.emit("stations.existsByName", this.stationName, res => {
+				if (res.status === "failure" || !res.exists) {
 					this.loading = false;
 					this.exists = false;
-				} else {
-					this.exists = true;
 				}
 			});
 			this.socket.on("event:songs.next", data => {

+ 9 - 10
frontend/components/Station/StationHeader.vue

@@ -4,7 +4,7 @@
 			<div class="nav-left">
 				<router-link class="nav-item is-brand" :to="{ path: '/' }">
 					<img
-						:src="`${this.siteSettings.logo}`"
+						:src="`${this.siteSettings.logo_white}`"
 						:alt="`${this.siteSettings.siteName}` || `Musare`"
 					/>
 				</router-link>
@@ -236,13 +236,12 @@ export default {
 		currentSong: state => state.station.currentSong
 	}),
 	mounted() {
-		lofig.get("frontendDomain", res => {
-			this.frontendDomain = res;
-			return res;
+		lofig.get("frontendDomain").then(frontendDomain => {
+			this.frontendDomain = frontendDomain;
 		});
-		lofig.get("siteSettings", res => {
-			this.siteSettings = res;
-			return res;
+
+		lofig.get("siteSettings").then(siteSettings => {
+			this.siteSettings = siteSettings;
 		});
 	},
 	methods: {
@@ -260,7 +259,8 @@ export default {
 				partyMode: this.station.partyMode,
 				description: this.station.description,
 				privacy: this.station.privacy,
-				displayName: this.station.displayName
+				displayName: this.station.displayName,
+				locked: this.station.locked
 			});
 			this.openModal({
 				sector: "station",
@@ -286,12 +286,11 @@ export default {
 		font-size: 2.1rem !important;
 		line-height: 38px !important;
 		padding: 0 20px;
-		color: $white;
 		font-family: Pacifico, cursive;
-		filter: brightness(0) invert(1);
 
 		img {
 			max-height: 38px;
+			color: $musareBlue;
 		}
 	}
 }

+ 16 - 7
frontend/components/User/ResetPassword.vue

@@ -69,7 +69,7 @@
 </template>
 
 <script>
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 
 import MainHeader from "../MainHeader.vue";
 import MainFooter from "../MainFooter.vue";
@@ -94,12 +94,15 @@ export default {
 	methods: {
 		submitEmail() {
 			if (!this.email)
-				return Toast.methods.addToast("Email cannot be empty", 8000);
+				return new Toast({
+					content: "Email cannot be empty",
+					timeout: 8000
+				});
 			return this.socket.emit(
 				"users.requestPasswordReset",
 				this.email,
 				res => {
-					Toast.methods.addToast(res.message, 8000);
+					new Toast({ content: res.message, timeout: 8000 });
 					if (res.status === "success") {
 						this.step = 2;
 					}
@@ -108,12 +111,15 @@ export default {
 		},
 		verifyCode() {
 			if (!this.code)
-				return Toast.methods.addToast("Code cannot be empty", 8000);
+				return new Toast({
+					content: "Code cannot be empty",
+					timeout: 8000
+				});
 			return this.socket.emit(
 				"users.verifyPasswordResetCode",
 				this.code,
 				res => {
-					Toast.methods.addToast(res.message, 8000);
+					new Toast({ content: res.message, timeout: 8000 });
 					if (res.status === "success") {
 						this.step = 3;
 					}
@@ -122,13 +128,16 @@ export default {
 		},
 		changePassword() {
 			if (!this.newPassword)
-				return Toast.methods.addToast("Password cannot be empty", 8000);
+				return new Toast({
+					content: "Password cannot be empty",
+					timeout: 8000
+				});
 			return this.socket.emit(
 				"users.changePasswordWithResetCode",
 				this.code,
 				this.newPassword,
 				res => {
-					Toast.methods.addToast(res.message, 8000);
+					new Toast({ content: res.message, timeout: 8000 });
 					if (res.status === "success") {
 						this.$router.go("/login");
 					}

+ 67 - 58
frontend/components/User/Settings.vue

@@ -149,7 +149,7 @@
 <script>
 import { mapState } from "vuex";
 
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 
 import MainHeader from "../MainHeader.vue";
 import MainFooter from "../MainFooter.vue";
@@ -175,8 +175,8 @@ export default {
 		userId: state => state.user.auth.userId
 	}),
 	mounted() {
-		lofig.get("serverDomain", res => {
-			this.serverDomain = res;
+		lofig.get("serverDomain").then(serverDomain => {
+			this.serverDomain = serverDomain;
 		});
 
 		io.getSocket(socket => {
@@ -187,10 +187,10 @@ export default {
 					this.password = this.user.password;
 					this.github = this.user.github;
 				} else {
-					Toast.methods.addToast(
-						"Your are currently not signed in",
-						3000
-					);
+					new Toast({
+						content: "Your are currently not signed in",
+						timeout: 3000
+					});
 				}
 			});
 			this.socket.on("event:user.linkPassword", () => {
@@ -211,15 +211,18 @@ export default {
 		changeEmail() {
 			const email = this.user.email.address;
 			if (!validation.isLength(email, 3, 254))
-				return Toast.methods.addToast(
-					"Email must have between 3 and 254 characters.",
-					8000
-				);
+				return new Toast({
+					content: "Email must have between 3 and 254 characters.",
+					timeout: 8000
+				});
 			if (
 				email.indexOf("@") !== email.lastIndexOf("@") ||
 				!validation.regex.emailSimple.test(email)
 			)
-				return Toast.methods.addToast("Invalid email format.", 8000);
+				return new Toast({
+					content: "Invalid email format.",
+					timeout: 8000
+				});
 
 			return this.socket.emit(
 				"users.updateEmail",
@@ -227,27 +230,28 @@ export default {
 				email,
 				res => {
 					if (res.status !== "success")
-						Toast.methods.addToast(res.message, 8000);
+						new Toast({ content: res.message, timeout: 8000 });
 					else
-						Toast.methods.addToast(
-							"Successfully changed email address",
-							4000
-						);
+						new Toast({
+							content: "Successfully changed email address",
+							timeout: 4000
+						});
 				}
 			);
 		},
 		changeUsername() {
 			const { username } = this.user;
 			if (!validation.isLength(username, 2, 32))
-				return Toast.methods.addToast(
-					"Username must have between 2 and 32 characters.",
-					8000
-				);
+				return new Toast({
+					content: "Username must have between 2 and 32 characters.",
+					timeout: 8000
+				});
 			if (!validation.regex.azAZ09_.test(username))
-				return Toast.methods.addToast(
-					"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Invalid username format. Allowed characters: a-z, A-Z, 0-9 and _.",
+					timeout: 8000
+				});
 
 			return this.socket.emit(
 				"users.updateUsername",
@@ -255,45 +259,46 @@ export default {
 				username,
 				res => {
 					if (res.status !== "success")
-						Toast.methods.addToast(res.message, 8000);
+						new Toast({ content: res.message, timeout: 8000 });
 					else
-						Toast.methods.addToast(
-							"Successfully changed username",
-							4000
-						);
+						new Toast({
+							content: "Successfully changed username",
+							timeout: 4000
+						});
 				}
 			);
 		},
 		changePassword() {
 			const { newPassword } = this;
 			if (!validation.isLength(newPassword, 6, 200))
-				return Toast.methods.addToast(
-					"Password must have between 6 and 200 characters.",
-					8000
-				);
+				return new Toast({
+					content: "Password must have between 6 and 200 characters.",
+					timeout: 8000
+				});
 			if (!validation.regex.password.test(newPassword))
-				return Toast.methods.addToast(
-					"Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.",
+					timeout: 8000
+				});
 
 			return this.socket.emit(
 				"users.updatePassword",
 				newPassword,
 				res => {
 					if (res.status !== "success")
-						Toast.methods.addToast(res.message, 8000);
+						new Toast({ content: res.message, timeout: 8000 });
 					else
-						Toast.methods.addToast(
-							"Successfully changed password",
-							4000
-						);
+						new Toast({
+							content: "Successfully changed password",
+							timeout: 4000
+						});
 				}
 			);
 		},
 		requestPassword() {
 			return this.socket.emit("users.requestPassword", res => {
-				Toast.methods.addToast(res.message, 8000);
+				new Toast({ content: res.message, timeout: 8000 });
 				if (res.status === "success") {
 					this.passwordStep = 2;
 				}
@@ -301,12 +306,15 @@ export default {
 		},
 		verifyCode() {
 			if (!this.passwordCode)
-				return Toast.methods.addToast("Code cannot be empty", 8000);
+				return new Toast({
+					content: "Code cannot be empty",
+					timeout: 8000
+				});
 			return this.socket.emit(
 				"users.verifyPasswordCode",
 				this.passwordCode,
 				res => {
-					Toast.methods.addToast(res.message, 8000);
+					new Toast({ content: res.message, timeout: 8000 });
 					if (res.status === "success") {
 						this.passwordStep = 3;
 					}
@@ -316,38 +324,39 @@ export default {
 		setPassword() {
 			const newPassword = this.setNewPassword;
 			if (!validation.isLength(newPassword, 6, 200))
-				return Toast.methods.addToast(
-					"Password must have between 6 and 200 characters.",
-					8000
-				);
+				return new Toast({
+					content: "Password must have between 6 and 200 characters.",
+					timeout: 8000
+				});
 			if (!validation.regex.password.test(newPassword))
-				return Toast.methods.addToast(
-					"Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.",
-					8000
-				);
+				return new Toast({
+					content:
+						"Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.",
+					timeout: 8000
+				});
 
 			return this.socket.emit(
 				"users.changePasswordWithCode",
 				this.passwordCode,
 				newPassword,
 				res => {
-					Toast.methods.addToast(res.message, 8000);
+					new Toast({ content: res.message, timeout: 8000 });
 				}
 			);
 		},
 		unlinkPassword() {
 			this.socket.emit("users.unlinkPassword", res => {
-				Toast.methods.addToast(res.message, 8000);
+				new Toast({ content: res.message, timeout: 8000 });
 			});
 		},
 		unlinkGitHub() {
 			this.socket.emit("users.unlinkGitHub", res => {
-				Toast.methods.addToast(res.message, 8000);
+				new Toast({ content: res.message, timeout: 8000 });
 			});
 		},
 		removeSessions() {
 			this.socket.emit(`users.removeSessions`, this.userId, res => {
-				Toast.methods.addToast(res.message, 4000);
+				new Toast({ content: res.message, timeout: 4000 });
 			});
 		}
 	}

+ 6 - 6
frontend/components/User/Show.vue

@@ -64,7 +64,7 @@
 
 <script>
 import { mapState } from "vuex";
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 import { format, parseISO } from "date-fns";
 
 import MainHeader from "../MainHeader.vue";
@@ -111,12 +111,12 @@ export default {
 				newRank === "admin" ? "admin" : "default",
 				res => {
 					if (res.status === "error")
-						Toast.methods.addToast(res.message, 2000);
+						new Toast({ content: res.message, timeout: 2000 });
 					else this.user.role = newRank;
-					Toast.methods.addToast(
-						`User ${this.$route.params.username}'s rank has been changed to: ${newRank}`,
-						2000
-					);
+					new Toast({
+						content: `User ${this.$route.params.username}'s rank has been changed to: ${newRank}`,
+						timeout: 2000
+					});
 				}
 			);
 		}

+ 293 - 287
frontend/components/pages/Home.vue

@@ -3,11 +3,11 @@
 		<metadata title="Home" />
 		<div class="app">
 			<main-header />
-			<div class="content-wrapper">
-				<div class="stationsTitle">
+			<div class="group">
+				<div class="group-title">
 					Stations&nbsp;
 					<a
-						v-if="loggedIn"
+						v-if="$parent.loggedIn"
 						href="#"
 						@click="
 							openModal({
@@ -21,118 +21,100 @@
 						>
 					</a>
 				</div>
-				<div class="stations">
-					<router-link
-						v-for="(station, index) in filteredStations"
-						:key="index"
-						:to="{
-							name: 'station',
-							params: { id: station.name }
-						}"
-						class="stationCard"
-					>
-						<div class="topContent">
-							<div class="albumArt">
-								<div
-									v-if="station.currentSong.ytThumbnail"
-									class="ytThumbnailBg"
-									v-bind:style="{
-										'background-image':
-											'url(' +
-											station.currentSong.ytThumbnail +
-											')'
-									}"
-								></div>
-								<img
-									v-if="station.currentSong.ytThumbnail"
-									:src="station.currentSong.ytThumbnail"
-									onerror="this.src='/assets/notes-transparent.png'"
-									class="ytThumbnail"
-								/>
-								<img
-									v-else
-									:src="station.currentSong.thumbnail"
-									onerror="this.src='/assets/notes-transparent.png'"
-								/>
+				<router-link
+					v-for="(station, index) in stations"
+					:key="index"
+					:to="{
+						name: 'station',
+						params: { id: station.name }
+					}"
+					class="card station-card"
+					:class="{
+						isPrivate: station.privacy === 'private',
+						isMine: isOwner(station)
+					}"
+				>
+					<div class="card-image">
+						<figure class="image is-square">
+							<div
+								v-if="station.currentSong.ytThumbnail"
+								class="ytThumbnailBg"
+								v-bind:style="{
+									'background-image':
+										'url(' +
+										station.currentSong.ytThumbnail +
+										')'
+								}"
+							></div>
+							<img
+								v-if="station.currentSong.ytThumbnail"
+								:src="station.currentSong.ytThumbnail"
+								onerror="this.src='/assets/notes-transparent.png'"
+							/>
+							<img
+								v-else
+								:src="station.currentSong.thumbnail"
+								onerror="this.src='/assets/notes-transparent.png'"
+							/>
+						</figure>
+					</div>
+					<div class="card-content">
+						<div class="media">
+							<div class="media-left displayName">
+								<h5>{{ station.displayName }}</h5>
+								<i
+									v-if="station.type === 'official'"
+									class="material-icons blue-icon"
+									title="Verified station"
+								>
+									check_circle
+								</i>
 							</div>
-							<div class="info">
-								<h5 class="displayName">
-									{{ station.displayName }}
-									<i
-										v-if="station.type === 'official'"
-										class="badge material-icons"
-									>
-										verified_user
-									</i>
-								</h5>
+						</div>
+
+						<div class="content">
+							{{ station.description }}
+						</div>
+						<div class="under-content">
+							<p v-if="station.type === 'community'">
+								Hosted by
+								<user-id-to-username
+									:userId="station.owner"
+									:link="true"
+								/>
+							</p>
+							<div class="icons">
 								<i
-									v-if="loggedIn && !isFavorite(station)"
-									@click="favoriteStation($event, station)"
-									class="favorite material-icons"
-									>star_border</i
+									v-if="isOwner(station)"
+									class="material-icons dark-grey-icon"
+									title="This is your station."
+									>home</i
 								>
 								<i
-									v-if="loggedIn && isFavorite(station)"
-									@click="unfavoriteStation($event, station)"
-									class="favorite material-icons"
-									>star</i
+									v-if="station.privacy !== 'public'"
+									class="material-icons dark-grey-icon"
+									title="This station is not visible to other users."
+									>lock</i
 								>
-								<p class="description">
-									{{ station.description }}
-								</p>
-								<p class="hostedBy">
-									Hosted by
-									<span class="host">
-										<span
-											v-if="station.type === 'official'"
-											title="Musare"
-											>Musare</span
-										>
-										<user-id-to-username
-											v-else
-											:userId="station.owner"
-											:link="true"
-										/>
-									</span>
-								</p>
-								<div class="bottomIcons">
-									<i
-										v-if="station.privacy !== 'public'"
-										class="privateIcon material-icons"
-										title="This station is not visible to other users."
-										>lock</i
-									>
-									<i
-										v-if="
-											station.type === 'community' &&
-												isOwner(station)
-										"
-										class="homeIcon material-icons"
-										title="This is your station."
-										>home</i
-									>
-								</div>
-							</div>
-						</div>
-						<div class="bottomBar">
-							<i class="material-icons">music_note</i>
-							<span
-								v-if="station.currentSong.title"
-								class="songTitle"
-								>{{ station.currentSong.title }}</span
-							>
-							<span v-else class="songTitle"
-								>No Songs Playing</span
-							>
-							<div class="right">
-								<i class="material-icons">people</i>
-								<span class="currentUsers">{{
-									station.userCount
-								}}</span>
 							</div>
 						</div>
-					</router-link>
-				</div>
+					</div>
+					<div class="bottomBar">
+						<i
+							v-if="station.currentSong.title"
+							class="material-icons"
+							>music_note</i
+						>
+						<i v-else class="material-icons">music_off</i>
+						<span
+							v-if="station.currentSong.title"
+							class="songTitle"
+							:title="'Now Playing: ' + station.currentSong.title"
+							>{{ station.currentSong.title }}</span
+						>
+						<span v-else class="songTitle">No Songs Playing</span>
+					</div>
+				</router-link>
 			</div>
 			<main-footer />
 		</div>
@@ -142,7 +124,7 @@
 
 <script>
 import { mapState, mapActions } from "vuex";
-import { Toast } from "vue-roaster";
+import Toast from "toasters";
 
 import MainHeader from "../MainHeader.vue";
 import MainFooter from "../MainFooter.vue";
@@ -195,6 +177,7 @@ export default {
 					station.currentSong.ytThumbnail = `https://img.youtube.com/vi/${station.currentSong.songId}/mqdefault.jpg`;
 				this.stations.push(station);
 			});
+
 			this.socket.on(
 				"event:userCount.updated",
 				(stationId, userCount) => {
@@ -206,6 +189,7 @@ export default {
 					});
 				}
 			);
+
 			this.socket.on("event:station.nextSong", (stationId, song) => {
 				let newSong = song;
 				this.stations.forEach(s => {
@@ -221,11 +205,14 @@ export default {
 					}
 				});
 			});
+
 			this.socket.on("event:user.favoritedStation", stationId => {
 				this.favoriteStations.push(stationId);
 			});
+
 			this.socket.on("event:user.unfavoritedStation", stationId => {
-				this.favoriteStations.$remove(stationId);
+				const index = this.favoriteStations.indexOf(stationId);
+				this.favoriteStations.splice(index, 1);
 			});
 		});
 	},
@@ -255,9 +242,7 @@ export default {
 			this.socket.emit("apis.joinRoom", "home", () => {});
 		},
 		isOwner(station) {
-			return (
-				station.owner === this.userId && station.privacy === "public"
-			);
+			return station.owner === this.userId;
 		},
 		isFavorite(station) {
 			return this.favoriteStations.indexOf(station._id) !== -1;
@@ -266,22 +251,22 @@ export default {
 			event.preventDefault();
 			this.socket.emit("stations.favoriteStation", station._id, res => {
 				if (res.status === "success") {
-					Toast.methods.addToast(
-						"Successfully favorited station.",
-						4000
-					);
-				} else Toast.methods.addToast(res.message, 8000);
+					new Toast({
+						content: "Successfully favorited station.",
+						timeout: 4000
+					});
+				} else new Toast({ content: res.message, timeout: 8000 });
 			});
 		},
 		unfavoriteStation(event, station) {
 			event.preventDefault();
 			this.socket.emit("stations.unfavoriteStation", station._id, res => {
 				if (res.status === "success") {
-					Toast.methods.addToast(
-						"Successfully unfavorited station.",
-						4000
-					);
-				} else Toast.methods.addToast(res.message, 8000);
+					new Toast({
+						content: "Successfully unfavorited station.",
+						timeout: 4000
+					});
+				} else new Toast({ content: res.message, timeout: 8000 });
 			});
 		},
 		...mapActions("modals", ["openModal"])
@@ -305,8 +290,7 @@ export default {
 html {
 	width: 100%;
 	height: 100%;
-	color: $dark-grey-2;
-
+	color: rgba(0, 0, 0, 0.87);
 	body {
 		width: 100%;
 		height: 100%;
@@ -315,213 +299,235 @@ html {
 	}
 }
 
-.stationsTitle {
-	width: 100%;
-	height: 64px;
-	line-height: 48px;
-	text-align: center;
-	font-size: 48px;
-	margin-bottom: 25px;
+@media only screen and (min-width: 1200px) {
+	html {
+		font-size: 15px;
+	}
 }
-.community-button {
-	cursor: pointer;
-	transition: 0.25s ease color;
-	font-size: 30px;
-	color: $dark-grey;
-	&:hover {
-		color: $primary-color;
+
+@media only screen and (min-width: 992px) {
+	html {
+		font-size: 14.5px;
+	}
+}
+
+@media only screen and (min-width: 0) {
+	html {
+		font-size: 14px;
 	}
 }
 
-.stations {
+.under-content {
+	height: 25px;
+	position: relative;
+	line-height: 1;
+	font-size: 24px;
 	display: flex;
-	flex: 1;
-	flex-wrap: wrap;
-	justify-content: center;
-	margin-left: 10px;
-	margin-right: 10px;
+	align-items: center;
+	text-align: left;
+	margin-top: 10px;
+
+	p {
+		font-size: 15px;
+		line-height: 15px;
+		display: inline;
+	}
+
+	i {
+		font-size: 20px;
+	}
+
+	* {
+		z-index: 10;
+		position: relative;
+	}
+
+	.icons {
+		position: absolute;
+		right: 0;
+
+		.dark-grey-icon {
+			color: $dark-grey-2;
+		}
+	}
+}
+
+.users-count {
+	font-size: 20px;
+	position: relative;
+	top: -4px;
 }
-.stationCard {
+
+.group {
+	min-height: 64px;
+}
+
+.station-card {
 	display: inline-flex;
 	flex-direction: column;
-	width: 450px;
-	height: 180px;
-	background: $white;
-	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
-	color: $dark-grey;
+	overflow: hidden;
 	margin: 10px;
-	transition: all ease-in-out 0.2s;
 	cursor: pointer;
-	overflow: hidden;
-	.albumArt {
-		display: inline-flex;
-		position: relative;
-		height: 150px;
-		width: 150px;
-		box-shadow: 1px 0px 3px rgba(7, 136, 191, 0.3);
-		overflow: hidden;
-		img {
-			width: auto;
-			height: 100%;
-		}
-		.ytThumbnailBg {
-			background: url("/assets/notes-transparent.png") no-repeat center
-				center;
-			background-size: cover;
-			height: 100%;
-			width: 100%;
-			position: absolute;
-			top: 0;
-			filter: blur(5px);
-		}
-		.ytThumbnail {
-			height: auto;
-			width: 100%;
-			top: 0;
-			margin-top: auto;
-			margin-bottom: auto;
-			z-index: 1;
-		}
-	}
-	.topContent {
-		width: 100%;
-		height: 100%;
-		display: inline-flex;
-		.info {
-			padding: 15px 12px 12px 15px;
-			position: relative;
-			width: 100%;
-			max-width: 300px;
+	height: 485px;
+	transition: all ease-in-out 0.2s;
+
+	.card-content {
+		padding: 10px 15px;
+
+		.media {
+			display: flex;
+			align-items: center;
+
 			.displayName {
-				color: $black;
-				margin: 0;
-				font-size: 20px;
-				font-weight: 500;
-				margin-bottom: 5px;
-				width: calc(100% - 30px);
+				display: flex;
+				align-items: center;
+				word-wrap: break-word;
+				width: 80%;
 				word-wrap: break-word;
 				overflow: hidden;
 				text-overflow: ellipsis;
-				display: -webkit-box;
-				-webkit-box-orient: vertical;
-				-webkit-line-clamp: 1;
+				display: flex;
 				line-height: 30px;
 				max-height: 30px;
-				.badge {
-					position: relative;
-					padding-right: 2px;
-					color: $lime;
-					top: 3px;
-					font-size: 22px;
-				}
-			}
-			.favorite {
-				color: $yellow;
-				top: 12px;
-				right: 12px;
-				position: absolute;
-				display: none;
-			}
-			.description {
-				width: calc(100% - 30px);
-				margin: 0;
-				font-size: 14px;
-				font-weight: 400;
-				word-wrap: break-word;
-				overflow: hidden;
-				text-overflow: ellipsis;
-				display: -webkit-box;
-				-webkit-box-orient: vertical;
-				-webkit-line-clamp: 3;
-				line-height: 20px;
-				max-height: 60px;
-			}
-			.hostedBy {
-				font-weight: 400;
-				font-size: 12px;
-				position: absolute;
-				bottom: 12px;
-				color: $black;
-				.host {
+
+				h5 {
 					font-weight: 400;
-					color: $primary-color;
+					margin: 0;
+					display: inline;
+					margin-right: 6px;
+					line-height: 30px;
 				}
-			}
-			.bottomIcons {
-				position: absolute;
-				bottom: 12px;
-				right: 12px;
-				.material-icons {
-					margin-left: 5px;
+
+				i {
 					font-size: 22px;
 				}
-				.privateIcon {
-					color: $dark-pink;
-				}
-				.homeIcon {
-					color: $light-purple;
+
+				.blue-icon {
+					color: $musareBlue;
 				}
 			}
 		}
+
+		.content {
+			word-wrap: break-word;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			display: -webkit-box;
+			-webkit-box-orient: vertical;
+			-webkit-line-clamp: 3;
+			line-height: 20px;
+			height: 60px;
+			text-align: left;
+			word-wrap: break-word;
+			margin-bottom: 0;
+		}
 	}
+
+	.card-image {
+		.image {
+			.ytThumbnailBg {
+				background: url("/assets/notes-transparent.png") no-repeat
+					center center;
+				background-size: cover;
+				height: 100%;
+				width: 100%;
+				position: absolute;
+				top: 0;
+				filter: blur(3px);
+			}
+			img {
+				height: auto;
+				width: 100%;
+				top: 0;
+				margin-top: auto;
+				margin-bottom: auto;
+				z-index: 1;
+			}
+		}
+	}
+
 	.bottomBar {
+		position: relative;
+		display: flex;
+		align-items: center;
 		background: $primary-color;
-		box-shadow: inset 0px 2px 4px rgba(7, 136, 191, 0.6);
 		width: 100%;
 		height: 30px;
 		line-height: 30px;
 		color: $white;
 		font-weight: 400;
 		font-size: 12px;
+
 		i.material-icons {
 			vertical-align: middle;
-			margin-left: 12px;
-			font-size: 22px;
+			margin-left: 5px;
+			font-size: 18px;
 		}
+
 		.songTitle {
+			text-align: left;
 			vertical-align: middle;
 			margin-left: 5px;
-		}
-		.right {
-			float: right;
-			margin-right: 12px;
-			.currentUsers {
-				vertical-align: middle;
-				margin-left: 5px;
-				font-size: 14px;
-			}
+			line-height: 30px;
+			flex: 2 1 0;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
 		}
 	}
 }
-.stationCard:hover {
+
+.station-card:hover {
 	box-shadow: 0 2px 3px rgba(10, 10, 10, 0.3), 0 0 10px rgba(10, 10, 10, 0.3);
 	transition: all ease-in-out 0.2s;
 }
 
-@media screen and (max-width: 490px) {
-	.stationCard {
-		width: calc(100% - 20px);
-		height: auto;
-		.topContent {
-			.albumArt {
-				max-height: 100px;
-				max-width: 100px;
-			}
-			.info {
-				width: calc(100% - 100px);
-				padding: 5px 2px 2px 10px !important;
-				.displayName {
-					font-size: 16px !important;
-					margin-bottom: 3px !important;
-				}
-				.description {
-					font-size: 12px !important;
-					-webkit-line-clamp: 2;
-					line-height: 15px;
-					max-height: 30px;
-				}
-			}
-		}
+/*.isPrivate {
+		background-color: #F8BBD0;
+	}
+	.isMine {
+		background-color: #29B6F6;
+	}*/
+
+.community-button {
+	cursor: pointer;
+	transition: 0.25s ease color;
+	font-size: 30px;
+	color: #4a4a4a;
+}
+
+.community-button:hover {
+	color: #03a9f4;
+}
+
+.station-privacy {
+	text-transform: capitalize;
+}
+
+.label {
+	display: flex;
+}
+
+.g-recaptcha {
+	display: flex;
+	justify-content: center;
+	margin-top: 20px;
+}
+
+.group {
+	text-align: center;
+	width: 100%;
+	margin: 64px 0 0 0;
+	padding-bottom: 240px;
+	.group-title {
+		float: left;
+		clear: none;
+		width: 100%;
+		height: 64px;
+		line-height: 48px;
+		text-align: center;
+		font-size: 48px;
+		margin-bottom: 25px;
 	}
 }
 </style>

+ 0 - 0
frontend/dist/assets/wordmark.png → frontend/dist/assets/blue_wordmark.png


BIN
frontend/dist/assets/white_wordmark.png


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

@@ -11,7 +11,8 @@
 		"SIDname": "SID"
 	},
 	"siteSettings": {
-		"logo": "/assets/wordmark.png",
+		"logo_white": "/assets/white_wordmark.png",
+		"logo_blue": "/assets/blue_wordmark.png",
 		"siteName": "Musare",
 		"socialLinks": {
 			"github": "https://github.com/Musare/MusareNode",

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

@@ -39,9 +39,12 @@
 	<script src='https://www.youtube.com/iframe_api'></script>
 	<script type='text/javascript' src='/vendor/can-autoplay.min.js'></script>
 	<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.2.0/socket.io.js" integrity="sha256-yr4fRk/GU1ehYJPAs8P4JlTgu0Hdsp4ZKrx8bDEDC3I=" crossorigin="anonymous"></script>
-	<script type='text/javascript' src='/lofig.min.js'></script>
+	<script type='text/javascript' src='https://unpkg.com/lofig@1.2.1/dist/lofig.min.js'></script>
 </head>
 <body>
 	<div id="root"></div>
+	<div id="toasts-container">
+		<div id="toasts-content"></div>
+	</div>
 </body>
 </html>

File diff suppressed because it is too large
+ 0 - 0
frontend/dist/lofig.min.js


+ 16 - 12
frontend/main.js

@@ -27,6 +27,20 @@ Vue.component("metadata", {
 
 Vue.use(VueRouter);
 
+Vue.directive("scroll", {
+	inserted(el, binding) {
+		const f = evt => {
+			clearTimeout(window.scrollDebounceId);
+			window.scrollDebounceId = setTimeout(() => {
+				if (binding.value(evt, el)) {
+					window.removeEventListener("scroll", f);
+				}
+			}, 200);
+		};
+		window.addEventListener("scroll", f);
+	}
+});
+
 const router = new VueRouter({
 	mode: "history",
 	routes: [
@@ -109,8 +123,8 @@ const router = new VueRouter({
 });
 
 lofig.folder = "../config/default.json";
-lofig.get("serverDomain", res => {
-	io.init(res);
+lofig.get("serverDomain").then(serverDomain => {
+	io.init(serverDomain);
 	io.getSocket(socket => {
 		socket.on("ready", (loggedIn, role, username, userId) => {
 			store.dispatch("user/auth/authData", {
@@ -156,16 +170,6 @@ router.beforeEach((to, from, next) => {
 			);
 		}
 	} else next();
-
-	if (to.name === "station") {
-		io.getSocket(socket => {
-			socket.emit("stations.findByName", to.params.id, res => {
-				if (res.status === "success") {
-					next();
-				}
-			});
-		});
-	}
 });
 
 // eslint-disable-next-line no-new

+ 1 - 1
frontend/package.json

@@ -50,9 +50,9 @@
     "date-fns": "^2.0.1",
     "eslint-config-airbnb-base": "^13.2.0",
     "html-webpack-plugin": "^3.2.0",
+    "toasters": "2.0.1",
     "vue": "^2.6.10",
     "vue-loader": "^15.7.0",
-    "vue-roaster": "^1.1.1",
     "vue-router": "^3.0.7",
     "vuex": "^3.1.1",
     "webpack-md5-hash": "0.0.6",

+ 4 - 2
frontend/validation.js

@@ -3,8 +3,10 @@ module.exports = {
 		azAZ09_: /^[A-Za-z0-9_]+$/,
 		az09_: /^[a-z0-9_]+$/,
 		emailSimple: /^[\x00-\x7F]+@[a-z0-9]+\.[a-z0-9]+(\.[a-z0-9]+)?$/,
-		password: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[$@$!%*?&])[A-Za-z\d$@$!%*?&]/,
-		ascii: /^[\x00-\x7F]+$/
+		ascii: /^[\x00-\x7F]+$/,
+		custom: regex => {
+			return new RegExp(`^[${regex}]+$`);
+		}
 	},
 	isLength: (string, min, max) => {
 		return !(

+ 13 - 146
yarn.lock

@@ -1789,11 +1789,6 @@ acorn-walk@^6.1.1:
   resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-6.2.0.tgz#123cb8f3b84c2171f1f7fb252615b1c78a6b1a8c"
   integrity sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==
 
-acorn@^5.2.1:
-  version "5.7.3"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279"
-  integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==
-
 acorn@^6.0.2, acorn@^6.0.7, acorn@^6.2.1:
   version "6.2.1"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.2.1.tgz#3ed8422d6dec09e6121cc7a843ca86a330a86b51"
@@ -2083,11 +2078,6 @@ assign-symbols@^1.0.0:
   resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
   integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=
 
-ast-types@0.9.6:
-  version "0.9.6"
-  resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.6.tgz#102c9e9e9005d3e7e3829bf0c4fa24ee862ee9b9"
-  integrity sha1-ECyenpAF0+fjgpvwxPok7oYu6bk=
-
 ast-types@0.x.x:
   version "0.13.2"
   resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.2.tgz#df39b677a911a83f3a049644fb74fdded23cea48"
@@ -2191,14 +2181,6 @@ babel-plugin-dynamic-import-node@^2.3.0:
   dependencies:
     object.assign "^4.1.0"
 
-babel-runtime@^6.0.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
-  integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4=
-  dependencies:
-    core-js "^2.4.0"
-    regenerator-runtime "^0.11.0"
-
 backo2@1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
@@ -2209,11 +2191,6 @@ balanced-match@^1.0.0:
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
   integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
 
-base62@^1.1.0:
-  version "1.2.8"
-  resolved "https://registry.yarnpkg.com/base62/-/base62-1.2.8.tgz#1264cb0fb848d875792877479dbe8bae6bae3428"
-  integrity sha512-V6YHUbjLxN1ymqNLb1DPHoU1CpfdL7d2YTIp5W3U4hhoG4hhxNmsFDs66M9EXxBiSEke5Bt5dwdfMwwZF70iLA==
-
 base64-arraybuffer@0.1.5:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
@@ -2948,7 +2925,7 @@ commander@2.17.x:
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
   integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
 
-commander@^2.18.0, commander@^2.20.0, commander@^2.5.0, commander@~2.20.0:
+commander@^2.18.0, commander@^2.20.0, commander@~2.20.0:
   version "2.20.0"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422"
   integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==
@@ -2963,21 +2940,6 @@ commondir@^1.0.1:
   resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
   integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
 
-commoner@^0.10.1:
-  version "0.10.8"
-  resolved "https://registry.yarnpkg.com/commoner/-/commoner-0.10.8.tgz#34fc3672cd24393e8bb47e70caa0293811f4f2c5"
-  integrity sha1-NPw2cs0kOT6LtH5wyqApOBH08sU=
-  dependencies:
-    commander "^2.5.0"
-    detective "^4.3.1"
-    glob "^5.0.15"
-    graceful-fs "^4.1.2"
-    iconv-lite "^0.4.5"
-    mkdirp "^0.5.0"
-    private "^0.1.6"
-    q "^1.1.2"
-    recast "^0.11.17"
-
 compare-func@^1.3.1:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/compare-func/-/compare-func-1.3.2.tgz#99dd0ba457e1f9bc722b12c08ec33eeab31fa648"
@@ -3283,11 +3245,6 @@ core-js-pure@3.1.4:
   resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.1.4.tgz#5fa17dc77002a169a3566cc48dc774d2e13e3769"
   integrity sha512-uJ4Z7iPNwiu1foygbcZYJsJs1jiXrTTCvxfLDXNhI/I+NHbSIEyr548y4fcsCEyWY0XgfAG/qqaunJ1SThHenA==
 
-core-js@^2.4.0:
-  version "2.6.9"
-  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.9.tgz#6b4b214620c834152e179323727fc19741b084f2"
-  integrity sha512-HOpZf6eXmnl7la+cUdMnLvUxKNqLUzJvgIziQ0DiF3JwSImNphIqdGqzj6hIKyX04MmV0poclQ7+wjWvxQyR2A==
-
 core-util-is@1.0.2, core-util-is@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -3632,11 +3589,6 @@ define-property@^2.0.2:
     is-descriptor "^1.0.2"
     isobject "^3.0.1"
 
-defined@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693"
-  integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM=
-
 degenerator@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-1.0.4.tgz#fcf490a37ece266464d9cc431ab98c5819ced095"
@@ -3712,14 +3664,6 @@ detect-node@^2.0.4:
   resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c"
   integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==
 
-detective@^4.3.1:
-  version "4.7.1"
-  resolved "https://registry.yarnpkg.com/detective/-/detective-4.7.1.tgz#0eca7314338442febb6d65da54c10bb1c82b246e"
-  integrity sha512-H6PmeeUcZloWtdt4DAkFyzFL94arpHr3NOwwmVILFiy+9Qd4JTxxXrzfyGk/lmct2qVGBwTSwSXagqu2BxmWig==
-  dependencies:
-    acorn "^5.2.1"
-    defined "^1.0.0"
-
 dezalgo@^1.0.0:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.3.tgz#7f742de066fc748bc8db820569dddce49bf0d456"
@@ -4028,14 +3972,6 @@ env-paths@^1.0.0:
   resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-1.0.0.tgz#4168133b42bb05c38a35b1ae4397c8298ab369e0"
   integrity sha1-QWgTO0K7BcOKNbGuQ5fIKYqzaeA=
 
-envify@^3.4.0:
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/envify/-/envify-3.4.1.tgz#d7122329e8df1688ba771b12501917c9ce5cbce8"
-  integrity sha1-1xIjKejfFoi6dxsSUBkXyc5cvOg=
-  dependencies:
-    jstransform "^11.0.3"
-    through "~2.3.4"
-
 err-code@^1.0.0:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/err-code/-/err-code-1.1.2.tgz#06e0116d3028f6aef4806849eb0ea6a748ae6960"
@@ -4281,12 +4217,7 @@ espree@^6.0.0:
     acorn-jsx "^5.0.0"
     eslint-visitor-keys "^1.0.0"
 
-esprima-fb@^15001.1.0-dev-harmony-fb:
-  version "15001.1.0-dev-harmony-fb"
-  resolved "https://registry.yarnpkg.com/esprima-fb/-/esprima-fb-15001.1.0-dev-harmony-fb.tgz#30a947303c6b8d5e955bee2b99b1d233206a6901"
-  integrity sha1-MKlHMDxrjV6VW+4rmbHSMyBqaQE=
-
-esprima@3.x.x, esprima@^3.1.3, esprima@~3.1.0:
+esprima@3.x.x, esprima@^3.1.3:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.1.3.tgz#fdca51cee6133895e3c88d535ce49dbff62a4633"
   integrity sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM=
@@ -4989,17 +4920,6 @@ glob-to-regexp@^0.3.0:
   resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
   integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=
 
-glob@^5.0.15:
-  version "5.0.15"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
-  integrity sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=
-  dependencies:
-    inflight "^1.0.4"
-    inherits "2"
-    minimatch "2 || 3"
-    once "^1.3.0"
-    path-is-absolute "^1.0.0"
-
 glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@~7.1.1:
   version "7.1.4"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255"
@@ -5481,7 +5401,7 @@ husky@^3.0.4:
     run-node "^1.0.0"
     slash "^3.0.0"
 
-iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@^0.4.5, iconv-lite@~0.4.13:
+iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
   integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@@ -6031,16 +5951,6 @@ isstream@~0.1.2:
   resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
   integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
 
-jquery-ui@^1.12.1:
-  version "1.12.1"
-  resolved "https://registry.yarnpkg.com/jquery-ui/-/jquery-ui-1.12.1.tgz#bcb4045c8dd0539c134bc1488cdd3e768a7a9e51"
-  integrity sha1-vLQEXI3QU5wTS8FIjN0+dop6nlE=
-
-jquery@^3.1.1:
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.4.1.tgz#714f1f8d9dde4bdfa55764ba37ef214630d80ef2"
-  integrity sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw==
-
 js-base64@^2.1.8:
   version "2.5.1"
   resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-2.5.1.tgz#1efa39ef2c5f7980bb1784ade4a8af2de3291121"
@@ -6160,17 +6070,6 @@ jsprim@^1.2.2:
     json-schema "0.2.3"
     verror "1.10.0"
 
-jstransform@^11.0.3:
-  version "11.0.3"
-  resolved "https://registry.yarnpkg.com/jstransform/-/jstransform-11.0.3.tgz#09a78993e0ae4d4ef4487f6155a91f6190cb4223"
-  integrity sha1-CaeJk+CuTU70SH9hVakfYZDLQiM=
-  dependencies:
-    base62 "^1.1.0"
-    commoner "^0.10.1"
-    esprima-fb "^15001.1.0-dev-harmony-fb"
-    object-assign "^2.0.0"
-    source-map "^0.4.2"
-
 jszip@^3.1.5:
   version "3.2.2"
   resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.2.2.tgz#b143816df7e106a9597a94c77493385adca5bd1d"
@@ -6793,7 +6692,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
   resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
   integrity sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=
 
-"minimatch@2 || 3", minimatch@^3.0.4, minimatch@~3.0.2:
+minimatch@^3.0.4, minimatch@~3.0.2:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
@@ -7376,11 +7275,6 @@ oauth@^0.9.15:
   resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
   integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE=
 
-object-assign@^2.0.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-2.1.1.tgz#43c36e5d569ff8e4816c4efa8be02d26967c18aa"
-  integrity sha1-Q8NuXVaf+OSBbE76i+AtJpZ8GKo=
-
 object-assign@^4, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
@@ -8155,7 +8049,7 @@ prism-media@^0.0.3:
   resolved "https://registry.yarnpkg.com/prism-media/-/prism-media-0.0.3.tgz#8842d4fae804f099d3b48a9a38e3c2bab6f4855b"
   integrity sha512-c9KkNifSMU/iXT8FFTaBwBMr+rdVcN+H/uNv1o+CuFeTThNZNTOrQ+RgXA1yL/DeLk098duAeRPP3QNPNbhxYQ==
 
-private@^0.1.6, private@~0.1.5:
+private@^0.1.6:
   version "0.1.8"
   resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
   integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==
@@ -8320,7 +8214,7 @@ punycode@^2.1.0:
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
-q@^1.1.2, q@^1.5.1:
+q@^1.5.1:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
   integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
@@ -8552,16 +8446,6 @@ readdirp@^2.2.1:
     micromatch "^3.1.10"
     readable-stream "^2.0.2"
 
-recast@^0.11.17:
-  version "0.11.23"
-  resolved "https://registry.yarnpkg.com/recast/-/recast-0.11.23.tgz#451fd3004ab1e4df9b4e4b66376b2a21912462d3"
-  integrity sha1-RR/TAEqx5N+bTktmN2sqIZEkYtM=
-  dependencies:
-    ast-types "0.9.6"
-    esprima "~3.1.0"
-    private "~0.1.5"
-    source-map "~0.5.0"
-
 redent@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
@@ -8609,11 +8493,6 @@ regenerate@^1.4.0:
   resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11"
   integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==
 
-regenerator-runtime@^0.11.0:
-  version "0.11.1"
-  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
-  integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
-
 regenerator-runtime@^0.13.2:
   version "0.13.3"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5"
@@ -9614,7 +9493,7 @@ source-map@^0.4.2:
   dependencies:
     amdefine ">=0.0.4"
 
-source-map@^0.5.0, source-map@^0.5.6, source-map@~0.5.0:
+source-map@^0.5.0, source-map@^0.5.6:
   version "0.5.7"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
   integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
@@ -10061,7 +9940,7 @@ through2@^3.0.0:
   dependencies:
     readable-stream "2 || 3"
 
-through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6, through@~2.3.4:
+through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6:
   version "2.3.8"
   resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
   integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
@@ -10147,6 +10026,11 @@ to-regex@^3.0.1, to-regex@^3.0.2:
     regex-not "^1.0.2"
     safe-regex "^1.1.0"
 
+toasters@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/toasters/-/toasters-2.0.1.tgz#a41c2e1652e51b9b782624027672363ed6547509"
+  integrity sha512-zqZz0itO/iQJhnLKkTGpj1FhWg0YKo2EnAGD0zjDPrR2rQgfSymCaHaQT0Gp9VIuPpu+3UY8m4w4HTI49HpdFw==
+
 toidentifier@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
@@ -10610,16 +10494,6 @@ vue-loader@^15.7.0:
     vue-hot-reload-api "^2.3.0"
     vue-style-loader "^4.1.0"
 
-vue-roaster@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/vue-roaster/-/vue-roaster-1.1.1.tgz#4718300137862e621e8d7f9130c6dacb5989ca1a"
-  integrity sha1-RxgwATeGLmIejX+RMMbay1mJyho=
-  dependencies:
-    babel-runtime "^6.0.0"
-    jquery "^3.1.1"
-    jquery-ui "^1.12.1"
-    vue "^1.0.0"
-
 vue-router@^3.0.7:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.1.1.tgz#0893c29548ba2dbe35ed104dcd1aa06743aa0ead"
@@ -10646,13 +10520,6 @@ vue-template-es2015-compiler@^1.9.0:
   resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825"
   integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==
 
-vue@^1.0.0:
-  version "1.0.28"
-  resolved "https://registry.yarnpkg.com/vue/-/vue-1.0.28.tgz#ed2ff07b200bde15c87a90ef8727ceea7d38567d"
-  integrity sha1-7S/weyAL3hXIepDvhyfO6n04Vn0=
-  dependencies:
-    envify "^3.4.0"
-
 vue@^2.6.10:
   version "2.6.10"
   resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.10.tgz#a72b1a42a4d82a721ea438d1b6bf55e66195c637"

Some files were not shown because too many files changed in this diff