Răsfoiți Sursa

Merge branch 'staging'

Kristian Vos 3 ani în urmă
părinte
comite
0facb3064b
100 a modificat fișierele cu 14021 adăugiri și 10772 ștergeri
  1. 0 20
      .editorconfig
  2. 0 3
      .env
  3. 18 0
      .env.example
  4. 14 7
      .gitignore
  5. 40 0
      .travis.yml
  6. 185 147
      README.md
  7. 2 1
      backend/Dockerfile
  8. 21 8
      backend/config/template.json
  9. 85 0
      backend/core.js
  10. 177 214
      backend/index.js
  11. 59 12
      backend/logic/actions/apis.js
  12. 9 7
      backend/logic/actions/hooks/adminRequired.js
  13. 8 6
      backend/logic/actions/hooks/loginRequired.js
  14. 10 8
      backend/logic/actions/hooks/ownerRequired.js
  15. 24 23
      backend/logic/actions/news.js
  16. 96 104
      backend/logic/actions/playlists.js
  17. 27 30
      backend/logic/actions/punishments.js
  18. 102 60
      backend/logic/actions/queueSongs.js
  19. 33 26
      backend/logic/actions/reports.js
  20. 96 75
      backend/logic/actions/songs.js
  21. 270 172
      backend/logic/actions/stations.js
  22. 176 126
      backend/logic/actions/users.js
  23. 32 26
      backend/logic/api.js
  24. 220 224
      backend/logic/app.js
  25. 102 92
      backend/logic/cache/index.js
  26. 218 189
      backend/logic/db/index.js
  27. 2 1
      backend/logic/db/schemas/queueSong.js
  28. 4 1
      backend/logic/db/schemas/report.js
  29. 2 1
      backend/logic/db/schemas/song.js
  30. 1 1
      backend/logic/db/schemas/user.js
  31. 91 0
      backend/logic/discord.js
  32. 165 137
      backend/logic/io.js
  33. 151 178
      backend/logic/logger.js
  34. 30 35
      backend/logic/mail/index.js
  35. 4 1
      backend/logic/mail/schemas/passwordRequest.js
  36. 4 1
      backend/logic/mail/schemas/resetPasswordRequest.js
  37. 4 1
      backend/logic/mail/schemas/verifyEmail.js
  38. 111 58
      backend/logic/notifications.js
  39. 79 74
      backend/logic/playlists.js
  40. 87 79
      backend/logic/punishments.js
  41. 80 72
      backend/logic/songs.js
  42. 95 0
      backend/logic/spotify.js
  43. 235 190
      backend/logic/stations.js
  44. 154 125
      backend/logic/tasks.js
  45. 232 149
      backend/logic/utils.js
  46. 0 2385
      backend/package-lock.json
  47. 24 28
      backend/package.json
  48. 20 5
      docker-compose.yml
  49. 6 2
      frontend/.babelrc
  50. 2 0
      frontend/.eslintignore
  51. 29 8
      frontend/.eslintrc
  52. 3 0
      frontend/.prettierignore
  53. 5 0
      frontend/.prettierrc
  54. 5 0
      frontend/.snyk
  55. 253 250
      frontend/App.vue
  56. 11 5
      frontend/Dockerfile
  57. 93 0
      frontend/api/auth.js
  58. 15 16
      frontend/auth.js
  59. 9 0
      frontend/bootstrap.sh
  60. 0 12
      frontend/build/browserconfig.xml
  61. 0 10
      frontend/build/config/template.json
  62. 0 57
      frontend/build/index.tpl.html
  63. 0 1
      frontend/build/vendor/jquery.min.js
  64. 0 0
      frontend/build/vendor/moment.min.js
  65. 18 17
      frontend/components/404.vue
  66. 334 201
      frontend/components/Admin/News.vue
  67. 172 95
      frontend/components/Admin/Punishments.vue
  68. 237 128
      frontend/components/Admin/QueueSongs.vue
  69. 142 92
      frontend/components/Admin/Reports.vue
  70. 263 131
      frontend/components/Admin/Songs.vue
  71. 348 187
      frontend/components/Admin/Stations.vue
  72. 274 224
      frontend/components/Admin/Statistics.vue
  73. 113 85
      frontend/components/Admin/Users.vue
  74. 138 28
      frontend/components/MainFooter.vue
  75. 131 118
      frontend/components/MainHeader.vue
  76. 122 96
      frontend/components/Modals/AddSongToPlaylist.vue
  77. 200 109
      frontend/components/Modals/AddSongToQueue.vue
  78. 123 69
      frontend/components/Modals/CreateCommunityStation.vue
  79. 312 166
      frontend/components/Modals/EditNews.vue
  80. 1749 439
      frontend/components/Modals/EditSong.vue
  81. 986 163
      frontend/components/Modals/EditStation.vue
  82. 185 95
      frontend/components/Modals/EditUser.vue
  83. 94 19
      frontend/components/Modals/IssuesModal.vue
  84. 127 53
      frontend/components/Modals/Login.vue
  85. 60 54
      frontend/components/Modals/MobileAlert.vue
  86. 31 25
      frontend/components/Modals/Modal.vue
  87. 98 65
      frontend/components/Modals/Playlists/Create.vue
  88. 354 208
      frontend/components/Modals/Playlists/Edit.vue
  89. 159 65
      frontend/components/Modals/Register.vue
  90. 241 175
      frontend/components/Modals/Report.vue
  91. 91 47
      frontend/components/Modals/ViewPunishment.vue
  92. 148 87
      frontend/components/Modals/WhatIsNew.vue
  93. 183 130
      frontend/components/Sidebars/Playlist.vue
  94. 237 121
      frontend/components/Sidebars/SongsList.vue
  95. 69 43
      frontend/components/Sidebars/UsersList.vue
  96. 0 339
      frontend/components/Station/CommunityHeader.vue
  97. 0 349
      frontend/components/Station/OfficialHeader.vue
  98. 1624 1004
      frontend/components/Station/Station.vue
  99. 491 0
      frontend/components/Station/StationHeader.vue
  100. 137 82
      frontend/components/User/ResetPassword.vue

+ 0 - 20
.editorconfig

@@ -1,20 +0,0 @@
-root = true
-
-[*]
-charset = utf-8
-indent_style = tab
-
-[frontend/nginx.conf]
-charset = utf-8
-indent_style = space
-indent_size = 4
-
-[docker-compose.yml]
-charset = utf-8
-indent_style = space
-indent_size = 2
-
-end_of_line = lf
-insert_final_newline = true
-trim_trailing_whitespace = true
-continuation_indent_size = 4

+ 0 - 3
.env

@@ -1,3 +0,0 @@
-REDIS_PASSWORD=PASSWORD
-BACKEND_PORT=8080
-FRONTEND_PORT=80

+ 18 - 0
.env.example

@@ -0,0 +1,18 @@
+REDIS_PASSWORD=PASSWORD
+
+BACKEND_PORT=8080
+FRONTEND_PORT=80
+
+MONGO_PORT=27017
+MONGO_ROOT_PASSWORD=PASSWORD_HERE
+MONGO_USER_USERNAME=musare
+MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
+
+MONGOCLIENT_PORT=3000
+
+REDIS_PORT=6379
+
+COMPOSE_PROJECT_NAME=musare
+
+FRONTEND_MODE=dev
+SNYK_TOKEN=

+ 14 - 7
.gitignore

@@ -2,27 +2,34 @@ Thumbs.db
 .DS_Store
 *.swp
 .idea/
+.vscode/
 .vagrant/
 
+.env
 startRedis.cmd
 startMongo.cmd
 .database
+.db
 .redis
-dump.rdb
+*.rdb
 npm-debug.log
+lerna-debug.log
 
-# Back End
+# Backend
 backend/node_modules/
 backend/config/default.json
 
-# Front End
+# Frontend
+frontend/yarn-error.log
+frontend/bundle-stats.json
+frontend/bundle-report.html
 frontend/node_modules/
-frontend/build/*.js
-frontend/build/index.html
-frontend/build/config/default.json
+frontend/dist/build/
+frontend/dist/index.html
+frontend/dist/config/default.json
 
 npm
+node_modules
 
 # Logs
 log/
-.env

+ 40 - 0
.travis.yml

@@ -0,0 +1,40 @@
+# .travis.yml
+
+
+language: minimal
+sudo: required
+services:
+  - docker
+
+env:
+  global:
+    - REDIS_PASSWORD=PASSWORD
+    - BACKEND_PORT=8080
+    - FRONTEND_PORT=80
+    - MONGO_PORT=27017
+    - MONGO_ROOT_PASSWORD=PASSWORD_HERE
+    - MONGO_USER_USERNAME=musare
+    - MONGO_USER_PASSWORD=OTHER_PASSWORD_HERE
+    - MONGOCLIENT_PORT=3000
+    - REDIS_PORT=6379
+    - COMPOSE_PROJECT_NAME=musare
+    - FRONTEND_MODE=prod
+
+before_install:
+  # create config files from template
+  - cp backend/config/template.json backend/config/default.json
+  - cp frontend/dist/config/template.json frontend/dist/config/default.json
+
+jobs:
+  include:
+    - stage: frontend
+      script:
+        - docker-compose build frontend # build frontend
+        - docker-compose up -d frontend # start frontend
+        - docker-compose exec frontend /bin/bash -c "cd app && yarn lint" # using eslint to check for formatting/linting issues
+    - stage: backend
+      script:
+        - docker-compose up -d mongo # start mongo (users automatically setup)
+        - docker-compose up -d redis # start redis
+        - docker-compose build backend # build backend
+        - docker-compose up -d backend # start backend

+ 185 - 147
README.md

@@ -1,44 +1,54 @@
+
 # MusareNode
-This is a rewrite of the original [Musare](https://github.com/Musare/MusareMeteor)
-in NodeJS, Express, SocketIO and VueJS. Everything is ran in it's own docker container, but you can also run it without Docker.
 
-The site is available at [https://musare.com](https://musare.com).
+Based off of the original [Musare](https://github.com/Musare/MusareMeteor), which utilized Meteor.
+
+MusareNode now uses NodeJS, Express, SocketIO and VueJS - among other technologies. We have also implemented the ability to host Musare in [Docker Containers](https://www.docker.com/).
+
+The master branch is available at [musare.com](https://musare.com)
+You can also find the staging branch at [musare.dev](https://musare.dev)
+
+## Contact
+
+Get in touch with us via email at [core@musare.com](mailto:core@musare.com) or join our [Discord Guild](https://discord.gg/Y5NxYGP).
+
+You can also find us on [Facebook](https://www.facebook.com/MusareMusic) and [Twitter](https://twitter.com/MusareApp).
 
 ### Our Stack
 
-   * NodeJS
-   * MongoDB
-   * Redis
-   * Nginx (not required)
-   * VueJS
+- NodeJS
+- MongoDB
+- Redis
+- Nginx (not required)
+- VueJS
 
 ### Frontend
-The frontend is a [vue-cli](https://github.com/vuejs/vue-cli) generated,
-[vue-loader](https://github.com/vuejs/vue-loader) single page app, that's
-served over Nginx or express. The Nginx server not only serves the frontend, but
-also serves as a load balancer for requests going to the backend.
+
+The frontend is a [vue-cli](https://github.com/vuejs/vue-cli) generated, [vue-loader](https://github.com/vuejs/vue-loader) single page app, that's served over Nginx or Express. The Nginx server not only serves the frontend, but can also serve as a load balancer for requests going to the backend.
 
 ### Backend
-The backend is a scalable NodeJS / Redis / MongoDB app. Each backend
-server handles a group of SocketIO connections. User sessions are stored
-in a central Redis server. All data is stored in a central MongoDB server.
-The Redis and MongoDB servers are replicated to several secondary nodes,
-which can become the primary node if the current primary node goes down.
 
-We currently only have 1 backend, 1 MongoDB server and 1 Redis server running for production, though it is relatively easy to expand.
+The backend is a scalable NodeJS / Redis / MongoDB app. Each backend server handles a group of SocketIO connections. User sessions are stored in a central Redis server. All data is stored in a central MongoDB server. The Redis and MongoDB servers are replicated to several secondary nodes, which can become the primary node if the current primary node goes down.
+
+We currently only utilize 1 backend, 1 MongoDB server and 1 Redis server running for production, though it is relatively easy to expand.
 
 ## Requirements
-Option 1: (not recommended for Windows users)
- * [Docker](https://www.docker.com/)
 
-Option 2:
- * [NodeJS](https://nodejs.org/en/download/)
- 	* nodemon: `npm install -g nodemon`
- 	* [node-gyp](https://github.com/nodejs/node-gyp#installation)
- * [MongoDB](https://www.mongodb.com/download-center)
- * [Redis (Windows)](https://github.com/MSOpenTech/redis/releases/tag/win-3.2.100) [Redis (Unix)](https://redis.io/download)
+Installing with Docker: (not recommended for Windows users)
+
+- [Docker](https://www.docker.com/)
+
+Standard Installation:
+
+- [NodeJS](https://nodejs.org/en/download/)
+  _ nodemon: `yarn global add nodemon`
+  _ [node-gyp](https://github.com/nodejs/node-gyp#installation): `yarn global add node-gyp`
+- [Yarn (Windows)](https://yarnpkg.com/lang/en/docs/install/#windows-stable) [Yarn (Unix)](https://yarnpkg.com/lang/en/docs/install/#debian-stable) ([npm](https://www.npmjs.com/) can also be used)
+- [MongoDB](https://www.mongodb.com/download-center) Currently version 4.0
+- [Redis (Windows)](https://github.com/MSOpenTech/redis/releases/tag/win-3.2.100) [Redis (Unix)](https://redis.io/download)
 
 ## Getting Started
+
 Once you've installed the required tools:
 
 1. `git clone https://github.com/Musare/MusareNode.git`
@@ -47,37 +57,62 @@ Once you've installed the required tools:
 
 3. `cp backend/config/template.json backend/config/default.json`
 
-	Values:  
-   	The `secret` key can be whatever. It's used by express's session module.  
-   	The `domain` should be the url where the site will be accessible from, usually `http://localhost` for non-Docker.  
-   	The `serverDomain` should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.  
-   	The `serverPort` should be the port where the backend will listen on, usually `8080` for non-Docker.  
-   	`isDocker` if you are using Docker or not.  
-   	The `apis.youtube.key` value can be obtained by setting up a [YouTube API Key](https://developers.google.com/youtube/v3/getting-started).  
-	To set up a GitHub OAuth Application, you need to fill in some value's. 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`.
-   	The `apis.recaptcha.secret` value can be obtained by setting up a [ReCaptcha Site](https://www.google.com/recaptcha/admin).  
-   	The `apis.github` values can be obtained by setting up a [GitHub OAuth Application](https://github.com/settings/developers).  
-   	The `apis.discord.token` is the token for the Discord bot.  
-   	The `apis.discord.loggingServer` is the Discord logging server id.  
-   	The `apis.discord.loggingChannel` is the Discord logging channel id.  
-   	The `apis.mailgun` values can be obtained by setting up a [Mailgun account](http://www.mailgun.com/).  
-   	The `redis.url` url should be left alone for Docker, and changed to `redis://localhost:6379/0` for non-Docker.
-   	The `redis.password` should be the Redis password you either put in your `startRedis.cmd` file for Windows, or `.env` for docker.
-   	The `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`.  
-   	The `cookie.domain` value should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.   
-   	The `cookie.secure` value 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`
 
-	Values:  
-   	The `serverDomain` should be the url where the backend will be accessible from, usually `http://localhost:8080` for non-Docker.   
-   	The `recaptcha.key` value can be obtained by setting up a [ReCaptcha Site](https://www.google.com/recaptcha/admin).  
-   	The `cookie.domain` value should be the ip or address you use to access the site, without protocols (http/https), so for example `localhost`.   
-   	The `cookie.secure` value should be `true` for SSL connections, and `false` for normal http connections.  
+   |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.|
 
-Now you have different paths here.
+5. Simply `cp .env.example .env` to setup your environment variables.
 
-####Docker
+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.
+
+We use snyk to test our dependencies / dev-dependencies for vulnerabilities.
+
+### Installing with Docker
+
+#### 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. 
+`COMPOSE_PROJECT_NAME` should be a unique name for this installation, especially if you have multiple instances of Musare on the same machine.
+`FRONTEND_MODE` should be either `dev` or `prod` (self-explanatory).
 
 1. Build the backend and frontend Docker images (from the main folder)
 
@@ -85,42 +120,21 @@ Now you have different paths here.
 
 2. Set up the MongoDB database
 
-	1. Disable auth
-	
-		In `docker-compose.yml` remove `--auth` from the line `command: "--auth"` for mongo.
-	
-	2. Start the database
-	
-		`docker-compose up mongo`
-		
-	3. Connect to Mongo
-	
-		`docker-compose exec mongo mongo admin`
-	
-	4. Create an admin user
-	
-		`db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'root', db: 'admin'}]})`
-		
-	5. Connect to the Musare database
-	
-		`use musare`
-		
-	6. Create the musare user
-	
-		`db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
-	
-	7. Exit
-	
-		`exit`
-	
-	8. Add back authentication
-	
-		In `docker-compose.yml` add back `--auth` on the line `command: ""` for mongo.
-	
-
-3. Start the databases and tools in the background, as we usually don't need to monitor these for errors
-
-   `docker-compose up -d mongo mongoclient redis`
+   1. Set the password for the admin/root user.
+
+      In `.env` set the environment variable of `MONGO_ROOT_PASSWORD`.
+
+   2. Set the password for the musare user (the one the backend will use).
+
+      In `.env` set the environment variable of `MONGO_USER_USERNAME` and `MONGO_USER_PASSWORD`.
+
+   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
+
+   `docker-compose up -d mongoclient redis`
 
 4. Start the backend and frontend in the foreground, so we can watch for errors during development
 
@@ -131,11 +145,18 @@ Now you have different paths here.
    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:
 
-   * Docker for Windows / Mac: This is just `localhost`
+   - Docker for Windows / Mac: This is just `localhost`
 
-   * Docker ToolBox: The output of `docker-machine ip default`
+   - Docker ToolBox: The output of `docker-machine ip default`
 
-####Non-docker
+If you are using linting extensions in IDEs/want to run `yarn lint`, you need to install the following locally (outside of Docker):
+
+   ```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.
 
@@ -143,57 +164,57 @@ Steps 1-4 are things you only have to do once. The steps to start servers follow
 
 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
-	
-	1. Start the database by executing the script `startMongo.cmd` you just made
-		
-	2. Connect to Mongo from a command prompt
-	
-		`mongo admin`
-	
-	3. Create an admin user
-	
-		`db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}]})`
-		
-	4. Connect to the Musare database
-	
-		`use musare`
-		
-	5. Create the musare user
-	
-		`db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
-	
-	6. Exit
-	
-		`exit`
-	
-	7. Add the authentication
-	
-		In `startMongo.cmd` add ` --auth` at the end of the first line
+
+    1. Start the database by executing the script `startMongo.cmd` you just made
+
+    2. Connect to Mongo from a command prompt
+
+       `mongo admin`
+
+    3. Create an admin user
+
+       `db.createUser({user: 'admin', pwd: 'PASSWORD_HERE', roles: [{role: 'userAdminAnyDatabase', db: 'admin'}]})`
+
+    4. Connect to the Musare database
+
+       `use musare`
+
+    5. Create the musare user
+
+       `db.createUser({user: 'musare', pwd: 'OTHER_PASSWORD_HERE', roles: [{role: 'readWrite', db: 'musare'}]})`
+
+    6. Exit
+
+       `exit`
+
+    7. Add the authentication
+
+       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.
 
 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
+### 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.
 
-2. In a command prompt with the pwd of frontend, run `npm run development-watch`
+2. In a command prompt with the pwd of frontend, run `yarn run dev`
 
 3. In a command prompt with the pwd of backend, run `nodemon`
 
@@ -216,24 +237,25 @@ of the following commands to give Docker Toolbox access to those files.
 
    `"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe" sharedfolder add default --name "d/Projects/MusareNode" --hostpath "D:\Projects\MusareNode" --automount`
 
-2. Now start the machine back up and ssh into it
+1. Now start the machine back up and ssh into it
 
    `docker-machine start default && docker-machine ssh default`
 
-3. Tell boot2docker to mount our volume at startup, by appending to its startup script
-	```bash
-	sudo tee -a /mnt/sda1/var/lib/boot2docker/profile >/dev/null <<EOF
+1. Tell boot2docker to mount our volume at startup, by appending to its startup script
+
+   ```bash
+   sudo tee -a /mnt/sda1/var/lib/boot2docker/profile >/dev/null <<EOF
 
-	mkdir -p /d/Projects/MusareNode
-	mount -t vboxsf -o uid=1000,gid=50 d/Projects/MusareNode /d/Projects/MusareNode
-	EOF
-	```
+   mkdir -p /d/Projects/MusareNode
+   mount -t vboxsf -o uid=1000,gid=50 d/Projects/MusareNode /d/Projects/MusareNode
+   EOF
+   ```
 
-4. Restart the docker machine so that it uses the new shared folder
+1. Restart the docker machine so that it uses the new shared folder
 
    `docker-machine restart default`
 
-5. You now should be good to go!
+1. You now should be good to go!
 
 ### Fixing the "couldn't connect to docker daemon" error
 
@@ -249,31 +271,47 @@ Run this command in your shell. You will have to do this command for every shell
 
 2. Install nodemon globally
 
-   `npm install nodemon -g`
+   `yarn global add nodemon`
 
 3. Install webpack globally
 
-   `npm install webpack -g`
+   `yarn global add webpack`
 
 4. Install node-gyp globally (first check out https://github.com/nodejs/node-gyp#installation)
 
-   `npm install node-gyp -g`.
+   `yarn global add node-gyp`.
 
-5. In both `frontend` and `backend` folders, do `npm install`.
+5. Run `yarn run bootstrap` to install dependencies and dev-dependencies for both the frontend and backend.
 
-6. `nodemon backend/index.js`
+6. Either execute `yarn run dev:frontend` and `yarn run dev:backend` separately, or in parallel with `yarn dev`.
 
 ### Calling Toasts
 
 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 });
 ```
 
-## Contact
+### 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
+db.auth("MUSAREDBUSER","MUSAREDBPASSWORD")
+db.users.update({username: "USERNAME"}, {$set: {role: "admin"}})
+```
+
+### Adding a package
+
+We use lerna to add an additional package to either the frontend or the backend.
 
-There are multiple ways to contact us. You can send an email to [musaremusic@gmail.com](musaremusic@gmail.com) or [krisvos130@gmail.com](krisvos130@gmail.com).
+For example, this is how we would to add the `webpack-bundle-analyser` package as a dev-dependency to the frontend:
 
-You can also message us on [Facebook](https://www.facebook.com/MusareMusic), [Twitter](https://twitter.com/MusareApp) or on our [Discord](https://discord.gg/Y5NxYGP).
+```bash
+npx lerna add webpack-bundle-analyser --scope=musare-frontend --dev
+```

+ 2 - 1
backend/Dockerfile

@@ -3,6 +3,7 @@ FROM node
 RUN apt-get update
 
 RUN npm install -g nodemon
+RUN npm install -g snyk
 
 RUN mkdir -p /opt
 WORKDIR /opt
@@ -12,4 +13,4 @@ RUN npm install
 
 EXPOSE 80
 
-CMD npm run development
+CMD npm run docker:dev

+ 21 - 8
backend/config/template.json

@@ -1,10 +1,12 @@
 {
-	"secret": "",
-	"domain": "",
+	"mode": "development",
+	"secret": "default",
+	"domain": "http://localhost",
 	"frontendPort": 80,
-	"serverDomain": "",
+	"serverDomain": "http://localhost:8080",
   	"serverPort": 8080,
-  	"isDocker": true,
+	"isDocker": true,
+	"fancyConsole": true,
 	"apis": {
 		"youtube": {
 			"key": ""
@@ -25,7 +27,17 @@
 		"mailgun": {
 			"key": "",
 			"domain": "",
-		  	"enabled": true
+		  	"enabled": false
+		},
+		"spotify": {
+			"client": "",
+			"secret": "",
+			"enabled": false
+		},
+		"discogs": {
+			"client": "",
+			"secret": "",
+			"enabled": false
 		}
 	},
 	"cors": {
@@ -40,10 +52,11 @@
 	    "password": "PASSWORD"
 	},
   	"mongo": {
-	  	"url": "mongodb://musare:PASSWORD@mongo:27017/musare"
+	  	"url": "mongodb://musare:OTHER_PASSWORD_HERE@mongo:27017/musare"
 	},
   	"cookie": {
-	  	"domain": "",
-	  	"secure": false
+	  	"domain": "localhost",
+		"secure": false,
+		"SIDname": "SID"  
 	}
 }

+ 85 - 0
backend/core.js

@@ -0,0 +1,85 @@
+const EventEmitter = require('events');
+
+const bus = new EventEmitter();
+
+bus.setMaxListeners(1000);
+
+module.exports = class {
+	constructor(name, moduleManager) {
+		this.name = name;
+		this.moduleManager = moduleManager;
+		this.lockdown = false;
+		this.dependsOn = [];
+		this.eventHandlers = [];
+		this.state = "NOT_INITIALIZED";
+		this.stage = 0;
+		this.lastTime = 0;
+		this.totalTimeInitialize = 0;
+		this.timeDifferences = [];
+		this.failed = false;
+	}
+
+	_initialize() {
+		this.logger = this.moduleManager.modules["logger"];
+		this.setState("INITIALIZING");
+
+		this.initialize().then(() => {
+			this.setState("INITIALIZED");
+			this.setStage(0);
+			this.moduleManager.printStatus();
+		}).catch(async (err) => {			
+			this.failed = true;
+
+			this.logger.error(err.stack);
+
+			this.moduleManager.aModuleFailed(this);
+		});
+	}
+
+	_onInitialize() {
+		return new Promise(resolve => bus.once(`stateChange:${this.name}:INITIALIZED`, resolve));
+	}
+
+	_isInitialized() {
+		return new Promise(resolve => {
+			if (this.state === "INITIALIZED") resolve();
+		});
+	}
+
+	_isNotLocked() {
+		return new Promise((resolve, reject) => {
+			if (this.state === "LOCKDOWN") reject();
+			else resolve();
+		});
+	}
+
+	setState(state) {
+		this.state = state;
+		bus.emit(`stateChange:${this.name}:${state}`);
+		this.logger.info(`MODULE_STATE`, `${state}: ${this.name}`);
+	}
+
+	setStage(stage) {
+		if (stage !== 1)
+			this.totalTimeInitialize += (Date.now() - this.lastTime);
+		//this.timeDifferences.push(this.stage + ": " + (Date.now() - this.lastTime) + "ms");
+		this.timeDifferences.push(Date.now() - this.lastTime);
+
+		this.lastTime = Date.now();
+		this.stage = stage;
+		this.moduleManager.printStatus();
+	}
+
+	_validateHook() {
+		return Promise.race([this._onInitialize(), this._isInitialized()]).then(
+			() => this._isNotLocked()
+		);
+	}
+
+	_lockdown() {
+		if (this.lockdown) return;
+		this.lockdown = true;
+		this.setState("LOCKDOWN");
+		this.moduleManager.printStatus();
+	}
+}

+ 177 - 214
backend/index.js

@@ -1,233 +1,196 @@
 'use strict';
 
+const util = require("util");
+
 process.env.NODE_CONFIG_DIR = `${__dirname}/config`;
 
-const async = require('async');
-const fs = require('fs');
-
-
-const Discord = require("discord.js");
-const client = new Discord.Client();
-const db = require('./logic/db');
-const app = require('./logic/app');
-const mail = require('./logic/mail');
-const api = require('./logic/api');
-const io = require('./logic/io');
-const stations = require('./logic/stations');
-const songs = require('./logic/songs');
-const playlists = require('./logic/playlists');
-const cache = require('./logic/cache');
-const notifications = require('./logic/notifications');
-const punishments = require('./logic/punishments');
-const logger = require('./logic/logger');
-const tasks = require('./logic/tasks');
-const config = require('config');
-
-let currentComponent;
-let initializedComponents = [];
-let lockdownB = false;
+const config = require("config");
 
 process.on('uncaughtException', err => {
-	if (lockdownB || err.code === 'ECONNREFUSED' || err.code === 'UNCERTAIN_STATE') return;
+	if (err.code === 'ECONNREFUSED' || err.code === 'UNCERTAIN_STATE') return;
 	console.log(`UNCAUGHT EXCEPTION: ${err.stack}`);
 });
 
-const getError = (err) => {
-	let error = 'An error occurred.';
-	if (typeof err === "string") error = err;
-	else if (err.message) {
-		if (err.message !== 'Validation failed') error = err.message;
-		else error = err.errors[Object.keys(err.errors)].message;
+const fancyConsole = config.get("fancyConsole");
+
+class ModuleManager {
+	constructor() {
+		this.modules = {};
+		this.modulesInitialized = 0;
+		this.totalModules = 0;
+		this.modulesLeft = [];
+		this.i = 0;
+		this.lockdown = false;
+		this.fancyConsole = fancyConsole;
 	}
-	return error;
-};
-
-client.on('ready', () => {
-	discordClientCBS.forEach((cb) => {
-		cb();
-	});
-	discordClientCBS = [];
-	console.log(`Logged in to Discord as ${client.user.username}#${client.user.discriminator}`);
-});
 
-client.on('disconnect', (err) => {
-	console.log(`Discord disconnected. Code: ${err.code}.`);
-});
+	addModule(moduleName) {
+		console.log("add module", moduleName);
+		const moduleClass = new require(`./logic/${moduleName}`);
+		this.modules[moduleName] = new moduleClass(moduleName, this);
+		this.totalModules++;
+		this.modulesLeft.push(moduleName);
+	}
 
-client.login(config.get('apis.discord.token'));
-
-let discordClientCBS = [];
-const getDiscordClient = (cb) => {
-	if (client.status === 0) return cb();
-	else discordClientCBS.push(cb);
-};
-
-const logToDiscord = (message, color, type, critical, extraFields, cb = ()=>{}) => {
-	getDiscordClient(() => {
-		let richEmbed = new Discord.RichEmbed();
-		richEmbed.setAuthor("Musare Logger", config.get("domain")+"/favicon-194x194.png", config.get("domain"));
-		richEmbed.setColor(color);
-		richEmbed.setDescription(message);
-		//richEmbed.setFooter("Footer", "https://musare.com/favicon-194x194.png");
-		//richEmbed.setImage("https://musare.com/favicon-194x194.png");
-		//richEmbed.setThumbnail("https://musare.com/favicon-194x194.png");
-		richEmbed.setTimestamp(new Date());
-		richEmbed.setTitle("MUSARE ALERT");
-		richEmbed.setURL(config.get("domain"));
-		richEmbed.addField("Type:", type, true);
-		richEmbed.addField("Critical:", (critical) ? 'True' : 'False', true);
-		extraFields.forEach((extraField) => {
-			richEmbed.addField(extraField.name, extraField.value, extraField.inline);
-		});
-		client.channels.get(config.get('apis.discord.loggingChannel')).sendEmbed(richEmbed).then(() => {
-			cb();
-		}).then((reason) => {
-			cb(reason);
-		});
-	});
-};
-
-function lockdown() {
-	if (lockdownB) return;
-	lockdownB = true;
-	initializedComponents.forEach((component) => {
-		component._lockdown();
-	});
-	console.log("Backend locked down.");
-}
+	initialize() {
+		if (!this.modules["logger"]) return console.error("There is no logger module");
+		this.logger = this.modules["logger"];
+		if (this.fancyConsole) {
+			this.replaceConsoleWithLogger();
+			this.logger.reservedLines = Object.keys(this.modules).length + 5;
+		}
+		
+		for (let moduleName in this.modules) {
+			let module = this.modules[moduleName];
+			if (this.lockdown) break;
 
-function errorCb(message, err, component) {
-	err = getError(err);
-	lockdown();
-	logToDiscord(message, "#FF0000", message, true, [{name: "Error:", value: err, inline: false}, {name: "Component:", value: component, inline: true}]);
-}
+			module._onInitialize().then(() => {
+				this.moduleInitialized(moduleName);
+			});
+
+			let dependenciesInitializedPromises = [];
+			
+			module.dependsOn.forEach(dependencyName => {
+				let dependency = this.modules[dependencyName];
+				dependenciesInitializedPromises.push(dependency._onInitialize());
+			});
+
+			module.lastTime = Date.now();
 
-async.waterfall([
-
-	// setup our Redis cache
-	(next) => {
-		currentComponent = 'Cache';
-		cache.init(config.get('redis').url, config.get('redis').password, errorCb, () => {
-			next();
-		});
-	},
-
-	// setup our MongoDB database
-	(next) => {
-		initializedComponents.push(cache);
-		currentComponent = 'DB';
-		db.init(config.get("mongo").url, errorCb, next);
-	},
-
-	// setup the express server
-	(next) => {
-		initializedComponents.push(db);
-		currentComponent = 'App';
-		app.init(next);
-	},
-
-	// setup the mail
-	(next) => {
-		initializedComponents.push(app);
-		currentComponent = 'Mail';
-		mail.init(next);
-	},
-
-	// setup the socket.io server (all client / server communication is done over this)
-	(next) => {
-		initializedComponents.push(mail);
-		currentComponent = 'IO';
-		io.init(next);
-	},
-
-	// setup the punishment system
-	(next) => {
-		initializedComponents.push(io);
-		currentComponent = 'Punishments';
-		punishments.init(next);
-	},
-
-	// setup the notifications
-	(next) => {
-		initializedComponents.push(punishments);
-		currentComponent = 'Notifications';
-		notifications.init(config.get('redis').url, config.get('redis').password, errorCb, next);
-	},
-
-	// setup the stations
-	(next) => {
-		initializedComponents.push(notifications);
-		currentComponent = 'Stations';
-		stations.init(next)
-	},
-
-	// setup the songs
-	(next) => {
-		initializedComponents.push(stations);
-		currentComponent = 'Songs';
-		songs.init(next)
-	},
-
-	// setup the playlists
-	(next) => {
-		initializedComponents.push(songs);
-		currentComponent = 'Playlists';
-		playlists.init(next)
-	},
-
-	// setup the API
-	(next) => {
-		initializedComponents.push(playlists);
-		currentComponent = 'API';
-		api.init(next)
-	},
-
-	// setup the logger
-	(next) => {
-		initializedComponents.push(api);
-		currentComponent = 'Logger';
-		logger.init(next)
-	},
-
-	// setup the tasks system
-	(next) => {
-		initializedComponents.push(logger);
-		currentComponent = 'Tasks';
-		tasks.init(next)
-	},
-
-	// setup the frontend for local setups
-	(next) => {
-		initializedComponents.push(tasks);
-		currentComponent = 'Windows';
-		if (!config.get("isDocker")) {
-			const express = require('express');
-			const app = express();
-			app.listen(config.get("frontendPort"));
-			const rootDir = __dirname.substr(0, __dirname.lastIndexOf("backend")) + "frontend/build/";
-
-			app.use(express.static(rootDir, {
-				setHeaders: function(res, path) {
-					console.log(path);
-					if (path.indexOf('.html') !== -1) res.setHeader('Cache-Control', 'public, max-age=0');
-					else res.setHeader('Cache-Control', 'public, max-age=2628000');
-				}
-			}));
-
-			app.get("/*", (req, res) => {
-				res.sendFile(rootDir + "index.html");
+			Promise.all(dependenciesInitializedPromises).then((res, res2) => {
+				if (this.lockdown) return;
+				this.logger.info("MODULE_MANAGER", `${moduleName} dependencies have been completed`);
+				module._initialize();
 			});
 		}
-		if (lockdownB) return;
-		next();
 	}
-], (err) => {
-	if (err && err !== true) {
-		lockdown();
-		logToDiscord("An error occurred while initializing the backend server.", "#FF0000", "Startup error", true, [{name: "Error:", value: err, inline: false}, {name: "Component:", value: currentComponent, inline: true}]);
-		console.error('An error occurred while initializing the backend server');
-	} else {
-		logToDiscord("The backend server started successfully.", "#00AA00", "Startup", false, []);
-		console.info('Backend server has been successfully started');
+
+	async printStatus() {
+		try { await Promise.race([this.logger._onInitialize(), this.logger._isInitialized()]); } catch { return; }
+		if (!this.fancyConsole) return;
+		
+		let colors = this.logger.colors;
+
+		const rows = process.stdout.rows;
+
+		process.stdout.cursorTo(0, rows - this.logger.reservedLines);
+		process.stdout.clearScreenDown();
+
+		process.stdout.cursorTo(0, (rows - this.logger.reservedLines) + 2);
+
+		process.stdout.write(`${colors.FgYellow}Modules${colors.FgWhite}:\n`);
+
+		for (let moduleName in this.modules) {
+			let module = this.modules[moduleName];
+			let tabsAmount = Math.max(0, Math.ceil(2 - (moduleName.length / 8)));
+
+			let tabs = Array(tabsAmount).fill(`\t`).join("");
+
+			let timing = module.timeDifferences.map((timeDifference) => {
+				return `${colors.FgMagenta}${timeDifference}${colors.FgCyan}ms${colors.FgWhite}`;
+			}).join(", ");
+
+			let stateColor;
+			if (module.state === "NOT_INITIALIZED") stateColor = colors.FgWhite;
+			else if (module.state === "INITIALIZED") stateColor = colors.FgGreen;
+			else if (module.state === "LOCKDOWN" && !module.failed) stateColor = colors.FgRed;
+			else if (module.state === "LOCKDOWN" && module.failed) stateColor = colors.FgMagenta;
+			else stateColor = colors.FgYellow;
+			
+			process.stdout.write(`${moduleName}${tabs}${stateColor}${module.state}\t${colors.FgYellow}Stage: ${colors.FgRed}${module.stage}${colors.FgWhite}. ${colors.FgYellow}Timing${colors.FgWhite}: [${timing}]${colors.FgWhite}${colors.FgWhite}. ${colors.FgYellow}Total time${colors.FgWhite}: ${colors.FgRed}${module.totalTimeInitialize}${colors.FgCyan}ms${colors.Reset}\n`);
+		}
+	}
+
+	moduleInitialized(moduleName) {
+		this.modulesInitialized++;
+		this.modulesLeft.splice(this.modulesLeft.indexOf(moduleName), 1);
+
+		this.logger.info("MODULE_MANAGER", `Initialized: ${this.modulesInitialized}/${this.totalModules}.`);
+
+		if (this.modulesLeft.length === 0) this.allModulesInitialized();
+	}
+
+	allModulesInitialized() {
+		this.logger.success("MODULE_MANAGER", "All modules have started!");
+		this.modules["discord"].sendAdminAlertMessage("The backend server started successfully.", "#00AA00", "Startup", false, []);
+	}
+
+	aModuleFailed(failedModule) {
+		this.logger.error("MODULE_MANAGER", `A module has failed, locking down. Module: ${failedModule.name}`);
+		this.modules["discord"].sendAdminAlertMessage(`The backend server failed to start due to a failing module: ${failedModule.name}.`, "#AA0000", "Startup", false, []);
+
+		this._lockdown();
+	}
+
+	replaceConsoleWithLogger() {
+		this.oldConsole = {
+			log: console.log,
+			debug: console.debug,
+			info: console.info,
+			warn: console.warn,
+			error: console.error
+		};
+		console.log = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+		console.debug = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+		console.info = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+		console.warn = (...args) => this.logger.debug(args.map(arg => util.format(arg)));
+		console.error = (...args) => this.logger.error("CONSOLE", args.map(arg => util.format(arg)));
+	}
+
+	replaceLoggerWithConsole() {
+		console.log = this.oldConsole.log;
+		console.debug = this.oldConsole.debug;
+		console.info = this.oldConsole.info;
+		console.warn = this.oldConsole.warn;
+		console.error = this.oldConsole.error;
+	}
+
+	_lockdown() {
+		this.lockdown = true;
+		
+		for (let moduleName in this.modules) {
+			let module = this.modules[moduleName];
+			if (module.lockdownImmune) continue;
+			module._lockdown();
+		}
 	}
+}
+
+const moduleManager = new ModuleManager();
+
+module.exports = moduleManager;
+
+moduleManager.addModule("cache");
+moduleManager.addModule("db");
+moduleManager.addModule("mail");
+moduleManager.addModule("api");
+moduleManager.addModule("app");
+moduleManager.addModule("discord");
+moduleManager.addModule("io");
+moduleManager.addModule("logger");
+moduleManager.addModule("notifications");
+moduleManager.addModule("playlists");
+moduleManager.addModule("punishments");
+moduleManager.addModule("songs");
+moduleManager.addModule("spotify");
+moduleManager.addModule("stations");
+moduleManager.addModule("tasks");
+moduleManager.addModule("utils");
+
+moduleManager.initialize();
+
+process.stdin.on("data", function (data) {
+    if(data.toString() === "lockdown\r\n"){
+        console.log("Locking down.");
+       	moduleManager._lockdown();
+    }
 });
+
+
+if (fancyConsole) {
+	const rows = process.stdout.rows;
+
+	for(let i = 0; i < rows; i++) {
+		process.stdout.write("\n");
+	}
+}

+ 59 - 12
backend/logic/actions/apis.js

@@ -1,11 +1,14 @@
 'use strict';
 
-const 	request = require('request'),
-		config  = require('config'),
-		async 	= require('async'),
-		utils 	= require('../utils'),
-		logger 	= require('../logger'),
-		hooks 	= require('./hooks');
+const request = require("request");
+const config = require("config");
+const async = require("async");
+
+const hooks = require('./hooks');
+const moduleManager = require("../../index");
+
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
 
 module.exports = {
 
@@ -34,9 +37,11 @@ module.exports = {
 			(res, body, next) => {
 				next(null, JSON.parse(body));
 			}
-		], (err, data) => {
-			if (err) {
-				err = utils.getError(err);
+		], async (err, data) => {
+			console.log(data.error);
+			if (err || data.error) {
+				if (!err) err = data.error.message;
+				err = await utils.getError(err);
 				logger.error("APIS_SEARCH_YOUTUBE", `Searching youtube failed with query "${query}". "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -53,17 +58,59 @@ 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});
 		});
 	}),
 
+	/**
+	 * Gets Discogs data
+	 *
+	 * @param session
+	 * @param query - the query
+	 * @param cb
+	 */
+	searchDiscogs: hooks.adminRequired((session, query, page, cb) => {
+		async.waterfall([
+			(next) => {
+				const params = [
+					`q=${encodeURIComponent(query)}`,
+					`per_page=20`,
+					`page=${page}`
+				].join('&');
+		
+				const options = {
+					url: `https://api.discogs.com/database/search?${params}`,
+					headers: {
+						"User-Agent": "Request",
+						"Authorization": `Discogs key=${config.get("apis.discogs.client")}, secret=${config.get("apis.discogs.secret")}`
+					}
+				};
+		
+				request(options, (err, res, body) => {
+					if (err) next(err);
+					body = JSON.parse(body);
+					next(null, body);
+					if (body.error) next(body.error);
+				});
+			}
+		], async (err, body) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("APIS_SEARCH_DISCOGS", `Searching discogs failed with query "${query}". "${err}"`);
+				return cb({status: 'failure', message: err});
+			}
+			logger.success('APIS_SEARCH_DISCOGS', `User "${session.userId}" searched Discogs succesfully for query "${query}".`);
+			cb({status: 'success', results: body.results, pages: body.pagination.pages});
+		});
+	}),
+
 	/**
 	 * Joins a room
 	 *
@@ -86,7 +133,7 @@ module.exports = {
 	 * @param cb
 	 */
 	joinAdminRoom: hooks.adminRequired((session, page, cb) => {
-		if (page === 'queue' || page === 'songs' || page === 'stations' || page === 'reports' || page === 'news' || page === 'users' || page === 'statistics') {
+		if (page === 'queue' || page === 'songs' || page === 'stations' || page === 'reports' || page === 'news' || page === 'users' || page === 'statistics' || page === 'punishments') {
 			utils.socketJoinRoom(session.socketId, `admin.${page}`);
 		}
 		cb({});

+ 9 - 7
backend/logic/actions/hooks/adminRequired.js

@@ -1,9 +1,12 @@
-const cache = require('../../cache');
-const db = require('../../db');
-const utils = require('../../utils');
-const logger = require('../../logger');
 const async = require('async');
 
+const moduleManager = require("../../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+
 module.exports = function(next) {
 	return function(session) {
 		let args = [];
@@ -23,14 +26,13 @@ module.exports = function(next) {
 				if (user.role !== 'admin') return next('Insufficient permissions.');
 				next();
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.info("ADMIN_REQUIRED", `User failed to pass admin required check. "${err}"`);
 				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);
 		});
 	}

+ 8 - 6
backend/logic/actions/hooks/loginRequired.js

@@ -1,8 +1,11 @@
-const cache = require('../../cache');
-const utils = require('../../utils');
-const logger = require('../../logger');
 const async = require('async');
 
+const moduleManager = require("../../../index");
+
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+
 module.exports = function(next) {
 	return function(session) {
 		let args = [];
@@ -17,14 +20,13 @@ module.exports = function(next) {
 				this.session = session;
 				next();
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.info("LOGIN_REQUIRED", `User failed to pass login required check.`);
 				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);
 		});
 	}

+ 10 - 8
backend/logic/actions/hooks/ownerRequired.js

@@ -1,9 +1,12 @@
-const cache = require('../../cache');
-const db = require('../../db');
-const utils = require('../../utils');
-const logger = require('../../logger');
 const async = require('async');
-const stations = require('../../stations');
+
+const moduleManager = require("../../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const stations = moduleManager.modules["stations"];
 
 module.exports = function(next) {
 	return function(session, stationId) {
@@ -29,14 +32,13 @@ module.exports = function(next) {
 				if (station.type === 'community' && station.owner === session.userId) return next(true);
 				next('Invalid permissions.');
 			}
-		], (err) => {
+		], async (err) => {
 			if (err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.info("OWNER_REQUIRED", `User failed to pass owner required check for station "${stationId}". "${err}"`);
 				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);
 		});
 	}

+ 24 - 23
backend/logic/actions/news.js

@@ -2,11 +2,13 @@
 
 const async = require('async');
 
-const db = require('../db');
-const cache = require('../cache');
-const utils = require('../utils');
-const logger = require('../logger');
 const hooks = require('./hooks');
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
 
 cache.sub('news.create', news => {
 	utils.socketsFromUser(news.createdBy, sockets => {
@@ -45,9 +47,9 @@ module.exports = {
 			(next) => {
 				db.models.news.find({}).sort({ createdAt: 'desc' }).exec(next);
 			}
-		], (err, news) => {
+		], async (err, news) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("NEWS_INDEX", `Indexing news failed. "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -62,18 +64,17 @@ 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);
 			}
-		], (err, news) => {
+		], async (err, news) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("NEWS_CREATE", `Creating news failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			}
@@ -94,9 +95,9 @@ module.exports = {
 			(next) => {
 				db.models.news.findOne({}).sort({ createdAt: 'desc' }).exec(next);
 			}
-		], (err, news) => {
+		], async (err, news) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("NEWS_NEWEST", `Getting the latest news failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			}
@@ -114,15 +115,15 @@ module.exports = {
 	 */
 	//TODO Pass in an id, not an object
 	//TODO Fix this
-	remove: hooks.adminRequired((session, news, cb, userId) => {
-		db.models.news.remove({ _id: news._id }, err => {
+	remove: hooks.adminRequired((session, news, cb) => {
+		db.models.news.deleteOne({ _id: news._id }, async err => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("NEWS_REMOVE", `Removing news "${news._id}" failed for user "${userId}". "${err}"`);
+				err = await utils.getError(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' });
 			}
 		});
@@ -137,15 +138,15 @@ module.exports = {
 	 * @param {Function} cb - gets called with the result
 	 */
 	//TODO Fix this
-	update: hooks.adminRequired((session, _id, news, cb, userId) => {
-		db.models.news.update({ _id }, news, { upsert: true }, err => {
+	update: hooks.adminRequired((session, _id, news, cb) => {
+		db.models.news.updateOne({ _id }, news, { upsert: true }, async err => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("NEWS_UPDATE", `Updating news "${_id}" failed for user "${userId}". "${err}"`);
+				err = await utils.getError(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' });
 			}
 		});

+ 96 - 104
backend/logic/actions/playlists.js

@@ -1,14 +1,16 @@
 'use strict';
 
-const db = require('../db');
-const io = require('../io');
-const cache = require('../cache');
-const utils = require('../utils');
-const logger = require('../logger');
-const hooks = require('./hooks');
 const async = require('async');
-const playlists = require('../playlists');
-const songs = require('../songs');
+
+const hooks = require('./hooks');
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const playlists = moduleManager.modules["playlists"];
+const songs = moduleManager.modules["songs"];
 
 cache.sub('playlist.create', playlistId => {
 	playlists.getPlaylist(playlistId, (err, playlist) => {
@@ -78,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]);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("PLAYLIST_GET_FIRST_SONG", `Getting the first song of playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				err = await utils.getError(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
@@ -109,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);
 			}
-		], (err, playlists) => {
+		], async (err, playlists) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("PLAYLIST_INDEX_FOR_USER", `Indexing playlists for user "${userId}" failed. "${err}"`);
+				err = await utils.getError(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
@@ -136,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) => {
@@ -150,20 +149,22 @@ let lib = {
 				db.models.playlist.create({
 					displayName,
 					songs,
-					createdBy: userId,
+					createdBy: session.userId,
 					createdAt: Date.now()
 				}, next);
 			}
 
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("PLAYLIST_CREATE", `Creating private playlist failed for user "${userId}". "${err}"`);
+				err = await utils.getError(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}".`);
-			cb({ 'status': 'success', 'message': 'Successfully created playlist' });
+			logger.success("PLAYLIST_CREATE", `Successfully created private playlist for user "${session.userId}".`);
+			cb({ status: 'success', message: 'Successfully created playlist', data: {
+				_id: playlist._id
+			} });
 		});
 	}),
 
@@ -173,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);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("PLAYLIST_GET", `Getting private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				err = await utils.getError(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
@@ -207,24 +207,23 @@ 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.update({ _id: playlistId, createdBy: userId }, playlist, {runValidators: true}, next);
+				db.models.playlist.updateOne({ _id: playlistId, createdBy: session.userId }, playlist, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next)
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("PLAYLIST_UPDATE", `Updating private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				err = await utils.getError(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
@@ -239,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');
@@ -270,7 +268,7 @@ let lib = {
 				});
 			},
 			(newSong, next) => {
-				db.models.playlist.update({_id: playlistId}, {$push: {songs: newSong}}, {runValidators: true}, (err) => {
+				db.models.playlist.updateOne({_id: playlistId}, {$push: {songs: newSong}}, {runValidators: true}, (err) => {
 					if (err) return next(err);
 					playlists.updatePlaylist(playlistId, (err, playlist) => {
 						next(err, playlist, newSong);
@@ -278,14 +276,14 @@ let lib = {
 				});
 			}
 		],
-		(err, playlist, newSong) => {
+		async (err, playlist, newSong) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("PLAYLIST_ADD_SONG", `Adding song "${songId}" to private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				err = await utils.getError(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 });
 			}
 		});
@@ -298,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 => {
@@ -324,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);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("PLAYLIST_IMPORT", `Importing a YouTube playlist to private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				err = await utils.getError(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 });
 			}
 		});
@@ -346,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.');
@@ -361,21 +357,21 @@ let lib = {
 			},
 
 			(playlist, next) => {
-				if (!playlist || playlist.createdBy !== userId) return next('Playlist not found');
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {songId: songId}}}, next);
+				if (!playlist || playlist.createdBy !== session.userId) return next('Playlist not found');
+				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId: songId}}}, next);
 			},
 
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("PLAYLIST_REMOVE_SONG", `Removing song "${songId}" from private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				err = await utils.getError(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 });
 			}
 		});
@@ -387,25 +383,24 @@ 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.update({ _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) => {
 				playlists.updatePlaylist(playlistId, next);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("PLAYLIST_UPDATE_DISPLAY_NAME", `Updating display name to "${displayName}" for private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				err = await utils.getError(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' });
 		});
 	}),
@@ -417,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();
@@ -437,14 +431,14 @@ let lib = {
 			},
 
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
+				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
 					if (err) return next(err);
 					return next(null, song);
 				});
 			},
 
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {
+				db.models.playlist.updateOne({_id: playlistId}, {
 					$push: {
 						songs: {
 							$each: [song],
@@ -457,14 +451,14 @@ let lib = {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = 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}"`);
+				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 "${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' });
 		});
 	}),
@@ -476,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();
@@ -496,14 +489,14 @@ let lib = {
 			},
 
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
+				db.models.playlist.updateOne({_id: playlistId}, {$pull: {songs: {songId}}}, (err) => {
 					if (err) return next(err);
 					return next(null, song);
 				});
 			},
 
 			(song, next) => {
-				db.models.playlist.update({_id: playlistId}, {
+				db.models.playlist.updateOne({_id: playlistId}, {
 					$push: {
 						songs: song
 					}
@@ -513,14 +506,14 @@ let lib = {
 			(res, next) => {
 				playlists.updatePlaylist(playlistId, next);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = 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}"`);
+				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 "${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' });
 		});
 	}),
@@ -531,21 +524,20 @@ 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);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("PLAYLIST_REMOVE", `Removing private playlist "${playlistId}" failed for user "${userId}". "${err}"`);
+				err = await utils.getError(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' });
 		});
 	})

+ 27 - 30
backend/logic/actions/punishments.js

@@ -1,17 +1,20 @@
 'use strict';
 
-const 	hooks 	    = require('./hooks'),
-	 	async 	    = require('async'),
-	 	logger 	    = require('../logger'),
-	 	utils 	    = require('../utils'),
-		cache       = require('../cache'),
-	 	db 	        = require('../db'),
-		punishments = require('../punishments');
+const async = require('async');
+
+const hooks = require('./hooks');
+const moduleManager = require("../../index");
+
+const logger = moduleManager.modules["logger"];
+const utils = moduleManager.modules["utils"];
+const cache = moduleManager.modules["cache"];
+const db = moduleManager.modules["db"];
+const punishments = moduleManager.modules["punishments"];
 
 cache.sub('ip.ban', data => {
+	utils.emitToRoom('admin.punishments', 'event:admin.punishment.added', data.punishment);
 	utils.socketsFromIP(data.ip, sockets => {
 		sockets.forEach(socket => {
-			socket.emit('keep.event:banned', data.punishment);
 			socket.disconnect(true);
 		});
 	});
@@ -30,9 +33,9 @@ module.exports = {
 			(next) => {
 				db.models.punishment.find({}, next);
 			}
-		], (err, punishments) => {
+		], async (err, punishments) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("PUNISHMENTS_INDEX", `Indexing punishments failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err});
 			}
@@ -49,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();
 			},
 
@@ -98,25 +100,20 @@ module.exports = {
 			},
 
 			(next) => {
-				punishments.addPunishment('banUserIp', value, reason, expiresAt, userId, next)
-			},
-
-			(punishment, next) => {
-				cache.pub('ip.ban', {ip: value, punishment});
-				next();
-			},
-		], (err) => {
+				punishments.addPunishment('banUserIp', value, reason, expiresAt, session.userId, next)
+			}
+		], async (err, punishment) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
-				logger.error("BAN_IP", `User ${userId} failed to ban IP address ${value} with the reason ${reason}. '${err}'`);
+				err = await utils.getError(err);
+				logger.error("BAN_IP", `User ${session.userId} failed to ban IP address ${value} with the reason ${reason}. '${err}'`);
 				cb({ status: 'failure', message: err });
-			} else {
-				logger.success("BAN_IP", `User ${userId} has successfully banned Ip address ${value} with the reason ${reason}.`);
-				cb({
-					status: 'success',
-					message: 'Successfully banned IP address.'
-				});
 			}
+			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',
+				message: 'Successfully banned IP address.'
+			});
 		});
 	}),
 

+ 102 - 60
backend/logic/actions/queueSongs.js

@@ -1,17 +1,20 @@
 'use strict';
 
-const db = require('../db');
-const utils = require('../utils');
-const logger = require('../logger');
-const notifications = require('../notifications');
-const cache = require('../cache');
-const async = require('async');
 const config = require('config');
+const async = require('async');
 const request = require('request');
+
 const hooks = require('./hooks');
 
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const cache = moduleManager.modules["cache"];
+
 cache.sub('queue.newSong', songId => {
-	db.models.queueSong.findOne({songId}, (err, song) => {
+	db.models.queueSong.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.queue', 'event:admin.queueSong.added', song);
 	});
 });
@@ -21,38 +24,32 @@ cache.sub('queue.removedSong', songId => {
 });
 
 cache.sub('queue.update', songId => {
-	db.models.queueSong.findOne({songId}, (err, song) => {
+	db.models.queueSong.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.queue', 'event:admin.queueSong.updated', song);
 	});
 });
 
-module.exports = {
+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);
 			}
-		], (err, songs) => {
+		], async (err, count) => {
 			if (err) {
-				err = 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
-					});
-				});
+				err = await utils.getError(err);
+				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);
 		});
 	}),
 
@@ -64,9 +61,18 @@ module.exports = {
 	 * @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);
 		});
 	}),
 
@@ -77,9 +83,8 @@ module.exports = {
 	 * @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);
@@ -91,16 +96,16 @@ module.exports = {
 				let $set = {};
 				for (let prop in updatedSong) if (updatedSong[prop] !== song[prop]) $set[prop] = updatedSong[prop]; updated = true;
 				if (!updated) return next('No properties changed');
-				db.models.queueSong.update({_id: songId}, {$set}, {runValidators: true}, next);
+				db.models.queueSong.updateOne({_id: songId}, {$set}, {runValidators: true}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("QUEUE_UPDATE", `Updating queuesong "${songId}" failed for user ${userId}. "${err}"`);
+				err = await  utils.getError(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.'});
 		});
 	}),
@@ -111,21 +116,20 @@ module.exports = {
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {String} songId - the id of the queuesong that gets removed
 	 * @param {Function} cb - gets called with the result
-	 * @param {String} userId - the userId automatically added by hooks
 	 */
 	remove: hooks.adminRequired((session, songId, cb, userId) => {
 		async.waterfall([
 			(next) => {
-				db.models.queueSong.remove({_id: songId}, next);
+				db.models.queueSong.deleteOne({_id: songId}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("QUEUE_REMOVE", `Removing queuesong "${songId}" failed for user ${userId}. "${err}"`);
+				err = await utils.getError(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.'});
 		});
 	}),
@@ -136,9 +140,8 @@ module.exports = {
 	 * @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([
@@ -155,34 +158,33 @@ module.exports = {
 			(song, next) => {
 				if (song) return next('This song has already been added.');
 				//TODO Add err object as first param of callback
-				console.log(52, songId);
 				utils.getSongFromYouTube(songId, (song) => {
+					song.duration = -1;
 					song.artists = [];
 					song.genres = [];
 					song.skipDuration = 0;
-					song.thumbnail = 'empty';
+					song.thumbnail = `${config.get("domain")}/assets/notes.png`;
 					song.explicit = false;
-					song.requestedBy = userId;
+					song.requestedBy = session.userId;
 					song.requestedAt = requestedAt;
 					next(null, song);
 				});
 			},
-			(newSong, next) => {
-				//TODO Add err object as first param of callback
-				utils.getSongFromSpotify(newSong, (song) => {
-					next(null, song);
+			/*(newSong, next) => {
+				utils.getSongFromSpotify(newSong, (err, song) => {
+					if (!song) next(null, newSong);
+					else next(err, song);
 				});
-			},
+			},*/
 			(newSong, next) => {
 				const song = new db.models.queueSong(newSong);
-				song.save((err, song) => {
-					console.log(err);
+				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;
@@ -193,15 +195,55 @@ module.exports = {
 					}
 				});
 			}
-		], (err, newSong) => {
+		], async (err, newSong) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("QUEUE_ADD", `Adding queuesong "${songId}" failed for user ${userId}. "${err}"`);
+				err = await utils.getError(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' });
 		});
+	}),
+
+	/**
+	 * Adds a set of songs to the queue
+	 *
+	 * @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
+	 */
+	addSetToQueue: hooks.loginRequired((session, url, cb) => {
+		async.waterfall([
+			(next) => {
+				utils.getPlaylistFromYouTube(url, songs => {
+					next(null, songs);
+				});
+			},
+			(songs, next) => {
+				let processed = 0;
+				function checkDone() {
+					if (processed === songs.length) next();
+				}
+				for (let s = 0; s < songs.length; s++) {
+					lib.add(session, songs[s].contentDetails.videoId, () => {
+						processed++;
+						checkDone();
+					});
+				}
+			}
+		], async (err) => {
+			if (err) {
+				err = await utils.getError(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 "${session.userId}".`);
+				cb({ status: 'success', message: 'Playlist has been successfully imported.' });
+			}
+		});
 	})
-};
+};
+
+module.exports = lib;

+ 33 - 26
backend/logic/actions/reports.js

@@ -2,12 +2,16 @@
 
 const async = require('async');
 
-const db = require('../db');
-const cache = require('../cache');
-const utils = require('../utils');
-const logger = require('../logger');
 const hooks = require('./hooks');
-const songs = require('../songs');
+
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const songs = moduleManager.modules["songs"];
+
 const reportableIssues = [
 	{
 		name: 'Video',
@@ -71,9 +75,9 @@ module.exports = {
 			(next) => {
 				db.models.report.find({ resolved: false }).sort({ released: 'desc' }).exec(next);
 			}
-		], (err, reports) => {
+		], async (err, reports) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REPORTS_INDEX", `Indexing reports failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err});
 			}
@@ -94,9 +98,9 @@ module.exports = {
 			(next) => {
 				db.models.report.findOne({ _id: reportId }).exec(next);
 			}
-		], (err, report) => {
+		], async (err, report) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REPORTS_FIND_ONE", `Finding report "${reportId}" failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			}
@@ -106,7 +110,7 @@ module.exports = {
 	}),
 
 	/**
-	 * Gets all reports for a songId
+	 * Gets all reports for a songId (_id)
 	 *
 	 * @param {Object} session - the session object automatically added by socket.io
 	 * @param {String} songId - the id of the song to index reports for
@@ -115,7 +119,7 @@ module.exports = {
 	getReportsForSong: hooks.adminRequired((session, songId, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.report.find({ songId, resolved: false }).sort({ released: 'desc' }).exec(next);
+				db.models.report.find({ song: { _id: songId }, resolved: false }).sort({ released: 'desc' }).exec(next);
 			},
 
 			(reports, next) => {
@@ -125,9 +129,9 @@ module.exports = {
 				}
 				next(null, data);
 			}
-		], (err, data) => {
+		], async (err, data) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("GET_REPORTS_FOR_SONG", `Indexing reports for song "${songId}" failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err});
 			} else {
@@ -143,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);
@@ -159,14 +162,14 @@ module.exports = {
 					else next();
 				});
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("REPORTS_RESOLVE", `Resolving report "${reportId}" failed by user "${userId}". "${err}"`);
+				err = await  utils.getError(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' });
 			}
 		});
@@ -178,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) => {
@@ -195,6 +197,11 @@ module.exports = {
 			(song, next) => {
 				if (!song) return next('Song not found.');
 
+				delete data.songId;
+				data.song = {
+					_id: song._id,
+					songId: song.songId
+				}
 
 				for (let z = 0; z < data.issues.length; z++) {
 					if (reportableIssues.filter(issue => { return issue.name == data.issues[z].name; }).length > 0) {
@@ -222,19 +229,19 @@ module.exports = {
 			},
 
 			(next) => {
-				data.createdBy = userId;
+				data.createdBy = session.userId;
 				data.createdAt = Date.now();
 				db.models.report.create(data, next);
 			}
 
-		], (err, report) => {
+		], async (err, report) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("REPORTS_CREATE", `Creating report for "${data.songId}" failed by user "${userId}". "${err}"`);
+				err = await utils.getError(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' });
 			}
 		});

+ 96 - 75
backend/logic/actions/songs.js

@@ -1,27 +1,30 @@
 'use strict';
 
-const db = require('../db');
-const io = require('../io');
-const songs = require('../songs');
-const cache = require('../cache');
 const async = require('async');
-const utils = require('../utils');
-const logger = require('../logger');
+
 const hooks = require('./hooks');
 const queueSongs = require('./queueSongs');
 
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const songs = moduleManager.modules["songs"];
+const cache = moduleManager.modules["cache"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+
 cache.sub('song.removed', songId => {
 	utils.emitToRoom('admin.songs', 'event:admin.song.removed', songId);
 });
 
 cache.sub('song.added', songId => {
-	db.models.song.findOne({songId}, (err, song) => {
+	db.models.song.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.songs', 'event:admin.song.added', song);
 	});
 });
 
 cache.sub('song.updated', songId => {
-	db.models.song.findOne({songId}, (err, song) => {
+	db.models.song.findOne({_id: songId}, (err, song) => {
 		utils.emitToRoom('admin.songs', 'event:admin.song.updated', song);
 	});
 });
@@ -73,11 +76,11 @@ module.exports = {
 	length: hooks.adminRequired((session, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.song.count({}, next);
+				db.models.song.countDocuments({}, next);
 			}
-		], (err, count) => {
+		], async (err, count) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_LENGTH", `Failed to get length from songs. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -96,18 +99,42 @@ module.exports = {
 	getSet: hooks.adminRequired((session, set, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.song.find({}).limit(15 * set).exec(next);
-			}
-		], (err, songs) => {
+				db.models.song.find({}).skip(15 * (set - 1)).limit(15).exec(next);
+			},
+		], async (err, songs) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_GET_SET", `Failed to get set from songs. "${err}"`);
 				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);
+		});
+	}),
+
+	/**
+	 * Gets a song
+	 *
+	 * @param session
+	 * @param songId - the song id
+	 * @param cb
+	 */
+	getSong: hooks.adminRequired((session, songId, cb) => {
+		console.log(songId);
+
+		async.waterfall([
+			(next) => {
+				db.models.song.findOne({ songId }).exec(next);
+			}
+		], async (err, song) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("SONGS_GET_SONG", `Failed to get song ${songId}. "${err}"`);
+				return cb({ status: 'failure', message: err });
+			} else {
+				logger.success("SONGS_GET_SONG", `Got song ${songId} successfully.`);
+				cb({ status: "success", data: song });
+			}
 		});
 	}),
 
@@ -122,15 +149,15 @@ module.exports = {
 	update: hooks.adminRequired((session, songId, song, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.song.update({_id: songId}, song, {runValidators: true}, next);
+				db.models.song.updateOne({_id: songId}, song, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				songs.updateSong(songId, next);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_UPDATE", `Failed to update song "${songId}". "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -150,15 +177,15 @@ module.exports = {
 	remove: hooks.adminRequired((session, songId, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.song.remove({_id: songId}, next);
+				db.models.song.deleteOne({_id: songId}, next);
 			},
 
 			(res, next) => {//TODO Check if res gets returned from above
 				cache.hdel('songs', songId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("SONGS_UPDATE", `Failed to remove song "${songId}". "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -174,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);
@@ -189,23 +215,23 @@ module.exports = {
 
 			(next) => {
 				const newSong = new db.models.song(song);
-				newSong.acceptedBy = userId;
+				newSong.acceptedBy = session.userId;
 				newSong.acceptedAt = Date.now();
 				newSong.save(next);
 			},
 
-			(next) => {
+			(res, next) => {
 				queueSongs.remove(session, song._id, () => {
 					next();
 				});
 			},
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("SONGS_ADD", `User "${userId}" failed to add song. "${err}"`);
+				err = await utils.getError(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.'});
 		});
@@ -218,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);
@@ -230,21 +255,21 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("SONGS_LIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
+				err = await utils.getError(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.update({_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.count({"liked": songId}, (err, likes) => {
+						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
-							db.models.user.count({"disliked": songId}, (err, dislikes) => {
+							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
 								if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
 								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
 									if (err) return cb({ status: 'failure', message: 'Something went wrong while liking this song.' });
@@ -266,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);
@@ -278,21 +302,21 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("SONGS_DISLIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
+				err = await utils.getError(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.update({_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.count({"liked": songId}, (err, likes) => {
+						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
-							db.models.user.count({"disliked": songId}, (err, dislikes) => {
+							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
 								if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
 								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err, res) => {
 									if (err) return cb({ status: 'failure', message: 'Something went wrong while disliking this song.' });
@@ -314,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);
@@ -326,27 +349,27 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("SONGS_UNDISLIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
+				err = await utils.getError(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.update({_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.count({"liked": songId}, (err, likes) => {
+						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({
 								status: 'failure',
 								message: 'Something went wrong while undisliking this song.'
 							});
-							db.models.user.count({"disliked": songId}, (err, dislikes) => {
+							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
 								if (err) return cb({
 									status: 'failure',
 									message: 'Something went wrong while undisliking this song.'
@@ -383,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);
@@ -395,23 +417,23 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("SONGS_UNLIKE", `User "${userId}" failed to like song ${songId}. "${err}"`);
+				err = await utils.getError(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.update({_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.count({"liked": songId}, (err, likes) => {
+						db.models.user.countDocuments({"liked": songId}, (err, likes) => {
 							if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
-							db.models.user.count({"disliked": songId}, (err, dislikes) => {
+							db.models.user.countDocuments({"disliked": songId}, (err, dislikes) => {
 								if (err) return cb({ status: 'failure', message: 'Something went wrong while undiking this song.' });
-								db.models.song.update({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
+								db.models.song.updateOne({_id: songId}, {$set: {likes: likes, dislikes: dislikes}}, (err) => {
 									if (err) return cb({ status: 'failure', message: 'Something went wrong while unliking this song.' });
 									songs.updateSong(songId, (err, song) => {});
 									cache.pub('song.unlike', JSON.stringify({ songId: oldSongId, userId: session.userId, likes: likes, dislikes: dislikes }));
@@ -431,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);
@@ -443,14 +464,14 @@ module.exports = {
 				if (!song) return next('No song found with that id.');
 				next(null, song);
 			}
-		], (err, song) => {
+		], async (err, song) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("SONGS_GET_OWN_RATINGS", `User "${userId}" failed to get ratings for ${songId}. "${err}"`);
+				err = await utils.getError(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',

+ 270 - 172
backend/logic/actions/stations.js

@@ -5,15 +5,18 @@ const async   = require('async'),
 	  config  = require('config'),
 	  _		  =  require('underscore')._;
 
-const io = require('../io');
-const db = require('../db');
-const cache = require('../cache');
-const notifications = require('../notifications');
-const utils = require('../utils');
-const logger = require('../logger');
-const stations = require('../stations');
-const songs = require('../songs');
 const hooks = require('./hooks');
+
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const cache = moduleManager.modules["cache"];
+const notifications = moduleManager.modules["notifications"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
+const stations = moduleManager.modules["stations"];
+const songs = moduleManager.modules["songs"];
+
 let userList = {};
 let usersPerStation = {};
 let usersPerStationCount = {};
@@ -29,39 +32,40 @@ setInterval(() => {
 	usersPerStationCount = {};
 
 	async.each(Object.keys(userList), function(socketId, next) {
-		let socket = utils.socketFromSession(socketId);
-		let stationId = userList[socketId];
-		if (!socket || Object.keys(socket.rooms).indexOf(`station.${stationId}`) === -1) {
-			if (stationsCountUpdated.indexOf(stationId) === -1) stationsCountUpdated.push(stationId);
-			if (stationsUpdated.indexOf(stationId) === -1) stationsUpdated.push(stationId);
-			delete userList[socketId];
-			return next();
-		}
-		if (!usersPerStationCount[stationId]) usersPerStationCount[stationId] = 0;
-		usersPerStationCount[stationId]++;
-		if (!usersPerStation[stationId]) usersPerStation[stationId] = [];
-
-		async.waterfall([
-			(next) => {
-				if (!socket.session || !socket.session.sessionId) return next('No session found.');
-				cache.hget('sessions', socket.session.sessionId, next);
-			},
-
-			(session, next) => {
-				if (!session) return next('Session not found.');
-				db.models.user.findOne({_id: session.userId}, next);
-			},
-
-			(user, next) => {
-				if (!user) return next('User not found.');
-				if (usersPerStation[stationId].indexOf(user.username) !== -1) return next('User already in the list.');
-				next(null, user.username);
-			}
-		], (err, username) => {
-			if (!err) {
-				usersPerStation[stationId].push(username);
+		utils.socketFromSession(socketId).then((socket) => {
+			let stationId = userList[socketId];
+			if (!socket || Object.keys(socket.rooms).indexOf(`station.${stationId}`) === -1) {
+				if (stationsCountUpdated.indexOf(stationId) === -1) stationsCountUpdated.push(stationId);
+				if (stationsUpdated.indexOf(stationId) === -1) stationsUpdated.push(stationId);
+				delete userList[socketId];
+				return next();
 			}
-			next();
+			if (!usersPerStationCount[stationId]) usersPerStationCount[stationId] = 0;
+			usersPerStationCount[stationId]++;
+			if (!usersPerStation[stationId]) usersPerStation[stationId] = [];
+
+			async.waterfall([
+				(next) => {
+					if (!socket.session || !socket.session.sessionId) return next('No session found.');
+					cache.hget('sessions', socket.session.sessionId, next);
+				},
+
+				(session, next) => {
+					if (!session) return next('Session not found.');
+					db.models.user.findOne({_id: session.userId}, next);
+				},
+
+				(user, next) => {
+					if (!user) return next('User not found.');
+					if (usersPerStation[stationId].indexOf(user.username) !== -1) return next('User already in the list.');
+					next(null, user.username);
+				}
+			], (err, username) => {
+				if (!err) {
+					usersPerStation[stationId].push(username);
+				}
+				next();
+			});
 		});
 		//TODO Code to show users
 	}, (err) => {
@@ -99,10 +103,10 @@ cache.sub('station.updateUsers', stationId => {
 cache.sub('station.updateUserCount', stationId => {
 	let count = usersPerStationCount[stationId] || 0;
 	utils.emitToRoom(`station.${stationId}`, "event:userCount.updated", count);
-	stations.getStation(stationId, (err, station) => {
+	stations.getStation(stationId, async (err, station) => {
 		if (station.privacy === 'public') utils.emitToRoom('home', "event:userCount.updated", stationId, count);
 		else {
-			let sockets = utils.getRoomSockets('home');
+			let sockets = await utils.getRoomSockets('home');
 			for (let socketId in sockets) {
 				let socket = sockets[socketId];
 				let session = sockets[socketId].session;
@@ -134,7 +138,9 @@ cache.sub('privatePlaylist.selected', data => {
 });
 
 cache.sub('station.pause', stationId => {
-	utils.emitToRoom(`station.${stationId}`, "event:stations.pause");
+	stations.getStation(stationId, (err, station) => {
+		utils.emitToRoom(`station.${stationId}`, "event:stations.pause", { pausedAt: station.pausedAt });
+	});
 });
 
 cache.sub('station.resume', stationId => {
@@ -159,14 +165,14 @@ cache.sub('station.remove', stationId => {
 });
 
 cache.sub('station.create', stationId => {
-	stations.initializeStation(stationId, (err, station) => {
+	stations.initializeStation(stationId, async (err, station) => {
 		station.userCount = usersPerStationCount[stationId] || 0;
 		if (err) console.error(err);
 		utils.emitToRoom('admin.stations', 'event:admin.station.added', station);
 		// TODO If community, check if on whitelist
 		if (station.privacy === 'public') utils.emitToRoom('home', "event:stations.created", station);
 		else {
-			let sockets = utils.getRoomSockets('home');
+			let sockets = await utils.getRoomSockets('home');
 			for (let socketId in sockets) {
 				let socket = sockets[socketId];
 				let session = sockets[socketId].session;
@@ -208,40 +214,27 @@ 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();
 					});
 				}, () => {
 					next(null, resultStations);
 				});
 			}
-		], (err, stations) => {
+		], async (err, stations) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_INDEX", `Indexing stations failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -251,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);
+				});
 			}
-		], (err, station) => {
+		], async (err, exists) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("STATIONS_FIND_BY_NAME", `Finding station "${stationName}" failed. "${err}"`);
+				err = await utils.getError(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});
 		});
 	},
 
@@ -291,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.');
@@ -305,9 +308,9 @@ module.exports = {
 				if (!playlist) return next('Playlist not found.');
 				next(null, playlist);
 			}
-		], (err, playlist) => {
+		], async (err, playlist) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_GET_PLAYLIST", `Getting playlist for station "${stationId}" failed. "${err}"`);
 				return cb({ status: 'failure', message: err });
 			} else {
@@ -333,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.');
-					}
-				], (err) => {
-					if (err === true) return next(null, station);
-					next(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);
 				});
 			},
 
@@ -366,6 +352,7 @@ module.exports = {
 					startedAt: station.startedAt,
 					paused: station.paused,
 					timePaused: station.timePaused,
+					pausedAt: station.pausedAt,
 					description: station.description,
 					displayName: station.displayName,
 					privacy: station.privacy,
@@ -395,9 +382,9 @@ module.exports = {
 					next(null, data);
 				});
 			}
-		], (err, data) => {
+		], async (err, data) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_JOIN", `Joining station "${stationName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -420,15 +407,15 @@ module.exports = {
 			},
 
 			(station, next) => {
-				db.models.station.update({ _id: stationId }, { $set: { locked: !station.locked} }, next);
+				db.models.station.updateOne({ _id: stationId }, { $set: { locked: !station.locked} }, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_LOCKED_STATUS", `Toggling the queue lock for station "${stationId}" failed. "${err}"`);
 				return cb({ status: 'failure', message: err });
 			} else {
@@ -445,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);
@@ -455,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.update({_id: stationId}, {$push: {"currentSong.skipVotes": userId}}, next)
+				db.models.station.updateOne({_id: stationId}, {$push: {"currentSong.skipVotes": session.userId}}, next)
 			},
 
 			(res, next) => {
@@ -479,9 +466,9 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				next(null, station);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_VOTE_SKIP", `Vote skipping station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -509,9 +496,9 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				next();
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_FORCE_SKIP", `Force skipping station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -540,9 +527,9 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				next();
 			}
-		], (err, userCount) => {
+		], async (err, userCount) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_LEAVE", `Leaving station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -564,19 +551,19 @@ module.exports = {
 	updateName: hooks.ownerRequired((session, stationId, newName, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.update({_id: stationId}, {$set: {name: newName}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {name: newName}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("STATIONS_UPDATE_DISPLAY_NAME", `Updating station "${stationId}" displayName to "${newName}" failed. "${err}"`);
+				err = await utils.getError(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.'});
 		});
 	}),
@@ -592,15 +579,15 @@ module.exports = {
 	updateDisplayName: hooks.ownerRequired((session, stationId, newDisplayName, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.update({_id: stationId}, {$set: {displayName: newDisplayName}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {displayName: newDisplayName}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_DISPLAY_NAME", `Updating station "${stationId}" displayName to "${newDisplayName}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -620,15 +607,15 @@ module.exports = {
 	updateDescription: hooks.ownerRequired((session, stationId, newDescription, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.update({_id: stationId}, {$set: {description: newDescription}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {description: newDescription}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_DESCRIPTION", `Updating station "${stationId}" description to "${newDescription}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -648,15 +635,15 @@ module.exports = {
 	updatePrivacy: hooks.ownerRequired((session, stationId, newPrivacy, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.update({_id: stationId}, {$set: {privacy: newPrivacy}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {privacy: newPrivacy}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_PRIVACY", `Updating station "${stationId}" privacy to "${newPrivacy}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -665,6 +652,62 @@ module.exports = {
 		});
 	}),
 
+	/**
+	 * Updates a station's genres
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newGenres - the new station genres
+	 * @param cb
+	 */
+	updateGenres: hooks.ownerRequired((session, stationId, newGenres, cb) => {
+		async.waterfall([
+			(next) => {
+				db.models.station.updateOne({_id: stationId}, {$set: {genres: newGenres}}, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], async (err) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("STATIONS_UPDATE_GENRES", `Updating station "${stationId}" genres to "${newGenres}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("STATIONS_UPDATE_GENRES", `Updated station "${stationId}" genres to "${newGenres}" successfully.`);
+			return cb({'status': 'success', 'message': 'Successfully updated the genres.'});
+		});
+	}),
+
+	/**
+	 * Updates a station's blacklisted genres
+	 *
+	 * @param session
+	 * @param stationId - the station id
+	 * @param newBlacklistedGenres - the new station blacklisted genres
+	 * @param cb
+	 */
+	updateBlacklistedGenres: hooks.ownerRequired((session, stationId, newBlacklistedGenres, cb) => {
+		async.waterfall([
+			(next) => {
+				db.models.station.updateOne({_id: stationId}, {$set: {blacklistedGenres: newBlacklistedGenres}}, {runValidators: true}, next);
+			},
+
+			(res, next) => {
+				stations.updateStation(stationId, next);
+			}
+		], async (err) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("STATIONS_UPDATE_BLACKLISTED_GENRES", `Updating station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("STATIONS_UPDATE_BLACKLISTED_GENRES", `Updated station "${stationId}" blacklisted genres to "${newBlacklistedGenres}" successfully.`);
+			return cb({'status': 'success', 'message': 'Successfully updated the blacklisted genres.'});
+		});
+	}),
+
 	/**
 	 * Updates a station's party mode
 	 *
@@ -682,15 +725,15 @@ module.exports = {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				if (station.partyMode === newPartyMode) return next('The party mode was already ' + ((newPartyMode) ? 'enabled.' : 'disabled.'));
-				db.models.station.update({_id: stationId}, {$set: {partyMode: newPartyMode}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {partyMode: newPartyMode}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_UPDATE_PARTY_MODE", `Updating station "${stationId}" party mode to "${newPartyMode}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -717,15 +760,15 @@ module.exports = {
 			(station, next) => {
 				if (!station) return next('Station not found.');
 				if (station.paused) return next('That station was already paused.');
-				db.models.station.update({_id: stationId}, {$set: {paused: true, pausedAt: Date.now()}}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {paused: true, pausedAt: Date.now()}}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_PAUSE", `Pausing station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -753,15 +796,15 @@ module.exports = {
 				if (!station) return next('Station not found.');
 				if (!station.paused) return next('That station is not paused.');
 				station.timePaused += (Date.now() - station.pausedAt);
-				db.models.station.update({_id: stationId}, {$set: {paused: false}, $inc: {timePaused: Date.now() - station.pausedAt}}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {paused: false}, $inc: {timePaused: Date.now() - station.pausedAt}}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_RESUME", `Resuming station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -781,15 +824,15 @@ module.exports = {
 	remove: hooks.ownerRequired((session, stationId, cb) => {
 		async.waterfall([
 			(next) => {
-				db.models.station.remove({ _id: stationId }, err => next(err));
+				db.models.station.deleteOne({ _id: stationId }, err => next(err));
 			},
 
 			(next) => {
 				cache.hdel('stations', stationId, err => next(err));
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_REMOVE", `Removing station "${stationId}" failed. "${err}"`);
 				return cb({ 'status': 'failure', 'message': err });
 			}
@@ -805,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([
@@ -824,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.');
@@ -848,15 +890,15 @@ module.exports = {
 						description,
 						type,
 						privacy: 'private',
-						owner: userId,
+						owner: session.userId,
 						queue: [],
 						currentSong: null
 					}, next);
 				}
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_CREATE", `Creating station failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -873,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);
@@ -884,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 {
@@ -895,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.');
 				});
 			},
@@ -928,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;
@@ -972,15 +1014,15 @@ module.exports = {
 			},
 
 			(song, next) => {
-				db.models.station.update({_id: stationId}, {$push: {queue: song}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$push: {queue: song}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_ADD_SONG_TO_QUEUE", `Adding song "${songId}" to station "${stationId}" queue failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -997,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.');
@@ -1019,15 +1060,15 @@ module.exports = {
 			},
 
 			(next) => {
-				db.models.station.update({_id: stationId}, {$pull: {queue: {songId: songId}}}, next);
+				db.models.station.updateOne({_id: stationId}, {$pull: {queue: {songId: songId}}}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_REMOVE_SONG_TO_QUEUE", `Removing song "${songId}" from station "${stationId}" queue failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -1057,14 +1098,15 @@ 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.');
 				});
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_GET_QUEUE", `Getting queue for station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -1080,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);
@@ -1098,15 +1139,15 @@ module.exports = {
 			(playlist, next) => {
 				if (!playlist) return next('Playlist not found.');
 				let currentSongIndex = (playlist.songs.length > 0) ? playlist.songs.length - 1 : 0;
-				db.models.station.update({_id: stationId}, {$set: {privatePlaylist: playlistId, currentSongIndex: currentSongIndex}}, {runValidators: true}, next);
+				db.models.station.updateOne({_id: stationId}, {$set: {privatePlaylist: playlistId, currentSongIndex: currentSongIndex}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
 				stations.updateStation(stationId, next);
 			}
-		], (err, station) => {
+		], async (err, station) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("STATIONS_SELECT_PRIVATE_PLAYLIST", `Selecting private playlist "${playlistId}" for station "${stationId}" failed. "${err}"`);
 				return cb({'status': 'failure', 'message': err});
 			}
@@ -1117,4 +1158,61 @@ module.exports = {
 			return cb({'status': 'success', 'message': 'Successfully selected playlist.'});
 		});
 	}),
+
+	favoriteStation: hooks.loginRequired((session, stationId, cb) => {
+		async.waterfall([
+			(next) => {
+				stations.getStation(stationId, next);
+			},
+
+			(station, next) => {
+				if (!station) return next('Station not found.');
+				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: session.userId }, { $addToSet: { favoriteStations: stationId } }, next);
+			},
+
+			(res, next) => {
+				if (res.nModified === 0) return next("The station was already favorited.");
+				next();
+			}
+		], async (err) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("FAVORITE_STATION", `Favoriting station "${stationId}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("FAVORITE_STATION", `Favorited station "${stationId}" successfully.`);
+			cache.pub('user.favoritedStation', { userId: session.userId, stationId });
+			return cb({'status': 'success', 'message': 'Succesfully favorited station.'});
+		});
+	}),
+
+	unfavoriteStation: hooks.loginRequired((session, stationId, cb) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.updateOne({ _id: session.userId }, { $pull: { favoriteStations: stationId } }, next);
+			},
+
+			(res, next) => {
+				if (res.nModified === 0) return next("The station wasn't favorited.");
+				next();
+			}
+		], async (err) => {
+			if (err) {
+				err = await utils.getError(err);
+				logger.error("UNFAVORITE_STATION", `Unfavoriting station "${stationId}" failed. "${err}"`);
+				return cb({'status': 'failure', 'message': err});
+			}
+			logger.success("UNFAVORITE_STATION", `Unfavorited station "${stationId}" successfully.`);
+			cache.pub('user.unfavoritedStation', { userId: session.userId, stationId });
+			return cb({'status': 'success', 'message': 'Succesfully unfavorited station.'});
+		});
+	}),
 };

+ 176 - 126
backend/logic/actions/users.js

@@ -4,15 +4,18 @@ const async = require('async');
 const config = require('config');
 const request = require('request');
 const bcrypt = require('bcrypt');
+const sha256 = require('sha256');
 
-const db = require('../db');
-const mail = require('../mail');
-const cache = require('../cache');
-const punishments = require('../punishments');
-const utils = require('../utils');
 const hooks = require('./hooks');
-const sha256 = require('sha256');
-const logger = require('../logger');
+
+const moduleManager = require("../../index");
+
+const db = moduleManager.modules["db"];
+const mail = moduleManager.modules["mail"];
+const cache = moduleManager.modules["cache"];
+const punishments = moduleManager.modules["punishments"];
+const utils = moduleManager.modules["utils"];
+const logger = moduleManager.modules["logger"];
 
 cache.sub('user.updateUsername', user => {
 	utils.socketsFromUser(user._id, sockets => {
@@ -71,6 +74,22 @@ cache.sub('user.ban', data => {
 	});
 });
 
+cache.sub('user.favoritedStation', data => {
+	utils.socketsFromUser(data.userId, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:user.favoritedStation', data.stationId);
+		});
+	});
+});
+
+cache.sub('user.unfavoritedStation', data => {
+	utils.socketsFromUser(data.userId, sockets => {
+		sockets.forEach(socket => {
+			socket.emit('event:user.unfavoritedStation', data.stationId);
+		});
+	});
+});
+
 module.exports = {
 
 	/**
@@ -84,9 +103,9 @@ module.exports = {
 			(next) => {
 				db.models.user.find({}).exec(next);
 			}
-		], (err, users) => {
+		], async (err, users) => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_INDEX", `Indexing users failed. "${err}"`);
 				return cb({status: 'failure', message: err});
 			} else {
@@ -147,16 +166,21 @@ module.exports = {
 			},
 
 			(user, next) => {
-				let sessionId = utils.guid();
+				utils.guid().then((sessionId) => {
+					next(null, user, sessionId);
+				});
+			},
+
+			(user, sessionId, next) => {
 				cache.hset('sessions', sessionId, cache.schemas.session(sessionId, user._id), (err) => {
 					if (err) return next(err);
 					next(null, sessionId);
 				});
 			}
 
-		], (err, sessionId) => {
+		], async (err, sessionId) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_PASSWORD_LOGIN", `Login failed with password for user "${identifier}". "${err}"`);
 				return cb({status: 'failure', message: err});
 			}
@@ -176,9 +200,9 @@ module.exports = {
 	 * @param {Object} recaptcha - the recaptcha data
 	 * @param {Function} cb - gets called with the result
 	 */
-	register: function(session, username, email, password, recaptcha, cb) {
+	register: async function(session, username, email, password, recaptcha, cb) {
 		email = email.toLowerCase();
-		let verificationToken = utils.generateRandomString(64);
+		let verificationToken = await utils.generateRandomString(64);
 		async.waterfall([
 
 			// verify the request with google recaptcha
@@ -225,10 +249,16 @@ module.exports = {
 				bcrypt.hash(sha256(password), salt, next)
 			},
 
-			// save the new user to the database
 			(hash, next) => {
+				utils.generateRandomString(12).then((_id) => {
+					next(null, hash, _id);
+				});
+			},
+
+			// save the new user to the database
+			(hash, _id, next) => {
 				db.models.user.create({
-					_id: utils.generateRandomString(12),//TODO Check if exists
+					_id,
 					username,
 					email: {
 						address: email,
@@ -250,9 +280,9 @@ module.exports = {
 				});
 			}
 
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_PASSWORD_REGISTER", `Register failed with password for user "${username}"."${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -290,9 +320,9 @@ module.exports = {
 			(session, next) => {
 				cache.hdel('sessions', session.sessionId, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("USER_LOGOUT", `Logout failed. "${err}" `);
 				cb({ status: 'failure', message: err });
 			} else {
@@ -309,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();
 				});
 			},
@@ -349,9 +379,9 @@ module.exports = {
 				});
 			}
 
-		], err => {
+		], async err => {
 			if (err) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REMOVE_SESSIONS_FOR_USER", `Couldn't remove all sessions for user "${userId}". "${err}"`);
 				return cb({ status: 'failure', message: err });
 			} else {
@@ -379,9 +409,9 @@ module.exports = {
 				if (!account) return next('User not found.');
 				next(null, account);
 			}
-		], (err, account) => {
+		], async (err, account) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("FIND_BY_USERNAME", `User not found for username "${username}". "${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -412,21 +442,26 @@ module.exports = {
 	 * @param {Function} cb - gets called with the result
 	 */
 	getUsernameFromId: (session, userId, cb) => {
-		async.waterfall([
-			(next) => {
-				db.models.user.findOne({ _id: userId }, next);
-			},
-		], (err, user) => {
-			if (err && err !== true) {
-				err = utils.getError(err);
-				logger.error("GET_USERNAME_FROM_ID", `Getting the username from userId "${userId}" failed. "${err}"`);
-				cb({status: 'failure', message: err});
-			} else {
+		db.models.user.findById(userId).then(user => {
+			if (user) {
 				logger.success("GET_USERNAME_FROM_ID", `Found username for userId "${userId}".`);
 				return cb({
 					status: 'success',
 					data: user.username
 				});
+			} else {
+				logger.error("GET_USERNAME_FROM_ID", `Getting the username from userId "${userId}" failed. User not found.`);
+				cb({
+					status: 'failure',
+					message: "Couldn't find the user."
+				});
+			}
+			
+		}).catch(async err => {
+			if (err && err !== true) {
+				err = await utils.getError(err);
+				logger.error("GET_USERNAME_FROM_ID", `Getting the username from userId "${userId}" failed. "${err}"`);
+				cb({ status: 'failure', message: err });
 			}
 		});
 	},
@@ -457,9 +492,9 @@ module.exports = {
 				if (!user) return next('User not found.');
 				next(null, user);
 			}
-		], (err, user) => {
+		], async (err, user) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("FIND_BY_SESSION", `User not found. "${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -487,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) => {
@@ -518,11 +552,11 @@ module.exports = {
 			},
 
 			(next) => {
-				db.models.user.update({ _id: updatingUserId }, {$set: {username: newUsername}}, {runValidators: true}, next);
+				db.models.user.updateOne({ _id: updatingUserId }, {$set: {username: newUsername}}, {runValidators: true}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UPDATE_USERNAME", `Couldn't update username for user "${updatingUserId}" to username "${newUsername}". "${err}"`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -543,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((session, updatingUserId, newEmail, cb, userId) => {
+	updateEmail: hooks.loginRequired(async (session, updatingUserId, newEmail, cb) => {
 		newEmail = newEmail.toLowerCase();
-		let verificationToken = utils.generateRandomString(64);
+		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) => {
@@ -576,7 +609,7 @@ module.exports = {
 			},
 
 			(next) => {
-				db.models.user.update({_id: updatingUserId}, {$set: {"email.address": newEmail, "email.verified": false, "email.verificationToken": verificationToken}}, {runValidators: true}, next);
+				db.models.user.updateOne({_id: updatingUserId}, {$set: {"email.address": newEmail, "email.verified": false, "email.verificationToken": verificationToken}}, {runValidators: true}, next);
 			},
 
 			(res, next) => {
@@ -588,9 +621,9 @@ module.exports = {
 					next();
 				});
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("UPDATE_EMAIL", `Couldn't update email for user "${updatingUserId}" to email "${newEmail}". '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -607,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([
 
@@ -623,16 +655,16 @@ module.exports = {
 				else return next();
 			},
 			(next) => {
-				db.models.user.update({_id: updatingUserId}, {$set: {role: newRole}}, {runValidators: true}, next);
+				db.models.user.updateOne({_id: updatingUserId}, {$set: {role: newRole}}, {runValidators: true}, next);
 			}
 
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
-				logger.error("UPDATE_ROLE", `User "${userId}" couldn't update role for user "${updatingUserId}" to role "${newRole}". "${err}"`);
+				err = await utils.getError(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.'
@@ -647,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) => {
@@ -675,16 +706,16 @@ module.exports = {
 			},
 
 			(hashedPassword, next) => {
-				db.models.user.update({_id: userId}, {$set: {"services.password.password": hashedPassword}}, next);
+				db.models.user.updateOne({_id: session.userId}, {$set: {"services.password.password": hashedPassword}}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err) {
-				err = utils.getError(err);
-				logger.error("UPDATE_PASSWORD", `Failed updating user password of user '${userId}'. '${err}'.`);
+				err = await utils.getError(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.'
@@ -698,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((session, cb, userId) => {
-		let code = utils.generateRandomString(8);
+	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) => {
@@ -722,13 +752,13 @@ module.exports = {
 			(user, next) => {
 				mail.schemas.passwordRequest(user.email.address, user.username, code, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
-				logger.error("REQUEST_PASSWORD", `UserId '${userId}' failed to request password. '${err}'`);
+				err = await utils.getError(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.'
@@ -743,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) => {
@@ -757,9 +786,9 @@ module.exports = {
 				if (user.services.password.set.expires < new Date()) return next('That code has expired.');
 				next(null);
 			}
-		], (err) => {
+		], async(err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("VERIFY_PASSWORD_CODE", `Code '${code}' failed to verify. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -779,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.');
@@ -809,16 +837,16 @@ module.exports = {
 			},
 
 			(hashedPassword, next) => {
-				db.models.user.update({"services.password.set.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.set": ''}}, {runValidators: true}, next);
+				db.models.user.updateOne({"services.password.set.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.set": ''}}, {runValidators: true}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("ADD_PASSWORD_WITH_CODE", `Code '${code}' failed to add password. '${err}'`);
 				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.'
@@ -832,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.update({_id: userId}, {$unset: {"services.password": ''}}, next);
+				db.models.user.updateOne({_id: session.userId}, {$unset: {"services.password": ''}}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
-				logger.error("UNLINK_PASSWORD", `Unlinking password failed for userId '${userId}'. '${err}'`);
+				err = await utils.getError(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.'
@@ -866,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.update({_id: userId}, {$unset: {"services.github": ''}}, next);
+				db.models.user.updateOne({_id: session.userId}, {$unset: {"services.github": ''}}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
-				logger.error("UNLINK_GITHUB", `Unlinking GitHub failed for userId '${userId}'. '${err}'`);
+				err = await utils.getError(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.'
@@ -902,8 +928,8 @@ module.exports = {
 	 * @param {String} email - the email of the user that requests a password reset
 	 * @param {Function} cb - gets called with the result
 	 */
-	requestPasswordReset: (session, email, cb) => {
-		let code = utils.generateRandomString(8);
+	requestPasswordReset: async (session, email, cb) => {
+		let code = await utils.generateRandomString(8);
 		async.waterfall([
 			(next) => {
 				if (!email || typeof email !== 'string') return next('Invalid email.');
@@ -926,9 +952,9 @@ module.exports = {
 			(user, next) => {
 				mail.schemas.resetPasswordRequest(user.email.address, user.username, code, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("REQUEST_PASSWORD_RESET", `Email '${email}' failed to request password reset. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -960,9 +986,9 @@ module.exports = {
 				if (!user.services.password.reset.expires > new Date()) return next('That code has expired.');
 				next(null);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("VERIFY_PASSWORD_RESET_CODE", `Code '${code}' failed to verify. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -1011,11 +1037,11 @@ module.exports = {
 			},
 
 			(hashedPassword, next) => {
-				db.models.user.update({"services.password.reset.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.reset": ''}}, {runValidators: true}, next);
+				db.models.user.updateOne({"services.password.reset.code": code}, {$set: {"services.password.password": hashedPassword}, $unset: {"services.password.reset": ''}}, {runValidators: true}, next);
 			}
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
+				err = await utils.getError(err);
 				logger.error("CHANGE_PASSWORD_WITH_RESET_CODE", `Code '${code}' failed to change password. '${err}'`);
 				cb({status: 'failure', message: err});
 			} else {
@@ -1036,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();
 			},
 
@@ -1085,25 +1110,50 @@ 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();
 			},
-		], (err) => {
+		], async (err) => {
 			if (err && err !== true) {
-				err = utils.getError(err);
-				logger.error("BAN_USER_BY_ID", `User ${userId} failed to ban user ${value} with the reason ${reason}. '${err}'`);
+				err = await utils.getError(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.'
 				});
 			}
 		});
+	}),
+
+	getFavoriteStations: hooks.loginRequired((session, cb) => {
+		async.waterfall([
+			(next) => {
+				db.models.user.findOne({ _id: session.userId }, next);
+			},
+
+			(user, next) => {
+				if (!user) return next("User not found.");
+				next(null, user);
+			}
+		], async (err, user) => {
+			if (err && err !== true) {
+				err = await utils.getError(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 ${session.userId} got favorite stations.`);
+				cb({
+					status: 'success',
+					favoriteStations: user.favoriteStations
+				});
+			}
+		});
 	})
 };

+ 32 - 26
backend/logic/api.js

@@ -1,34 +1,40 @@
-let lockdown = false;
+const coreClass = require("../core");
 
-module.exports = {
-	init: (cb) => {
-		const { app } = require('./app.js');
-		const actions = require('./actions');
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-		app.get('/', (req, res) => {
-			res.json({
-				status: 'success',
-				message: 'Coming Soon'
-			});
-		});
+		this.dependsOn = ["app", "db", "cache"];
+	}
 
-		Object.keys(actions).forEach((namespace) => {
-			Object.keys(actions[namespace]).forEach((action) => {
-				let name = `/${namespace}/${action}`;
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
 
-				app.get(name, (req, res) => {
-					actions[namespace][action](null, (result) => {
-						if (typeof cb === 'function') return res.json(result);
-					});
+			this.app = this.moduleManager.modules["app"];
+
+			this.app.app.get('/', (req, res) => {
+				res.json({
+					status: 'success',
+					message: 'Coming Soon'
 				});
-			})
-		});
+			});
 
-		if (lockdown) return this._lockdown();
-		cb();
-	},
+			const actions = require("../logic/actions");
+	
+			Object.keys(actions).forEach((namespace) => {
+				Object.keys(actions[namespace]).forEach((action) => {
+					let name = `/${namespace}/${action}`;
+	
+					this.app.app.get(name, (req, res) => {
+						actions[namespace][action](null, (result) => {
+							if (typeof cb === 'function') return res.json(result);
+						});
+					});
+				})
+			});
 
-	_lockdown: () => {
-		lockdown = true;
+			resolve();
+		});
 	}
-}
+}

+ 220 - 224
backend/logic/app.js

@@ -1,251 +1,247 @@
 'use strict';
 
+const coreClass = require("../core");
+
 const express = require('express');
 const bodyParser = require('body-parser');
 const cookieParser = require('cookie-parser');
 const cors = require('cors');
 const config = require('config');
 const async = require('async');
-const logger = require('./logger');
-const mail = require('./mail');
 const request = require('request');
 const OAuth2 = require('oauth').OAuth2;
 
-const api = require('./api');
-const cache = require('./cache');
-const db = require('./db');
-
-let utils;
-let initialized = false;
-let lockdown = false;
-
-const lib = {
-
-	app: null,
-	server: null,
-
-	init: (cb) => {
-
-		utils = require('./utils');
-
-		let app = lib.app = express();
-
-		lib.server = app.listen(config.get('serverPort'));
-
-		app.use(cookieParser());
-
-		app.use(bodyParser.json());
-		app.use(bodyParser.urlencoded({ extended: true }));
-
-		let corsOptions = Object.assign({}, config.get('cors'));
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			const 	logger 	= this.logger,
+					mail	= this.moduleManager.modules["mail"],
+					cache	= this.moduleManager.modules["cache"],
+					db		= this.moduleManager.modules["db"];
+			
+			this.utils = this.moduleManager.modules["utils"];
+
+			let app = this.app = express();
+			const SIDname = config.get("cookie.SIDname");
+			this.server = app.listen(config.get('serverPort'));
+
+			app.use(cookieParser());
+
+			app.use(bodyParser.json());
+			app.use(bodyParser.urlencoded({ extended: true }));
+
+			let corsOptions = Object.assign({}, config.get('cors'));
+
+			app.use(cors(corsOptions));
+			app.options('*', cors(corsOptions));
+
+			let oauth2 = new OAuth2(
+				config.get('apis.github.client'),
+				config.get('apis.github.secret'),
+				'https://github.com/',
+				'login/oauth/authorize',
+				'login/oauth/access_token',
+				null
+			);
+
+			let redirect_uri = config.get('serverDomain') + '/auth/github/authorize/callback';
+
+			app.get('/auth/github/authorize', async (req, res) => {
+				try { await this._validateHook(); } catch { return; }
+				let params = [
+					`client_id=${config.get('apis.github.client')}`,
+					`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
+					`scope=user:email`
+				].join('&');
+				res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+			});
 
-		app.use(cors(corsOptions));
-		app.options('*', cors(corsOptions));
+			app.get('/auth/github/link', async (req, res) => {
+				try { await this._validateHook(); } catch { return; }
+				let params = [
+					`client_id=${config.get('apis.github.client')}`,
+					`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
+					`scope=user:email`,
+					`state=${req.cookies[SIDname]}`
+				].join('&');
+				res.redirect(`https://github.com/login/oauth/authorize?${params}`);
+			});
 
-		let oauth2 = new OAuth2(
-			config.get('apis.github.client'),
-			config.get('apis.github.secret'),
-			'https://github.com/',
-			'login/oauth/authorize',
-			'login/oauth/access_token',
-			null
-		);
+			function redirectOnErr (res, err){
+				return res.redirect(`${config.get('domain')}/?err=${encodeURIComponent(err)}`);
+			}
+
+			app.get('/auth/github/authorize/callback', async (req, res) => {
+				try { await this._validateHook(); } catch { return; }
+				let code = req.query.code;
+				let access_token;
+				let body;
+				let address;
+				const state = req.query.state;
+
+				async.waterfall([
+					(next) => {
+						if (req.query.error) return next(req.query.error_description);
+						next();
+					},
+
+					(next) => {
+						oauth2.getOAuthAccessToken(code, {redirect_uri}, next);
+					},
+
+					(_access_token, refresh_token, results, next) => {
+						if (results.error) return next(results.error_description);
+						access_token = _access_token;
+						request.get({
+							url: `https://api.github.com/user?access_token=${access_token}`,
+							headers: {'User-Agent': 'request'}
+						}, next);
+					},
+
+					(httpResponse, _body, next) => {
+						body = _body = JSON.parse(_body);
+						if (httpResponse.statusCode !== 200) return next(body.message);
+						if (state) {
+							return async.waterfall([
+								(next) => {
+									cache.hget('sessions', state, next);
+								},
+
+								(session, next) => {
+									if (!session) return next('Invalid session.');
+									db.models.user.findOne({_id: session.userId}, next);
+								},
+
+								(user, next) => {
+									if (!user) return next('User not found.');
+									if (user.services.github && user.services.github.id) return next('Account already has GitHub linked.');
+									db.models.user.updateOne({_id: user._id}, {$set: {"services.github": {id: body.id, access_token}}}, {runValidators: true}, (err) => {
+										if (err) return next(err);
+										next(null, user, body);
+									});
+								},
+
+								(user) => {
+									cache.pub('user.linkGitHub', user._id);
+									res.redirect(`${config.get('domain')}/settings`);
+								}
+							], next);
+						}
+						if (!body.id) return next("Something went wrong, no id.");
+						db.models.user.findOne({'services.github.id': body.id}, (err, user) => {
+							next(err, user, body);
+						});
+					},
+
+					(user, body, next) => {
+						if (user) {
+							user.services.github.access_token = access_token;
+							return user.save(() => {
+								next(true, user._id);
+							});
+						}
+						db.models.user.findOne({ username: new RegExp(`^${body.login}$`, 'i' )}, (err, user) => {
+							next(err, user);
+						});
+					},
+
+					(user, next) => {
+						if (user) return next('An account with that username already exists.');
+						request.get({
+							url: `https://api.github.com/user/emails?access_token=${access_token}`,
+							headers: {'User-Agent': 'request'}
+						}, next);
+					},
+
+					(httpResponse, body2, next) => {
+						body2 = JSON.parse(body2);
+						if (!Array.isArray(body2)) return next(body2.message);
+						body2.forEach(email => {
+							if (email.primary) address = email.email.toLowerCase();
+						});
+						db.models.user.findOne({'email.address': address}, next);
+					},
+
+					async (user, next) => {
+						const verificationToken = await this.utils.generateRandomString(64);
+						if (user) return next('An account with that email address already exists.');
+						db.models.user.create({
+							_id: await this.utils.generateRandomString(12),//TODO Check if exists
+							username: body.login,
+							email: {
+								address,
+								verificationToken: verificationToken
+							},
+							services: {
+								github: {id: body.id, access_token}
+							}
+						}, next);
+					},
 
-		let redirect_uri = config.get('serverDomain') + '/auth/github/authorize/callback';
+					(user, next) => {
+						mail.schemas.verifyEmail(address, body.login, user.email.verificationToken);
+						next(null, user._id);
+					}
+				], async (err, userId) => {
+					if (err && err !== true) {
+						err = await this.utils.getError(err);
+						logger.error('AUTH_GITHUB_AUTHORIZE_CALLBACK', `Failed to authorize with GitHub. "${err}"`);
+						return redirectOnErr(res, err);
+					}
 
-		app.get('/auth/github/authorize', (req, res) => {
-			if (lockdown) return res.json({status: 'failure', message: 'Lockdown'});
-			let params = [
-				`client_id=${config.get('apis.github.client')}`,
-				`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
-				`scope=user:email`
-			].join('&');
-			res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-		});
+					const sessionId = await this.utils.guid();
+					cache.hset('sessions', sessionId, cache.schemas.session(sessionId, userId), err => {
+						if (err) return redirectOnErr(res, err.message);
+						let date = new Date();
+						date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
+						res.cookie(SIDname, sessionId, {
+							expires: date,
+							secure: config.get("cookie.secure"),
+							path: "/",
+							domain: config.get("cookie.domain")
+						});
+						logger.success('AUTH_GITHUB_AUTHORIZE_CALLBACK', `User "${userId}" successfully authorized with GitHub.`);
+						res.redirect(`${config.get('domain')}/`);
+					});
+				});
+			});
 
-		app.get('/auth/github/link', (req, res) => {
-			if (lockdown) return res.json({status: 'failure', message: 'Lockdown'});
-			let params = [
-				`client_id=${config.get('apis.github.client')}`,
-				`redirect_uri=${config.get('serverDomain')}/auth/github/authorize/callback`,
-				`scope=user:email`,
-				`state=${req.cookies.SID}`
-			].join('&');
-			res.redirect(`https://github.com/login/oauth/authorize?${params}`);
-		});
+			app.get('/auth/verify_email', async (req, res) => {
+				try { await this._validateHook(); } catch { return; }
 
-		function redirectOnErr (res, err){
-			return res.redirect(`${config.get('domain')}/?err=${encodeURIComponent(err)}`);
-		}
-
-		app.get('/auth/github/authorize/callback', (req, res) => {
-			if (lockdown) return res.json({status: 'failure', message: 'Lockdown'});
-			let code = req.query.code;
-			let access_token;
-			let body;
-			let address;
-			const state = req.query.state;
-
-			async.waterfall([
-				(next) => {
-					oauth2.getOAuthAccessToken(code, {redirect_uri}, next);
-				},
-
-				(_access_token, refresh_token, results, next) => {
-					access_token = _access_token;
-					request.get({
-						url: `https://api.github.com/user?access_token=${access_token}`,
-						headers: {'User-Agent': 'request'}
-					}, next);
-				},
-
-				(httpResponse, _body, next) => {
-					body = _body = JSON.parse(_body);
-					if (state) {
-						return async.waterfall([
-							(next) => {
-								cache.hget('sessions', state, next);
-							},
+				let code = req.query.code;
 
-							(session, next) => {
-								if (!session) return next('Invalid session.');
-								db.models.user.findOne({_id: session.userId}, next);
-							},
+				async.waterfall([
+					(next) => {
+						if (!code) return next('Invalid code.');
+						next();
+					},
 
-							(user, next) => {
-								if (!user) return next('User not found.');
-								if (user.services.github && user.services.github.id) return next('Account already has GitHub linked.');
-								db.models.user.update({_id: user._id}, {$set: {"services.github": {id: body.id, access_token}}}, {runValidators: true}, (err) => {
-									if (err) return next(err);
-									next(null, user, body);
-								});
-							},
+					(next) => {
+						db.models.user.findOne({"email.verificationToken": code}, next);
+					},
 
-							(user) => {
-								cache.pub('user.linkGitHub', user._id);
-								res.redirect(`${config.get('domain')}/settings`);
-							}
-						], next);
+					(user, next) => {
+						if (!user) return next('User not found.');
+						if (user.email.verified) return next('This email is already verified.');
+						db.models.user.updateOne({"email.verificationToken": code}, {$set: {"email.verified": true}, $unset: {"email.verificationToken": ''}}, {runValidators: true}, next);
 					}
-					db.models.user.findOne({'services.github.id': body.id}, (err, user) => {
-						next(err, user, body);
-					});
-				},
-
-				(user, body, next) => {
-					if (user) {
-						user.services.github.access_token = access_token;
-						return user.save(() => {
-							next(true, user._id);
-						});
+				], (err) => {
+					if (err) {
+						let error = 'An error occurred.';
+						if (typeof err === "string") error = err;
+						else if (err.message) error = err.message;
+						logger.error("VERIFY_EMAIL", `Verifying email failed. "${error}"`);
+						return res.json({ status: 'failure', message: error});
 					}
-					db.models.user.findOne({ username: new RegExp(`^${body.login}$`, 'i' )}, (err, user) => {
-						next(err, user);
-					});
-				},
-
-				(user, next) => {
-					if (user) return next('An account with that username already exists.');
-					request.get({
-						url: `https://api.github.com/user/emails?access_token=${access_token}`,
-						headers: {'User-Agent': 'request'}
-					}, next);
-				},
-
-				(httpResponse, body2, next) => {
-					body2 = JSON.parse(body2);
-					if (!Array.isArray(body2)) return next(body2.message);
-					body2.forEach(email => {
-						if (email.primary) address = email.email.toLowerCase();
-					});
-					db.models.user.findOne({'email.address': address}, next);
-				},
-
-				(user, next) => {
-					const verificationToken = utils.generateRandomString(64);
-					if (user) return next('An account with that email address already exists.');
-					db.models.user.create({
-						_id: utils.generateRandomString(12),//TODO Check if exists
-						username: body.login,
-						email: {
-							address,
-							verificationToken: verificationToken
-						},
-						services: {
-							github: {id: body.id, access_token}
-						}
-					}, next);
-				},
-
-				(user, next) => {
-					mail.schemas.verifyEmail(address, body.login, user.email.verificationToken);
-					next(null, user._id);
-				}
-			], (err, userId) => {
-				if (err && err !== true) {
-					err = utils.getError(err);
-					logger.error('AUTH_GITHUB_AUTHORIZE_CALLBACK', `Failed to authorize with GitHub. "${err}"`);
-					return redirectOnErr(res, err);
-				}
-
-				const sessionId = utils.guid();
-				cache.hset('sessions', sessionId, cache.schemas.session(sessionId, userId), err => {
-					if (err) return redirectOnErr(res, err.message);
-					let date = new Date();
-					date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-					res.cookie('SID', sessionId, {
-						expires: date,
-						secure: config.get("cookie.secure"),
-						path: "/",
-						domain: config.get("cookie.domain")
-					});
-					logger.success('AUTH_GITHUB_AUTHORIZE_CALLBACK', `User "${userId}" successfully authorized with GitHub.`);
-					res.redirect(`${config.get('domain')}/`);
+					logger.success("VERIFY_EMAIL", `Successfully verified email.`);
+					res.redirect(`${config.get("domain")}?msg=Thank you for verifying your email`);
 				});
 			});
-		});
 
-		app.get('/auth/verify_email', (req, res) => {
-			let code = req.query.code;
-
-			async.waterfall([
-				(next) => {
-					if (!code) return next('Invalid code.');
-					next();
-				},
-
-				(next) => {
-					db.models.user.findOne({"email.verificationToken": code}, next);
-				},
-
-				(user, next) => {
-					if (!user) return next('User not found.');
-					if (user.email.verified) return next('This email is already verified.');
-					db.models.user.update({"email.verificationToken": code}, {$set: {"email.verified": true}, $unset: {"email.verificationToken": ''}}, {runValidators: true}, next);
-				}
-			], (err) => {
-				if (err) {
-					let error = 'An error occurred.';
-					if (typeof err === "string") error = err;
-					else if (err.message) error = err.message;
-					logger.error("VERIFY_EMAIL", `Verifying email failed. "${error}"`);
-					return res.json({ status: 'failure', message: error});
-				}
-				logger.success("VERIFY_EMAIL", `Successfully verified email.`);
-				res.redirect(config.get("domain"));
-			});
+			resolve();
 		});
-
-		initialized = true;
-
-		if (lockdown) return this._lockdown();
-		cb();
-	},
-
-	_lockdown: () => {
-		lib.server.close();
-		lockdown = true;
 	}
-};
-
-module.exports = lib;
+}

+ 102 - 92
backend/logic/cache/index.js

@@ -1,67 +1,84 @@
 'use strict';
 
+const coreClass = require("../../core");
+
 const redis = require('redis');
+const config = require('config');
 const mongoose = require('mongoose');
 
 // Lightweight / convenience wrapper around redis module for our needs
 
 const pubs = {}, subs = {};
-let callbacks = [];
-let initialized = false;
-let lockdown = false;
-
-const lib = {
-
-	client: null,
-	errorCb: null,
-	url: '',
-	schemas: {
-		session: require('./schemas/session'),
-		station: require('./schemas/station'),
-		playlist: require('./schemas/playlist'),
-		officialPlaylist: require('./schemas/officialPlaylist'),
-		song: require('./schemas/song'),
-		punishment: require('./schemas/punishment')
-	},
 
-	/**
-	 * Initializes the cache module
-	 *
-	 * @param {String} url - the url of the redis server
-	 * @param {String} password - the password of the redis server
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	init: (url, password, errorCb, cb) => {
-		lib.errorCb = errorCb;
-		lib.url = url;
-		lib.password = password;
-
-		lib.client = redis.createClient({ url: lib.url, password: lib.password });
-		lib.client.on('error', (err) => {
-			if (lockdown) return;
-			errorCb('Cache connection error.', err, 'Cache');
-		});
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.schemas = {
+				session: require('./schemas/session'),
+				station: require('./schemas/station'),
+				playlist: require('./schemas/playlist'),
+				officialPlaylist: require('./schemas/officialPlaylist'),
+				song: require('./schemas/song'),
+				punishment: require('./schemas/punishment')
+			}
 
-		callbacks.forEach((callback) => {
-			callback();
-		});
+			this.url = config.get("redis").url;
+			this.password = config.get("redis").password;
+
+			this.logger.info("REDIS", "Connecting...");
+
+			this.client = redis.createClient({
+				url: this.url,
+				password: this.password,
+				retry_strategy: (options) => {
+					if (this.state === "LOCKDOWN") return;
+					if (this.state !== "RECONNECTING") this.setState("RECONNECTING");
+
+					this.logger.info("CACHE_MODULE", `Attempting to reconnect.`);
 
-		initialized = true;
+					if (options.attempt >= 10) {
+						this.logger.error("CACHE_MODULE", `Stopped trying to reconnect.`);
 
-		if (lockdown) return this._lockdown();
-		cb();
-	},
+						this.failed = true;
+						this._lockdown();
+
+						return undefined;
+					}
+
+					return 3000;
+				}
+			});
+
+			this.client.on('error', err => {
+				if (this.state === "INITIALIZING") reject(err);
+				if(this.state === "LOCKDOWN") return;
+
+				this.logger.error("CACHE_MODULE", `Error ${err.message}.`);
+			});
+
+			this.client.on("connect", () => {
+				this.logger.info("CACHE_MODULE", "Connected succesfully.");
+
+				if (this.state === "INITIALIZING") resolve();
+				else if (this.state === "LOCKDOWN" || this.state === "RECONNECTING") this.setState("INITIALIZED");
+			});
+		});
+	}
 
 	/**
 	 * Gracefully closes all the Redis client connections
 	 */
-	quit: () => {
-		if (lib.client.connected) {
-			lib.client.quit();
+	async quit() {
+		try { await this._validateHook(); } catch { return; }
+
+		if (this.client.connected) {
+			this.client.quit();
 			Object.keys(pubs).forEach((channel) => pubs[channel].quit());
 			Object.keys(subs).forEach((channel) => subs[channel].client.quit());
 		}
-	},
+	}
 
 	/**
 	 * Sets a single value in a table
@@ -72,19 +89,20 @@ const lib = {
 	 * @param {Function} cb - gets called when the value has been set in Redis
 	 * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
 	 */
-	hset: (table, key, value, cb, stringifyJson = true) => {
-		if (lockdown) return cb('Lockdown');
+	async hset(table, key, value, cb, stringifyJson = true) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
 		// automatically stringify objects and arrays into JSON
 		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
 
-		lib.client.hset(table, key, value, err => {
+		this.client.hset(table, key, value, err => {
 			if (cb !== undefined) {
 				if (err) return cb(err);
 				cb(null, JSON.parse(value));
 			}
 		});
-	},
+	}
 
 	/**
 	 * Gets a single value from a table
@@ -94,11 +112,13 @@ const lib = {
 	 * @param {Function} cb - gets called when the value is returned from Redis
 	 * @param {Boolean} [parseJson=true] - attempt to parse returned data as JSON
 	 */
-	hget: (table, key, cb, parseJson = true) => {
-		if (lockdown) return cb('Lockdown');
+	async hget(table, key, cb, parseJson = true) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (!key || !table) return typeof cb === 'function' ? cb(null, null) : null;
 		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
-		lib.client.hget(table, key, (err, value) => {
+
+		this.client.hget(table, key, (err, value) => {
 			if (err) return typeof cb === 'function' ? cb(err) : null;
 			if (parseJson) try {
 				value = JSON.parse(value);
@@ -106,7 +126,7 @@ const lib = {
 			}
 			if (typeof cb === 'function') cb(null, value);
 		});
-	},
+	}
 
 	/**
 	 * Deletes a single value from a table
@@ -115,15 +135,17 @@ const lib = {
 	 * @param {String} key - name of the key to delete
 	 * @param {Function} cb - gets called when the value has been deleted from Redis or when it returned an error
 	 */
-	hdel: (table, key, cb) => {
-		if (lockdown) return cb('Lockdown');
-		if (!key || !table) return cb(null, null);
+	async hdel(table, key, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		if (!key || !table || typeof key !== "string") return cb(null, null);
 		if (mongoose.Types.ObjectId.isValid(key)) key = key.toString();
-		lib.client.hdel(table, key, (err) => {
+
+		this.client.hdel(table, key, (err) => {
 			if (err) return cb(err);
 			else return cb(null);
 		});
-	},
+	}
 
 	/**
 	 * Returns all the keys for a table
@@ -132,16 +154,18 @@ const lib = {
 	 * @param {Function} cb - gets called when the values are returned from Redis
 	 * @param {Boolean} [parseJson=true] - attempts to parse all values as JSON by default
 	 */
-	hgetall: (table, cb, parseJson = true) => {
-		if (lockdown) return cb('Lockdown');
+	async hgetall(table, cb, parseJson = true) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (!table) return cb(null, null);
-		lib.client.hgetall(table, (err, obj) => {
+
+		this.client.hgetall(table, (err, obj) => {
 			if (err) return typeof cb === 'function' ? cb(err) : null;
 			if (parseJson && obj) Object.keys(obj).forEach((key) => { try { obj[key] = JSON.parse(obj[key]); } catch (e) {} });
 			if (parseJson && !obj) obj = [];
 			cb(null, obj);
 		});
-	},
+	}
 
 	/**
 	 * Publish a message to a channel, caches the redis client connection
@@ -150,18 +174,18 @@ const lib = {
 	 * @param {*} value - the value we want to send
 	 * @param {Boolean} [stringifyJson=true] - stringify 'value' if it's an Object or Array
 	 */
-	pub: (channel, value, stringifyJson = true) => {
-
+	async pub(channel, value, stringifyJson = true) {
+		try { await this._validateHook(); } catch { return; }
 		/*if (pubs[channel] === undefined) {
-		 pubs[channel] = redis.createClient({ url: lib.url });
+		 pubs[channel] = redis.createClient({ url: this.url });
 		 pubs[channel].on('error', (err) => console.error);
 		 }*/
 
 		if (stringifyJson && ['object', 'array'].includes(typeof value)) value = JSON.stringify(value);
 
 		//pubs[channel].publish(channel, value);
-		lib.client.publish(channel, value);
-	},
+		this.client.publish(channel, value);
+	}
 
 	/**
 	 * Subscribe to a channel, caches the redis client connection
@@ -170,32 +194,18 @@ const lib = {
 	 * @param {Function} cb - gets called when a message is received
 	 * @param {Boolean} [parseJson=true] - parse the message as JSON
 	 */
-	sub: (channel, cb, parseJson = true) => {
-		if (lockdown) return;
-		if (initialized) subToChannel();
-		else {
-			callbacks.push(() => {
-				subToChannel();
+	async sub(channel, cb, parseJson = true) {
+		try { await this._validateHook(); } catch { return; }
+
+		if (subs[channel] === undefined) {
+			subs[channel] = { client: redis.createClient({ url: this.url, password: this.password }), cbs: [] };
+			subs[channel].client.on('message', (channel, message) => {
+				if (parseJson) try { message = JSON.parse(message); } catch (e) {}
+				subs[channel].cbs.forEach((cb) => cb(message));
 			});
+			subs[channel].client.subscribe(channel);
 		}
-		function subToChannel() {
-			if (subs[channel] === undefined) {
-				subs[channel] = { client: redis.createClient({ url: lib.url, password: lib.password }), cbs: [] };
-				subs[channel].client.on('message', (channel, message) => {
-					if (parseJson) try { message = JSON.parse(message); } catch (e) {}
-					subs[channel].cbs.forEach((cb) => cb(message));
-				});
-				subs[channel].client.subscribe(channel);
-			}
 
-			subs[channel].cbs.push(cb);
-		}
-	},
-
-	_lockdown: () => {
-		lib.quit();
-		lockdown = true;
+		subs[channel].cbs.push(cb);
 	}
-};
-
-module.exports = lib;
+}

+ 218 - 189
backend/logic/db/index.js

@@ -1,209 +1,238 @@
 'use strict';
 
+const coreClass = require("../../core");
+
 const mongoose = require('mongoose');
 const config = require('config');
 
-const bluebird = require('bluebird');
-
 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) => {
 	return !(typeof string !== 'string' || string.length < min || string.length > max);
 }
 
-mongoose.Promise = bluebird;
+const bluebird = require('bluebird');
 
-let initialized = false;
-let lockdown = false;
-
-let lib = {
-
-	connection: null,
-	schemas: {},
-	models: {},
-
-	init: (url, errorCb,  cb) => {
-		lib.connection = mongoose.connect(url).connection;
-
-		lib.connection.on('error', err => {
-			errorCb('Database connection error.', err, 'DB');
-		});
-
-		lib.connection.once('open', _ => {
-
-			lib.schemas = {
-				song: new mongoose.Schema(require(`./schemas/song`)),
-				queueSong: new mongoose.Schema(require(`./schemas/queueSong`)),
-				station: new mongoose.Schema(require(`./schemas/station`)),
-				user: new mongoose.Schema(require(`./schemas/user`)),
-				playlist: new mongoose.Schema(require(`./schemas/playlist`)),
-				news: new mongoose.Schema(require(`./schemas/news`)),
-				report: new mongoose.Schema(require(`./schemas/report`)),
-				punishment: new mongoose.Schema(require(`./schemas/punishment`))
-			};
-
-			lib.models = {
-				song: mongoose.model('song', lib.schemas.song),
-				queueSong: mongoose.model('queueSong', lib.schemas.queueSong),
-				station: mongoose.model('station', lib.schemas.station),
-				user: mongoose.model('user', lib.schemas.user),
-				playlist: mongoose.model('playlist', lib.schemas.playlist),
-				news: mongoose.model('news', lib.schemas.news),
-				report: mongoose.model('report', lib.schemas.report),
-				punishment: mongoose.model('punishment', lib.schemas.punishment)
-			};
-
-			lib.schemas.user.path('username').validate((username) => {
-				return (isLength(username, 2, 32) && regex.azAZ09_.test(username));
-			}, 'Invalid username.');
-
-			lib.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);
-			}, 'Invalid email.');
-
-			lib.schemas.station.path('name').validate((id) => {
-				return (isLength(id, 2, 16) && regex.az09_.test(id));
-			}, 'Invalid station name.');
-
-			lib.schemas.station.path('displayName').validate((displayName) => {
-				return (isLength(displayName, 2, 32) && regex.azAZ09_.test(displayName));
-			}, 'Invalid display name.');
-
-			lib.schemas.station.path('description').validate((description) => {
-				if (!isLength(description, 2, 200)) return false;
-				let characters = description.split("");
-				return characters.filter((character) => {
-					return character.charCodeAt(0) === 21328;
-				}).length === 0;
-			}, 'Invalid display name.');
-
-
-			lib.schemas.station.path('owner').validate((owner, callback) => {
-				lib.models.station.count({owner: owner}, (err, c) => {
-					callback(!(err || c >= 3));
-				});
-			}, 'User already has 3 stations.');
+mongoose.Promise = bluebird;
 
-			/*
-			lib.schemas.station.path('queue').validate((queue, callback) => {
-				let totalDuration = 0;
-				queue.forEach((song) => {
-					totalDuration += song.duration;
-				});
-				return callback(totalDuration <= 3600 * 3);
-			}, 'The max length of the queue is 3 hours.');
-
-			lib.schemas.station.path('queue').validate((queue, callback) => {
-				if (queue.length === 0) return callback(true);
-				let totalDuration = 0;
-				const userId = queue[queue.length - 1].requestedBy;
-				queue.forEach((song) => {
-					if (userId === song.requestedBy) {
-						totalDuration += song.duration;
-					}
-				});
-				return callback(totalDuration <= 900);
-			}, 'The max length of songs per user is 15 minutes.');
-
-			lib.schemas.station.path('queue').validate((queue, callback) => {
-				if (queue.length === 0) return callback(true);
-				let totalSongs = 0;
-				const userId = queue[queue.length - 1].requestedBy;
-				queue.forEach((song) => {
-					if (userId === song.requestedBy) {
-						totalSongs++;
-					}
-				});
-				if (totalSongs <= 2) return callback(true);
-				if (totalSongs > 3) return callback(false);
-				if (queue[queue.length - 2].requestedBy !== userId || queue[queue.length - 3] !== userId) return callback(true);
-				return callback(false);
-			}, 'The max amount of songs per user is 3, and only 2 in a row is allowed.');
-			*/
-
-			let songTitle = (title) => {
-				return (isLength(title, 1, 64) && regex.ascii.test(title));
-			};
-			lib.schemas.song.path('title').validate(songTitle, 'Invalid title.');
-			lib.schemas.queueSong.path('title').validate(songTitle, 'Invalid title.');
-
-			lib.schemas.song.path('artists').validate((artists) => {
-				return !(artists.length < 1 || artists.length > 10);
-			}, 'Invalid artists.');
-			lib.schemas.queueSong.path('artists').validate((artists) => {
-				return !(artists.length < 0 || artists.length > 10);
-			}, 'Invalid artists.');
-
-			let songArtists = (artists) => {
-				return artists.filter((artist) => {
-						return (isLength(artist, 1, 32) && regex.ascii.test(artist) && artist !== "NONE");
-					}).length === artists.length;
-			};
-			lib.schemas.song.path('artists').validate(songArtists, 'Invalid artists.');
-			lib.schemas.queueSong.path('artists').validate(songArtists, 'Invalid artists.');
-
-			let songGenres = (genres) => {
-				return genres.filter((genre) => {
-						return (isLength(genre, 1, 16) && regex.az09_.test(genre));
-					}).length === genres.length;
-			};
-			lib.schemas.song.path('genres').validate(songGenres, 'Invalid genres.');
-			lib.schemas.queueSong.path('genres').validate(songGenres, 'Invalid genres.');
-
-			lib.schemas.song.path('thumbnail').validate((thumbnail) => {
-				return isLength(thumbnail, 8, 256);
-			}, 'Invalid thumbnail.');
-			lib.schemas.queueSong.path('thumbnail').validate((thumbnail) => {
-				return isLength(thumbnail, 0, 256);
-			}, 'Invalid thumbnail.');
-
-			lib.schemas.playlist.path('displayName').validate((displayName) => {
-				return (isLength(displayName, 1, 16) && regex.ascii.test(displayName));
-			}, 'Invalid display name.');
-
-			lib.schemas.playlist.path('createdBy').validate((createdBy, callback) => {
-				lib.models.playlist.count({createdBy: createdBy}, (err, c) => {
-					callback(!(err || c >= 10));
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.schemas = {};
+			this.models = {};
+
+			const mongoUrl = config.get("mongo").url;
+
+			mongoose.connect(mongoUrl, {
+				useNewUrlParser: true,
+				useCreateIndex: true,
+				reconnectInterval: 3000,
+				reconnectTries: 10
+			})
+				.then(() => {
+					this.schemas = {
+						song: new mongoose.Schema(require(`./schemas/song`)),
+						queueSong: new mongoose.Schema(require(`./schemas/queueSong`)),
+						station: new mongoose.Schema(require(`./schemas/station`)),
+						user: new mongoose.Schema(require(`./schemas/user`)),
+						playlist: new mongoose.Schema(require(`./schemas/playlist`)),
+						news: new mongoose.Schema(require(`./schemas/news`)),
+						report: new mongoose.Schema(require(`./schemas/report`)),
+						punishment: new mongoose.Schema(require(`./schemas/punishment`))
+					};
+		
+					this.models = {
+						song: mongoose.model('song', this.schemas.song),
+						queueSong: mongoose.model('queueSong', this.schemas.queueSong),
+						station: mongoose.model('station', this.schemas.station),
+						user: mongoose.model('user', this.schemas.user),
+						playlist: mongoose.model('playlist', this.schemas.playlist),
+						news: mongoose.model('news', this.schemas.news),
+						report: mongoose.model('report', this.schemas.report),
+						punishment: mongoose.model('punishment', this.schemas.punishment)
+					};
+
+					mongoose.connection.on('error', err => {
+						this.logger.error("DB_MODULE", err);
+					});
+
+					mongoose.connection.on('disconnected', () => {
+						this.logger.error("DB_MODULE", "Disconnected, going to try to reconnect...");
+						this.setState("RECONNECTING");
+					});
+
+					mongoose.connection.on('reconnected', () => {
+						this.logger.success("DB_MODULE", "Reconnected.");
+						this.setState("INITIALIZED");
+					});
+
+					mongoose.connection.on('reconnectFailed', () => {
+						this.logger.error("DB_MODULE", "Reconnect failed, stopping reconnecting.");
+						this.failed = true;
+						this._lockdown();
+					});
+		
+					// 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) && 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.ascii.test(displayName));
+					}, 'Invalid display name.');
+		
+					this.schemas.station.path('description').validate((description) => {
+						if (!isLength(description, 2, 200)) return false;
+						let characters = description.split("");
+						return characters.filter((character) => {
+							return character.charCodeAt(0) === 21328;
+						}).length === 0;
+					}, 'Invalid display name.');
+		
+					this.schemas.station.path('owner').validate({
+						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.'
+					});
+		
+					/*
+					this.schemas.station.path('queue').validate((queue, callback) => { //Callback no longer works, see station max count
+						let totalDuration = 0;
+						queue.forEach((song) => {
+							totalDuration += song.duration;
+						});
+						return callback(totalDuration <= 3600 * 3);
+					}, 'The max length of the queue is 3 hours.');
+		
+					this.schemas.station.path('queue').validate((queue, callback) => { //Callback no longer works, see station max count
+						if (queue.length === 0) return callback(true);
+						let totalDuration = 0;
+						const userId = queue[queue.length - 1].requestedBy;
+						queue.forEach((song) => {
+							if (userId === song.requestedBy) {
+								totalDuration += song.duration;
+							}
+						});
+						return callback(totalDuration <= 900);
+					}, 'The max length of songs per user is 15 minutes.');
+		
+					this.schemas.station.path('queue').validate((queue, callback) => { //Callback no longer works, see station max count
+						if (queue.length === 0) return callback(true);
+						let totalSongs = 0;
+						const userId = queue[queue.length - 1].requestedBy;
+						queue.forEach((song) => {
+							if (userId === song.requestedBy) {
+								totalSongs++;
+							}
+						});
+						if (totalSongs <= 2) return callback(true);
+						if (totalSongs > 3) return callback(false);
+						if (queue[queue.length - 2].requestedBy !== userId || queue[queue.length - 3] !== userId) return callback(true);
+						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);
+					};
+					this.schemas.song.path('title').validate(songTitle, 'Invalid title.');
+					this.schemas.queueSong.path('title').validate(songTitle, 'Invalid title.');
+		
+					this.schemas.song.path('artists').validate((artists) => {
+						return !(artists.length < 1 || artists.length > 10);
+					}, 'Invalid artists.');
+					this.schemas.queueSong.path('artists').validate((artists) => {
+						return !(artists.length < 0 || artists.length > 10);
+					}, 'Invalid artists.');
+		
+					let songArtists = (artists) => {
+						return artists.filter((artist) => {
+								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) => {
+						if (genres.length < 1 || genres.length > 16) return false;
+						return genres.filter((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.');
+		
+					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, 32) && regex.ascii.test(displayName));
+					}, 'Invalid display name.');
+		
+					this.schemas.playlist.path('createdBy').validate((createdBy) => {
+						this.models.playlist.countDocuments({ createdBy: createdBy }, (err, c) => {
+							return !(err || c >= 10);
+						});
+					}, 'Max 10 playlists per user.');
+		
+					this.schemas.playlist.path('songs').validate((songs) => {
+						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.');
+
+					resolve();
+				})
+				.catch(err => {
+					this.logger.error("DB_MODULE", err);
+					reject(err);
 				});
-			}, 'Max 10 playlists per user.');
-
-			lib.schemas.playlist.path('songs').validate((songs) => {
-				return songs.length <= 2000;
-			}, 'Max 2000 songs per playlist.');
-
-			lib.schemas.playlist.path('songs').validate((songs) => {
-				if (songs.length === 0) return true;
-				return songs[0].duration <= 10800;
-			}, 'Max 3 hours per song.');
-
-			lib.schemas.report.path('description').validate((description) => {
-				return (!description || (isLength(description, 0, 400) && regex.ascii.test(description)));
-			}, 'Invalid description.');
-
-			initialized = true;
-
-			if (lockdown) return this._lockdown();
-			cb();
-		});
-	},
-
-	passwordValid: (password) => {
-		if (!isLength(password, 6, 200)) return false;
-		return regex.password.test(password);
-	},
-
-	_lockdown: () => {
-		lib.connection.close();
-		lockdown = true;
+		})
 	}
-};
 
-module.exports = lib;
+	passwordValid(password) {
+		return isLength(password, 6, 200);
+	}
+}

+ 2 - 1
backend/logic/db/schemas/queueSong.js

@@ -8,5 +8,6 @@ module.exports = {
 	thumbnail: { type: String, required: true },
 	explicit: { type: Boolean, required: true },
 	requestedBy: { type: String, required: true },
-	requestedAt: { type: Date, default: Date.now(), required: true }
+	requestedAt: { type: Date, default: Date.now(), required: true },
+	discogs: { type: Object }
 };

+ 4 - 1
backend/logic/db/schemas/report.js

@@ -1,6 +1,9 @@
 module.exports = {
 	resolved: { type: Boolean, default: false, required: true },
-	songId: { type: String, required: true },
+	song: {
+		_id: { type: String, required: true },
+		songId: { type: String, required: true },
+	},
 	description: { type: String },
 	issues: [{
 		name: String,

+ 2 - 1
backend/logic/db/schemas/song.js

@@ -12,5 +12,6 @@ module.exports = {
 	requestedBy: { type: String, required: true },
 	requestedAt: { type: Date, required: true },
 	acceptedBy: { type: String, required: true },
-	acceptedAt: { type: Date, default: Date.now(), required: true }
+	acceptedAt: { type: Date, default: Date.now(), required: true },
+	discogs: { type: Object }
 };

+ 1 - 1
backend/logic/db/schemas/user.js

@@ -1,5 +1,4 @@
 module.exports = {
-	_id: { type: String, required: true, index: true, unique: true, min: 12, max: 12 },
 	username: { type: String, required: true },
 	role: { type: String, default: 'default', required: true },
 	email: {
@@ -29,5 +28,6 @@ module.exports = {
 	},
 	liked: [{ type: String }],
 	disliked: [{ type: String }],
+	favoriteStations: [{ type: String }],
 	createdAt: { type: Date, default: Date.now() }
 };

+ 91 - 0
backend/logic/discord.js

@@ -0,0 +1,91 @@
+const coreClass = require("../core");
+
+const EventEmitter = require('events');
+const Discord = require("discord.js");
+const config = require("config");
+
+const bus = new EventEmitter();
+
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.client = new Discord.Client();
+			this.adminAlertChannelId = config.get("apis.discord").loggingChannel;
+			
+			this.client.on("ready", () => {
+				this.logger.info("DISCORD_MODULE", `Logged in as ${this.client.user.tag}!`);
+
+				if (this.state === "INITIALIZING") resolve();
+				else {
+					this.logger.info("DISCORD_MODULE", `Discord client reconnected.`);
+					this.setState("INITIALIZED");
+				}
+			});
+		  
+			this.client.on("disconnect", () => {
+				this.logger.info("DISCORD_MODULE", `Discord client disconnected.`);
+
+				if (this.state === "INITIALIZING") reject();
+				else {
+					this.failed = true;
+					this._lockdown;
+				} 
+			});
+
+			this.client.on("reconnecting", () => {
+				this.logger.info("DISCORD_MODULE", `Discord client reconnecting.`);
+				this.setState("RECONNECTING");
+			});
+		
+			this.client.on("error", err => {
+				this.logger.info("DISCORD_MODULE", `Discord client encountered an error: ${err.message}.`);
+			});
+
+			this.client.login(config.get("apis.discord").token);
+		});
+	}
+
+	async sendAdminAlertMessage(message, color, type, critical, extraFields) {
+		try { await this._validateHook(); } catch { return; }
+
+		const channel = this.client.channels.find(channel => channel.id === this.adminAlertChannelId);
+		if (channel !== null) {
+			let richEmbed = new Discord.RichEmbed();
+			richEmbed.setAuthor(
+				"Musare Logger",
+				`${config.get("domain")}/favicon-194x194.png`,
+				config.get("domain")
+			);
+			richEmbed.setColor(color);
+			richEmbed.setDescription(message);
+			//richEmbed.setFooter("Footer", "https://musare.com/favicon-194x194.png");
+			//richEmbed.setImage("https://musare.com/favicon-194x194.png");
+			//richEmbed.setThumbnail("https://musare.com/favicon-194x194.png");
+			richEmbed.setTimestamp(new Date());
+			richEmbed.setTitle("MUSARE ALERT");
+			richEmbed.setURL(config.get("domain"));
+			richEmbed.addField("Type:", type, true);
+			richEmbed.addField("Critical:", critical ? "True" : "False", true);
+			extraFields.forEach(extraField => {
+				richEmbed.addField(
+					extraField.name,
+					extraField.value,
+					extraField.inline
+				);
+			});
+
+			channel
+			.send(message, { embed: richEmbed })
+			.then(message =>
+				this.logger.success("SEND_ADMIN_ALERT_MESSAGE", `Sent admin alert message: ${message}`)
+			)
+			.catch(() =>
+				this.logger.error("SEND_ADMIN_ALERT_MESSAGE", "Couldn't send admin alert message")
+			);
+		} else {
+			this.logger.error("SEND_ADMIN_ALERT_MESSAGE", "Couldn't send admin alert message, channel was not found.");
+		}
+	}
+}

+ 165 - 137
backend/logic/io.js

@@ -2,165 +2,193 @@
 
 // This file contains all the logic for Socket.IO
 
-const app = require('./app');
-const actions = require('./actions');
-const async = require('async');
-const cache = require('./cache');
-const utils = require('./utils');
-const db = require('./db');
-const logger = require('./logger');
-const punishments = require('./punishments');
+const coreClass = require("../core");
 
-let initialized = false;
-let lockdown = false;
+const socketio = require("socket.io");
+const async = require("async");
+const config = require("config");
 
-module.exports = {
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-	io: null,
+		this.dependsOn = ["app", "db", "cache", "utils"];
+	}
 
-	init: (cb) => {
-		//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 = require('socket.io')(app.server);
+	initialize() {
+		return new Promise(resolve => {
+			this.setStage(1);
 
-		this.io.use((socket, next) => {
-			if (lockdown) return;
-			let cookies = socket.request.headers.cookie;
-			let SID = utils.cookies.parseCookies(cookies).SID;
+			const 	logger		= this.logger,
+					app			= this.moduleManager.modules["app"],
+					cache		= this.moduleManager.modules["cache"],
+					utils		= this.moduleManager.modules["utils"],
+					db			= this.moduleManager.modules["db"],
+					punishments	= this.moduleManager.modules["punishments"];
+			
+			const actions = require('../logic/actions');
 
-			socket.ip = socket.request.headers['x-forwarded-for'] || '0.0.0.0';
+			const SIDname = config.get("cookie.SIDname");
 
-			async.waterfall([
-				(next) => {
-					if (!SID) return next('No SID.');
-					next();
-				},
-				(next) => {
-					cache.hget('sessions', SID, next);
-				},
-				(session, next) => {
-					if (!session) return next('No session found.');
-					session.refreshDate = Date.now();
-					socket.session = session;
-					cache.hset('sessions', SID, session, next);
-				},
-				(res, next) => {
-					punishments.getPunishments((err, punishments) => {
-						const isLoggedIn = !!(socket.session && socket.session.refreshDate);
-						const userId = (isLoggedIn) ? socket.session.userId : null;
-						let ban = 0;
-						let banned = false;
-						punishments.forEach(punishment => {
-							if (punishment.expiresAt > ban) ban = punishment;
-							if (punishment.type === 'banUserId' && isLoggedIn && punishment.value === userId) banned = true;
-							if (punishment.type === 'banUserIp' && punishment.value === socket.ip) banned = true;
+			// 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.use(async (socket, next) => {
+				try { await this._validateHook(); } catch { return; }
+
+				let SID;
+
+				socket.ip = socket.request.headers['x-forwarded-for'] || '0.0.0.0';
+
+				async.waterfall([
+					(next) => {
+						utils.parseCookies(
+							socket.request.headers.cookie
+						).then(res => {
+							SID = res[SIDname];
+							next(null);
 						});
-						socket.banned = banned;
-						socket.ban = ban;
+					},
+
+					(next) => {
+						if (!SID) return next('No SID.');
 						next();
-					});
-				}
-			], () => {
-				if (!socket.session) {
-					socket.session = { socketId: socket.id };
-				} else socket.session.socketId = socket.id;
-				next();
-			});
-		});
+					},
 
-		this.io.on('connection', socket => {
-			if (lockdown) return socket.disconnect(true);
-			let sessionInfo = '';
-			if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
-			if (socket.banned) {
-				logger.info('IO_BANNED_CONNECTION', `A user tried to connect, but is currently banned. IP: ${socket.ip}.${sessionInfo}`);
-				socket.emit('keep.event:banned', socket.ban);
-				socket.disconnect(true);
-			} else {
-				logger.info('IO_CONNECTION', `User connected. IP: ${socket.ip}.${sessionInfo}`);
-
-				// catch when the socket has been disconnected
-				socket.on('disconnect', (reason) => {
-					let sessionInfo = '';
-					if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
-					logger.info('IO_DISCONNECTION', `User disconnected. IP: ${socket.ip}.${sessionInfo}`);
-				});
+					(next) => {
+						cache.hget('sessions', SID, next);
+					},
 
-				// catch errors on the socket (internal to socket.io)
-				socket.on('error', err => console.error(err));
+					(session, next) => {
+						if (!session) return next('No session found.');
 
-				// have the socket listen for each action
-				Object.keys(actions).forEach((namespace) => {
-					Object.keys(actions[namespace]).forEach((action) => {
+						session.refreshDate = Date.now();
+						
+						socket.session = session;
+						cache.hset('sessions', SID, session, next);
+					},
 
-						// the full name of the action
-						let name = `${namespace}.${action}`;
+					(res, next) => {
+						// check if a session's user / IP is banned
+						punishments.getPunishments((err, punishments) => {
+							const isLoggedIn = !!(socket.session && socket.session.refreshDate);
+							const userId = (isLoggedIn) ? socket.session.userId : null;
 
-						// listen for this action to be called
-						socket.on(name, function () {
-							let args = Array.prototype.slice.call(arguments, 0, -1);
-							let cb = arguments[arguments.length - 1];
+							let banishment = { banned: false, ban: 0 };
 
-							if (lockdown) return cb({status: 'failure', message: 'Lockdown'});
+							punishments.forEach(punishment => {
+								if (punishment.expiresAt > banishment.ban) banishment.ban = punishment;
+								if (punishment.type === 'banUserId' && isLoggedIn && punishment.value === userId) banishment.banned = true;
+								if (punishment.type === 'banUserIp' && punishment.value === socket.ip) banishment.banned = true;
+							});
+							
+							socket.banishment = banishment;
 
-							// load the session from the cache
-							cache.hget('sessions', socket.session.sessionId, (err, session) => {
-								if (err && err !== true) {
-									if (typeof cb === 'function') return cb({
-										status: 'error',
-										message: 'An error occurred while obtaining your session'
-									});
-								}
+							next();
+						});
+					}
+				], () => {
+					if (!socket.session) socket.session = { socketId: socket.id };
+					else socket.session.socketId = socket.id;
 
-								// make sure the sockets sessionId isn't set if there is no session
-								if (socket.session.sessionId && session === null) delete socket.session.sessionId;
+					next();
+				});
+			});
+
+			this._io.on('connection', async socket => {
+				try { await this._validateHook(); } catch { return; }
+
+				let sessionInfo = '';
+				
+				if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
+
+				// if session is banned
+				if (socket.banishment && socket.banishment.banned) {
+					logger.info('IO_BANNED_CONNECTION', `A user tried to connect, but is currently banned. IP: ${socket.ip}.${sessionInfo}`);
+					socket.emit('keep.event:banned', socket.banishment.ban);
+					socket.disconnect(true);
+				} else {
+					logger.info('IO_CONNECTION', `User connected. IP: ${socket.ip}.${sessionInfo}`);
+
+					// catch when the socket has been disconnected
+					socket.on('disconnect', () => {
+						if (socket.session.sessionId) sessionInfo = ` UserID: ${socket.session.userId}.`;
+						logger.info('IO_DISCONNECTION', `User disconnected. IP: ${socket.ip}.${sessionInfo}`);
+					});
 
-								// call the action, passing it the session, and the arguments socket.io passed us
-								actions[namespace][action].apply(null, [socket.session].concat(args).concat([
-									(result) => {
-										// respond to the socket with our message
-										if (typeof cb === 'function') return cb(result);
+					// catch errors on the socket (internal to socket.io)
+					socket.on('error', console.error);
+
+					// have the socket listen for each action
+					Object.keys(actions).forEach(namespace => {
+						Object.keys(actions[namespace]).forEach(action => {
+
+							// the full name of the action
+							let name = `${namespace}.${action}`;
+
+							// listen for this action to be called
+							socket.on(name, async (...args) => {
+								let cb = args[args.length - 1];
+								if (typeof cb !== "function")
+									cb = () => {
+										this.logger.info("IO_MODULE", `There was no callback provided for ${name}.`);
 									}
-								]));
-							});
-						})
-					})
-				});
+								else args.pop();
 
-				if (socket.session.sessionId) {
-					cache.hget('sessions', socket.session.sessionId, (err, session) => {
-						if (err && err !== true) socket.emit('ready', false);
-						else if (session && session.userId) {
-							db.models.user.findOne({ _id: session.userId }, (err, user) => {
-								if (err || !user) return socket.emit('ready', false);
-								let role = '';
-								let username = '';
-								let userId = '';
-								if (user) {
-									role = user.role;
-									username = user.username;
-									userId = session.userId;
-								}
-								socket.emit('ready', true, role, username, userId);
-							});
-						} else socket.emit('ready', false);
-					})
-				} else socket.emit('ready', false);
-			}
-		});
+								try { await this._validateHook(); } catch { return cb({status: 'failure', message: 'Lockdown'}); } 
+
+								// load the session from the cache
+								cache.hget('sessions', socket.session.sessionId, (err, session) => {
+									if (err && err !== true) {
+										if (typeof cb === 'function') return cb({
+											status: 'error',
+											message: 'An error occurred while obtaining your session'
+										});
+									}
 
-		initialized = true;
+									// make sure the sockets sessionId isn't set if there is no session
+									if (socket.session.sessionId && session === null) delete socket.session.sessionId;
+
+									// call the action, passing it the session, and the arguments socket.io passed us
+									actions[namespace][action].apply(null, [socket.session].concat(args).concat([
+										(result) => {
+											// respond to the socket with our message
+											if (typeof cb === 'function') return cb(result);
+										}
+									]));
+								});
+							})
+						})
+					});
 
-		if (lockdown) return this._lockdown();
-		cb();
-	},
+					if (socket.session.sessionId) {
+						cache.hget('sessions', socket.session.sessionId, (err, session) => {
+							if (err && err !== true) socket.emit('ready', false);
+							else if (session && session.userId) {
+								db.models.user.findOne({ _id: session.userId }, (err, user) => {
+									if (err || !user) return socket.emit('ready', false);
+									let role = '';
+									let username = '';
+									let userId = '';
+									if (user) {
+										role = user.role;
+										username = user.username;
+										userId = session.userId;
+									}
+									socket.emit('ready', true, role, username, userId);
+								});
+							} else socket.emit('ready', false);
+						})
+					} else socket.emit('ready', false);
+				}
+			});
 
-	_lockdown: () => {
-		this.io.close();
-		let connected = this.io.of('/').connected;
-		for (let key in connected) {
-			connected[key].disconnect('Lockdown');
-		}
-		lockdown = true;
+			resolve();
+		});
 	}
 
-};
+	async io () {
+		try { await this._validateHook(); } catch { return; }
+		return this._io;
+	}
+}

+ 151 - 178
backend/logic/logger.js

@@ -1,78 +1,15 @@
 'use strict';
 
-const dir = `${__dirname}/../../log`;
-const fs = require('fs');
-const config = require('config');
-const Discord = require("discord.js");
-let client;
-let utils;
-
-if (!config.isDocker && !fs.existsSync(`${dir}`)) {
-	fs.mkdirSync(dir);
-}
-
-let started;
-let success = 0;
-let successThisMinute = 0;
-let successThisHour = 0;
-let error = 0;
-let errorThisMinute = 0;
-let errorThisHour = 0;
-let info = 0;
-let infoThisMinute = 0;
-let infoThisHour = 0;
-
-let successUnitsPerMinute = [0,0,0,0,0,0,0,0,0,0];
-let errorUnitsPerMinute = [0,0,0,0,0,0,0,0,0,0];
-let infoUnitsPerMinute = [0,0,0,0,0,0,0,0,0,0];
-
-let successUnitsPerHour = [0,0,0,0,0,0,0,0,0,0];
-let errorUnitsPerHour = [0,0,0,0,0,0,0,0,0,0];
-let infoUnitsPerHour = [0,0,0,0,0,0,0,0,0,0];
-
-function calculateUnits(units, unit) {
-	units.push(unit);
-	if (units.length > 10) units.shift();
-	return units;
-}
-
-function calculateHourUnits() {
-	successUnitsPerHour = calculateUnits(successUnitsPerHour, successThisHour);
-	errorUnitsPerHour = calculateUnits(errorUnitsPerHour, errorThisHour);
-	infoUnitsPerHour = calculateUnits(infoUnitsPerHour, infoThisHour);
+const coreClass = require("../core");
 
-	successThisHour = 0;
-	errorThisHour = 0;
-	infoThisHour = 0;
-
-	utils.emitToRoom('admin.statistics', 'event:admin.statistics.success.units.hour', successUnitsPerHour);
-	utils.emitToRoom('admin.statistics', 'event:admin.statistics.error.units.hour', errorUnitsPerHour);
-	utils.emitToRoom('admin.statistics', 'event:admin.statistics.info.units.hour', infoUnitsPerHour);
-
-	setTimeout(calculateHourUnits, 1000 * 60 * 60)
-}
-
-function calculateMinuteUnits() {
-	successUnitsPerMinute = calculateUnits(successUnitsPerMinute, successThisMinute);
-	errorUnitsPerMinute = calculateUnits(errorUnitsPerMinute, errorThisMinute);
-	infoUnitsPerMinute = calculateUnits(infoUnitsPerMinute, infoThisMinute);
-
-	successThisMinute = 0;
-	errorThisMinute = 0;
-	infoThisMinute = 0;
-
-	utils.emitToRoom('admin.statistics', 'event:admin.statistics.success.units.minute', successUnitsPerMinute);
-	utils.emitToRoom('admin.statistics', 'event:admin.statistics.error.units.minute', errorUnitsPerMinute);
-	utils.emitToRoom('admin.statistics', 'event:admin.statistics.info.units.minute', infoUnitsPerMinute);
-	
-	setTimeout(calculateMinuteUnits, 1000 * 60)
-}
+const config = require('config');
+const fs = require('fs');
 
-let twoDigits = (num) => {
+const twoDigits = (num) => {
 	return (num < 10) ? '0' + num : num;
 };
 
-let getTime = () => {
+const getTime = () => {
 	let time = new Date();
 	return {
 		year: time.getFullYear(),
@@ -84,121 +21,157 @@ let getTime = () => {
 	}
 };
 
-let getTimeFormatted = () => {
+const getTimeFormatted = () => {
 	let time = getTime();
 	return `${time.year}-${twoDigits(time.month)}-${twoDigits(time.day)} ${twoDigits(time.hour)}:${twoDigits(time.minute)}:${twoDigits(time.second)}`;
 }
 
-let initialized = false;
-let lockdown = false;
-
-module.exports = {
-	init: function(cb) {
-		utils = require('./utils');
-		started = Date.now();
-
-		setTimeout(calculateMinuteUnits, 1000 * 60);
-		setTimeout(calculateHourUnits, 1000 * 60 * 60);
-		setTimeout(this.calculate, 1000 * 30);
-
-		let time = getTimeFormatted();
-		fs.appendFile(dir + '/all.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-		fs.appendFile(dir + '/success.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-		fs.appendFile(dir + '/error.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-		fs.appendFile(dir + '/info.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-		fs.appendFile(dir + '/debugStation.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
-
-		initialized = true;
-
-		if (lockdown) return this._lockdown();
-		cb();
-	},
-	success: (type, message, display = true) => {
-		if (lockdown) return;
-		success++;
-		successThisMinute++;
-		successThisHour++;
-		let time = getTimeFormatted();
-		fs.appendFile(dir + '/all.log', `${time} SUCCESS - ${type} - ${message}\n`, ()=>{});
-		fs.appendFile(dir + '/success.log', `${time} SUCCESS - ${type} - ${message}\n`, ()=>{});
-		if (display) console.info('\x1b[32m', time, 'SUCCESS', '-', type, '-', message, '\x1b[0m');
-	},
-	error: (type, message, display = true) => {
-		if (lockdown) return;
-		error++;
-		errorThisMinute++;
-		errorThisHour++;
-		let time = getTimeFormatted();
-		fs.appendFile(dir + '/all.log', `${time} ERROR - ${type} - ${message}\n`, ()=>{});
-		fs.appendFile(dir + '/error.log', `${time} ERROR - ${type} - ${message}\n`, ()=>{});
-		if (display) console.warn('\x1b[31m', time, 'ERROR', '-', type, '-', message, '\x1b[0m');
-	},
-	info: (type, message, display = true) => {
-		if (lockdown) return;
-		info++;
-		infoThisMinute++;
-		infoThisHour++;
-		let time = getTimeFormatted();
-		fs.appendFile(dir + '/all.log', `${time} INFO - ${type} - ${message}\n`, ()=>{});
-		fs.appendFile(dir + '/info.log', `${time} INFO - ${type} - ${message}\n`, ()=>{});
-		if (display) console.info('\x1b[36m', time, 'INFO', '-', type, '-', message, '\x1b[0m');
-	},
-	stationIssue: (string, display = false) => {
-		if (lockdown) return;
-		let time = getTimeFormatted();
-		fs.appendFile(dir + '/debugStation.log', `${time} - ${string}\n`, ()=>{});
-		if (display) console.info('\x1b[35m', time, '-', string, '\x1b[0m');
-	},
-	calculatePerSecond: function(number) {
-		if (lockdown) return;
-		let secondsRunning = Math.floor((Date.now() - started) / 1000);
-		let perSecond = number / secondsRunning;
-		return perSecond;
-	},
-	calculatePerMinute: function(number) {
-		if (lockdown) return;
-		let perMinute = this.calculatePerSecond(number) * 60;
-		return perMinute;
-	},
-	calculatePerHour: function(number) {
-		if (lockdown) return;
-		let perHour = this.calculatePerMinute(number) * 60;
-		return perHour;
-	},
-	calculatePerDay: function(number) {
-		if (lockdown) return;
-		let perDay = this.calculatePerHour(number) * 24;
-		return perDay;
-	},
-	calculate: function() {
-		if (lockdown) return;
-		let _this = module.exports;
-		utils.emitToRoom('admin.statistics', 'event:admin.statistics.logs', {
-			second: {
-				success: _this.calculatePerSecond(success),
-				error: _this.calculatePerSecond(error),
-				info: _this.calculatePerSecond(info)
-			},
-			minute: {
-				success: _this.calculatePerMinute(success),
-				error: _this.calculatePerMinute(error),
-				info: _this.calculatePerMinute(info)
-			},
-			hour: {
-				success: _this.calculatePerHour(success),
-				error: _this.calculatePerHour(error),
-				info: _this.calculatePerHour(info)
-			},
-			day: {
-				success: _this.calculatePerDay(success),
-				error: _this.calculatePerDay(error),
-				info: _this.calculatePerDay(info)
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
+		this.lockdownImmune = true;
+	}
+
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.configDirectory = `${__dirname}/../../log`;
+
+			if (!config.isDocker && !fs.existsSync(`${this.configDirectory}`))
+				fs.mkdirSync(this.configDirectory);
+
+			let time = getTimeFormatted();
+
+			this.logCbs = [];
+
+			this.colors = {
+				Reset: "\x1b[0m",
+				Bright: "\x1b[1m",
+				Dim: "\x1b[2m",
+				Underscore: "\x1b[4m",
+				Blink: "\x1b[5m",
+				Reverse: "\x1b[7m",
+				Hidden: "\x1b[8m",
+
+				FgBlack: "\x1b[30m",
+				FgRed: "\x1b[31m",
+				FgGreen: "\x1b[32m",
+				FgYellow: "\x1b[33m",
+				FgBlue: "\x1b[34m",
+				FgMagenta: "\x1b[35m",
+				FgCyan: "\x1b[36m",
+				FgWhite: "\x1b[37m",
+
+				BgBlack: "\x1b[40m",
+				BgRed: "\x1b[41m",
+				BgGreen: "\x1b[42m",
+				BgYellow: "\x1b[43m",
+				BgBlue: "\x1b[44m",
+				BgMagenta: "\x1b[45m",
+				BgCyan: "\x1b[46m",
+				BgWhite: "\x1b[47m"
+			};
+
+			fs.appendFile(this.configDirectory + '/all.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
+			fs.appendFile(this.configDirectory + '/success.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
+			fs.appendFile(this.configDirectory + '/error.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
+			fs.appendFile(this.configDirectory + '/info.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
+			fs.appendFile(this.configDirectory + '/debugStation.log', `${time} BACKEND_RESTARTED\n`, ()=>{});
+
+			if (this.moduleManager.fancyConsole) {
+				process.stdout.write(Array(this.reservedLines).fill(`\n`).join(""));
 			}
+
+			resolve();
 		});
-		setTimeout(_this.calculate, 1000 * 30);
-	},
+	}
+
+	async success(type, text, display = true) {
+		try { await this._validateHook(); } catch { return; }
 
-	_lockdown: () => {
-		lockdown = true;
+		const time = getTimeFormatted();
+		const message = `${time} SUCCESS - ${type} - ${text}`;
+
+		this.writeFile('all.log', message);
+		this.writeFile('success.log', message);
+
+		if (display) this.log(this.colors.FgGreen, message);
 	}
-};
+
+	async error(type, text, display = true) {
+		try { await this._validateHook(); } catch { return; }
+
+		const time = getTimeFormatted();
+		const message = `${time} ERROR - ${type} - ${text}`;
+
+		this.writeFile('all.log', message);
+		this.writeFile('error.log', message);
+
+		if (display) this.log(this.colors.FgRed, message);
+	}
+
+	async info(type, text, display = true) {
+		try { await this._validateHook(); } catch { return; }
+
+		const time = getTimeFormatted();
+		const message = `${time} INFO - ${type} - ${text}`;
+
+		this.writeFile('all.log', message);
+		this.writeFile('info.log', message);
+		if (display) this.log(this.colors.FgCyan, message);
+	}
+
+	async debug(text, display = true) {
+		try { await this._validateHook(); } catch { return; }
+
+		const time = getTimeFormatted();
+		const message = `${time} DEBUG - ${text}`;
+
+		if (display) this.log(this.colors.FgMagenta, message);
+	}
+
+	async stationIssue(text, display = false) {
+		try { await this._validateHook(); } catch { return; }
+
+		const time = getTimeFormatted();
+		const message = `${time} DEBUG_STATION - ${text}`;
+
+		this.writeFile('debugStation.log', message);
+
+		if (display) this.log(this.colors.FgMagenta, message);
+	}
+
+	log(color, message) {
+		if (this.moduleManager.fancyConsole) {
+			const rows = process.stdout.rows;
+			const columns = process.stdout.columns;
+			const lineNumber = rows - this.reservedLines;
+
+			
+			let lines = 0;
+			
+			message.split("\n").forEach((line) => {
+				lines += Math.floor(line.replace("\t", "    ").length / columns) + 1;
+			});
+
+			if (lines > this.logger.reservedLines)
+				lines = this.logger.reservedLines;
+
+			process.stdout.cursorTo(0, rows - this.logger.reservedLines);
+			process.stdout.clearScreenDown();
+
+			process.stdout.cursorTo(0, lineNumber);
+			process.stdout.write(`${color}${message}${this.colors.Reset}\n`);
+
+			process.stdout.cursorTo(0, process.stdout.rows);
+			process.stdout.write(Array(lines).fill(`\n!`).join(""));
+
+			this.moduleManager.printStatus();
+		} else console.log(`${color}${message}${this.colors.Reset}`);
+	}
+
+	writeFile(fileName, message) {
+		fs.appendFile(`${this.configDirectory}/${fileName}`, `${message}\n`, ()=>{});
+	}
+}

+ 30 - 35
backend/logic/mail/index.js

@@ -1,45 +1,40 @@
 'use strict';
 
-const config = require('config');
-const enabled = config.get('apis.mailgun.enabled');
-let mailgun = null;
-if (enabled) {
-	mailgun = require('mailgun-js')({
-		apiKey: config.get("apis.mailgun.key"),
-		domain: config.get("apis.mailgun.domain")
-	});
-}
+const coreClass = require("../../core");
 
-let initialized = false;
-let lockdown = false;
-
-let lib = {
-
-	schemas: {},
+const config = require('config');
 
-	init: (cb) => {
-		lib.schemas = {
-			verifyEmail: require('./schemas/verifyEmail'),
-			resetPasswordRequest: require('./schemas/resetPasswordRequest'),
-			passwordRequest: require('./schemas/passwordRequest')
-		};
+let mailgun = null;
 
-		initialized = true;
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.schemas = {
+				verifyEmail: require('./schemas/verifyEmail'),
+				resetPasswordRequest: require('./schemas/resetPasswordRequest'),
+				passwordRequest: require('./schemas/passwordRequest')
+			};
+
+			this.enabled = config.get('apis.mailgun.enabled');
+
+			if (this.enabled)
+				mailgun = require('mailgun-js')({
+					apiKey: config.get("apis.mailgun.key"),
+					domain: config.get("apis.mailgun.domain")
+				});
+			
+			resolve();
+		});
+	}
 
-		if (lockdown) return this._lockdown();
-		cb();
-	},
+	async sendMail(data, cb) {
+		try { await this._validateHook(); } catch { return; }
 
-	sendMail: (data, cb) => {
-		if (lockdown) return cb('Lockdown');
 		if (!cb) cb = ()=>{};
-		if (enabled) mailgun.messages().send(data, cb);
-		else cb();
-	},
 
-	_lockdown: () => {
-		lockdown = true;
+		if (this.enabled) mailgun.messages().send(data, cb);
+		else cb();
 	}
-};
-
-module.exports = lib;
+}

+ 4 - 1
backend/logic/mail/schemas/passwordRequest.js

@@ -1,5 +1,8 @@
 const config = require('config');
-const mail = require('../index');
+
+const moduleManager = require('../../../index');
+
+const mail = moduleManager.modules["mail"];
 
 /**
  * Sends a request password email

+ 4 - 1
backend/logic/mail/schemas/resetPasswordRequest.js

@@ -1,5 +1,8 @@
 const config = require('config');
-const mail = require('../index');
+
+const moduleManager = require('../../../index');
+
+const mail = moduleManager.modules["mail"];
 
 /**
  * Sends a request password reset email

+ 4 - 1
backend/logic/mail/schemas/verifyEmail.js

@@ -1,5 +1,8 @@
 const config = require('config');
-const mail = require('../index');
+
+const moduleManager = require('../../../index');
+
+const mail = moduleManager.modules["mail"];
 
 /**
  * Sends a verify email email

+ 111 - 58
backend/logic/notifications.js

@@ -1,49 +1,105 @@
 'use strict';
 
+const coreClass = require("../core");
+
 const crypto = require('crypto');
 const redis = require('redis');
-const logger = require('./logger');
+const config = require('config');
 
 const subscriptions = [];
 
-let initialized = false;
-let lockdown = false;
-let errorCb;
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
 
-const lib = {
+			const url = this.url = config.get("redis").url;
+			const password = this.password = config.get("redis").password;
 
-	pub: null,
-	sub: null,
-	errorCb: null,
+			this.pub = redis.createClient({
+				url,
+				password,
+				retry_strategy: (options) => {
+					if (this.state === "LOCKDOWN") return;
+					if (this.state !== "RECONNECTING") this.setState("RECONNECTING");
 
-	/**
-	 * Initializes the notifications module
-	 *
-	 * @param {String} url - the url of the redis server
-	 * @param {String} password - the password of the redis server
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	init: (url, password, errorCb, cb) => {
-		lib.errorCb = errorCb;
-		lib.pub = redis.createClient({ url, password });
-		lib.sub = redis.createClient({ url, password });
-		lib.sub.on('error', (err) => {
-			errorCb('Cache connection error.', err, 'Notifications');
-		});
-		lib.sub.on('pmessage', (pattern, channel, expiredKey) => {
-			logger.stationIssue(`PMESSAGE - Pattern: ${pattern}; Channel: ${channel}; ExpiredKey: ${expiredKey}`);
-			subscriptions.forEach((sub) => {
-				if (sub.name !== expiredKey) return;
-				sub.cb();
+					this.logger.info("NOTIFICATIONS_MODULE", `Attempting to reconnect pub.`);
+
+					if (options.attempt >= 10) {
+						this.logger.error("NOTIFICATIONS_MODULE", `Stopped trying to reconnect pub.`);
+
+						this.failed = true;
+						this._lockdown();
+
+						return undefined;
+					}
+
+					return 3000;
+				}
 			});
-		});
-		lib.sub.psubscribe('__keyevent@0__:expired');
+			this.sub = redis.createClient({
+				url,
+				password,
+				retry_strategy: (options) => {
+					if (this.state === "LOCKDOWN") return;
+					if (this.state !== "RECONNECTING") this.setState("RECONNECTING");
 
-		initialized = true;
+					this.logger.info("NOTIFICATIONS_MODULE", `Attempting to reconnect sub.`);
 
-		if (lockdown) return this._lockdown();
-		cb();
-	},
+					if (options.attempt >= 10) {
+						this.logger.error("NOTIFICATIONS_MODULE", `Stopped trying to reconnect sub.`);
+
+						this.failed = true;
+						this._lockdown();
+
+						return undefined;
+					}
+
+					return 3000;
+				}
+			});
+
+			this.sub.on('error', (err) => {
+				if (this.state === "INITIALIZING") reject(err);
+				if(this.state === "LOCKDOWN") return;
+
+				this.logger.error("NOTIFICATIONS_MODULE", `Sub error ${err.message}.`);
+			});
+
+			this.pub.on('error', (err) => {
+				if (this.state === "INITIALIZING") reject(err);
+				if(this.state === "LOCKDOWN") return; 
+
+				this.logger.error("NOTIFICATIONS_MODULE", `Pub error ${err.message}.`);
+			});
+
+			this.sub.on("connect", () => {
+				this.logger.info("NOTIFICATIONS_MODULE", "Sub connected succesfully.");
+
+				if (this.state === "INITIALIZING") resolve();
+				else if (this.state === "LOCKDOWN" || this.state === "RECONNECTING") this.setState("INITIALIZED");
+				
+			});
+
+			this.pub.on("connect", () => {
+				this.logger.info("NOTIFICATIONS_MODULE", "Pub connected succesfully.");
+
+				if (this.state === "INITIALIZING") resolve();
+				else if (this.state === "LOCKDOWN" || this.state === "RECONNECTING") this.setState("INITIALIZED");
+			});
+
+			this.sub.on('pmessage', (pattern, channel, 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();
+				});
+			});
+
+			this.sub.psubscribe('__keyevent@0__:expired');
+		});
+	}
 
 	/**
 	 * Schedules a notification to be dispatched in a specific amount of milliseconds,
@@ -54,13 +110,15 @@ const lib = {
 	 * @param {Integer} time - how long in milliseconds until the notification should be fired
 	 * @param {Function} cb - gets called when the notification has been scheduled
 	 */
-	schedule: (name, time, cb, station) => {
-		if (lockdown) return;
+	async schedule(name, time, cb, station) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (!cb) cb = ()=>{};
+
 		time = Math.round(time);
-		logger.stationIssue(`SCHEDULE - Time: ${time}; Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}; StationId: ${station._id}; StationName: ${station.name}`);
-		lib.pub.set(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), '', 'PX', time, 'NX', cb);
-	},
+		this.logger.stationIssue(`SCHEDULE - Time: ${time}; Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}; StationId: ${station._id}; StationName: ${station.name}`);
+		this.pub.set(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), '', 'PX', time, 'NX', cb);
+	}
 
 	/**
 	 * Subscribes a callback function to be called when a notification gets called
@@ -70,37 +128,32 @@ const lib = {
 	 * @param {Boolean} unique - only subscribe if another subscription with the same name doesn't already exist
 	 * @return {Object} - the subscription object
 	 */
-	subscribe: (name, cb, unique = false, station) => {
-		if (lockdown) return;
-		logger.stationIssue(`SUBSCRIBE - Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}, StationId: ${station._id}; StationName: ${station.name}; Unique: ${unique}; SubscriptionExists: ${!!subscriptions.find((subscription) => subscription.originalName == name)};`);
+	async subscribe(name, cb, unique = false, station) {
+		try { await this._validateHook(); } catch { return; }
+
+		this.logger.stationIssue(`SUBSCRIBE - Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}, StationId: ${station._id}; StationName: ${station.name}; Unique: ${unique}; SubscriptionExists: ${!!subscriptions.find((subscription) => subscription.originalName == name)};`);
 		if (unique && !!subscriptions.find((subscription) => subscription.originalName == name)) return;
 		let subscription = { originalName: name, name: crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'), cb };
 		subscriptions.push(subscription);
 		return subscription;
-	},
+	}
 
 	/**
 	 * Remove a notification subscription
 	 *
 	 * @param {Object} subscription - the subscription object returned by {@link subscribe}
 	 */
-	remove: (subscription) => {
-		if (lockdown) return;
+	async remove(subscription) {
+		try { await this._validateHook(); } catch { return; }
+
 		let index = subscriptions.indexOf(subscription);
 		if (index) subscriptions.splice(index, 1);
-	},
-
-	unschedule: (name) => {
-		if (lockdown) return;
-		logger.stationIssue(`UNSCHEDULE - Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}`);
-		lib.pub.del(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'));
-	},
-
-	_lockdown: () => {
-		lib.pub.quit();
-		lib.sub.quit();
-		lockdown = true;
 	}
-};
 
-module.exports = lib;
+	async unschedule(name) {
+		try { await this._validateHook(); } catch { return; }
+
+		this.logger.stationIssue(`UNSCHEDULE - Name: ${name}; Key: ${crypto.createHash('md5').update(`_notification:${name}_`).digest('hex')}`);
+		this.pub.del(crypto.createHash('md5').update(`_notification:${name}_`).digest('hex'));
+	}
+}

+ 79 - 74
backend/logic/playlists.js

@@ -1,59 +1,66 @@
 'use strict';
 
-const cache = require('./cache');
-const db = require('./db');
-const async = require('async');
-
-let initialized = false;
-let lockdown = false;
-
-module.exports = {
+const coreClass = require("../core");
 
-	/**
-	 * Initializes the playlists module, and exits if it is unsuccessful
-	 *
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	init: cb => {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('playlists', next);
-			},
+const async = require('async');
 
-			(playlists, next) => {
-				if (!playlists) return next();
-				let playlistIds = Object.keys(playlists);
-				async.each(playlistIds, (playlistId, next) => {
-					db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
-						if (err) next(err);
-						else if (!playlist) {
-							cache.hdel('playlists', playlistId, next);
-						}
-						else next();
-					});
-				}, next);
-			},
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-			(next) => {
-				db.models.playlist.find({}, next);
-			},
+		this.dependsOn = ["cache", "db", "utils"];
+	}
 
-			(playlists, next) => {
-				async.each(playlists, (playlist, next) => {
-					cache.hset('playlists', playlist._id, cache.schemas.playlist(playlist), next);
-				}, next);
-			}
-		], (err) => {
-			if (lockdown) return this._lockdown();
-			if (err) {
-				err = utils.getError(err);
-				cb(err);
-			} else {
-				initialized = true;
-				cb();
-			}
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.cache = this.moduleManager.modules["cache"];
+			this.db	= this.moduleManager.modules["db"];
+			this.utils	= this.moduleManager.modules["utils"];
+
+			async.waterfall([
+				(next) => {
+					this.setStage(2);
+					this.cache.hgetall('playlists', next);
+				},
+	
+				(playlists, next) => {
+					this.setStage(3);
+					if (!playlists) return next();
+					let playlistIds = Object.keys(playlists);
+					async.each(playlistIds, (playlistId, next) => {
+						this.db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
+							if (err) next(err);
+							else if (!playlist) {
+								this.cache.hdel('playlists', playlistId, next);
+							}
+							else next();
+						});
+					}, next);
+				},
+	
+				(next) => {
+					this.setStage(4);
+					this.db.models.playlist.find({}, next);
+				},
+	
+				(playlists, next) => {
+					this.setStage(5);
+					async.each(playlists, (playlist, next) => {
+						this.cache.hset('playlists', playlist._id, this.cache.schemas.playlist(playlist), next);
+					}, next);
+				}
+			], async (err) => {
+				if (err) {
+					err = await this.utils.getError(err);
+					reject(err);
+				} else {
+					resolve();
+				}
+			});
 		});
-	},
+	}
 
 	/**
 	 * Gets a playlist by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
@@ -61,21 +68,22 @@ module.exports = {
 	 * @param {String} playlistId - the id of the playlist we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getPlaylist: (playlistId, cb) => {
-		if (lockdown) return cb('Lockdown');
+	async getPlaylist(playlistId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 			(next) => {
-				cache.hgetall('playlists', next);
+				this.cache.hgetall('playlists', next);
 			},
 
 			(playlists, next) => {
 				if (!playlists) return next();
 				let playlistIds = Object.keys(playlists);
 				async.each(playlistIds, (playlistId, next) => {
-					db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
+					this.db.models.playlist.findOne({_id: playlistId}, (err, playlist) => {
 						if (err) next(err);
 						else if (!playlist) {
-							cache.hdel('playlists', playlistId, next);
+							this.cache.hdel('playlists', playlistId, next);
 						}
 						else next();
 					});
@@ -83,17 +91,17 @@ module.exports = {
 			},
 
 			(next) => {
-				cache.hget('playlists', playlistId, next);
+				this.cache.hget('playlists', playlistId, next);
 			},
 
 			(playlist, next) => {
 				if (playlist) return next(true, playlist);
-				db.models.playlist.findOne({ _id: playlistId }, next);
+				this.db.models.playlist.findOne({ _id: playlistId }, next);
 			},
 
 			(playlist, next) => {
 				if (playlist) {
-					cache.hset('playlists', playlistId, playlist, next);
+					this.cache.hset('playlists', playlistId, playlist, next);
 				} else next('Playlist not found');
 			},
 
@@ -101,7 +109,7 @@ module.exports = {
 			if (err && err !== true) return cb(err);
 			else cb(null, playlist);
 		});
-	},
+	}
 
 	/**
 	 * Gets a playlist from id from Mongo and updates the cache with it
@@ -109,27 +117,27 @@ module.exports = {
 	 * @param {String} playlistId - the id of the playlist we are trying to update
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
-	updatePlaylist: (playlistId, cb) => {
-		if (lockdown) return cb('Lockdown');
-		async.waterfall([
+	async updatePlaylist(playlistId, cb) {
+		try { await this._validateHook(); } catch { return; }
 
+		async.waterfall([
 			(next) => {
-				db.models.playlist.findOne({ _id: playlistId }, next);
+				this.db.models.playlist.findOne({ _id: playlistId }, next);
 			},
 
 			(playlist, next) => {
 				if (!playlist) {
-					cache.hdel('playlists', playlistId);
+					this.cache.hdel('playlists', playlistId);
 					return next('Playlist not found');
 				}
-				cache.hset('playlists', playlistId, playlist, next);
+				this.cache.hset('playlists', playlistId, playlist, next);
 			}
 
 		], (err, playlist) => {
 			if (err && err !== true) return cb(err);
 			cb(null, playlist);
 		});
-	},
+	}
 
 	/**
 	 * Deletes playlist from id from Mongo and cache
@@ -137,16 +145,17 @@ module.exports = {
 	 * @param {String} playlistId - the id of the playlist we are trying to delete
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
-	deletePlaylist: (playlistId, cb) => {
-		if (lockdown) return cb('Lockdown');
+	async deletePlaylist(playlistId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 
 			(next) => {
-				db.models.playlist.remove({ _id: playlistId }, next);
+				this.db.models.playlist.deleteOne({ _id: playlistId }, next);
 			},
 
 			(res, next) => {
-				cache.hdel('playlists', playlistId, next);
+				this.cache.hdel('playlists', playlistId, next);
 			}
 
 		], (err) => {
@@ -154,9 +163,5 @@ module.exports = {
 
 			cb(null);
 		});
-	},
-
-	_lockdown: () => {
-		lockdown = true;
 	}
-};
+}

+ 87 - 79
backend/logic/punishments.js

@@ -1,73 +1,80 @@
 'use strict';
 
-const cache = require('./cache');
-const db = require('./db');
-const io = require('./io');
-const utils = require('./utils');
+const coreClass = require("../core");
+
 const async = require('async');
 const mongoose = require('mongoose');
 
-let initialized = false;
-let lockdown = false;
-
-module.exports = {
-
-	/**
-	 * Initializes the punishments module, and exits if it is unsuccessful
-	 *
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	init: cb => {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('punishments', next);
-			},
-
-			(punishments, next) => {
-				if (!punishments) return next();
-				let punishmentIds = Object.keys(punishments);
-				async.each(punishmentIds, (punishmentId, next) => {
-					db.models.punishment.findOne({_id: punishmentId}, (err, punishment) => {
-						if (err) next(err);
-						else if (!punishment) cache.hdel('punishments', punishmentId, next);
-						else next();
-					});
-				}, next);
-			},
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-			(next) => {
-				db.models.punishment.find({}, next);
-			},
+		this.dependsOn = ["cache", "db", "utils"];
+	}
 
-			(punishments, next) => {
-				async.each(punishments, (punishment, next) => {
-					if (punishment.active === false || punishment.expiresAt < Date.now()) return next();
-					cache.hset('punishments', punishment._id, cache.schemas.punishment(punishment, punishment._id), next);
-				}, next);
-			}
-		], (err) => {
-			if (lockdown) return this._lockdown();
-			if (err) {
-				err = utils.getError(err);
-				cb(err);
-			} else {
-				initialized = true;
-				cb();
-			}
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.cache = this.moduleManager.modules['cache'];
+			this.db = this.moduleManager.modules['db'];
+			this.io = this.moduleManager.modules['io'];
+			this.utils = this.moduleManager.modules['utils'];
+
+			async.waterfall([
+				(next) => {
+					this.setStage(2);
+					this.cache.hgetall('punishments', next);
+				},
+	
+				(punishments, next) => {
+					this.setStage(3);
+					if (!punishments) return next();
+					let punishmentIds = Object.keys(punishments);
+					async.each(punishmentIds, (punishmentId, next) => {
+						this.db.models.punishment.findOne({_id: punishmentId}, (err, punishment) => {
+							if (err) next(err);
+							else if (!punishment) this.cache.hdel('punishments', punishmentId, next);
+							else next();
+						});
+					}, next);
+				},
+	
+				(next) => {
+					this.setStage(4);
+					this.db.models.punishment.find({}, next);
+				},
+	
+				(punishments, next) => {
+					this.setStage(5);
+					async.each(punishments, (punishment, next) => {
+						if (punishment.active === false || punishment.expiresAt < Date.now()) return next();
+						this.cache.hset('punishments', punishment._id, this.cache.schemas.punishment(punishment, punishment._id), next);
+					}, next);
+				}
+			], async (err) => {
+				if (err) {
+					err = await utils.getError(err);
+					reject(err);
+				} else {
+					resolve();
+				}
+			});
 		});
-	},
+	}
 
 	/**
 	 * Gets all punishments in the cache that are active, and removes those that have expired
 	 *
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getPunishments: function(cb) {
-		if (lockdown) return cb('Lockdown');
+	async getPunishments(cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		let punishmentsToRemove = [];
 		async.waterfall([
 			(next) => {
-				cache.hgetall('punishments', next);
+				this.cache.hgetall('punishments', next);
 			},
 
 			(punishmentsObj, next) => {
@@ -88,7 +95,7 @@ module.exports = {
 				async.each(
 					punishmentsToRemove,
 					(punishment, next2) => {
-						cache.hdel('punishments', punishment.punishmentId, () => {
+						this.cache.hdel('punishments', punishment.punishmentId, () => {
 							next2();
 						});
 					},
@@ -102,7 +109,7 @@ module.exports = {
 
 			cb(null, punishments);
 		});
-	},
+	}
 
 	/**
 	 * Gets a punishment by id
@@ -110,23 +117,24 @@ module.exports = {
 	 * @param {String} id - the id of the punishment we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getPunishment: function(id, cb) {
-		if (lockdown) return cb('Lockdown');
+	async getPunishment(id, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 
 			(next) => {
 				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
-				cache.hget('punishments', id, next);
+				this.cache.hget('punishments', id, next);
 			},
 
 			(punishment, next) => {
 				if (punishment) return next(true, punishment);
-				db.models.punishment.findOne({_id: id}, next);
+				this.db.models.punishment.findOne({_id: id}, next);
 			},
 
 			(punishment, next) => {
 				if (punishment) {
-					cache.hset('punishments', id, punishment, next);
+					this.cache.hset('punishments', id, punishment, next);
 				} else next('Punishment not found.');
 			},
 
@@ -135,7 +143,7 @@ module.exports = {
 
 			cb(null, punishment);
 		});
-	},
+	}
 
 	/**
 	 * Gets all punishments from a userId
@@ -143,11 +151,12 @@ module.exports = {
 	 * @param {String} userId - the userId of the punishment(s) we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getPunishmentsFromUserId: function(userId, cb) {
-		if (lockdown) return cb('Lockdown');
+	async getPunishmentsFromUserId(userId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 			(next) => {
-				module.exports.getPunishments(next);
+				this.getPunishments(next);
 			},
 			(punishments, next) => {
 				punishments = punishments.filter((punishment) => {
@@ -160,13 +169,14 @@ module.exports = {
 
 			cb(null, punishments);
 		});
-	},
+	}
+
+	async addPunishment(type, value, reason, expiresAt, punishedBy, cb) {
+		try { await this._validateHook(); } catch { return; }
 
-	addPunishment: function(type, value, reason, expiresAt, punishedBy, cb) {
-		if (lockdown) return cb('Lockdown');
 		async.waterfall([
 			(next) => {
-				const punishment = new db.models.punishment({
+				const punishment = new this.db.models.punishment({
 					type,
 					value,
 					reason,
@@ -182,7 +192,7 @@ module.exports = {
 			},
 
 			(punishment, next) => {
-				cache.hset('punishments', punishment._id, cache.schemas.punishment(punishment, punishment._id), (err) => {
+				this.cache.hset('punishments', punishment._id, this.cache.schemas.punishment(punishment, punishment._id), (err) => {
 					next(err, punishment);
 				});
 			},
@@ -194,13 +204,14 @@ module.exports = {
 		], (err, punishment) => {
 			cb(err, punishment);
 		});
-	},
+	}
+
+	async removePunishmentFromCache(punishmentId, cb) {
+		try { await this._validateHook(); } catch { return; }
 
-	removePunishmentFromCache: function(punishmentId, cb) {
-		if (lockdown) return cb('Lockdown');
 		async.waterfall([
 			(next) => {
-				const punishment = new db.models.punishment({
+				const punishment = new this.db.models.punishment({
 					type,
 					value,
 					reason,
@@ -217,7 +228,7 @@ module.exports = {
 			},
 
 			(punishment, next) => {
-				cache.hset('punishments', punishment._id, punishment, next);
+				this.cache.hset('punishments', punishment._id, punishment, next);
 			},
 
 			(punishment, next) => {
@@ -227,9 +238,6 @@ module.exports = {
 		], (err) => {
 			cb(err);
 		});
-	},
-
-	_lockdown: () => {
-		lockdown = true;
 	}
-};
+}
+

+ 80 - 72
backend/logic/songs.js

@@ -1,60 +1,69 @@
 'use strict';
 
-const cache = require('./cache');
-const db = require('./db');
-const io = require('./io');
-const utils = require('./utils');
+const coreClass = require("../core");
+
 const async = require('async');
 const mongoose = require('mongoose');
 
-let initialized = false;
-let lockdown = false;
 
-module.exports = {
 
-	/**
-	 * Initializes the songs module, and exits if it is unsuccessful
-	 *
-	 * @param {Function} cb - gets called once we're done initializing
-	 */
-	init: cb => {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('songs', next);
-			},
 
-			(songs, next) => {
-				if (!songs) return next();
-				let songIds = Object.keys(songs);
-				async.each(songIds, (songId, next) => {
-					db.models.song.findOne({songId}, (err, song) => {
-						if (err) next(err);
-						else if (!song) cache.hdel('songs', songId, next);
-						else next();
-					});
-				}, next);
-			},
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-			(next) => {
-				db.models.song.find({}, next);
-			},
+		this.dependsOn = ["utils", "cache", "db"];
+	}
 
-			(songs, next) => {
-				async.each(songs, (song, next) => {
-					cache.hset('songs', song.songId, cache.schemas.song(song), next);
-				}, next);
-			}
-		], (err) => {
-			if (lockdown) return this._lockdown();
-			if (err) {
-				err = utils.getError(err);
-				cb(err);
-			} else {
-				initialized = true;
-				cb();
-			}
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.cache = this.moduleManager.modules["cache"];
+			this.db = this.moduleManager.modules["db"];
+			this.io = this.moduleManager.modules["io"];
+			this.utils = this.moduleManager.modules["utils"];
+
+			async.waterfall([
+				(next) => {
+					this.setStage(2);
+					this.cache.hgetall('songs', next);
+				},
+	
+				(songs, next) => {
+					this.setStage(3);
+					if (!songs) return next();
+					let songIds = Object.keys(songs);
+					async.each(songIds, (songId, next) => {
+						this.db.models.song.findOne({songId}, (err, song) => {
+							if (err) next(err);
+							else if (!song) this.cache.hdel('songs', songId, next);
+							else next();
+						});
+					}, next);
+				},
+	
+				(next) => {
+					this.setStage(4);
+					this.db.models.song.find({}, next);
+				},
+	
+				(songs, next) => {
+					this.setStage(5);
+					async.each(songs, (song, next) => {
+						this.cache.hset('songs', song.songId, this.cache.schemas.song(song), next);
+					}, next);
+				}
+			], async (err) => {
+				if (err) {
+					err = await this.utils.getError(err);
+					reject(err);
+				} else {
+					resolve();
+				}
+			});
 		});
-	},
+	}
 
 	/**
 	 * Gets a song by id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
@@ -62,23 +71,23 @@ module.exports = {
 	 * @param {String} id - the id of the song we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getSong: function(id, cb) {
-		if (lockdown) return cb('Lockdown');
-		async.waterfall([
+	async getSong(id, cb) {
+		try { await this._validateHook(); } catch { return; }
 
+		async.waterfall([
 			(next) => {
 				if (!mongoose.Types.ObjectId.isValid(id)) return next('Id is not a valid ObjectId.');
-				cache.hget('songs', id, next);
+				this.cache.hget('songs', id, next);
 			},
 
 			(song, next) => {
 				if (song) return next(true, song);
-				db.models.song.findOne({_id: id}, next);
+				this.db.models.song.findOne({_id: id}, next);
 			},
 
 			(song, next) => {
 				if (song) {
-					cache.hset('songs', id, song, next);
+					this.cache.hset('songs', id, song, next);
 				} else next('Song not found.');
 			},
 
@@ -87,7 +96,7 @@ module.exports = {
 
 			cb(null, song);
 		});
-	},
+	}
 
 	/**
 	 * Gets a song by song id from the cache or Mongo, and if it isn't in the cache yet, adds it the cache
@@ -95,17 +104,18 @@ module.exports = {
 	 * @param {String} songId - the mongo id of the song we are trying to get
 	 * @param {Function} cb - gets called once we're done initializing
 	 */
-	getSongFromId: function(songId, cb) {
-		if (lockdown) return cb('Lockdown');
+	async getSongFromId(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 			(next) => {
-				db.models.song.findOne({ songId }, next);
+				this.db.models.song.findOne({ songId }, next);
 			}
 		], (err, song) => {
 			if (err && err !== true) return cb(err);
 			else return cb(null, song);
 		});
-	},
+	}
 
 	/**
 	 * Gets a song from id from Mongo and updates the cache with it
@@ -113,21 +123,22 @@ module.exports = {
 	 * @param {String} songId - the id of the song we are trying to update
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
-	updateSong: (songId, cb) => {
-		if (lockdown) return cb('Lockdown');
+	async updateSong(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 
 			(next) => {
-				db.models.song.findOne({_id: songId}, next);
+				this.db.models.song.findOne({_id: songId}, next);
 			},
 
 			(song, next) => {
 				if (!song) {
-					cache.hdel('songs', songId);
+					this.cache.hdel('songs', songId);
 					return next('Song not found.');
 				}
 
-				cache.hset('songs', songId, song, next);
+				this.cache.hset('songs', songId, song, next);
 			}
 
 		], (err, song) => {
@@ -135,7 +146,7 @@ module.exports = {
 
 			cb(null, song);
 		});
-	},
+	}
 
 	/**
 	 * Deletes song from id from Mongo and cache
@@ -143,16 +154,17 @@ module.exports = {
 	 * @param {String} songId - the id of the song we are trying to delete
 	 * @param {Function} cb - gets called when an error occurred or when the operation was successful
 	 */
-	deleteSong: (songId, cb) => {
-		if (lockdown) return cb('Lockdown');
+	async deleteSong(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 
 			(next) => {
-				db.models.song.remove({ songId }, next);
+				this.db.models.song.deleteOne({ songId }, next);
 			},
 
 			(next) => {
-				cache.hdel('songs', songId, next);
+				this.cache.hdel('songs', songId, next);
 			}
 
 		], (err) => {
@@ -160,9 +172,5 @@ module.exports = {
 
 			cb(null);
 		});
-	},
-
-	_lockdown: () => {
-		lockdown = true;
 	}
-};
+}

+ 95 - 0
backend/logic/spotify.js

@@ -0,0 +1,95 @@
+const coreClass = require("../core");
+
+const config = require('config'),
+	async  = require('async');
+
+let apiResults = {
+	access_token: "",
+	token_type: "",
+	expires_in: 0,
+	expires_at: 0,
+	scope: "",
+};
+
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
+
+		this.dependsOn = ["cache"];
+	}
+
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+
+			this.cache = this.moduleManager.modules["cache"];
+			this.utils = this.moduleManager.modules["utils"];
+
+			const client = config.get("apis.spotify.client");
+			const secret = config.get("apis.spotify.secret");
+
+			const OAuth2 = require('oauth').OAuth2;
+			this.SpotifyOauth = new OAuth2(
+				client,
+				secret, 
+				'https://accounts.spotify.com/', 
+				null,
+				'api/token',
+				null);
+
+			async.waterfall([
+				(next) => {
+					this.setStage(2);
+					this.cache.hget("api", "spotify", next, true);
+				},
+	
+				(data, next) => {
+					this.setStage(3);
+					if (data) apiResults = data;
+					next();
+				}
+			], async (err) => {
+				if (err) {
+					err = await this.utils.getError(err);
+					reject(err);
+				} else {
+					resolve();
+				}
+			});
+		});
+	}
+
+	async getToken() {
+		try { await this._validateHook(); } catch { return; }
+
+		return new Promise((resolve, reject) => {
+			if (Date.now() > apiResults.expires_at) {
+				this.requestToken(() => {
+					resolve(apiResults.access_token);
+				});
+			} else resolve(apiResults.access_token);
+		});
+	}
+
+	async requestToken(cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		async.waterfall([
+			(next) => {
+				this.logger.info("SPOTIFY_REQUEST_TOKEN", "Requesting new Spotify token.");
+				this.SpotifyOauth.getOAuthAccessToken(
+					'',
+					{ 'grant_type': 'client_credentials' },
+					next
+				);
+			},
+			(access_token, refresh_token, results, next) => {
+				apiResults = results;
+				apiResults.expires_at = Date.now() + (results.expires_in * 1000);
+				this.cache.hset("api", "spotify", apiResults, next, true);
+			}
+		], () => {
+			cb();
+		});
+	}
+}

+ 235 - 190
backend/logic/stations.js

@@ -1,130 +1,156 @@
 'use strict';
 
-const cache = require('./cache');
-const db = require('./db');
-const io = require('./io');
-const utils = require('./utils');
-const logger = require('./logger');
-const songs = require('./songs');
-const notifications = require('./notifications');
+const coreClass = require("../core");
+
 const async = require('async');
 
 let subscription = null;
 
-let initialized = false;
-let lockdown = false;
-
-//TEMP
-cache.sub('station.pause', (stationId) => {
-	if (lockdown) return;
-	notifications.remove(`stations.nextSong?id=${stationId}`);
-});
-
-cache.sub('station.resume', (stationId) => {
-	if (lockdown) return;
-	module.exports.initializeStation(stationId)
-});
-
-cache.sub('station.queueUpdate', (stationId) => {
-	if (lockdown) return;
-	module.exports.getStation(stationId, (err, station) => {
-		if (!station.currentSong && station.queue.length > 0) {
-			module.exports.initializeStation(stationId);
-		}
-	});
-});
-
-cache.sub('station.newOfficialPlaylist', (stationId) => {
-	if (lockdown) return;
-	cache.hget("officialPlaylists", stationId, (err, playlistObj) => {
-		if (!err && playlistObj) {
-			utils.emitToRoom(`station.${stationId}`, "event:newOfficialPlaylist", playlistObj.songs);
-		}
-	})
-});
+module.exports = class extends coreClass {
+	constructor(name, moduleManager) {
+		super(name, moduleManager);
 
-module.exports = {
+		this.dependsOn = ["cache", "db", "utils"];
+	}
 
-	init: function(cb) {
-		async.waterfall([
-			(next) => {
-				cache.hgetall('stations', next);
-			},
+	initialize() {
+		return new Promise(async (resolve, reject) => {
+			this.setStage(1);
+
+			this.cache = this.moduleManager.modules["cache"];
+			this.db = this.moduleManager.modules["db"];
+			this.utils = this.moduleManager.modules["utils"];
+			this.songs = this.moduleManager.modules["songs"];
+			this.notifications = this.moduleManager.modules["notifications"];
+
+			this.defaultSong = {
+				songId: '60ItHLz5WEA',
+				title: 'Faded - Alan Walker',
+				duration: 212,
+				skipDuration: 0,
+				likes: -1,
+				dislikes: -1
+			};
+
+			//TEMP
+			this.cache.sub('station.pause', async (stationId) => {
+				try { await this._validateHook(); } catch { return; }
+
+				this.notifications.remove(`stations.nextSong?id=${stationId}`);
+			});
 
-			(stations, next) => {
-				if (!stations) return next();
-				let stationIds = Object.keys(stations);
-				async.each(stationIds, (stationId, next) => {
-					db.models.station.findOne({_id: stationId}, (err, station) => {
-						if (err) next(err);
-						else if (!station) {
-							cache.hdel('stations', stationId, next);
-						} else next();
-					});
-				}, next);
-			},
+			this.cache.sub('station.resume', async (stationId) => {
+				try { await this._validateHook(); } catch { return; }
 
-			(next) => {
-				db.models.station.find({}, next);
-			},
+				this.initializeStation(stationId)
+			});
 
-			(stations, next) => {
-				async.each(stations, (station, next) => {
-					async.waterfall([
-						(next) => {
-							cache.hset('stations', station._id, cache.schemas.station(station), next);
-						},
+			this.cache.sub('station.queueUpdate', async (stationId) => {
+				try { await this._validateHook(); } catch { return; }
 
-						(station, next) => {
-							this.initializeStation(station._id, next);
-						}
-					], (err) => {
-						next(err);
-					});
-				}, next);
-			}
-		], (err) => {
-			if (lockdown) return this._lockdown();
-			if (err) {
-				err = utils.getError(err);
-				cb(err);
-			} else {
-				initialized = true;
-				cb();
-			}
+				this.getStation(stationId, (err, station) => {
+					if (!station.currentSong && station.queue.length > 0) {
+						this.initializeStation(stationId);
+					}
+				});
+			});
+
+			this.cache.sub('station.newOfficialPlaylist', async (stationId) => {
+				try { await this._validateHook(); } catch { return; }
+
+				this.cache.hget("officialPlaylists", stationId, (err, playlistObj) => {
+					if (!err && playlistObj) {
+						this.utils.emitToRoom(`station.${stationId}`, "event:newOfficialPlaylist", playlistObj.songs);
+					}
+				})
+			});
+
+
+			async.waterfall([
+				(next) => {
+					this.setStage(2);
+					this.cache.hgetall('stations', next);
+				},
+	
+				(stations, next) => {
+					this.setStage(3);
+					if (!stations) return next();
+					let stationIds = Object.keys(stations);
+					async.each(stationIds, (stationId, next) => {
+						this.db.models.station.findOne({_id: stationId}, (err, station) => {
+							if (err) next(err);
+							else if (!station) {
+								this.cache.hdel('stations', stationId, next);
+							} else next();
+						});
+					}, next);
+				},
+	
+				(next) => {
+					this.setStage(4);
+					this.db.models.station.find({}, next);
+				},
+	
+				(stations, 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()
+								}, true);
+							}
+						], (err) => {
+							next2(err);
+						});
+					}, next);
+				}
+			], async (err) => {
+				if (err) {
+					err = await this.utils.getError(err);
+					reject(err);
+				} else {
+					resolve();
+				}
+			});
 		});
-	},
+	}
+
+	async initializeStation(stationId, cb, bypassValidate = false) {
+		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
 
-	initializeStation: function(stationId, cb) {
-		if (lockdown) return;
 		if (typeof cb !== 'function') cb = ()=>{};
-		let _this = this;
+
 		async.waterfall([
 			(next) => {
-				_this.getStation(stationId, next);
+				this.getStation(stationId, next, true);
 			},
 			(station, next) => {
 				if (!station) return next('Station not found.');
-				notifications.unschedule(`stations.nextSong?id=${station._id}`);
-				subscription = notifications.subscribe(`stations.nextSong?id=${station._id}`, _this.skipStation(station._id), true, station);
+				this.notifications.unschedule(`stations.nextSong?id=${station._id}`);
+				subscription = this.notifications.subscribe(`stations.nextSong?id=${station._id}`, this.skipStation(station._id), true, station);
 				if (station.paused) return next(true, station);
 				next(null, station);
 			},
 			(station, next) => {
 				if (!station.currentSong) {
-					return _this.skipStation(station._id)((err, station) => {
+					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 {
-					notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft, null, station);
+					this.notifications.schedule(`stations.nextSong?id=${station._id}`, timeLeft, null, station);
 					next(null, station);
 				}
 			}
@@ -132,17 +158,18 @@ module.exports = {
 			if (err && err !== true) return cb(err);
 			cb(null, station);
 		});
-	},
+	}
+
+	async calculateSongForStation(station, cb, bypassValidate = false) {
+		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
 
-	calculateSongForStation: function(station, cb) {
-		if (lockdown) return;
-		let _this = this;
 		let songList = [];
 		async.waterfall([
 			(next) => {
+				if (station.genres.length === 0) return next();
 				let genresDone = [];
 				station.genres.forEach((genre) => {
-					db.models.song.find({genres: genre}, (err, songs) => {
+					this.db.models.song.find({genres: genre}, (err, songs) => {
 						if (!err) {
 							songs.forEach((song) => {
 								if (songList.indexOf(song._id) === -1) {
@@ -171,47 +198,51 @@ module.exports = {
 					if (songList.indexOf(songId) !== -1) playlist.push(songId);
 				});
 
-				playlist = utils.shuffle(playlist);
-
-				_this.calculateOfficialPlaylistList(station._id, playlist, () => {
+				this.utils.shuffle(playlist).then((playlist) => {
 					next(null, playlist);
 				});
 			},
 
 			(playlist, next) => {
-				db.models.station.update({_id: station._id}, {$set: {playlist: playlist}}, {runValidators: true}, (err) => {
-					_this.updateStation(station._id, () => {
+				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);
 				});
 			}
 
 		], (err, newPlaylist) => {
 			cb(err, newPlaylist);
 		});
-	},
+	}
 
 	// Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
-	getStation: function(stationId, cb) {
-		if (lockdown) return;
-		let _this = this;
+	async getStation(stationId, cb, bypassValidate = false) {
+		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 			(next) => {
-				cache.hget('stations', stationId, next);
+				this.cache.hget('stations', stationId, next);
 			},
 
 			(station, next) => {
 				if (station) return next(true, station);
-				db.models.station.findOne({ _id: stationId }, next);
+				this.db.models.station.findOne({ _id: stationId }, next);
 			},
 
 			(station, next) => {
 				if (station) {
 					if (station.type === 'official') {
-						_this.calculateOfficialPlaylistList(station._id, station.playlist, () => {});
+						this.calculateOfficialPlaylistList(station._id, station.playlist, () => {});
 					}
-					station = cache.schemas.station(station);
-					cache.hset('stations', stationId, station);
+					station = this.cache.schemas.station(station);
+					this.cache.hset('stations', stationId, station);
 					next(true, station);
 				} else next('Station not found');
 			},
@@ -220,25 +251,25 @@ module.exports = {
 			if (err && err !== true) return cb(err);
 			cb(null, station);
 		});
-	},
+	}
 
 	// Attempts to get the station from Redis. If it's not in Redis, get it from Mongo and add it to Redis.
-	getStationByName: function(stationName, cb) {
-		if (lockdown) return;
-		let _this = this;
+	async getStationByName(stationName, cb) {
+		try { await this._validateHook(); } catch { return; }
+
 		async.waterfall([
 
 			(next) => {
-				db.models.station.findOne({ name: stationName }, next);
+				this.db.models.station.findOne({ name: stationName }, next);
 			},
 
 			(station, next) => {
 				if (station) {
 					if (station.type === 'official') {
-						_this.calculateOfficialPlaylistList(station._id, station.playlist, ()=>{});
+						this.calculateOfficialPlaylistList(station._id, station.playlist, ()=>{});
 					}
-					station = cache.schemas.station(station);
-					cache.hset('stations', station._id, station);
+					station = this.cache.schemas.station(station);
+					this.cache.hset('stations', station._id, station);
 					next(true, station);
 				} else next('Station not found');
 			},
@@ -247,36 +278,37 @@ module.exports = {
 			if (err && err !== true) return cb(err);
 			cb(null, station);
 		});
-	},
+	}
+
+	async updateStation(stationId, cb, bypassValidate = false) {
+		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
 
-	updateStation: function(stationId, cb) {
-		if (lockdown) return;
-		let _this = this;
 		async.waterfall([
 
 			(next) => {
-				db.models.station.findOne({ _id: stationId }, next);
+				this.db.models.station.findOne({ _id: stationId }, next);
 			},
 
 			(station, next) => {
 				if (!station) {
-					cache.hdel('stations', stationId);
+					this.cache.hdel('stations', stationId);
 					return next('Station not found');
 				}
-				cache.hset('stations', stationId, station, next);
+				this.cache.hset('stations', stationId, station, next);
 			}
 
 		], (err, station) => {
 			if (err && err !== true) return cb(err);
 			cb(null, station);
 		});
-	},
+	}
+
+	async calculateOfficialPlaylistList(stationId, songList, cb, bypassValidate = false) {
+		if (!bypassValidate) try { await this._validateHook(); } catch { return; }
 
-	calculateOfficialPlaylistList: (stationId, songList, cb) => {
-		if (lockdown) return;
 		let lessInfoPlaylist = [];
 		async.each(songList, (song, next) => {
-			songs.getSong(song, (err, song) => {
+			this.songs.getSong(song, (err, song) => {
 				if (!err && song) {
 					let newSong = {
 						songId: song.songId,
@@ -289,36 +321,36 @@ module.exports = {
 				next();
 			});
 		}, () => {
-			cache.hset("officialPlaylists", stationId, cache.schemas.officialPlaylist(stationId, lessInfoPlaylist), () => {
-				cache.pub("station.newOfficialPlaylist", stationId);
+			this.cache.hset("officialPlaylists", stationId, this.cache.schemas.officialPlaylist(stationId, lessInfoPlaylist), () => {
+				this.cache.pub("station.newOfficialPlaylist", stationId);
 				cb();
 			});
 		});
-	},
-
-	skipStation: function(stationId) {
-		if (lockdown) return;
-		logger.info("STATION_SKIP", `Skipping station ${stationId}.`, false);
-		let _this = this;
-		return (cb) => {
-			if (lockdown) return;
+	}
+
+	skipStation(stationId) {
+		this.logger.info("STATION_SKIP", `Skipping station ${stationId}.`, false);
+		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.');
 					if (station.type === 'community' && station.partyMode && station.queue.length === 0) return next(null, null, -11, station); // Community station with party mode enabled and no songs in the queue
 					if (station.type === 'community' && station.partyMode && station.queue.length > 0) { // Community station with party mode enabled and songs in the queue
-						return db.models.station.update({_id: stationId}, {$pull: {queue: {_id: station.queue[0]._id}}}, (err) => {
+						return this.db.models.station.updateOne({_id: stationId}, {$pull: {queue: {_id: station.queue[0]._id}}}, (err) => {
 							if (err) return next(err);
 							next(null, station.queue[0], -12, station);
 						});
 					}
 					if (station.type === 'community' && !station.partyMode) {
-						return db.models.playlist.findOne({_id: station.privatePlaylist}, (err, playlist) => {
+						return this.db.models.playlist.findOne({_id: station.privatePlaylist}, (err, playlist) => {
 							if (err) return next(err);
 							if (!playlist) return next(null, null, -13, station);
 							playlist = playlist.songs;
@@ -341,45 +373,46 @@ module.exports = {
 										return next(null, currentSong, currentSongIndex, station);
 									}
 								};
-								if (playlist[currentSongIndex]._id) songs.getSong(playlist[currentSongIndex]._id, callback);
-								else songs.getSongFromId(playlist[currentSongIndex].songId, callback);
+								if (playlist[currentSongIndex]._id) this.songs.getSong(playlist[currentSongIndex]._id, callback);
+								else this.songs.getSongFromId(playlist[currentSongIndex].songId, callback);
 							} else return next(null, null, -14, station);
 						});
 					}
 					if (station.type === 'official' && station.playlist.length === 0) {
-						return _this.calculateSongForStation(station, (err, playlist) => {
+						return this.calculateSongForStation(station, (err, playlist) => {
 							if (err) return next(err);
-							if (playlist.length === 0) return next(null, _this.defaultSong, 0, station);
+							if (playlist.length === 0) return next(null, this.defaultSong, 0, station);
 							else {
-								songs.getSong(playlist[0], (err, song) => {
-									if (err || !song) return next(null, _this.defaultSong, 0, station);
+								this.songs.getSong(playlist[0], (err, song) => {
+									if (err || !song) return next(null, this.defaultSong, 0, station);
 									return next(null, song, 0, station);
 								});
 							}
-						});
+						}, true);
 					}
 					if (station.type === 'official' && station.playlist.length > 0) {
 						async.doUntil((next) => {
 							if (station.currentSongIndex < station.playlist.length - 1) {
-								songs.getSong(station.playlist[station.currentSongIndex + 1], (err, song) => {
+								this.songs.getSong(station.playlist[station.currentSongIndex + 1], (err, song) => {
 									if (!err) return next(null, song, station.currentSongIndex + 1);
 									else {
 										station.currentSongIndex++;
-										next(null, null);
+										next(null, null, null);
 									}
 								});
 							} else {
-								_this.calculateSongForStation(station, (err, newPlaylist) => {
-									if (err) return next(null, _this.defaultSong, 0);
-									songs.getSong(newPlaylist[0], (err, song) => {
-										if (err || !song) return next(null, _this.defaultSong, 0);
+								this.calculateSongForStation(station, (err, newPlaylist) => {
+									if (err) return next(null, this.defaultSong, 0);
+									this.songs.getSong(newPlaylist[0], (err, song) => {
+										if (err || !song) return next(null, this.defaultSong, 0);
 										station.playlist = newPlaylist;
 										next(null, song, 0);
 									});
-								});
+								}, true);
 							}
-						}, (song) => {
-							return !!song;
+						}, (song, currentSongIndex, next) => {
+							if (!!song) return next(null, true, currentSongIndex);
+							else return next(null, false);
 						}, (err, song, currentSongIndex) => {
 							return next(err, song, currentSongIndex, station);
 						});
@@ -417,37 +450,37 @@ module.exports = {
 				},
 
 				($set, station, next) => {
-					db.models.station.update({_id: station._id}, {$set}, (err) => {
-						_this.updateStation(station._id, (err, station) => {
+					this.db.models.station.updateOne({_id: station._id}, {$set}, (err) => {
+						this.updateStation(station._id, (err, station) => {
 							if (station.type === 'community' && station.partyMode === true)
-								cache.pub('station.queueUpdate', stationId);
+								this.cache.pub('station.queueUpdate', stationId);
 							next(null, station);
-						});
+						}, true);
 					});
 				},
-			], (err, station) => {
+			], async (err, station) => {
 				if (!err) {
 					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
 						station.currentSong.skipVotes = 0;
 					}
 					//TODO Pub/Sub this
-					utils.emitToRoom(`station.${station._id}`, "event:songs.next", {
+					this.utils.emitToRoom(`station.${station._id}`, "event:songs.next", {
 						currentSong: station.currentSong,
 						startedAt: station.startedAt,
 						paused: station.paused,
 						timePaused: 0
 					});
 
-					if (station.privacy === 'public') utils.emitToRoom('home', "event:station.nextSong", station._id, station.currentSong);
+					if (station.privacy === 'public') this.utils.emitToRoom('home', "event:station.nextSong", station._id, station.currentSong);
 					else {
-						let sockets = utils.getRoomSockets('home');
+						let sockets = await this.utils.getRoomSockets('home');
 						for (let socketId in sockets) {
 							let socket = sockets[socketId];
 							let session = sockets[socketId].session;
 							if (session.sessionId) {
-								cache.hget('sessions', session.sessionId, (err, session) => {
+								this.cache.hget('sessions', session.sessionId, (err, session) => {
 									if (!err && session) {
-										db.models.user.findOne({_id: session.userId}, (err, user) => {
+										this.db.models.user.findOne({_id: session.userId}, (err, user) => {
 											if (!err && user) {
 												if (user.role === 'admin') socket.emit("event:station.nextSong", station._id, station.currentSong);
 												else if (station.type === "community" && station.owner === session.userId) socket.emit("event:station.nextSong", station._id, station.currentSong);
@@ -459,34 +492,46 @@ module.exports = {
 						}
 					}
 					if (station.currentSong !== null && station.currentSong.songId !== undefined) {
-						utils.socketsJoinSongRoom(utils.getRoomSockets(`station.${station._id}`), `song.${station.currentSong.songId}`);
+						this.utils.socketsJoinSongRoom(await this.utils.getRoomSockets(`station.${station._id}`), `song.${station.currentSong.songId}`);
 						if (!station.paused) {
-							notifications.schedule(`stations.nextSong?id=${station._id}`, station.currentSong.duration * 1000, null, station);
+							this.notifications.schedule(`stations.nextSong?id=${station._id}`, station.currentSong.duration * 1000, null, station);
 						}
 					} else {
-						utils.socketsLeaveSongRooms(utils.getRoomSockets(`station.${station._id}`));
+						this.utils.socketsLeaveSongRooms(await this.utils.getRoomSockets(`station.${station._id}`));
 					}
 					cb(null, station);
 				} else {
-					err = utils.getError(err);
-					logger.error('SKIP_STATION', `Skipping station "${stationId}" failed. "${err}"`);
+					err = await this.utils.getError(err);
+					this.logger.error('SKIP_STATION', `Skipping station "${stationId}" failed. "${err}"`);
 					cb(err);
 				}
 			});
 		}
-	},
-
-	defaultSong: {
-		songId: '60ItHLz5WEA',
-		title: 'Faded - Alan Walker',
-		duration: 212,
-		skipDuration: 0,
-		likes: -1,
-		dislikes: -1
-	},
-
-	_lockdown: () => {
-		lockdown = true;
 	}
 
-};
+	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));
+		});
+	}
+}

+ 154 - 125
backend/logic/tasks.js

@@ -1,113 +1,34 @@
 'use strict';
 
-const cache = require("./cache");
-const logger = require("./logger");
-const Stations = require("./stations");
-const notifications = require("./notifications");
+const coreClass = require("../core");
+
 const async = require("async");
-let utils;
+const fs = require("fs");
+
 let tasks = {};
 
-let testTask = (callback) => {
-	//Stuff
-	console.log("Starting task");
-	setTimeout(() => {
-		console.log("Callback");
-		callback();
-	}, 10000);
-};
-
-let checkStationSkipTask = (callback) => {
-	logger.info("TASK_STATIONS_SKIP_CHECK", `Checking for stations to be skipped.`, false);
-	async.waterfall([
-		(next) => {
-			cache.hgetall('stations', next);
-		},
-		(stations, next) => {
-			async.each(stations, (station, next2) => {
-				if (station.paused || !station.currentSong || !station.currentSong.title) return next2();
-				const timeElapsed = Date.now() - station.startedAt - station.timePaused;
-				if (timeElapsed <= station.currentSong.duration) return next2();
-				else {
-					logger.error("TASK_STATIONS_SKIP_CHECK", `Skipping ${station._id} as it should have skipped already.`);
-					Stations.initializeStation(station._id);
-					next2();
-				}
-			}, () => {
-				next();
-			});
-		}
-	], () => {
-		callback();
-	});
-};
-
-let sessionClearingTask = (callback) => {
-	logger.info("TASK_SESSION_CLEAR", `Checking for sessions to be cleared.`, false);
-	async.waterfall([
-		(next) => {
-			cache.hgetall('sessions', next);
-		},
-		(sessions, next) => {
-			if (!sessions) return next();
-			let keys = Object.keys(sessions);
-			async.each(keys, (sessionId, next2) => {
-				let session = sessions[sessionId];
-				if (session && session.refreshDate && (Date.now() - session.refreshDate) < (60 * 60 * 24 * 30 * 1000)) return next2();
-				if (!session) {
-					logger.info("TASK_SESSION_CLEAR", 'Removing an empty session.');
-					cache.hdel('sessions', sessionId, () => {
-						next2();
-					});
-				} else if (!session.refreshDate) {
-					session.refreshDate = Date.now();
-					cache.hset('sessions', sessionId, session, () => {
-						next2();
-					});
-				} else if ((Date.now() - session.refreshDate) > (60 * 60 * 24 * 30 * 1000)) {
-					utils.socketsFromSessionId(session.sessionId, (sockets) => {
-						if (sockets.length > 0) {
-							session.refreshDate = Date.now();
-							cache.hset('sessions', sessionId, session, () => {
-								next2()
-							});
-						} else {
-							logger.info("TASK_SESSION_CLEAR", `Removing session ${sessionId} for user ${session.userId} since inactive for 30 days and not currently in use.`);
-							cache.hdel('sessions', session.sessionId, () => {
-								next2();
-							});
-						}
-					});
-				} else {
-					logger.error("TASK_SESSION_CLEAR", "This should never log.");
-					next2();
-				}
-			}, () => {
-				next();
-			});
-		}
-	], () => {
-		callback();
-	});
-};
-
-let initialized = false;
-let lockdown = false;
-
-module.exports = {
-	init: function(cb) {
-		utils = require('./utils');
-		this.createTask("testTask", testTask, 5000, true);
-		this.createTask("stationSkipTask", checkStationSkipTask, 1000 * 60 * 30);
-		this.createTask("sessionClearTask", sessionClearingTask, 1000 * 60 * 60 * 6);
-
-		initialized = true;
-
-		if (lockdown) return this._lockdown();
-		cb();
-	},
-	createTask: function(name, fn, timeout, paused = false) {
-		if (lockdown) return;
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+			
+			this.cache = this.moduleManager.modules["cache"];
+			this.stations = this.moduleManager.modules["stations"];
+			this.notifications = this.moduleManager.modules["notifications"];
+			this.utils = this.moduleManager.modules["utils"];
+
+			//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();
+		});
+	}
+
+	async createTask(name, fn, timeout, paused = false) {
+		try { await this._validateHook(); } catch { return; }
+
 		tasks[name] = {
 			name,
 			fn,
@@ -116,29 +37,137 @@ module.exports = {
 			timer: null
 		};
 		if (!paused) this.handleTask(tasks[name]);
-	},
-	pauseTask: (name) => {
+	}
+
+	async pauseTask(name) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (tasks[name].timer) tasks[name].timer.pause();
-	},
-	resumeTask: (name) => {
+	}
+
+	async resumeTask(name) {
+		try { await this._validateHook(); } catch { return; }
+
 		tasks[name].timer.resume();
-	},
-	handleTask: function(task) {
-		if (lockdown) return;
+	}
+
+	async handleTask(task) {
+		try { await this._validateHook(); } catch { return; }
+
 		if (task.timer) task.timer.pause();
+		
+		task.fn.apply(this, [
+			() => {
+				task.lastRan = Date.now();
+				task.timer = new this.utils.Timer(() => {
+					this.handleTask(task);
+				}, task.timeout, false);
+			}
+		]);
+	}
 
-		task.fn(() => {
-			task.lastRan = Date.now();
-			task.timer = new utils.Timer(() => {
-				this.handleTask(task);
-			}, task.timeout, false);
+	/*testTask(callback) {
+		//Stuff
+		console.log("Starting task");
+		setTimeout(() => {
+			console.log("Callback");
+			callback();
+		}, 10000);
+	}*/
+
+	async checkStationSkipTask(callback) {
+		this.logger.info("TASK_STATIONS_SKIP_CHECK", `Checking for stations to be skipped.`, false);
+		async.waterfall([
+			(next) => {
+				this.cache.hgetall('stations', next);
+			},
+			(stations, next) => {
+				async.each(stations, (station, next2) => {
+					if (station.paused || !station.currentSong || !station.currentSong.title) return next2();
+					const timeElapsed = Date.now() - station.startedAt - station.timePaused;
+					if (timeElapsed <= station.currentSong.duration) return next2();
+					else {
+						this.logger.error("TASK_STATIONS_SKIP_CHECK", `Skipping ${station._id} as it should have skipped already.`);
+						this.stations.initializeStation(station._id);
+						next2();
+					}
+				}, () => {
+					next();
+				});
+			}
+		], () => {
+			callback();
 		});
-	},
-	_lockdown: function() {
-		for (let key in tasks) {
-			this.pauseTask(key);
-		}
-		tasks = {};
-		lockdown = true;
 	}
-};
+
+	async sessionClearingTask(callback) {
+		this.logger.info("TASK_SESSION_CLEAR", `Checking for sessions to be cleared.`, false);
+		async.waterfall([
+			(next) => {
+				this.cache.hgetall('sessions', next);
+			},
+			(sessions, next) => {
+				if (!sessions) return next();
+				let keys = Object.keys(sessions);
+				async.each(keys, (sessionId, next2) => {
+					let session = sessions[sessionId];
+					if (session && session.refreshDate && (Date.now() - session.refreshDate) < (60 * 60 * 24 * 30 * 1000)) return next2();
+					if (!session) {
+						this.logger.info("TASK_SESSION_CLEAR", 'Removing an empty session.');
+						this.cache.hdel('sessions', sessionId, () => {
+							next2();
+						});
+					} else if (!session.refreshDate) {
+						session.refreshDate = Date.now();
+						this.cache.hset('sessions', sessionId, session, () => {
+							next2();
+						});
+					} else if ((Date.now() - session.refreshDate) > (60 * 60 * 24 * 30 * 1000)) {
+						this.utils.socketsFromSessionId(session.sessionId, (sockets) => {
+							if (sockets.length > 0) {
+								session.refreshDate = Date.now();
+								this.cache.hset('sessions', sessionId, session, () => {
+									next2()
+								});
+							} else {
+								this.logger.info("TASK_SESSION_CLEAR", `Removing session ${sessionId} for user ${session.userId} since inactive for 30 days and not currently in use.`);
+								this.cache.hdel('sessions', session.sessionId, () => {
+									next2();
+								});
+							}
+						});
+					} else {
+						this.logger.error("TASK_SESSION_CLEAR", "This should never log.");
+						next2();
+					}
+				}, () => {
+					next();
+				});
+			}
+		], () => {
+			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();
+			}
+		);
+	}
+}

+ 232 - 149
backend/logic/utils.js

@@ -1,12 +1,10 @@
 'use strict';
 
-const moment  = require('moment'),
-	  io      = require('./io'),
-	  db      = require('./db'),
-	  config  = require('config'),
+const coreClass = require("../core");
+
+const config  = require('config'),
 	  async	  = require('async'),
-	  request = require('request'),
-	  cache   = require('./cache');
+	  request = require('request');
 
 class Timer {
 	constructor(callback, delay, paused) {
@@ -55,96 +53,137 @@ class Timer {
 			return Date.now() - this.timePaused;
 		}
 	}
-}
+} 
 
-function convertTime (duration) {
-	let a = duration.match(/\d+/g);
+let youtubeRequestCallbacks = [];
+let youtubeRequestsPending = 0;
+let youtubeRequestsActive = false;
 
-	if (duration.indexOf('M') >= 0 && duration.indexOf('H') == -1 && duration.indexOf('S') == -1) {
-		a = [0, a[0], 0];
-	}
+module.exports = class extends coreClass {
+	initialize() {
+		return new Promise((resolve, reject) => {
+			this.setStage(1);
+			
+			this.io = this.moduleManager.modules["io"];
+			this.db = this.moduleManager.modules["db"];
+			this.spotify = this.moduleManager.modules["spotify"];
+			this.cache = this.moduleManager.modules["cache"];
 
-	if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1) {
-		a = [a[0], 0, a[1]];
-	}
-	if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1 && duration.indexOf('S') == -1) {
-		a = [a[0], 0, 0];
-	}
-
-	duration = 0;
+			this.Timer = Timer;
 
-	if (a.length == 3) {
-		duration = duration + parseInt(a[0]) * 3600;
-		duration = duration + parseInt(a[1]) * 60;
-		duration = duration + parseInt(a[2]);
+			resolve();
+		});
 	}
 
-	if (a.length == 2) {
-		duration = duration + parseInt(a[0]) * 60;
-		duration = duration + parseInt(a[1]);
+	async parseCookies(cookieString) {
+		try { await this._validateHook(); } catch { return; }
+		let cookies = {};
+		if (cookieString) cookieString.split("; ").map((cookie) => {
+			(cookies[cookie.substring(0, cookie.indexOf("="))] = cookie.substring(cookie.indexOf("=") + 1, cookie.length));
+		});
+		return cookies;
 	}
 
-	if (a.length == 1) {
-		duration = duration + parseInt(a[0]);
+	async cookiesToString(cookies) {
+		try { await this._validateHook(); } catch { return; }
+		let newCookie = [];
+		for (let prop in cookie) {
+			newCookie.push(prop + "=" + cookie[prop]);
+		}
+		return newCookie.join("; ");
 	}
 
-	let hours = Math.floor(duration / 3600);
-	let minutes = Math.floor(duration % 3600 / 60);
-	let seconds = Math.floor(duration % 3600 % 60);
-
-	return (hours < 10 ? ("0" + hours + ":") : (hours + ":")) + (minutes < 10 ? ("0" + minutes + ":") : (minutes + ":")) + (seconds < 10 ? ("0" + seconds) : seconds);
-}
+	async removeCookie(cookieString, cookieName) {
+		try { await this._validateHook(); } catch { return; }
+		var cookies = this.parseCookies(cookieString);
+		delete cookies[cookieName];
+		return this.toString(cookies);
+	}
 
-let youtubeRequestCallbacks = [];
-let youtubeRequestsPending = 0;
-let youtubeRequestsActive = false;
+	async htmlEntities(str) {
+		try { await this._validateHook(); } catch { return; }
+		return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
+	}
 
-module.exports = {
-	htmlEntities: str => String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
-	generateRandomString: function(len) {
+	async generateRandomString(len) {
+		try { await this._validateHook(); } catch { return; }
 		let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".split("");
 		let result = [];
 		for (let i = 0; i < len; i++) {
-			result.push(chars[this.getRandomNumber(0, chars.length - 1)]);
+			result.push(chars[await this.getRandomNumber(0, chars.length - 1)]);
 		}
 		return result.join("");
-	},
-	getSocketFromId: function(socketId) {
+	}
+
+	async getSocketFromId(socketId) {
+		try { await this._validateHook(); } catch { return; }
 		return globals.io.sockets.sockets[socketId];
-	},
-	getRandomNumber: (min, max) => Math.floor(Math.random() * (max - min + 1)) + min,
-	convertTime,
-	Timer,
-	guid: () => [1,1,0,1,0,1,0,1,0,1,1,1].map(b => b ? Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) : '-').join(''),
-	cookies: {
-		parseCookies: cookieString => {
-			let cookies = {};
-			if (cookieString) cookieString.split("; ").map((cookie) => {
-				(cookies[cookie.substring(0, cookie.indexOf("="))] = cookie.substring(cookie.indexOf("=") + 1, cookie.length));
-			});
-			return cookies;
-		},
-		toString: cookies => {
-			let newCookie = [];
-			for (let prop in cookie) {
-				newCookie.push(prop + "=" + cookie[prop]);
-			}
-			return newCookie.join("; ");
-		},
-		removeCookie: (cookieString, cookieName) => {
-			var cookies = this.parseCookies(cookieString);
-			delete cookies[cookieName];
-			return this.toString(cookies);
+	}
+
+	async getRandomNumber(min, max) {
+		try { await this._validateHook(); } catch { return; }
+		return Math.floor(Math.random() * (max - min + 1)) + min
+	}
+
+	async convertTime(duration) {
+		try { await this._validateHook(); } catch { return; }
+		let a = duration.match(/\d+/g);
+	
+		if (duration.indexOf('M') >= 0 && duration.indexOf('H') == -1 && duration.indexOf('S') == -1) {
+			a = [0, a[0], 0];
+		}
+	
+		if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1) {
+			a = [a[0], 0, a[1]];
+		}
+		if (duration.indexOf('H') >= 0 && duration.indexOf('M') == -1 && duration.indexOf('S') == -1) {
+			a = [a[0], 0, 0];
+		}
+	
+		duration = 0;
+	
+		if (a.length == 3) {
+			duration = duration + parseInt(a[0]) * 3600;
+			duration = duration + parseInt(a[1]) * 60;
+			duration = duration + parseInt(a[2]);
+		}
+	
+		if (a.length == 2) {
+			duration = duration + parseInt(a[0]) * 60;
+			duration = duration + parseInt(a[1]);
 		}
-	},
-	socketFromSession: function(socketId) {
-		let ns = io.io.of("/");
+	
+		if (a.length == 1) {
+			duration = duration + parseInt(a[0]);
+		}
+	
+		let hours = Math.floor(duration / 3600);
+		let minutes = Math.floor(duration % 3600 / 60);
+		let seconds = Math.floor(duration % 3600 % 60);
+	
+		return (hours < 10 ? ("0" + hours + ":") : (hours + ":")) + (minutes < 10 ? ("0" + minutes + ":") : (minutes + ":")) + (seconds < 10 ? ("0" + seconds) : seconds);
+	}
+
+	async guid () {
+		try { await this._validateHook(); } catch { return; }
+		return [1,1,0,1,0,1,0,1,0,1,1,1].map(b => b ? Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1) : '-').join('');
+	}
+
+	async socketFromSession(socketId) {
+		try { await this._validateHook(); } catch { return; }
+
+		let io = await this.io.io();
+		let ns = io.of("/");
 		if (ns) {
 			return ns.connected[socketId];
 		}
-	},
-	socketsFromSessionId: function(sessionId, cb) {
-		let ns = io.io.of("/");
+	}
+
+	async socketsFromSessionId(sessionId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let io = await this.io.io();
+		let ns = io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
@@ -155,14 +194,18 @@ module.exports = {
 				cb(sockets);
 			});
 		}
-	},
-	socketsFromUser: function(userId, cb) {
-		let ns = io.io.of("/");
+	}
+
+	async socketsFromUser(userId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let io = await this.io.io();
+		let ns = io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
 				let session = ns.connected[id].session;
-				cache.hget('sessions', session.sessionId, (err, session) => {
+				this.cache.hget('sessions', session.sessionId, (err, session) => {
 					if (!err && session && session.userId === userId) sockets.push(ns.connected[id]);
 					next();
 				});
@@ -170,14 +213,18 @@ module.exports = {
 				cb(sockets);
 			});
 		}
-	},
-	socketsFromIP: function(ip, cb) {
-		let ns = io.io.of("/");
+	}
+
+	async socketsFromIP(ip, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let io = await this.io.io();
+		let ns = io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
 				let session = ns.connected[id].session;
-				cache.hget('sessions', session.sessionId, (err, session) => {
+				this.cache.hget('sessions', session.sessionId, (err, session) => {
 					if (!err && session && ns.connected[id].ip === ip) sockets.push(ns.connected[id]);
 					next();
 				});
@@ -185,9 +232,13 @@ module.exports = {
 				cb(sockets);
 			});
 		}
-	},
-	socketsFromUserWithoutCache: function(userId, cb) {
-		let ns = io.io.of("/");
+	}
+
+	async socketsFromUserWithoutCache(userId, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		let io = await this.io.io();
+		let ns = io.of("/");
 		let sockets = [];
 		if (ns) {
 			async.each(Object.keys(ns.connected), (id, next) => {
@@ -198,31 +249,43 @@ module.exports = {
 				cb(sockets);
 			});
 		}
-	},
-	socketLeaveRooms: function(socketid) {
-		let socket = this.socketFromSession(socketid);
+	}
+
+	async socketLeaveRooms(socketid) {
+		try { await this._validateHook(); } catch { return; }
+
+		let socket = await this.socketFromSession(socketid);
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 			socket.leave(room);
 		}
-	},
-	socketJoinRoom: function(socketId, room) {
-		let socket = this.socketFromSession(socketId);
+	}
+
+	async socketJoinRoom(socketId, room) {
+		try { await this._validateHook(); } catch { return; }
+
+		let socket = await this.socketFromSession(socketId);
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 			socket.leave(room);
 		}
 		socket.join(room);
-	},
-	socketJoinSongRoom: function(socketId, room) {
-		let socket = this.socketFromSession(socketId);
+	}
+
+	async socketJoinSongRoom(socketId, room) {
+		try { await this._validateHook(); } catch { return; }
+
+		let socket = await this.socketFromSession(socketId);
 		let rooms = socket.rooms;
 		for (let room in rooms) {
 			if (room.indexOf('song.') !== -1) socket.leave(rooms);
 		}
 		socket.join(room);
-	},
-	socketsJoinSongRoom: function(sockets, room) {
+	}
+
+	async socketsJoinSongRoom(sockets, room) {
+		try { await this._validateHook(); } catch { return; }
+
 		for (let id in sockets) {
 			let socket = sockets[id];
 			let rooms = socket.rooms;
@@ -231,8 +294,11 @@ module.exports = {
 			}
 			socket.join(room);
 		}
-	},
-	socketsLeaveSongRooms: function(sockets) {
+	}
+
+	async socketsLeaveSongRooms(sockets) {
+		try { await this._validateHook(); } catch { return; }
+
 		for (let id in sockets) {
 			let socket = sockets[id];
 			let rooms = socket.rooms;
@@ -240,30 +306,36 @@ module.exports = {
 				if (room.indexOf('song.') !== -1) socket.leave(room);
 			}
 		}
-	},
-	emitToRoom: function(room) {
-		let sockets = io.io.sockets.sockets;
+	}
+
+	async emitToRoom(room, ...args) {
+		try { await this._validateHook(); } catch { return; }
+
+		let io = await this.io.io();
+		let sockets = io.sockets.sockets;
 		for (let id in sockets) {
 			let socket = sockets[id];
 			if (socket.rooms[room]) {
-				let args = [];
-				for (let i = 1; i < Object.keys(arguments).length; i++) {
-					args.push(arguments[i]);
-				}
 				socket.emit.apply(socket, args);
 			}
 		}
-	},
-	getRoomSockets: function(room) {
-		let sockets = io.io.sockets.sockets;
+	}
+
+	async getRoomSockets(room) {
+		try { await this._validateHook(); } catch { return; }
+
+		let io = await this.io.io();
+		let sockets = io.sockets.sockets;
 		let roomSockets = [];
 		for (let id in sockets) {
 			let socket = sockets[id];
 			if (socket.rooms[room]) roomSockets.push(socket);
 		}
 		return roomSockets;
-	},
-	getSongFromYouTube: (songId, cb) => {
+	}
+
+	async getSongFromYouTube(songId, cb) {
+		try { await this._validateHook(); } catch { return; }
 
 		youtubeRequestCallbacks.push({cb: (test) => {
 			youtubeRequestsActive = true;
@@ -319,8 +391,10 @@ module.exports = {
 		if (!youtubeRequestsActive) {
 			youtubeRequestCallbacks[0].cb(youtubeRequestCallbacks[0].songId);
 		}
-	},
-	getPlaylistFromYouTube: (url, cb) => {
+	}
+
+	async getPlaylistFromYouTube(url, cb) {
+		try { await this._validateHook(); } catch { return; }
 
 		let name = 'list'.replace(/[\[]/,"\\\[").replace(/[\]]/,"\\\]");
 		var regex = new RegExp('[\\?&]' + name + '=([^&#]*)');
@@ -352,18 +426,30 @@ module.exports = {
 			});
 		}
 		getPage(null, []);
-	},
-	getSongFromSpotify: (song, cb) => {
+	}
+
+	async getSongFromSpotify(song, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		if (!config.get("apis.spotify.enabled")) return cb("Spotify is not enabled", null);
+
 		const spotifyParams = [
 			`q=${encodeURIComponent(song.title)}`,
 			`type=track`
 		].join('&');
 
-		request(`https://api.spotify.com/v1/search?${spotifyParams}`, (err, res, body) => {
+		const token = await this.spotify.getToken();
+		const options = {
+			url: `https://api.spotify.com/v1/search?${spotifyParams}`,
+			headers: {
+				Authorization: `Bearer ${token}`
+			}
+		};
 
+		request(options, (err, res, body) => {
 			if (err) console.error(err);
-
 			body = JSON.parse(body);
+			if (body.error) console.error(body.error);
 
 			durationArtistLoop:
 			for (let i in body) {
@@ -388,18 +474,33 @@ module.exports = {
 				}
 			}
 
-			cb(song);
+			cb(null, song);
 		});
-	},
-	getSongsFromSpotify: (title, artist, cb) => {
+	}
+
+	async getSongsFromSpotify(title, artist, cb) {
+		try { await this._validateHook(); } catch { return; }
+
+		if (!config.get("apis.spotify.enabled")) return cb([]);
+
 		const spotifyParams = [
 			`q=${encodeURIComponent(title)}`,
 			`type=track`
 		].join('&');
+		
+		const token = await this.spotify.getToken();
+		const options = {
+			url: `https://api.spotify.com/v1/search?${spotifyParams}`,
+			headers: {
+				Authorization: `Bearer ${token}`
+			}
+		};
 
-		request(`https://api.spotify.com/v1/search?${spotifyParams}`, (err, res, body) => {
+		request(options, (err, res, body) => {
 			if (err) return console.error(err);
 			body = JSON.parse(body);
+			if (body.error) return console.error(body.error);
+
 			let songs = [];
 
 			for (let i in body) {
@@ -427,8 +528,11 @@ module.exports = {
 
 			cb(songs);
 		});
-	},
-	shuffle: (array) => {
+	}
+
+	async shuffle(array) {
+		try { await this._validateHook(); } catch { return; }
+
 		let currentIndex = array.length, temporaryValue, randomIndex;
 
 		// While there remain elements to shuffle...
@@ -445,8 +549,11 @@ module.exports = {
 		}
 
 		return array;
-	},
-	getError: (err) => {
+	}
+
+	async getError(err) {
+		try { await this._validateHook(); } catch { return; }
+
 		let error = 'An error occurred.';
 		if (typeof err === "string") error = err;
 		else if (err.message) {
@@ -454,29 +561,5 @@ module.exports = {
 			else error = err.errors[Object.keys(err.errors)].message;
 		}
 		return error;
-	},
-	canUserBeInStation: (station, userId, cb) => {
-		async.waterfall([
-			(next) => {
-				if (station.privacy !== 'private') return next(true);
-				if (!userId) return next(false);
-				next();
-			},
-
-			(next) => {
-				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);
-		});
 	}
-};
+}

+ 0 - 2385
backend/package-lock.json

@@ -1,2385 +0,0 @@
-{
-  "name": "musare-backend",
-  "version": "0.0.1",
-  "lockfileVersion": 1,
-  "requires": true,
-  "dependencies": {
-    "abbrev": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.0.tgz",
-      "integrity": "sha1-0FVMIlZjbi9W58LlrRg/hZQo2B8="
-    },
-    "accepts": {
-      "version": "https://registry.npmjs.org/accepts/-/accepts-1.3.3.tgz",
-      "integrity": "sha1-w8p0NJOGSMPg2cHjKN1otiLChMo=",
-      "requires": {
-        "mime-types": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz",
-        "negotiator": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz"
-      }
-    },
-    "acorn": {
-      "version": "https://registry.npmjs.org/acorn/-/acorn-4.0.11.tgz",
-      "integrity": "sha1-7c2jvZN+dVZBDULtWGD2c5nHlMA="
-    },
-    "after": {
-      "version": "https://registry.npmjs.org/after/-/after-0.8.2.tgz",
-      "integrity": "sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8="
-    },
-    "agent-base": {
-      "version": "https://registry.npmjs.org/agent-base/-/agent-base-2.0.1.tgz",
-      "integrity": "sha1-vY+ehqjrIh//oHvRS+/VXfFCgV4=",
-      "requires": {
-        "extend": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
-        "semver": "https://registry.npmjs.org/semver/-/semver-5.0.3.tgz"
-      },
-      "dependencies": {
-        "semver": {
-          "version": "https://registry.npmjs.org/semver/-/semver-5.0.3.tgz",
-          "integrity": "sha1-d0Zt5YnNXTyV8TiqeLxWmjy10no="
-        }
-      }
-    },
-    "ajv": {
-      "version": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz",
-      "integrity": "sha1-gv+wKynmYq5TvcIK8VlHcGc5xTY=",
-      "requires": {
-        "co": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
-        "json-stable-stringify": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz"
-      },
-      "dependencies": {
-        "co": {
-          "version": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
-          "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ="
-        }
-      }
-    },
-    "align-text": {
-      "version": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz",
-      "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=",
-      "requires": {
-        "kind-of": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.0.tgz",
-        "longest": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz",
-        "repeat-string": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz"
-      }
-    },
-    "alter": {
-      "version": "https://registry.npmjs.org/alter/-/alter-0.2.0.tgz",
-      "integrity": "sha1-x1iICGF1cgNKrmJICvJrHU0cs80=",
-      "requires": {
-        "stable": "https://registry.npmjs.org/stable/-/stable-0.1.6.tgz"
-      }
-    },
-    "amdefine": {
-      "version": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz",
-      "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=",
-      "optional": true
-    },
-    "ansi-regex": {
-      "version": "2.1.1",
-      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
-      "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
-    },
-    "aproba": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.1.2.tgz",
-      "integrity": "sha512-ZpYajIfO0j2cOFTO955KUMIKNmj6zhX8kVztMAxFsDaMwz+9Z9SV0uou2pC9HJqcfpffOsjnbrDMvkNy+9RXPw=="
-    },
-    "are-we-there-yet": {
-      "version": "1.1.4",
-      "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.4.tgz",
-      "integrity": "sha1-u13KOCu5TwXhUZQ3PRb9O6HKEQ0=",
-      "requires": {
-        "delegates": "1.0.0",
-        "readable-stream": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.7.tgz"
-      }
-    },
-    "array-flatten": {
-      "version": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
-      "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI="
-    },
-    "arraybuffer.slice": {
-      "version": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz",
-      "integrity": "sha1-8zshWfBTKj8xB6JywMz70a0peco="
-    },
-    "asn1": {
-      "version": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz",
-      "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y="
-    },
-    "assert-plus": {
-      "version": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz",
-      "integrity": "sha1-104bh+ev/A24qttwIfP+SBAasjQ="
-    },
-    "ast-traverse": {
-      "version": "https://registry.npmjs.org/ast-traverse/-/ast-traverse-0.1.1.tgz",
-      "integrity": "sha1-ac8rg4bxnc2hux4F1o/jWdiJfeY="
-    },
-    "ast-types": {
-      "version": "https://registry.npmjs.org/ast-types/-/ast-types-0.9.11.tgz",
-      "integrity": "sha1-NxF3u1kjL/XOqh0J7lytcFsaWqk="
-    },
-    "async": {
-      "version": "https://registry.npmjs.org/async/-/async-2.0.1.tgz",
-      "integrity": "sha1-twnMAoCpw28J9FNr6CPIOKkEniU=",
-      "requires": {
-        "lodash": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz"
-      }
-    },
-    "asynckit": {
-      "version": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
-    },
-    "aws-sign2": {
-      "version": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz",
-      "integrity": "sha1-FDQt0428yU0OW4fXY81jYSwOeU8="
-    },
-    "aws4": {
-      "version": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz",
-      "integrity": "sha1-g+9cqGCysy5KDe7e6MdxudtXRx4="
-    },
-    "backo2": {
-      "version": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
-      "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc="
-    },
-    "balanced-match": {
-      "version": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz",
-      "integrity": "sha1-yz8+PHMtwPAe5wtAPzAuYddwmDg="
-    },
-    "base64-arraybuffer": {
-      "version": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz",
-      "integrity": "sha1-c5JncZI7Whl0etZmqlzUv5xunOg="
-    },
-    "base64id": {
-      "version": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz",
-      "integrity": "sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY="
-    },
-    "bcrypt": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-1.0.2.tgz",
-      "integrity": "sha1-0F/F0iMXPg4o7DgcDwDMJf+vJzY=",
-      "requires": {
-        "bindings": "1.2.1",
-        "nan": "2.5.0",
-        "node-pre-gyp": "0.6.32"
-      }
-    },
-    "bcrypt-pbkdf": {
-      "version": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz",
-      "integrity": "sha1-Y7xdy2EzG5K8Bf1SiVPDNGKgb40=",
-      "optional": true,
-      "requires": {
-        "tweetnacl": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz"
-      }
-    },
-    "better-assert": {
-      "version": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz",
-      "integrity": "sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=",
-      "requires": {
-        "callsite": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz"
-      }
-    },
-    "bindings": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.2.1.tgz",
-      "integrity": "sha1-FK1hE4EtLTfXLme0ystLtyZQXxE="
-    },
-    "blob": {
-      "version": "https://registry.npmjs.org/blob/-/blob-0.0.4.tgz",
-      "integrity": "sha1-vPEwUspURj8w+fx+lbmkdjCpSSE="
-    },
-    "block-stream": {
-      "version": "0.0.9",
-      "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz",
-      "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=",
-      "requires": {
-        "inherits": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz"
-      }
-    },
-    "bluebird": {
-      "version": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.0.tgz",
-      "integrity": "sha1-eRQg1/VR7qKJdFOop3ZT+WYG1nw="
-    },
-    "body-parser": {
-      "version": "https://registry.npmjs.org/body-parser/-/body-parser-1.17.1.tgz",
-      "integrity": "sha1-dbO8mN3W5+DY/+dQ36ylxmmT+kc=",
-      "requires": {
-        "bytes": "https://registry.npmjs.org/bytes/-/bytes-2.4.0.tgz",
-        "content-type": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.6.1.tgz",
-        "depd": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz",
-        "http-errors": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.1.tgz",
-        "iconv-lite": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.15.tgz",
-        "on-finished": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
-        "qs": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz",
-        "raw-body": "https://registry.npmjs.org/raw-body/-/raw-body-2.2.0.tgz",
-        "type-is": "https://registry.npmjs.org/type-is/-/type-is-1.6.15.tgz"
-      }
-    },
-    "boom": {
-      "version": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz",
-      "integrity": "sha1-OciRjO/1eZ+D+UkqhI9iWt0Mdm8=",
-      "requires": {
-        "hoek": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz"
-      }
-    },
-    "brace-expansion": {
-      "version": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.7.tgz",
-      "integrity": "sha1-Pv/DxQ4ABTH7cg6v+A8K6O8jz1k=",
-      "requires": {
-        "balanced-match": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz",
-        "concat-map": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
-      }
-    },
-    "breakable": {
-      "version": "https://registry.npmjs.org/breakable/-/breakable-1.0.0.tgz",
-      "integrity": "sha1-eEp5eRWjjq0nutRWtVcstLuqeME="
-    },
-    "bson": {
-      "version": "https://registry.npmjs.org/bson/-/bson-1.0.4.tgz",
-      "integrity": "sha1-k8ENOeqltYQVy8QFLz5T5WKwtyw="
-    },
-    "buffer-shims": {
-      "version": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz",
-      "integrity": "sha1-mXjOMXOIxkmth5MCjDR37wRKi1E="
-    },
-    "bytes": {
-      "version": "https://registry.npmjs.org/bytes/-/bytes-2.4.0.tgz",
-      "integrity": "sha1-fZcZb51br39pNeJZhVSe3SpsIzk="
-    },
-    "callsite": {
-      "version": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz",
-      "integrity": "sha1-KAOY5dZkvXQDi28JBRU+borxvCA="
-    },
-    "camelcase": {
-      "version": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz",
-      "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk="
-    },
-    "caseless": {
-      "version": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
-      "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
-    },
-    "center-align": {
-      "version": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz",
-      "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=",
-      "requires": {
-        "align-text": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz",
-        "lazy-cache": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz"
-      }
-    },
-    "cliui": {
-      "version": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz",
-      "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=",
-      "requires": {
-        "center-align": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz",
-        "right-align": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz",
-        "wordwrap": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz"
-      },
-      "dependencies": {
-        "wordwrap": {
-          "version": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz",
-          "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8="
-        }
-      }
-    },
-    "co": {
-      "version": "https://registry.npmjs.org/co/-/co-3.0.6.tgz",
-      "integrity": "sha1-FEXyJsXrlWE45oyawwFn6n0ua9o="
-    },
-    "code-point-at": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
-      "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
-    },
-    "combined-stream": {
-      "version": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz",
-      "integrity": "sha1-k4NwpXtKUd6ix3wV1cX9+JUWQAk=",
-      "requires": {
-        "delayed-stream": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz"
-      }
-    },
-    "commander": {
-      "version": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz",
-      "integrity": "sha1-nJkJQXbhIkDLItbFFGCYQA/g99Q=",
-      "requires": {
-        "graceful-readlink": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz"
-      }
-    },
-    "commoner": {
-      "version": "https://registry.npmjs.org/commoner/-/commoner-0.10.8.tgz",
-      "integrity": "sha1-NPw2cs0kOT6LtH5wyqApOBH08sU=",
-      "requires": {
-        "commander": "https://registry.npmjs.org/commander/-/commander-2.9.0.tgz",
-        "detective": "https://registry.npmjs.org/detective/-/detective-4.5.0.tgz",
-        "glob": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz",
-        "graceful-fs": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
-        "iconv-lite": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.15.tgz",
-        "mkdirp": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
-        "private": "https://registry.npmjs.org/private/-/private-0.1.7.tgz",
-        "q": "https://registry.npmjs.org/q/-/q-1.5.0.tgz",
-        "recast": "https://registry.npmjs.org/recast/-/recast-0.11.23.tgz"
-      },
-      "dependencies": {
-        "ast-types": {
-          "version": "https://registry.npmjs.org/ast-types/-/ast-types-0.9.6.tgz",
-          "integrity": "sha1-ECyenpAF0+fjgpvwxPok7oYu6bk="
-        },
-        "recast": {
-          "version": "https://registry.npmjs.org/recast/-/recast-0.11.23.tgz",
-          "integrity": "sha1-RR/TAEqx5N+bTktmN2sqIZEkYtM=",
-          "requires": {
-            "ast-types": "https://registry.npmjs.org/ast-types/-/ast-types-0.9.6.tgz",
-            "esprima": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz",
-            "private": "https://registry.npmjs.org/private/-/private-0.1.7.tgz",
-            "source-map": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz"
-          }
-        },
-        "source-map": {
-          "version": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
-          "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI="
-        }
-      }
-    },
-    "component-bind": {
-      "version": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz",
-      "integrity": "sha1-AMYIq33Nk4l8AAllGx06jh5zu9E="
-    },
-    "component-emitter": {
-      "version": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.1.2.tgz",
-      "integrity": "sha1-KWWU8nU9qmOZbSrwjRWpURbJrsM="
-    },
-    "component-inherit": {
-      "version": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz",
-      "integrity": "sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM="
-    },
-    "concat-map": {
-      "version": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
-      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
-    },
-    "config": {
-      "version": "https://registry.npmjs.org/config/-/config-1.26.1.tgz",
-      "integrity": "sha1-9kfOMsNF6AunOo6qeppLTlspDKE=",
-      "requires": {
-        "json5": "https://registry.npmjs.org/json5/-/json5-0.4.0.tgz",
-        "os-homedir": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz"
-      }
-    },
-    "connect-mongo": {
-      "version": "https://registry.npmjs.org/connect-mongo/-/connect-mongo-1.3.2.tgz",
-      "integrity": "sha1-fL9Y3/8mdg5eAOAX0KhbS8kLnTc=",
-      "requires": {
-        "bluebird": "https://registry.npmjs.org/bluebird/-/bluebird-3.5.0.tgz",
-        "mongodb": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.26.tgz"
-      }
-    },
-    "console-control-strings": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
-      "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
-    },
-    "content-disposition": {
-      "version": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
-      "integrity": "sha1-DPaLud318r55YcOoUXjLhdunjLQ="
-    },
-    "content-type": {
-      "version": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz",
-      "integrity": "sha1-t9ETrueo3Se9IRM8TcJSnfFyHu0="
-    },
-    "convert-hex": {
-      "version": "https://registry.npmjs.org/convert-hex/-/convert-hex-0.1.0.tgz",
-      "integrity": "sha1-CMBFaJIsJ3drii6BqV05M2LqC2U="
-    },
-    "convert-string": {
-      "version": "https://registry.npmjs.org/convert-string/-/convert-string-0.1.0.tgz",
-      "integrity": "sha1-ec5BqbsNA7z3LNxqjzxW+7xkQQo="
-    },
-    "cookie": {
-      "version": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
-      "integrity": "sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s="
-    },
-    "cookie-parser": {
-      "version": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.3.tgz",
-      "integrity": "sha1-D+MfoZ0AC5X0qt8fU/3CuKIDuqU=",
-      "requires": {
-        "cookie": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
-        "cookie-signature": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz"
-      }
-    },
-    "cookie-signature": {
-      "version": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
-      "integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
-    },
-    "core-util-is": {
-      "version": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
-      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
-    },
-    "cors": {
-      "version": "https://registry.npmjs.org/cors/-/cors-2.8.3.tgz",
-      "integrity": "sha1-TPeOHSMymnSWsvwiJbd8pbteuAI=",
-      "requires": {
-        "object-assign": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
-        "vary": "https://registry.npmjs.org/vary/-/vary-1.1.1.tgz"
-      }
-    },
-    "crc": {
-      "version": "https://registry.npmjs.org/crc/-/crc-3.4.4.tgz",
-      "integrity": "sha1-naHpgOO9RPxck79as9ozeNheRms="
-    },
-    "cryptiles": {
-      "version": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz",
-      "integrity": "sha1-O9/s3GCBR8HGcgL6KR59ylnqo7g=",
-      "requires": {
-        "boom": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz"
-      }
-    },
-    "dashdash": {
-      "version": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
-      "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
-      "requires": {
-        "assert-plus": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz"
-      },
-      "dependencies": {
-        "assert-plus": {
-          "version": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
-          "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
-        }
-      }
-    },
-    "data-uri-to-buffer": {
-      "version": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-0.0.4.tgz",
-      "integrity": "sha1-RuE6udqOMJdFyNAc5UchPr2y/j8="
-    },
-    "debug": {
-      "version": "https://registry.npmjs.org/debug/-/debug-2.6.1.tgz",
-      "integrity": "sha1-eYVQkLosTjEVzH2HaUkdWPBJE1E=",
-      "requires": {
-        "ms": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz"
-      }
-    },
-    "decamelize": {
-      "version": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
-      "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
-    },
-    "deep-extend": {
-      "version": "0.4.2",
-      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.4.2.tgz",
-      "integrity": "sha1-SLaZwn4zS/ifEIkr5DL25MfTSn8="
-    },
-    "deep-is": {
-      "version": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
-      "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ="
-    },
-    "defined": {
-      "version": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz",
-      "integrity": "sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM="
-    },
-    "defs": {
-      "version": "https://registry.npmjs.org/defs/-/defs-1.1.1.tgz",
-      "integrity": "sha1-siYJ8sehG6ej2xFoBcE5scr/qdI=",
-      "requires": {
-        "alter": "https://registry.npmjs.org/alter/-/alter-0.2.0.tgz",
-        "ast-traverse": "https://registry.npmjs.org/ast-traverse/-/ast-traverse-0.1.1.tgz",
-        "breakable": "https://registry.npmjs.org/breakable/-/breakable-1.0.0.tgz",
-        "esprima-fb": "15001.1001.0-dev-harmony-fb",
-        "simple-fmt": "https://registry.npmjs.org/simple-fmt/-/simple-fmt-0.1.0.tgz",
-        "simple-is": "https://registry.npmjs.org/simple-is/-/simple-is-0.2.0.tgz",
-        "stringmap": "https://registry.npmjs.org/stringmap/-/stringmap-0.2.2.tgz",
-        "stringset": "https://registry.npmjs.org/stringset/-/stringset-0.2.1.tgz",
-        "tryor": "https://registry.npmjs.org/tryor/-/tryor-0.1.2.tgz",
-        "yargs": "https://registry.npmjs.org/yargs/-/yargs-3.27.0.tgz"
-      },
-      "dependencies": {
-        "esprima-fb": {
-          "version": "15001.1001.0-dev-harmony-fb",
-          "resolved": "https://registry.npmjs.org/esprima-fb/-/esprima-fb-15001.1001.0-dev-harmony-fb.tgz",
-          "integrity": "sha1-Q761fsJujPI3092LM+QlM1d/Jlk="
-        }
-      }
-    },
-    "degenerator": {
-      "version": "https://registry.npmjs.org/degenerator/-/degenerator-1.0.4.tgz",
-      "integrity": "sha1-/PSQo37OJmRk2cxDGrmMWBnO0JU=",
-      "requires": {
-        "ast-types": "https://registry.npmjs.org/ast-types/-/ast-types-0.9.11.tgz",
-        "escodegen": "https://registry.npmjs.org/escodegen/-/escodegen-1.8.1.tgz",
-        "esprima": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz"
-      }
-    },
-    "delayed-stream": {
-      "version": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
-      "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
-    },
-    "delegates": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
-      "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o="
-    },
-    "depd": {
-      "version": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz",
-      "integrity": "sha1-4b2Cxqq2ztlluXuIsX7T5SjKGMM="
-    },
-    "destroy": {
-      "version": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
-      "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
-    },
-    "detective": {
-      "version": "https://registry.npmjs.org/detective/-/detective-4.5.0.tgz",
-      "integrity": "sha1-blqMaybmx6JUsca210kNmOyR7dE=",
-      "requires": {
-        "acorn": "https://registry.npmjs.org/acorn/-/acorn-4.0.11.tgz",
-        "defined": "https://registry.npmjs.org/defined/-/defined-1.0.0.tgz"
-      }
-    },
-    "discord.js": {
-      "version": "https://registry.npmjs.org/discord.js/-/discord.js-11.1.0.tgz",
-      "integrity": "sha1-U1HVnjeY9TbeGXXpLK5NEa89kmY=",
-      "requires": {
-        "long": "https://registry.npmjs.org/long/-/long-3.2.0.tgz",
-        "prism-media": "https://registry.npmjs.org/prism-media/-/prism-media-0.0.1.tgz",
-        "snekfetch": "https://registry.npmjs.org/snekfetch/-/snekfetch-3.1.6.tgz",
-        "tweetnacl": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
-        "ws": "https://registry.npmjs.org/ws/-/ws-2.3.1.tgz"
-      }
-    },
-    "double-ended-queue": {
-      "version": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz",
-      "integrity": "sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw="
-    },
-    "ecc-jsbn": {
-      "version": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz",
-      "integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=",
-      "optional": true,
-      "requires": {
-        "jsbn": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz"
-      }
-    },
-    "ee-first": {
-      "version": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
-      "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0="
-    },
-    "encodeurl": {
-      "version": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.1.tgz",
-      "integrity": "sha1-eePVhlU0aQn+bw9Fpd5oEDspTSA="
-    },
-    "engine.io": {
-      "version": "https://registry.npmjs.org/engine.io/-/engine.io-1.8.4.tgz",
-      "integrity": "sha1-d7zhK4Dl1gQpM3/sOw2vaR68kAM=",
-      "requires": {
-        "accepts": "https://registry.npmjs.org/accepts/-/accepts-1.3.3.tgz",
-        "base64id": "https://registry.npmjs.org/base64id/-/base64id-1.0.0.tgz",
-        "cookie": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
-        "engine.io-parser": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-1.3.2.tgz",
-        "ws": "https://registry.npmjs.org/ws/-/ws-1.1.4.tgz"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
-          "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=",
-          "requires": {
-            "ms": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz"
-          }
-        },
-        "ultron": {
-          "version": "https://registry.npmjs.org/ultron/-/ultron-1.0.2.tgz",
-          "integrity": "sha1-rOEWq1V80Zc4ak6I9GhTeMiy5Po="
-        },
-        "ws": {
-          "version": "https://registry.npmjs.org/ws/-/ws-1.1.4.tgz",
-          "integrity": "sha1-V/QNA2gy5fUFVmKjl8Tedu1mv2E=",
-          "requires": {
-            "options": "https://registry.npmjs.org/options/-/options-0.0.6.tgz",
-            "ultron": "https://registry.npmjs.org/ultron/-/ultron-1.0.2.tgz"
-          }
-        }
-      }
-    },
-    "engine.io-client": {
-      "version": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-1.8.4.tgz",
-      "integrity": "sha1-n+hd7iWFPKa6viW9KtaHEIY+kcI=",
-      "requires": {
-        "component-emitter": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz",
-        "component-inherit": "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
-        "engine.io-parser": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-1.3.2.tgz",
-        "has-cors": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz",
-        "indexof": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz",
-        "parsejson": "https://registry.npmjs.org/parsejson/-/parsejson-0.0.3.tgz",
-        "parseqs": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz",
-        "parseuri": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz",
-        "ws": "https://registry.npmjs.org/ws/-/ws-1.1.2.tgz",
-        "xmlhttprequest-ssl": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz",
-        "yeast": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz"
-      },
-      "dependencies": {
-        "component-emitter": {
-          "version": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz",
-          "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY="
-        },
-        "debug": {
-          "version": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
-          "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=",
-          "requires": {
-            "ms": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz"
-          }
-        },
-        "ultron": {
-          "version": "https://registry.npmjs.org/ultron/-/ultron-1.0.2.tgz",
-          "integrity": "sha1-rOEWq1V80Zc4ak6I9GhTeMiy5Po="
-        },
-        "ws": {
-          "version": "https://registry.npmjs.org/ws/-/ws-1.1.2.tgz",
-          "integrity": "sha1-iiRPoFJAHgjJiGz0SoUYnh/UBn8=",
-          "requires": {
-            "options": "https://registry.npmjs.org/options/-/options-0.0.6.tgz",
-            "ultron": "https://registry.npmjs.org/ultron/-/ultron-1.0.2.tgz"
-          }
-        }
-      }
-    },
-    "engine.io-parser": {
-      "version": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-1.3.2.tgz",
-      "integrity": "sha1-k3sHnwAH0Ik+xW1GyyILjLQ1Igo=",
-      "requires": {
-        "after": "https://registry.npmjs.org/after/-/after-0.8.2.tgz",
-        "arraybuffer.slice": "https://registry.npmjs.org/arraybuffer.slice/-/arraybuffer.slice-0.0.6.tgz",
-        "base64-arraybuffer": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz",
-        "blob": "https://registry.npmjs.org/blob/-/blob-0.0.4.tgz",
-        "has-binary": "https://registry.npmjs.org/has-binary/-/has-binary-0.1.7.tgz",
-        "wtf-8": "https://registry.npmjs.org/wtf-8/-/wtf-8-1.0.0.tgz"
-      }
-    },
-    "es6-promise": {
-      "version": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz",
-      "integrity": "sha1-7FYjOGgDKQkgcXDDlEjiREndH8Q="
-    },
-    "escape-html": {
-      "version": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
-      "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
-    },
-    "escodegen": {
-      "version": "https://registry.npmjs.org/escodegen/-/escodegen-1.8.1.tgz",
-      "integrity": "sha1-WltTr0aTEQvrsIZ6o0MN07cKEBg=",
-      "requires": {
-        "esprima": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz",
-        "estraverse": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz",
-        "esutils": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz",
-        "optionator": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz",
-        "source-map": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz"
-      },
-      "dependencies": {
-        "esprima": {
-          "version": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz",
-          "integrity": "sha1-luO3DVd59q1JzQMmc9HDEnZ7pYE="
-        }
-      }
-    },
-    "esprima": {
-      "version": "https://registry.npmjs.org/esprima/-/esprima-3.1.3.tgz",
-      "integrity": "sha1-/cpRzuYTOJXjyI1TXOSdv/YqRjM="
-    },
-    "estraverse": {
-      "version": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz",
-      "integrity": "sha1-r2fy3JIlgkFZUJJgkaQAXSnJu0Q="
-    },
-    "esutils": {
-      "version": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz",
-      "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs="
-    },
-    "etag": {
-      "version": "https://registry.npmjs.org/etag/-/etag-1.8.0.tgz",
-      "integrity": "sha1-b2Ma7zNtbEY2K1F2QETOIWvjwFE="
-    },
-    "express": {
-      "version": "https://registry.npmjs.org/express/-/express-4.15.2.tgz",
-      "integrity": "sha1-rxB/wUhQRFfy3Kmm8lcdcSm5ezU=",
-      "requires": {
-        "accepts": "https://registry.npmjs.org/accepts/-/accepts-1.3.3.tgz",
-        "array-flatten": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
-        "content-disposition": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.2.tgz",
-        "content-type": "https://registry.npmjs.org/content-type/-/content-type-1.0.2.tgz",
-        "cookie": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
-        "cookie-signature": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.6.1.tgz",
-        "depd": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz",
-        "encodeurl": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.1.tgz",
-        "escape-html": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
-        "etag": "https://registry.npmjs.org/etag/-/etag-1.8.0.tgz",
-        "finalhandler": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.0.2.tgz",
-        "fresh": "https://registry.npmjs.org/fresh/-/fresh-0.5.0.tgz",
-        "merge-descriptors": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
-        "methods": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
-        "on-finished": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
-        "parseurl": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz",
-        "path-to-regexp": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
-        "proxy-addr": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-1.1.4.tgz",
-        "qs": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz",
-        "range-parser": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz",
-        "send": "https://registry.npmjs.org/send/-/send-0.15.1.tgz",
-        "serve-static": "https://registry.npmjs.org/serve-static/-/serve-static-1.12.1.tgz",
-        "setprototypeof": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz",
-        "statuses": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz",
-        "type-is": "https://registry.npmjs.org/type-is/-/type-is-1.6.15.tgz",
-        "utils-merge": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz",
-        "vary": "https://registry.npmjs.org/vary/-/vary-1.1.1.tgz"
-      }
-    },
-    "express-session": {
-      "version": "https://registry.npmjs.org/express-session/-/express-session-1.15.2.tgz",
-      "integrity": "sha1-2YUWRDpMy4aI4XJa5YTALapAk9Q=",
-      "requires": {
-        "cookie": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
-        "cookie-signature": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
-        "crc": "https://registry.npmjs.org/crc/-/crc-3.4.4.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.6.3.tgz",
-        "depd": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz",
-        "on-headers": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz",
-        "parseurl": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz",
-        "uid-safe": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.4.tgz",
-        "utils-merge": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "https://registry.npmjs.org/debug/-/debug-2.6.3.tgz",
-          "integrity": "sha1-D364wwll7AjHKsz6ATDIt5mEFB0=",
-          "requires": {
-            "ms": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz"
-          }
-        }
-      }
-    },
-    "extend": {
-      "version": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
-      "integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ="
-    },
-    "extsprintf": {
-      "version": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.2.tgz",
-      "integrity": "sha1-4QgOBljjALBilJkMxw4VAiNf1VA="
-    },
-    "fast-levenshtein": {
-      "version": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
-      "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc="
-    },
-    "file-uri-to-path": {
-      "version": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-0.0.2.tgz",
-      "integrity": "sha1-N83RtbkFQEs/BeGyNkW+aU/3D4I="
-    },
-    "finalhandler": {
-      "version": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.0.2.tgz",
-      "integrity": "sha1-0ONvnbxVfy3hRCPfYmGInp1gyTo=",
-      "requires": {
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.6.4.tgz",
-        "encodeurl": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.1.tgz",
-        "escape-html": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
-        "on-finished": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
-        "parseurl": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz",
-        "statuses": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz",
-        "unpipe": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "https://registry.npmjs.org/debug/-/debug-2.6.4.tgz",
-          "integrity": "sha1-dYaps8OXQcAoKuM0RcTorHRzT+A=",
-          "requires": {
-            "ms": "https://registry.npmjs.org/ms/-/ms-0.7.3.tgz"
-          }
-        },
-        "ms": {
-          "version": "https://registry.npmjs.org/ms/-/ms-0.7.3.tgz",
-          "integrity": "sha1-cIFVpeROM/X9D8U+gdDUCpG+H/8="
-        }
-      }
-    },
-    "forever-agent": {
-      "version": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
-      "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE="
-    },
-    "form-data": {
-      "version": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz",
-      "integrity": "sha1-M8GDrPGTJ27KqYFDpp6Uv+4XUNE=",
-      "requires": {
-        "asynckit": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
-        "combined-stream": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz",
-        "mime-types": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz"
-      }
-    },
-    "forwarded": {
-      "version": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.0.tgz",
-      "integrity": "sha1-Ge+YdMSuHCl7zweP3mOgm2aoQ2M="
-    },
-    "fresh": {
-      "version": "https://registry.npmjs.org/fresh/-/fresh-0.5.0.tgz",
-      "integrity": "sha1-9HTKXmqSRtb9jglTz6m5yAWvp44="
-    },
-    "fs.realpath": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
-      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
-    },
-    "fstream": {
-      "version": "1.0.11",
-      "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz",
-      "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=",
-      "requires": {
-        "graceful-fs": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
-        "inherits": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
-        "mkdirp": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
-        "rimraf": "2.5.4"
-      }
-    },
-    "fstream-ignore": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/fstream-ignore/-/fstream-ignore-1.0.5.tgz",
-      "integrity": "sha1-nDHa40dnAY/h0kmyTa2mfQktoQU=",
-      "requires": {
-        "fstream": "1.0.11",
-        "inherits": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
-        "minimatch": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz"
-      }
-    },
-    "ftp": {
-      "version": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz",
-      "integrity": "sha1-kZfYYa2BQvPmPVqDv+TFn3MwiF0=",
-      "requires": {
-        "readable-stream": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
-        "xregexp": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz"
-      },
-      "dependencies": {
-        "isarray": {
-          "version": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
-          "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
-        },
-        "readable-stream": {
-          "version": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
-          "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=",
-          "requires": {
-            "core-util-is": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
-            "inherits": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
-            "isarray": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
-            "string_decoder": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz"
-          }
-        },
-        "string_decoder": {
-          "version": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
-          "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
-        }
-      }
-    },
-    "gauge": {
-      "version": "2.7.4",
-      "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
-      "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=",
-      "requires": {
-        "aproba": "1.1.2",
-        "console-control-strings": "1.1.0",
-        "has-unicode": "2.0.1",
-        "object-assign": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
-        "signal-exit": "3.0.2",
-        "string-width": "1.0.2",
-        "strip-ansi": "3.0.1",
-        "wide-align": "1.1.2"
-      }
-    },
-    "get-uri": {
-      "version": "https://registry.npmjs.org/get-uri/-/get-uri-1.1.0.tgz",
-      "integrity": "sha1-c3XQTa9/y1hLNjJnnL3zObUbsUk=",
-      "requires": {
-        "data-uri-to-buffer": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-0.0.4.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.6.1.tgz",
-        "extend": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
-        "file-uri-to-path": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-0.0.2.tgz",
-        "ftp": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz",
-        "readable-stream": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.7.tgz"
-      }
-    },
-    "getpass": {
-      "version": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
-      "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
-      "requires": {
-        "assert-plus": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz"
-      },
-      "dependencies": {
-        "assert-plus": {
-          "version": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
-          "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
-        }
-      }
-    },
-    "glob": {
-      "version": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz",
-      "integrity": "sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=",
-      "requires": {
-        "inflight": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
-        "inherits": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
-        "minimatch": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
-        "once": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-        "path-is-absolute": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz"
-      }
-    },
-    "graceful-fs": {
-      "version": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
-      "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg="
-    },
-    "graceful-readlink": {
-      "version": "https://registry.npmjs.org/graceful-readlink/-/graceful-readlink-1.0.1.tgz",
-      "integrity": "sha1-TK+tdrxi8C+gObL5Tpo906ORpyU="
-    },
-    "har-schema": {
-      "version": "https://registry.npmjs.org/har-schema/-/har-schema-1.0.5.tgz",
-      "integrity": "sha1-0mMTX0MwfALGAq/I/pWXDAFRNp4="
-    },
-    "har-validator": {
-      "version": "https://registry.npmjs.org/har-validator/-/har-validator-4.2.1.tgz",
-      "integrity": "sha1-M0gdDxu/9gDdID11gSpqX7oALio=",
-      "requires": {
-        "ajv": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz",
-        "har-schema": "https://registry.npmjs.org/har-schema/-/har-schema-1.0.5.tgz"
-      }
-    },
-    "has-binary": {
-      "version": "https://registry.npmjs.org/has-binary/-/has-binary-0.1.7.tgz",
-      "integrity": "sha1-aOYesWIQyVRaClzOBqhzkS/h5ow=",
-      "requires": {
-        "isarray": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz"
-      },
-      "dependencies": {
-        "isarray": {
-          "version": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
-          "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
-        }
-      }
-    },
-    "has-cors": {
-      "version": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz",
-      "integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk="
-    },
-    "has-unicode": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
-      "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk="
-    },
-    "hawk": {
-      "version": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz",
-      "integrity": "sha1-B4REvXwWQLD+VA0sm3PVlnjo4cQ=",
-      "requires": {
-        "boom": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz",
-        "cryptiles": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz",
-        "hoek": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz",
-        "sntp": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz"
-      }
-    },
-    "hoek": {
-      "version": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz",
-      "integrity": "sha1-ILt0A9POo5jpHcRxCo/xuCdKJe0="
-    },
-    "hooks-fixed": {
-      "version": "https://registry.npmjs.org/hooks-fixed/-/hooks-fixed-2.0.0.tgz",
-      "integrity": "sha1-oB2JTVKsf2WZu7H2PfycQR33DLo="
-    },
-    "http-errors": {
-      "version": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.1.tgz",
-      "integrity": "sha1-X4uO2YrKVFZWv1cplzh/kEpyIlc=",
-      "requires": {
-        "depd": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz",
-        "inherits": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
-        "setprototypeof": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz",
-        "statuses": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz"
-      }
-    },
-    "http-proxy-agent": {
-      "version": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-1.0.0.tgz",
-      "integrity": "sha1-zBzjjkU7+YSg93AtLdWcc9CBKEo=",
-      "requires": {
-        "agent-base": "https://registry.npmjs.org/agent-base/-/agent-base-2.0.1.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.6.1.tgz",
-        "extend": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz"
-      }
-    },
-    "http-signature": {
-      "version": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz",
-      "integrity": "sha1-33LiZwZs0Kxn+3at+OE0qPvPkb8=",
-      "requires": {
-        "assert-plus": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz",
-        "jsprim": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.0.tgz",
-        "sshpk": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.0.tgz"
-      }
-    },
-    "https-proxy-agent": {
-      "version": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-1.0.0.tgz",
-      "integrity": "sha1-NffabEjOTdv6JkiRrFk+5f+GceY=",
-      "requires": {
-        "agent-base": "https://registry.npmjs.org/agent-base/-/agent-base-2.0.1.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.6.1.tgz",
-        "extend": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz"
-      }
-    },
-    "iconv-lite": {
-      "version": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.15.tgz",
-      "integrity": "sha1-/iZaIYrGpXz+hUkn6dBMGYJe3es="
-    },
-    "indexof": {
-      "version": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz",
-      "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10="
-    },
-    "inflection": {
-      "version": "https://registry.npmjs.org/inflection/-/inflection-1.10.0.tgz",
-      "integrity": "sha1-W//LEZetPoEFD44X4hZoCH7p6y8="
-    },
-    "inflight": {
-      "version": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
-      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
-      "requires": {
-        "once": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-        "wrappy": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
-      }
-    },
-    "inherits": {
-      "version": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
-      "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
-    },
-    "ini": {
-      "version": "1.3.4",
-      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.4.tgz",
-      "integrity": "sha1-BTfLedr1m1mhpRff9wbIbsA5Fi4="
-    },
-    "invert-kv": {
-      "version": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz",
-      "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY="
-    },
-    "ip": {
-      "version": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
-      "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo="
-    },
-    "ipaddr.js": {
-      "version": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.3.0.tgz",
-      "integrity": "sha1-HgOlL9rYOou7KyXL9JmLTP/NPew="
-    },
-    "is-buffer": {
-      "version": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.5.tgz",
-      "integrity": "sha1-Hzsm72E7IUuIy8ojzGwB2Hlh7sw="
-    },
-    "is-fullwidth-code-point": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
-      "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
-      "requires": {
-        "number-is-nan": "1.0.1"
-      }
-    },
-    "is-stream": {
-      "version": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
-      "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ="
-    },
-    "is-typedarray": {
-      "version": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
-      "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo="
-    },
-    "isarray": {
-      "version": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
-      "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
-    },
-    "isstream": {
-      "version": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
-      "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo="
-    },
-    "jodid25519": {
-      "version": "https://registry.npmjs.org/jodid25519/-/jodid25519-1.0.2.tgz",
-      "integrity": "sha1-BtSRIlUJNBlHfUJWM2BuDpB4KWc=",
-      "optional": true,
-      "requires": {
-        "jsbn": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz"
-      }
-    },
-    "jsbn": {
-      "version": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
-      "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=",
-      "optional": true
-    },
-    "json-schema": {
-      "version": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
-      "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM="
-    },
-    "json-stable-stringify": {
-      "version": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz",
-      "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=",
-      "requires": {
-        "jsonify": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz"
-      }
-    },
-    "json-stringify-safe": {
-      "version": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
-      "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
-    },
-    "json3": {
-      "version": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz",
-      "integrity": "sha1-PAQ0dD35Pi9cQq7nsZvLSDV19OE="
-    },
-    "json5": {
-      "version": "https://registry.npmjs.org/json5/-/json5-0.4.0.tgz",
-      "integrity": "sha1-BUNS5MTIDIbAkjh31EneF2pzLI0="
-    },
-    "jsonify": {
-      "version": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz",
-      "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM="
-    },
-    "jsprim": {
-      "version": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.0.tgz",
-      "integrity": "sha1-o7h+QCmNjDgFUtjMdiigu5WiKRg=",
-      "requires": {
-        "assert-plus": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
-        "extsprintf": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.2.tgz",
-        "json-schema": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
-        "verror": "https://registry.npmjs.org/verror/-/verror-1.3.6.tgz"
-      },
-      "dependencies": {
-        "assert-plus": {
-          "version": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
-          "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
-        }
-      }
-    },
-    "kareem": {
-      "version": "https://registry.npmjs.org/kareem/-/kareem-1.4.1.tgz",
-      "integrity": "sha1-7XYgAET6BB7zK02oJh4lU/EXNTE="
-    },
-    "kind-of": {
-      "version": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.0.tgz",
-      "integrity": "sha1-tYq+TVwEStM3JqjBUltIz4kb/wc=",
-      "requires": {
-        "is-buffer": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.5.tgz"
-      }
-    },
-    "lazy-cache": {
-      "version": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz",
-      "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4="
-    },
-    "lcid": {
-      "version": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz",
-      "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=",
-      "requires": {
-        "invert-kv": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz"
-      }
-    },
-    "levn": {
-      "version": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
-      "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=",
-      "requires": {
-        "prelude-ls": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
-        "type-check": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz"
-      }
-    },
-    "lodash": {
-      "version": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz",
-      "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4="
-    },
-    "long": {
-      "version": "https://registry.npmjs.org/long/-/long-3.2.0.tgz",
-      "integrity": "sha1-2CG3E4yhy1gcFymQ7xTbIAtcR0s="
-    },
-    "longest": {
-      "version": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz",
-      "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc="
-    },
-    "lru-cache": {
-      "version": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.6.5.tgz",
-      "integrity": "sha1-5W1jVBSO3o13B7WNFDIg/QjfD9U="
-    },
-    "mailgun-js": {
-      "version": "https://registry.npmjs.org/mailgun-js/-/mailgun-js-0.8.2.tgz",
-      "integrity": "sha1-oQjO2jFFqTzWFrBHLAVm90VrbzQ=",
-      "requires": {
-        "async": "https://registry.npmjs.org/async/-/async-2.1.5.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
-        "form-data": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz",
-        "inflection": "https://registry.npmjs.org/inflection/-/inflection-1.10.0.tgz",
-        "is-stream": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
-        "path-proxy": "https://registry.npmjs.org/path-proxy/-/path-proxy-1.0.0.tgz",
-        "promisify-call": "https://registry.npmjs.org/promisify-call/-/promisify-call-2.0.4.tgz",
-        "proxy-agent": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-2.0.0.tgz",
-        "tsscmp": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.5.tgz"
-      },
-      "dependencies": {
-        "async": {
-          "version": "https://registry.npmjs.org/async/-/async-2.1.5.tgz",
-          "integrity": "sha1-5YfGhYCZSsZ/xW/4bTrFa9voELw=",
-          "requires": {
-            "lodash": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz"
-          }
-        },
-        "debug": {
-          "version": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
-          "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=",
-          "requires": {
-            "ms": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
-          }
-        },
-        "ms": {
-          "version": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz",
-          "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg="
-        }
-      }
-    },
-    "media-typer": {
-      "version": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
-      "integrity": "sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g="
-    },
-    "merge-descriptors": {
-      "version": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz",
-      "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E="
-    },
-    "methods": {
-      "version": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
-      "integrity": "sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4="
-    },
-    "mime": {
-      "version": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz",
-      "integrity": "sha1-EV+eO2s9rylZmDyzjxSaLUDrXVM="
-    },
-    "mime-db": {
-      "version": "https://registry.npmjs.org/mime-db/-/mime-db-1.27.0.tgz",
-      "integrity": "sha1-gg9XIpa70g7CXtVeW13oaeVDbrE="
-    },
-    "mime-types": {
-      "version": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz",
-      "integrity": "sha1-pOv1BkCUVpI3uM9wBGd20J/JKu0=",
-      "requires": {
-        "mime-db": "https://registry.npmjs.org/mime-db/-/mime-db-1.27.0.tgz"
-      }
-    },
-    "minimatch": {
-      "version": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
-      "integrity": "sha1-UWbihkV/AzBgZL5Ul+jbsMPTIIM=",
-      "requires": {
-        "brace-expansion": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.7.tgz"
-      }
-    },
-    "minimist": {
-      "version": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
-      "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
-    },
-    "mkdirp": {
-      "version": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
-      "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
-      "requires": {
-        "minimist": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz"
-      }
-    },
-    "moment": {
-      "version": "https://registry.npmjs.org/moment/-/moment-2.18.1.tgz",
-      "integrity": "sha1-w2GT3Tzhwu7SrbfIAtu8d6gbHA8="
-    },
-    "mongodb": {
-      "version": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.26.tgz",
-      "integrity": "sha1-G9UMVXwnfJjhoF2jjJg5xJIrA0o=",
-      "requires": {
-        "es6-promise": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.2.1.tgz",
-        "mongodb-core": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-2.1.10.tgz",
-        "readable-stream": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.7.tgz"
-      }
-    },
-    "mongodb-core": {
-      "version": "https://registry.npmjs.org/mongodb-core/-/mongodb-core-2.1.10.tgz",
-      "integrity": "sha1-6ykGgdGW0zRqSSFhqi6gkF5jFRs=",
-      "requires": {
-        "bson": "https://registry.npmjs.org/bson/-/bson-1.0.4.tgz",
-        "require_optional": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.0.tgz"
-      }
-    },
-    "mongoose": {
-      "version": "https://registry.npmjs.org/mongoose/-/mongoose-4.9.9.tgz",
-      "integrity": "sha1-hnH74GyUX1X7p60DeXvALxlRZ2I=",
-      "requires": {
-        "async": "https://registry.npmjs.org/async/-/async-2.1.4.tgz",
-        "bson": "https://registry.npmjs.org/bson/-/bson-1.0.4.tgz",
-        "hooks-fixed": "https://registry.npmjs.org/hooks-fixed/-/hooks-fixed-2.0.0.tgz",
-        "kareem": "https://registry.npmjs.org/kareem/-/kareem-1.4.1.tgz",
-        "mongodb": "https://registry.npmjs.org/mongodb/-/mongodb-2.2.26.tgz",
-        "mpath": "https://registry.npmjs.org/mpath/-/mpath-0.2.1.tgz",
-        "mpromise": "https://registry.npmjs.org/mpromise/-/mpromise-0.5.5.tgz",
-        "mquery": "https://registry.npmjs.org/mquery/-/mquery-2.3.0.tgz",
-        "ms": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz",
-        "muri": "https://registry.npmjs.org/muri/-/muri-1.2.1.tgz",
-        "regexp-clone": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-0.0.1.tgz",
-        "sliced": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz"
-      },
-      "dependencies": {
-        "async": {
-          "version": "https://registry.npmjs.org/async/-/async-2.1.4.tgz",
-          "integrity": "sha1-LSFgx3iAMuTdbL4lAvH5osj2zeQ=",
-          "requires": {
-            "lodash": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz"
-          }
-        }
-      }
-    },
-    "mpath": {
-      "version": "https://registry.npmjs.org/mpath/-/mpath-0.2.1.tgz",
-      "integrity": "sha1-Ok6Ck1mAHeljCcJ6ay4QLon56W4="
-    },
-    "mpromise": {
-      "version": "https://registry.npmjs.org/mpromise/-/mpromise-0.5.5.tgz",
-      "integrity": "sha1-9bJCWddjrMIlewoMjG2Gb9UXMuY="
-    },
-    "mquery": {
-      "version": "https://registry.npmjs.org/mquery/-/mquery-2.3.0.tgz",
-      "integrity": "sha1-PRcXrYlY0MmeQuokYaEJ8+Xz5Fg=",
-      "requires": {
-        "bluebird": "https://registry.npmjs.org/bluebird/-/bluebird-2.10.2.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
-        "regexp-clone": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-0.0.1.tgz",
-        "sliced": "https://registry.npmjs.org/sliced/-/sliced-0.0.5.tgz"
-      },
-      "dependencies": {
-        "bluebird": {
-          "version": "https://registry.npmjs.org/bluebird/-/bluebird-2.10.2.tgz",
-          "integrity": "sha1-AkpVFylTCIV/FPkfEQb8O1VfRGs="
-        },
-        "debug": {
-          "version": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
-          "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=",
-          "requires": {
-            "ms": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
-          }
-        },
-        "ms": {
-          "version": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz",
-          "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg="
-        },
-        "sliced": {
-          "version": "https://registry.npmjs.org/sliced/-/sliced-0.0.5.tgz",
-          "integrity": "sha1-XtwETKTrb3gW1Qui/GPiXY/kcH8="
-        }
-      }
-    },
-    "ms": {
-      "version": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz",
-      "integrity": "sha1-riXPJRKziFodldfwN4aNhDESR2U="
-    },
-    "muri": {
-      "version": "https://registry.npmjs.org/muri/-/muri-1.2.1.tgz",
-      "integrity": "sha1-7H6lzmympSPrGrNbrNpfqBbJqjw="
-    },
-    "nan": {
-      "version": "2.5.0",
-      "resolved": "https://registry.npmjs.org/nan/-/nan-2.5.0.tgz",
-      "integrity": "sha1-qo8eNFMdgH6eJ3VbI0tKbsDBUqg="
-    },
-    "negotiator": {
-      "version": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.1.tgz",
-      "integrity": "sha1-KzJxhOiZIQEXeyhWP7XnECrNDKk="
-    },
-    "netmask": {
-      "version": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz",
-      "integrity": "sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU="
-    },
-    "node-pre-gyp": {
-      "version": "0.6.32",
-      "resolved": "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.6.32.tgz",
-      "integrity": "sha1-/EUrN25zGbPSVfXzSFPvb9j+H9U=",
-      "requires": {
-        "mkdirp": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
-        "nopt": "3.0.6",
-        "npmlog": "4.1.2",
-        "rc": "1.1.7",
-        "request": "https://registry.npmjs.org/request/-/request-2.81.0.tgz",
-        "rimraf": "2.5.4",
-        "semver": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
-        "tar": "2.2.1",
-        "tar-pack": "3.3.0"
-      }
-    },
-    "nopt": {
-      "version": "3.0.6",
-      "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz",
-      "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=",
-      "requires": {
-        "abbrev": "1.1.0"
-      }
-    },
-    "npmlog": {
-      "version": "4.1.2",
-      "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz",
-      "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==",
-      "requires": {
-        "are-we-there-yet": "1.1.4",
-        "console-control-strings": "1.1.0",
-        "gauge": "2.7.4",
-        "set-blocking": "2.0.0"
-      }
-    },
-    "number-is-nan": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
-      "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
-    },
-    "oauth": {
-      "version": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
-      "integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE="
-    },
-    "oauth-sign": {
-      "version": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz",
-      "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM="
-    },
-    "object-assign": {
-      "version": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
-      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
-    },
-    "object-component": {
-      "version": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz",
-      "integrity": "sha1-8MaapQ78lbhmwYb0AKM3acsvEpE="
-    },
-    "on-finished": {
-      "version": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
-      "integrity": "sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=",
-      "requires": {
-        "ee-first": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"
-      }
-    },
-    "on-headers": {
-      "version": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz",
-      "integrity": "sha1-ko9dD0cNSTQmUepnlLCFfBAGk/c="
-    },
-    "once": {
-      "version": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
-      "requires": {
-        "wrappy": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
-      }
-    },
-    "optionator": {
-      "version": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz",
-      "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=",
-      "requires": {
-        "deep-is": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
-        "fast-levenshtein": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
-        "levn": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
-        "prelude-ls": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
-        "type-check": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
-        "wordwrap": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz"
-      }
-    },
-    "options": {
-      "version": "https://registry.npmjs.org/options/-/options-0.0.6.tgz",
-      "integrity": "sha1-7CLTEoBrtT5zF3Pnza788cZDEo8="
-    },
-    "os-homedir": {
-      "version": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
-      "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M="
-    },
-    "os-locale": {
-      "version": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz",
-      "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=",
-      "requires": {
-        "lcid": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz"
-      }
-    },
-    "pac-proxy-agent": {
-      "version": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-1.0.0.tgz",
-      "integrity": "sha1-3NW3RlgTZ0MKI26I6s/U5bjQaKU=",
-      "requires": {
-        "agent-base": "https://registry.npmjs.org/agent-base/-/agent-base-2.0.1.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.6.1.tgz",
-        "extend": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
-        "get-uri": "https://registry.npmjs.org/get-uri/-/get-uri-1.1.0.tgz",
-        "http-proxy-agent": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-1.0.0.tgz",
-        "https-proxy-agent": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-1.0.0.tgz",
-        "pac-resolver": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-1.2.6.tgz",
-        "socks-proxy-agent": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-2.0.0.tgz",
-        "stream-to-buffer": "https://registry.npmjs.org/stream-to-buffer/-/stream-to-buffer-0.1.0.tgz"
-      }
-    },
-    "pac-resolver": {
-      "version": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-1.2.6.tgz",
-      "integrity": "sha1-7QOvDFtZM1Bb3T8H91F1Rm1efPs=",
-      "requires": {
-        "co": "https://registry.npmjs.org/co/-/co-3.0.6.tgz",
-        "degenerator": "https://registry.npmjs.org/degenerator/-/degenerator-1.0.4.tgz",
-        "netmask": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz",
-        "regenerator": "https://registry.npmjs.org/regenerator/-/regenerator-0.8.46.tgz",
-        "thunkify": "https://registry.npmjs.org/thunkify/-/thunkify-2.1.2.tgz"
-      }
-    },
-    "parsejson": {
-      "version": "https://registry.npmjs.org/parsejson/-/parsejson-0.0.3.tgz",
-      "integrity": "sha1-q343WfIJ7OmUN5c/fQ8fZK4OZKs=",
-      "requires": {
-        "better-assert": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz"
-      }
-    },
-    "parseqs": {
-      "version": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.5.tgz",
-      "integrity": "sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=",
-      "requires": {
-        "better-assert": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz"
-      }
-    },
-    "parseuri": {
-      "version": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz",
-      "integrity": "sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=",
-      "requires": {
-        "better-assert": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz"
-      }
-    },
-    "parseurl": {
-      "version": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz",
-      "integrity": "sha1-yKuMkiO6NIiKpkopeyiFO+wY2lY="
-    },
-    "passport": {
-      "version": "https://registry.npmjs.org/passport/-/passport-0.3.2.tgz",
-      "integrity": "sha1-ndAJ+RXo/glbASSgG4+C2gdRAQI=",
-      "requires": {
-        "passport-strategy": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
-        "pause": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz"
-      }
-    },
-    "passport-discord": {
-      "version": "https://registry.npmjs.org/passport-discord/-/passport-discord-0.1.2.tgz",
-      "integrity": "sha1-W4k6IAdgPGyC7IJOZ6NaRijUeVU=",
-      "requires": {
-        "passport-oauth2": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.4.0.tgz"
-      }
-    },
-    "passport-github": {
-      "version": "https://registry.npmjs.org/passport-github/-/passport-github-1.1.0.tgz",
-      "integrity": "sha1-jOHj/NYa11eOsd9ZWDnkrqEjVdQ=",
-      "requires": {
-        "passport-oauth2": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.4.0.tgz"
-      }
-    },
-    "passport-local": {
-      "version": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
-      "integrity": "sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4=",
-      "requires": {
-        "passport-strategy": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz"
-      }
-    },
-    "passport-oauth2": {
-      "version": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.4.0.tgz",
-      "integrity": "sha1-9i+BWDy+EmCb585vFguTlaJ7hq0=",
-      "requires": {
-        "oauth": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
-        "passport-strategy": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
-        "uid2": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
-        "utils-merge": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz"
-      }
-    },
-    "passport-strategy": {
-      "version": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
-      "integrity": "sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ="
-    },
-    "passport.socketio": {
-      "version": "https://registry.npmjs.org/passport.socketio/-/passport.socketio-3.7.0.tgz",
-      "integrity": "sha1-LuX6/paV1CgcjN3T/pdezRjmcm4=",
-      "requires": {
-        "xtend": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz"
-      }
-    },
-    "path-is-absolute": {
-      "version": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
-      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
-    },
-    "path-proxy": {
-      "version": "https://registry.npmjs.org/path-proxy/-/path-proxy-1.0.0.tgz",
-      "integrity": "sha1-GOijaFn8nS8aU7SN7hOFQ8Ag3l4=",
-      "requires": {
-        "inflection": "https://registry.npmjs.org/inflection/-/inflection-1.3.8.tgz"
-      },
-      "dependencies": {
-        "inflection": {
-          "version": "https://registry.npmjs.org/inflection/-/inflection-1.3.8.tgz",
-          "integrity": "sha1-y9Fg2p91sUw8xjV41POWeEvzAU4="
-        }
-      }
-    },
-    "path-to-regexp": {
-      "version": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
-      "integrity": "sha1-32BBeABfUi8V60SQ5yR6G/qmf4w="
-    },
-    "pause": {
-      "version": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
-      "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10="
-    },
-    "performance-now": {
-      "version": "https://registry.npmjs.org/performance-now/-/performance-now-0.2.0.tgz",
-      "integrity": "sha1-M+8wxcd9TqIcWlOGnZG1bY8lVeU="
-    },
-    "prelude-ls": {
-      "version": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
-      "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ="
-    },
-    "prism-media": {
-      "version": "https://registry.npmjs.org/prism-media/-/prism-media-0.0.1.tgz",
-      "integrity": "sha1-o0JcnKvVDRxsAuVDlBoRiVZnvRA="
-    },
-    "private": {
-      "version": "https://registry.npmjs.org/private/-/private-0.1.7.tgz",
-      "integrity": "sha1-aM5eih7woju1cMwoU3tTMqumPvE="
-    },
-    "process-nextick-args": {
-      "version": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz",
-      "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M="
-    },
-    "promisify-call": {
-      "version": "https://registry.npmjs.org/promisify-call/-/promisify-call-2.0.4.tgz",
-      "integrity": "sha1-1IwtRWUszM1SgB3ey9UzptS9X7o=",
-      "requires": {
-        "with-callback": "https://registry.npmjs.org/with-callback/-/with-callback-1.0.2.tgz"
-      }
-    },
-    "proxy-addr": {
-      "version": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-1.1.4.tgz",
-      "integrity": "sha1-J+VF9pYKRKYn2bREZ+NcG2tM4vM=",
-      "requires": {
-        "forwarded": "https://registry.npmjs.org/forwarded/-/forwarded-0.1.0.tgz",
-        "ipaddr.js": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.3.0.tgz"
-      }
-    },
-    "proxy-agent": {
-      "version": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-2.0.0.tgz",
-      "integrity": "sha1-V+tTR6qAXXTsaByyVknbo5yTNJk=",
-      "requires": {
-        "agent-base": "https://registry.npmjs.org/agent-base/-/agent-base-2.0.1.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.6.1.tgz",
-        "extend": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
-        "http-proxy-agent": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-1.0.0.tgz",
-        "https-proxy-agent": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-1.0.0.tgz",
-        "lru-cache": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.6.5.tgz",
-        "pac-proxy-agent": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-1.0.0.tgz",
-        "socks-proxy-agent": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-2.0.0.tgz"
-      }
-    },
-    "punycode": {
-      "version": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
-      "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4="
-    },
-    "q": {
-      "version": "https://registry.npmjs.org/q/-/q-1.5.0.tgz",
-      "integrity": "sha1-3QG6ydBtMObyGa7LglPunr3DCPE="
-    },
-    "qs": {
-      "version": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz",
-      "integrity": "sha1-E+JtKK1rD/qpExLNO/cI7TUecjM="
-    },
-    "random-bytes": {
-      "version": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz",
-      "integrity": "sha1-T2ih3Arli9P7lYSMMDJNt11kNgs="
-    },
-    "range-parser": {
-      "version": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz",
-      "integrity": "sha1-9JvmtIeJTdxA3MlKMi9hEJLgDV4="
-    },
-    "raw-body": {
-      "version": "https://registry.npmjs.org/raw-body/-/raw-body-2.2.0.tgz",
-      "integrity": "sha1-mUl2z2pQlqQRYoQEkvC9xdbn+5Y=",
-      "requires": {
-        "bytes": "https://registry.npmjs.org/bytes/-/bytes-2.4.0.tgz",
-        "iconv-lite": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.15.tgz",
-        "unpipe": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz"
-      }
-    },
-    "rc": {
-      "version": "1.1.7",
-      "resolved": "https://registry.npmjs.org/rc/-/rc-1.1.7.tgz",
-      "integrity": "sha1-xepWS7B6/5/TpbMukGwdOmWUD+o=",
-      "requires": {
-        "deep-extend": "0.4.2",
-        "ini": "1.3.4",
-        "minimist": "1.2.0",
-        "strip-json-comments": "2.0.1"
-      },
-      "dependencies": {
-        "minimist": {
-          "version": "1.2.0",
-          "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",
-          "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ="
-        }
-      }
-    },
-    "readable-stream": {
-      "version": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.2.7.tgz",
-      "integrity": "sha1-BwV6y+JGeyIELTb5jFrVBwVOlbE=",
-      "requires": {
-        "buffer-shims": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz",
-        "core-util-is": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
-        "inherits": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
-        "isarray": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
-        "process-nextick-args": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz",
-        "string_decoder": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.0.tgz",
-        "util-deprecate": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
-      }
-    },
-    "recast": {
-      "version": "https://registry.npmjs.org/recast/-/recast-0.10.33.tgz",
-      "integrity": "sha1-lCgI96oBbx+nFCxGHX5XBKqo1pc=",
-      "requires": {
-        "ast-types": "https://registry.npmjs.org/ast-types/-/ast-types-0.8.12.tgz",
-        "esprima-fb": "15001.1001.0-dev-harmony-fb",
-        "private": "https://registry.npmjs.org/private/-/private-0.1.7.tgz",
-        "source-map": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz"
-      },
-      "dependencies": {
-        "ast-types": {
-          "version": "https://registry.npmjs.org/ast-types/-/ast-types-0.8.12.tgz",
-          "integrity": "sha1-oNkOQ1G7iHcWyD/WN+v4GK9K38w="
-        },
-        "esprima-fb": {
-          "version": "15001.1001.0-dev-harmony-fb",
-          "resolved": "https://registry.npmjs.org/esprima-fb/-/esprima-fb-15001.1001.0-dev-harmony-fb.tgz",
-          "integrity": "sha1-Q761fsJujPI3092LM+QlM1d/Jlk="
-        },
-        "source-map": {
-          "version": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
-          "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI="
-        }
-      }
-    },
-    "redis": {
-      "version": "https://registry.npmjs.org/redis/-/redis-2.7.1.tgz",
-      "integrity": "sha1-fVb3h1uYsgQQtxU58dh47Vjr9Go=",
-      "requires": {
-        "double-ended-queue": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz",
-        "redis-commands": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.1.tgz",
-        "redis-parser": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.6.0.tgz"
-      }
-    },
-    "redis-commands": {
-      "version": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.1.tgz",
-      "integrity": "sha1-gdgm9F+pyLIBH0zXoP5ZfSQdRCs="
-    },
-    "redis-parser": {
-      "version": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.6.0.tgz",
-      "integrity": "sha1-Uu0J2srBCPGmMcB+m2mUHnoZUEs="
-    },
-    "regenerator": {
-      "version": "https://registry.npmjs.org/regenerator/-/regenerator-0.8.46.tgz",
-      "integrity": "sha1-FUwydoY2HtUsrWmyVF78U6PQdpY=",
-      "requires": {
-        "commoner": "https://registry.npmjs.org/commoner/-/commoner-0.10.8.tgz",
-        "defs": "https://registry.npmjs.org/defs/-/defs-1.1.1.tgz",
-        "esprima-fb": "https://registry.npmjs.org/esprima-fb/-/esprima-fb-15001.1001.0-dev-harmony-fb.tgz",
-        "private": "https://registry.npmjs.org/private/-/private-0.1.7.tgz",
-        "recast": "https://registry.npmjs.org/recast/-/recast-0.10.33.tgz",
-        "regenerator-runtime": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.9.6.tgz",
-        "through": "https://registry.npmjs.org/through/-/through-2.3.8.tgz"
-      },
-      "dependencies": {
-        "esprima-fb": {
-          "version": "https://registry.npmjs.org/esprima-fb/-/esprima-fb-15001.1001.0-dev-harmony-fb.tgz",
-          "integrity": "sha1-Q761fsJujPI3092LM+QlM1d/Jlk="
-        }
-      }
-    },
-    "regenerator-runtime": {
-      "version": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.9.6.tgz",
-      "integrity": "sha1-0z65XQ0gAaS+OWWXB8UbDLcc4Ck="
-    },
-    "regexp-clone": {
-      "version": "https://registry.npmjs.org/regexp-clone/-/regexp-clone-0.0.1.tgz",
-      "integrity": "sha1-p8LgmJH9vzj7sQ03b7cwA+aKxYk="
-    },
-    "repeat-string": {
-      "version": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
-      "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc="
-    },
-    "request": {
-      "version": "https://registry.npmjs.org/request/-/request-2.81.0.tgz",
-      "integrity": "sha1-xpKJRqDgbF+Nb4qTM0af/aRimKA=",
-      "requires": {
-        "aws-sign2": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz",
-        "aws4": "https://registry.npmjs.org/aws4/-/aws4-1.6.0.tgz",
-        "caseless": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
-        "combined-stream": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.5.tgz",
-        "extend": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
-        "forever-agent": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
-        "form-data": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz",
-        "har-validator": "https://registry.npmjs.org/har-validator/-/har-validator-4.2.1.tgz",
-        "hawk": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz",
-        "http-signature": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz",
-        "is-typedarray": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
-        "isstream": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
-        "json-stringify-safe": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
-        "mime-types": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz",
-        "oauth-sign": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz",
-        "performance-now": "https://registry.npmjs.org/performance-now/-/performance-now-0.2.0.tgz",
-        "qs": "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz",
-        "safe-buffer": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.0.1.tgz",
-        "stringstream": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz",
-        "tough-cookie": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz",
-        "tunnel-agent": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
-        "uuid": "https://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz"
-      }
-    },
-    "require_optional": {
-      "version": "https://registry.npmjs.org/require_optional/-/require_optional-1.0.0.tgz",
-      "integrity": "sha1-UqhhN6hJco62ClVTNhf4+RT1mr8=",
-      "requires": {
-        "resolve-from": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz",
-        "semver": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz"
-      }
-    },
-    "resolve-from": {
-      "version": "https://registry.npmjs.org/resolve-from/-/resolve-from-2.0.0.tgz",
-      "integrity": "sha1-lICrIOlP+h2egKgEx+oUdhGWa1c="
-    },
-    "right-align": {
-      "version": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz",
-      "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=",
-      "requires": {
-        "align-text": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz"
-      }
-    },
-    "rimraf": {
-      "version": "2.5.4",
-      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.5.4.tgz",
-      "integrity": "sha1-loAAk8vxoMhr2VtGJUZ1NcKd+gQ=",
-      "requires": {
-        "glob": "7.1.2"
-      },
-      "dependencies": {
-        "glob": {
-          "version": "7.1.2",
-          "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
-          "integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
-          "requires": {
-            "fs.realpath": "1.0.0",
-            "inflight": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
-            "inherits": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
-            "minimatch": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
-            "once": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
-            "path-is-absolute": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz"
-          }
-        }
-      }
-    },
-    "safe-buffer": {
-      "version": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.0.1.tgz",
-      "integrity": "sha1-0mPKVGls2KMGtcplUekt5XkY++c="
-    },
-    "semver": {
-      "version": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
-      "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8="
-    },
-    "send": {
-      "version": "https://registry.npmjs.org/send/-/send-0.15.1.tgz",
-      "integrity": "sha1-igI1TCbm9cynAAZfXwzeupDse18=",
-      "requires": {
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.6.1.tgz",
-        "depd": "https://registry.npmjs.org/depd/-/depd-1.1.0.tgz",
-        "destroy": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
-        "encodeurl": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.1.tgz",
-        "escape-html": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
-        "etag": "https://registry.npmjs.org/etag/-/etag-1.8.0.tgz",
-        "fresh": "https://registry.npmjs.org/fresh/-/fresh-0.5.0.tgz",
-        "http-errors": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.1.tgz",
-        "mime": "https://registry.npmjs.org/mime/-/mime-1.3.4.tgz",
-        "ms": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz",
-        "on-finished": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
-        "range-parser": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz",
-        "statuses": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz"
-      }
-    },
-    "serve-static": {
-      "version": "https://registry.npmjs.org/serve-static/-/serve-static-1.12.1.tgz",
-      "integrity": "sha1-dEOpZePO1kes61Y5+ga/TRu+ADk=",
-      "requires": {
-        "encodeurl": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.1.tgz",
-        "escape-html": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
-        "parseurl": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz",
-        "send": "https://registry.npmjs.org/send/-/send-0.15.1.tgz"
-      }
-    },
-    "set-blocking": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
-      "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc="
-    },
-    "setprototypeof": {
-      "version": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.0.3.tgz",
-      "integrity": "sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ="
-    },
-    "sha256": {
-      "version": "https://registry.npmjs.org/sha256/-/sha256-0.2.0.tgz",
-      "integrity": "sha1-c6C0GNqrcDW/+G6EkeNjQS/CqwU=",
-      "requires": {
-        "convert-hex": "https://registry.npmjs.org/convert-hex/-/convert-hex-0.1.0.tgz",
-        "convert-string": "https://registry.npmjs.org/convert-string/-/convert-string-0.1.0.tgz"
-      }
-    },
-    "signal-exit": {
-      "version": "3.0.2",
-      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
-      "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0="
-    },
-    "simple-fmt": {
-      "version": "https://registry.npmjs.org/simple-fmt/-/simple-fmt-0.1.0.tgz",
-      "integrity": "sha1-GRv1ZqWeZTBILLJatTtKjchcOms="
-    },
-    "simple-is": {
-      "version": "https://registry.npmjs.org/simple-is/-/simple-is-0.2.0.tgz",
-      "integrity": "sha1-Krt1qt453rXMgVzhDmGRFkhQuvA="
-    },
-    "sliced": {
-      "version": "https://registry.npmjs.org/sliced/-/sliced-1.0.1.tgz",
-      "integrity": "sha1-CzpmK10Ewxd7GSa+qCsD+Dei70E="
-    },
-    "smart-buffer": {
-      "version": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-1.1.15.tgz",
-      "integrity": "sha1-fxFLW2X6s+KjWqd1uxLw0cZJvxY="
-    },
-    "snekfetch": {
-      "version": "https://registry.npmjs.org/snekfetch/-/snekfetch-3.1.6.tgz",
-      "integrity": "sha1-MJDVzT9bweRW+Kr6UCT2S3ylseA="
-    },
-    "sntp": {
-      "version": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz",
-      "integrity": "sha1-ZUEYTMkK7qbG57NeJlkIJEPGYZg=",
-      "requires": {
-        "hoek": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz"
-      }
-    },
-    "socket.io": {
-      "version": "https://registry.npmjs.org/socket.io/-/socket.io-1.7.4.tgz",
-      "integrity": "sha1-L37O3DORvy1cc+KR/iM+bjTU3QA=",
-      "requires": {
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
-        "engine.io": "https://registry.npmjs.org/engine.io/-/engine.io-1.8.4.tgz",
-        "has-binary": "https://registry.npmjs.org/has-binary/-/has-binary-0.1.7.tgz",
-        "object-assign": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz",
-        "socket.io-adapter": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-0.5.0.tgz",
-        "socket.io-client": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-1.7.4.tgz",
-        "socket.io-parser": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-2.3.1.tgz"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
-          "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=",
-          "requires": {
-            "ms": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz"
-          }
-        },
-        "object-assign": {
-          "version": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz",
-          "integrity": "sha1-ejs9DpgGPUP0wD8uiubNUahog6A="
-        }
-      }
-    },
-    "socket.io-adapter": {
-      "version": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-0.5.0.tgz",
-      "integrity": "sha1-y21LuL7IHhB4uZZ3+c7QBGBmu4s=",
-      "requires": {
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
-        "socket.io-parser": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-2.3.1.tgz"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
-          "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=",
-          "requires": {
-            "ms": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz"
-          }
-        }
-      }
-    },
-    "socket.io-client": {
-      "version": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-1.7.4.tgz",
-      "integrity": "sha1-7J+CA1btme9tNX8HVtZIcXvdQoE=",
-      "requires": {
-        "backo2": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
-        "component-bind": "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz",
-        "component-emitter": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
-        "engine.io-client": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-1.8.4.tgz",
-        "has-binary": "https://registry.npmjs.org/has-binary/-/has-binary-0.1.7.tgz",
-        "indexof": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz",
-        "object-component": "https://registry.npmjs.org/object-component/-/object-component-0.0.3.tgz",
-        "parseuri": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.5.tgz",
-        "socket.io-parser": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-2.3.1.tgz",
-        "to-array": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz"
-      },
-      "dependencies": {
-        "component-emitter": {
-          "version": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz",
-          "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY="
-        },
-        "debug": {
-          "version": "https://registry.npmjs.org/debug/-/debug-2.3.3.tgz",
-          "integrity": "sha1-QMRT5n5uE8kB3ewxeviYbNqe/4w=",
-          "requires": {
-            "ms": "https://registry.npmjs.org/ms/-/ms-0.7.2.tgz"
-          }
-        }
-      }
-    },
-    "socket.io-parser": {
-      "version": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-2.3.1.tgz",
-      "integrity": "sha1-3VMgJRA85Clpcya+/WQAX8/ltKA=",
-      "requires": {
-        "component-emitter": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.1.2.tgz",
-        "debug": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
-        "isarray": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
-        "json3": "https://registry.npmjs.org/json3/-/json3-3.3.2.tgz"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
-          "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=",
-          "requires": {
-            "ms": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
-          }
-        },
-        "isarray": {
-          "version": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
-          "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8="
-        },
-        "ms": {
-          "version": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz",
-          "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg="
-        }
-      }
-    },
-    "socks": {
-      "version": "https://registry.npmjs.org/socks/-/socks-1.1.10.tgz",
-      "integrity": "sha1-W4t/x8jzQcU+0FbpKbe/Tei6e1o=",
-      "requires": {
-        "ip": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
-        "smart-buffer": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-1.1.15.tgz"
-      }
-    },
-    "socks-proxy-agent": {
-      "version": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-2.0.0.tgz",
-      "integrity": "sha1-xnSELXBBD7KK4ekuYTWpJ4VLwnU=",
-      "requires": {
-        "agent-base": "https://registry.npmjs.org/agent-base/-/agent-base-2.0.1.tgz",
-        "extend": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
-        "socks": "https://registry.npmjs.org/socks/-/socks-1.1.10.tgz"
-      }
-    },
-    "source-map": {
-      "version": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz",
-      "integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=",
-      "optional": true,
-      "requires": {
-        "amdefine": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz"
-      }
-    },
-    "sshpk": {
-      "version": "https://registry.npmjs.org/sshpk/-/sshpk-1.13.0.tgz",
-      "integrity": "sha1-/yo+T9BEl1Vf7Zezmg/YL6+zozw=",
-      "requires": {
-        "asn1": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz",
-        "assert-plus": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
-        "bcrypt-pbkdf": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.1.tgz",
-        "dashdash": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
-        "ecc-jsbn": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz",
-        "getpass": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
-        "jodid25519": "https://registry.npmjs.org/jodid25519/-/jodid25519-1.0.2.tgz",
-        "jsbn": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
-        "tweetnacl": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz"
-      },
-      "dependencies": {
-        "assert-plus": {
-          "version": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
-          "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
-        }
-      }
-    },
-    "stable": {
-      "version": "https://registry.npmjs.org/stable/-/stable-0.1.6.tgz",
-      "integrity": "sha1-kQ9dKu17Ugxud3SZwfMuE5/eyxA="
-    },
-    "statuses": {
-      "version": "https://registry.npmjs.org/statuses/-/statuses-1.3.1.tgz",
-      "integrity": "sha1-+vUbnrdKrvOzrPStX2Gr8ky3uT4="
-    },
-    "stream-to": {
-      "version": "https://registry.npmjs.org/stream-to/-/stream-to-0.2.2.tgz",
-      "integrity": "sha1-hDBgmNhf25kLn6MAsbPM9V6O8B0="
-    },
-    "stream-to-buffer": {
-      "version": "https://registry.npmjs.org/stream-to-buffer/-/stream-to-buffer-0.1.0.tgz",
-      "integrity": "sha1-JnmdkDqyAlyb1VCsRxcbAPjdgKk=",
-      "requires": {
-        "stream-to": "https://registry.npmjs.org/stream-to/-/stream-to-0.2.2.tgz"
-      }
-    },
-    "string_decoder": {
-      "version": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.0.tgz",
-      "integrity": "sha1-8G9BFXtmTYYGn4S9vcmw2KsoFmc=",
-      "requires": {
-        "buffer-shims": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz"
-      }
-    },
-    "string-width": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
-      "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
-      "requires": {
-        "code-point-at": "1.1.0",
-        "is-fullwidth-code-point": "1.0.0",
-        "strip-ansi": "3.0.1"
-      }
-    },
-    "stringmap": {
-      "version": "https://registry.npmjs.org/stringmap/-/stringmap-0.2.2.tgz",
-      "integrity": "sha1-VWwTeyWPlCuHdvWy71gqoGnX0bE="
-    },
-    "stringset": {
-      "version": "https://registry.npmjs.org/stringset/-/stringset-0.2.1.tgz",
-      "integrity": "sha1-7yWcTjSTRDd/zRyRPdLoSMnAQrU="
-    },
-    "stringstream": {
-      "version": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.5.tgz",
-      "integrity": "sha1-TkhM1N5aC7vuGORjB3EKioFiGHg="
-    },
-    "strip-ansi": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
-      "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
-      "requires": {
-        "ansi-regex": "2.1.1"
-      }
-    },
-    "strip-json-comments": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
-      "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
-    },
-    "tar": {
-      "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz",
-      "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=",
-      "requires": {
-        "block-stream": "0.0.9",
-        "fstream": "1.0.11",
-        "inherits": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz"
-      }
-    },
-    "tar-pack": {
-      "version": "3.3.0",
-      "resolved": "https://registry.npmjs.org/tar-pack/-/tar-pack-3.3.0.tgz",
-      "integrity": "sha1-MJMYFkGPVa/E0hd1r91nIM7kXa4=",
-      "requires": {
-        "debug": "2.2.0",
-        "fstream": "1.0.11",
-        "fstream-ignore": "1.0.5",
-        "once": "1.3.3",
-        "readable-stream": "2.1.5",
-        "rimraf": "2.5.4",
-        "tar": "2.2.1",
-        "uid-number": "0.0.6"
-      },
-      "dependencies": {
-        "debug": {
-          "version": "2.2.0",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
-          "integrity": "sha1-+HBX6ZWxofauaklgZkE3vFbwOdo=",
-          "requires": {
-            "ms": "0.7.1"
-          }
-        },
-        "ms": {
-          "version": "0.7.1",
-          "resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz",
-          "integrity": "sha1-nNE8A62/8ltl7/3nzoZO6VIBcJg="
-        },
-        "once": {
-          "version": "1.3.3",
-          "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz",
-          "integrity": "sha1-suJhVXzkwxTsgwTz+oJmPkKXyiA=",
-          "requires": {
-            "wrappy": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
-          }
-        },
-        "readable-stream": {
-          "version": "2.1.5",
-          "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.1.5.tgz",
-          "integrity": "sha1-ZvqLcg4UOLNkaB8q0aY8YYRIydA=",
-          "requires": {
-            "buffer-shims": "https://registry.npmjs.org/buffer-shims/-/buffer-shims-1.0.0.tgz",
-            "core-util-is": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
-            "inherits": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
-            "isarray": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
-            "process-nextick-args": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz",
-            "string_decoder": "0.10.31",
-            "util-deprecate": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
-          }
-        },
-        "string_decoder": {
-          "version": "0.10.31",
-          "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
-          "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ="
-        }
-      }
-    },
-    "through": {
-      "version": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
-      "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU="
-    },
-    "thunkify": {
-      "version": "https://registry.npmjs.org/thunkify/-/thunkify-2.1.2.tgz",
-      "integrity": "sha1-+qDp0jDFGsyVyhOjYawFyn4EVT0="
-    },
-    "to-array": {
-      "version": "https://registry.npmjs.org/to-array/-/to-array-0.1.4.tgz",
-      "integrity": "sha1-F+bBH3PdTz10zaek/zI46a2b+JA="
-    },
-    "tough-cookie": {
-      "version": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.2.tgz",
-      "integrity": "sha1-8IH3bkyFcg5sN6X6ztc3FQ2EByo=",
-      "requires": {
-        "punycode": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz"
-      }
-    },
-    "tryor": {
-      "version": "https://registry.npmjs.org/tryor/-/tryor-0.1.2.tgz",
-      "integrity": "sha1-gUXkynyv9ArN48z5Rui4u3W0Fys="
-    },
-    "tsscmp": {
-      "version": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.5.tgz",
-      "integrity": "sha1-fcSjOvcVgatDN9qR2FylQn69mpc="
-    },
-    "tunnel-agent": {
-      "version": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
-      "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
-      "requires": {
-        "safe-buffer": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.0.1.tgz"
-      }
-    },
-    "tweetnacl": {
-      "version": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
-      "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
-    },
-    "type-check": {
-      "version": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
-      "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=",
-      "requires": {
-        "prelude-ls": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz"
-      }
-    },
-    "type-is": {
-      "version": "https://registry.npmjs.org/type-is/-/type-is-1.6.15.tgz",
-      "integrity": "sha1-yrEPtJCeRByChC6v4a1kbIGARBA=",
-      "requires": {
-        "media-typer": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
-        "mime-types": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz"
-      }
-    },
-    "uid-number": {
-      "version": "0.0.6",
-      "resolved": "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz",
-      "integrity": "sha1-DqEOgDXo61uOREnwbaHHMGY7qoE="
-    },
-    "uid-safe": {
-      "version": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.4.tgz",
-      "integrity": "sha1-Otbzg2jG1MjHXsF2I/t5qh0HHYE=",
-      "requires": {
-        "random-bytes": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz"
-      }
-    },
-    "uid2": {
-      "version": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
-      "integrity": "sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I="
-    },
-    "ultron": {
-      "version": "https://registry.npmjs.org/ultron/-/ultron-1.1.0.tgz",
-      "integrity": "sha1-sHoualQagV/Go0zNRTO67DB8qGQ="
-    },
-    "underscore": {
-      "version": "https://registry.npmjs.org/underscore/-/underscore-1.8.3.tgz",
-      "integrity": "sha1-Tz+1OxBuYJf8+ctBCfKl6b36UCI="
-    },
-    "unpipe": {
-      "version": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
-      "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
-    },
-    "util-deprecate": {
-      "version": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
-      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
-    },
-    "utils-merge": {
-      "version": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz",
-      "integrity": "sha1-ApT7kiu5N1FTVBxPcJYjHyh8ivg="
-    },
-    "uuid": {
-      "version": "https://registry.npmjs.org/uuid/-/uuid-3.0.1.tgz",
-      "integrity": "sha1-ZUS7ot/ajBzxfmKaOjBeK7H+5sE="
-    },
-    "vary": {
-      "version": "https://registry.npmjs.org/vary/-/vary-1.1.1.tgz",
-      "integrity": "sha1-Z1Neu2lMHVIldFeYRmUyP1h+jTc="
-    },
-    "verror": {
-      "version": "https://registry.npmjs.org/verror/-/verror-1.3.6.tgz",
-      "integrity": "sha1-z/XfEpRtKX0rqu+qJoniW+AcAFw=",
-      "requires": {
-        "extsprintf": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.0.2.tgz"
-      }
-    },
-    "wide-align": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.2.tgz",
-      "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==",
-      "requires": {
-        "string-width": "1.0.2"
-      }
-    },
-    "window-size": {
-      "version": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz",
-      "integrity": "sha1-+OGqHuWlPsW/FR/6CXQqatdpeHY="
-    },
-    "with-callback": {
-      "version": "https://registry.npmjs.org/with-callback/-/with-callback-1.0.2.tgz",
-      "integrity": "sha1-oJYpuakgAo1yFAT7Q1vc/1yRvCE="
-    },
-    "wordwrap": {
-      "version": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
-      "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus="
-    },
-    "wrappy": {
-      "version": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
-      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
-    },
-    "ws": {
-      "version": "https://registry.npmjs.org/ws/-/ws-2.3.1.tgz",
-      "integrity": "sha1-a5Sz5EfLajY/eF6vlK9jWejoHIA=",
-      "requires": {
-        "safe-buffer": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.0.1.tgz",
-        "ultron": "https://registry.npmjs.org/ultron/-/ultron-1.1.0.tgz"
-      }
-    },
-    "wtf-8": {
-      "version": "https://registry.npmjs.org/wtf-8/-/wtf-8-1.0.0.tgz",
-      "integrity": "sha1-OS2LotDxw00e4tYw8V0O+2jhBIo="
-    },
-    "xmlhttprequest-ssl": {
-      "version": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.3.tgz",
-      "integrity": "sha1-GFqIjATspGw+QHDZn3tJ3jUomS0="
-    },
-    "xregexp": {
-      "version": "https://registry.npmjs.org/xregexp/-/xregexp-2.0.0.tgz",
-      "integrity": "sha1-UqY+VsoLhKfzpfPWGHLxJq16WUM="
-    },
-    "xtend": {
-      "version": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",
-      "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68="
-    },
-    "y18n": {
-      "version": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz",
-      "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE="
-    },
-    "yargs": {
-      "version": "https://registry.npmjs.org/yargs/-/yargs-3.27.0.tgz",
-      "integrity": "sha1-ISBUaTFuk5Ex1Z8toMbX+YIh6kA=",
-      "requires": {
-        "camelcase": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz",
-        "cliui": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz",
-        "decamelize": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
-        "os-locale": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz",
-        "window-size": "https://registry.npmjs.org/window-size/-/window-size-0.1.4.tgz",
-        "y18n": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz"
-      }
-    },
-    "yeast": {
-      "version": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz",
-      "integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk="
-    }
-  }
-}

+ 24 - 28
backend/package.json

@@ -1,39 +1,35 @@
 {
   "name": "musare-backend",
-  "version": "0.0.1",
+  "private": true,
+  "version": "2.1.0",
   "description": "A modern, open-source, collaborative music app https://musare.com",
-  "main": "app.js",
+  "main": "index.js",
   "author": "Musare Team",
+  "license": "GPL-3.0",
   "repository": "https://github.com/Musare/MusareNode",
   "scripts": {
-    "development": "nodemon -L /opt/app",
-    "production": "node /opt/app"
+    "dev": "nodemon",
+    "docker:dev": "nodemon -L /opt/app",
+    "docker:prod": "node /opt/app"
   },
   "dependencies": {
-    "async": "2.0.1",
-    "bcrypt": "^1.0.2",
-    "bluebird": "^3.4.6",
-    "body-parser": "^1.15.2",
-    "config": "^1.21.0",
-    "connect-mongo": "^1.3.2",
-    "cookie-parser": "^1.4.3",
-    "cors": "^2.8.1",
-    "discord.js": "^11.0.0",
-    "express": "^4.14.0",
-    "express-session": "^1.14.0",
-    "mailgun-js": "^0.8.0",
-    "moment": "^2.15.2",
-    "mongoose": "^4.9.0",
-    "oauth": "^0.9.14",
-    "passport": "^0.3.2",
-    "passport-discord": "^0.1.1",
-    "passport-github": "^1.1.0",
-    "passport-local": "^1.0.0",
-    "passport.socketio": "^3.7.0",
-    "redis": "^2.6.3",
-    "request": "^2.74.0",
+    "async": "3.1.0",
+    "bcrypt": "^3.0.6",
+    "bluebird": "^3.5.5",
+    "body-parser": "^1.19.0",
+    "config": "^3.2.0",
+    "cookie-parser": "^1.4.4",
+    "cors": "^2.8.5",
+    "discord.js": "^11.5.1",
+    "express": "^4.17.1",
+    "mailgun-js": "^0.22.0",
+    "moment": "^2.24.0",
+    "mongoose": "^5.6.4",
+    "oauth": "^0.9.15",
+    "redis": "^2.8.0",
+    "request": "^2.88.0",
     "sha256": "^0.2.0",
-    "socket.io": "^1.5.0",
-    "underscore": "^1.8.3"
+    "socket.io": "^2.2.0",
+    "underscore": "^1.9.1"
   }
 }

+ 20 - 5
docker-compose.yml

@@ -10,25 +10,40 @@ services:
     links:
     - mongo
     - redis
+    environment:
+    - SNYK_TOKEN=${SNYK_TOKEN}
   frontend:
     build: ./frontend
+    environment:
     ports:
     - "${FRONTEND_PORT}:80"
     volumes:
     - ./frontend:/opt/app
+    environment:
+    - FRONTEND_MODE=${FRONTEND_MODE}
+    - SNYK_TOKEN=${SNYK_TOKEN}
   mongo:
-    image: mongo
+    image: mongo:4.0
     ports:
-    - "27017:27017"
-    command: "--auth"
+    - "${MONGO_PORT}:27017"
+    environment:
+      - MONGO_INITDB_ROOT_USERNAME=admin
+      - MONGO_INITDB_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
+      - MONGO_INITDB_DATABASE=musare
+      - MONGO_PORT=${MONGO_PORT}
+      - MONGO_ROOT_PASSWORD=${MONGO_ROOT_PASSWORD}
+      - MONGO_USER_USERNAME=${MONGO_USER_USERNAME}
+      - MONGO_USER_PASSWORD=${MONGO_USER_PASSWORD}
+    volumes:
+      - ./tools/docker/setup-mongo.sh:/docker-entrypoint-initdb.d/setup-mongo.sh
   mongoclient:
     image: mongoclient/mongoclient
     ports:
-    - "3000:3000"
+    - "${MONGOCLIENT_PORT}:3000"
   redis:
     image: redis
     command: "--notify-keyspace-events Ex --requirepass ${REDIS_PASSWORD}"
     volumes:
     - .redis:/data
     ports:
-    - "6379:6379"
+    - "${REDIS_PORT}:6379"

+ 6 - 2
frontend/.babelrc

@@ -1,5 +1,9 @@
 {
-	"presets": ["es2015"],
-	"plugins": ["transform-runtime"],
+	"presets": ["@babel/preset-env"],
+	"plugins": [
+		"@babel/plugin-transform-runtime",
+		"@babel/plugin-syntax-dynamic-import",
+		"@babel/plugin-proposal-object-rest-spread"
+	],
 	"comments": false
 }

+ 2 - 0
frontend/.eslintignore

@@ -0,0 +1,2 @@
+node_modules
+dist

+ 29 - 8
frontend/.eslintrc

@@ -1,14 +1,35 @@
 {
-	"rules": {
-		"indent": [2, "tab", { "SwitchCase": 1 }]
+	"root": true,
+	"env": {
+		"browser": true,
+		"amd": true,
+		"node": true,
+		"es6": true,
+		"jquery": true
 	},
 	"parserOptions": {
-		"ecmaVersion": 6,
-		"sourceType": "module"
+		"ecmaVersion": 2018,
+		"sourceType": "module",
+		"parser": "babel-eslint"
+	},
+	"extends": [
+		"airbnb-base",
+		"plugin:vue/essential",
+		"plugin:prettier/recommended",
+		"eslint:recommended"
+	],
+	"globals": {
+		"lofig": "writable",
+		"grecaptcha": "readonly"
 	},
-	"plugins": [ "html" ],
-	"settings": {
-		"html/indent": "tab",
-		"html/report-bad-indent": 2
+	"rules": {
+		"no-console": 0,
+		"no-control-regex": 0,
+		"no-var": 2,
+		"no-underscore-dangle": 0,
+		"radix": 0,
+		"no-multi-assign": 0,
+		"no-shadow": 0,
+		"no-new": 0
 	}
 }

+ 3 - 0
frontend/.prettierignore

@@ -0,0 +1,3 @@
+node_modules/
+build/
+yarn.lock

+ 5 - 0
frontend/.prettierrc

@@ -0,0 +1,5 @@
+{
+    "singleQuote": false,
+    "tabWidth": 4,
+    "useTabs": true
+}

+ 5 - 0
frontend/.snyk

@@ -0,0 +1,5 @@
+# Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities.
+version: v1.13.5
+# ignores vulnerabilities until expiry date; change duration by modifying expiry date
+ignore:
+patch: {}

+ 253 - 250
frontend/App.vue

@@ -1,298 +1,301 @@
 <template>
-	<banned v-if="banned"></banned>
-	<div v-else>
-		<h1 v-if="!socketConnected" class="alert">Could not connect to the server.</h1>
-		<router-view></router-view>
-		<toast></toast>
-		<what-is-new></what-is-new>
-		<mobile-alert></mobile-alert>
-		<login-modal v-if='isLoginActive'></login-modal>
-		<register-modal v-if='isRegisterActive'></register-modal>
+	<div>
+		<banned v-if="banned" />
+		<div v-else>
+			<h1 v-if="!socketConnected" class="alert">
+				Could not connect to the server.
+			</h1>
+			<!-- should be a persistant toast -->
+			<router-view />
+			<what-is-new />
+			<mobile-alert />
+			<login-modal v-if="modals.header.login" />
+			<register-modal v-if="modals.header.register" />
+		</div>
 	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-
-	import Banned from './components/pages/Banned.vue';
-	import WhatIsNew from './components/Modals/WhatIsNew.vue';
-	import MobileAlert from './components/Modals/MobileAlert.vue';
-	import LoginModal from './components/Modals/Login.vue';
-	import RegisterModal from './components/Modals/Register.vue';
-	import auth from './auth';
-	import io from './io';
-	import validation from './validation';
-
-	export default {
-		replace: false,
-		data() {
-			return {
-				banned: false,
-				ban: {},
-				register: {
-					email: '',
-					username: '',
-					password: ''
-				},
-				login: {
-					email: '',
-					password: ''
-				},
-				loggedIn: false,
-				role: '',
-				username: '',
-				userId: '',
-				isRegisterActive: false,
-				isLoginActive: false,
-				serverDomain: '',
-				socketConnected: true,
-				userIdMap: {},
-				currentlyGettingUsernameFrom: {}
-			}
+import { mapState, mapActions } from "vuex";
+
+import Toast from "toasters";
+
+import Banned from "./components/pages/Banned.vue";
+import WhatIsNew from "./components/Modals/WhatIsNew.vue";
+import MobileAlert from "./components/Modals/MobileAlert.vue";
+import LoginModal from "./components/Modals/Login.vue";
+import RegisterModal from "./components/Modals/Register.vue";
+import io from "./io";
+
+export default {
+	replace: false,
+	data() {
+		return {
+			serverDomain: "",
+			socketConnected: true
+		};
+	},
+	computed: mapState({
+		loggedIn: state => state.user.auth.loggedIn,
+		role: state => state.user.auth.role,
+		username: state => state.user.auth.username,
+		userId: state => state.user.auth.userId,
+		banned: state => state.user.auth.banned,
+		modals: state => state.modals.modals,
+		currentlyActive: state => state.modals.currentlyActive
+	}),
+	methods: {
+		submitOnEnter: (cb, event) => {
+			if (event.which === 13) cb();
 		},
-		methods: {
-			logout: function () {
-				let _this = this;
-				_this.socket.emit('users.logout', result => {
-					if (result.status === 'success') {
-						document.cookie = 'SID=;expires=Thu, 01 Jan 1970 00:00:01 GMT;';
-						location.reload();
-					} else Toast.methods.addToast(result.message, 4000);
-				});
-			},
-			'submitOnEnter': (cb, event) => {
-				if (event.which == 13) cb();
-			},
-			getUsernameFromId: function(userId) {
-			    if (typeof this.userIdMap[userId] !== 'string' && !this.currentlyGettingUsernameFrom[userId]) {
-					this.currentlyGettingUsernameFrom[userId] = true;
-			        io.getSocket(socket => {
-			            socket.emit('users.getUsernameFromId', userId, (data) => {
-			                if (data.status === 'success') this.$set(`userIdMap.Z${userId}`, data.data);
-							this.currentlyGettingUsernameFrom[userId] = false;
-						});
-					});
-				}
+		...mapActions("modals", ["closeCurrentModal"])
+	},
+	mounted() {
+		document.onkeydown = ev => {
+			const event = ev || window.event;
+			if (
+				event.keyCode === 27 &&
+				Object.keys(this.currentlyActive).length !== 0
+			)
+				this.closeCurrentModal();
+		};
+
+		if (localStorage.getItem("github_redirect")) {
+			this.$router.go(localStorage.getItem("github_redirect"));
+			localStorage.removeItem("github_redirect");
+		}
+
+		io.onConnect(true, () => {
+			this.socketConnected = true;
+		});
+		io.onConnectError(true, () => {
+			this.socketConnected = false;
+		});
+		io.onDisconnect(true, () => {
+			this.socketConnected = false;
+		});
+
+		lofig.get("serverDomain").then(serverDomain => {
+			this.serverDomain = serverDomain;
+		});
+
+		this.$router.onReady(() => {
+			if (this.$route.query.err) {
+				let { err } = this.$route.query;
+				err = err
+					.replace(new RegExp("<", "g"), "&lt;")
+					.replace(new RegExp(">", "g"), "&gt;");
+				this.$router.push({ query: {} });
+				new Toast({ content: err, timeout: 20000 });
 			}
-		},
-		ready: function () {
-			let _this = this;
-			if (localStorage.getItem('github_redirect')) {
-			    this.$router.go(localStorage.getItem('github_redirect'));
-			    localStorage.removeItem('github_redirect');
+			if (this.$route.query.msg) {
+				let { msg } = this.$route.query;
+				msg = msg
+					.replace(new RegExp("<", "g"), "&lt;")
+					.replace(new RegExp(">", "g"), "&gt;");
+				this.$router.push({ query: {} });
+				new Toast({ content: msg, timeout: 20000 });
 			}
-			auth.isBanned((banned, ban) => {
-				_this.ban = ban;
-				_this.banned = banned;
-			});
-			auth.getStatus((authenticated, role, username, userId) => {
-				_this.socket = window.socket;
-				_this.loggedIn = authenticated;
-				_this.role = role;
-				_this.username = username;
-				_this.userId = userId;
-			});
-			io.onConnect(true, () => {
-				_this.socketConnected = true;
-			});
-			io.onConnectError(true, () => {
-				_this.socketConnected = false;
-			});
-			io.onDisconnect(true, () => {
-				_this.socketConnected = false;
-			});
-			lofig.get('serverDomain', res => {
-				_this.serverDomain = res;
+		});
+		io.getSocket(true, socket => {
+			socket.on("keep.event:user.session.removed", () => {
+				window.location.reload();
 			});
-			if (_this.$route.query.err) {
-				let err = _this.$route.query.err;
-				err = err.replace(new RegExp('<', 'g'), '&lt;').replace(new RegExp('>', 'g'), '&gt;');
-				Toast.methods.addToast(err, 20000);
-			}
-			io.getSocket(true, socket => {
-				socket.on('keep.event:user.session.removed', () => {
-					location.reload();
-				});
-			});
-
-		},
-		events: {
-			'register': function (recaptchaId) {
-				let { register: { email, username, password } } = this;
-				let _this = this;
-				if (!email || !username || !password) return Toast.methods.addToast('Please fill in all fields', 8000);
-
-
-				if (!validation.isLength(email, 3, 254)) return Toast.methods.addToast('Email must have between 3 and 254 characters.', 8000);
-				if (email.indexOf('@') !== email.lastIndexOf('@') || !validation.regex.emailSimple.test(email)) return Toast.methods.addToast('Invalid email format.', 8000);
-
-
-				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);
-
-
-				if (!validation.isLength(password, 6, 200)) return Toast.methods.addToast('Password must have between 6 and 200 characters.', 8000);
-				if (!validation.regex.password.test(password)) return Toast.methods.addToast('Invalid password format. Must have one lowercase letter, one uppercase letter, one number and one special character.', 8000);
-
-				this.socket.emit('users.register', username, email, password, grecaptcha.getResponse(recaptchaId), result => {
-					if (result.status === 'success') {
-						Toast.methods.addToast(`You have successfully registered.`, 4000);
-						if (result.SID) {
-							lofig.get('cookie', cookie => {
-								let date = new Date();
-								date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-								let secure = (cookie.secure) ? 'secure=true; ' : '';
-								document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; domain=${cookie.domain}; ${secure}path=/`;
-								location.reload();
-							});
-						} else _this.$router.go('/login');
-					} else Toast.methods.addToast(result.message, 8000);
-				});
-			},
-			'login': function () {
-				let { login: { email, password } } = this;
-				let _this = this;
-				this.socket.emit('users.login', email, password, result => {
-					if (result.status === 'success') {
-						lofig.get('cookie', cookie => {
-							let date = new Date();
-							date.setTime(new Date().getTime() + (2 * 365 * 24 * 60 * 60 * 1000));
-							let secure = (cookie.secure) ? 'secure=true; ' : '';
-							let domain = '';
-							if (cookie.domain !== 'localhost') domain = ` domain=${cookie.domain};`;
-							document.cookie = `SID=${result.SID}; expires=${date.toGMTString()}; ${domain}${secure}path=/`;
-							Toast.methods.addToast(`You have been successfully logged in`, 2000);
-							location.reload();
-						});
-					} else Toast.methods.addToast(result.message, 2000);
-				});
-			},
-			'toggleModal': function (type) {
-				switch(type) {
-					case 'register':
-						this.isRegisterActive = !this.isRegisterActive;
-						break;
-					case 'login':
-						this.isLoginActive = !this.isLoginActive;
-						break;
-				}
-			},
-			'closeModal': function() {
-				this.$broadcast('closeModal');
-			}
-		},
-		components: { Toast, WhatIsNew, MobileAlert, LoginModal, RegisterModal, Banned }
+		});
+	},
+	components: {
+		WhatIsNew,
+		MobileAlert,
+		LoginModal,
+		RegisterModal,
+		Banned
 	}
+};
 </script>
 
-<style type='scss'>
-	.center { text-align: center; }
+<style lang="scss">
+@import "styles/global.scss";
 
-	#toast-container { z-index: 10000 !important; }
-
-	html {
-		overflow: auto !important;
-	}
+#toasts-container {
+	z-index: 10000 !important;
 
-	.modal-card {
-		margin: 0 !important;
+	.toast {
+		font-weight: 600;
 	}
-
-	.absolute-a {
-		width: 100%;
-		height: 100%;
+}
+
+.toast:not(:first-of-type) {
+	margin-top: 5px;
+}
+
+html {
+	overflow: auto !important;
+}
+
+body {
+	background-color: $light-grey;
+	color: $dark-grey;
+	font-family: "Roboto", Helvetica, Arial, sans-serif;
+}
+
+a {
+	color: $primary-color;
+	text-decoration: none;
+}
+
+.modal-card {
+	margin: 0 !important;
+}
+
+.absolute-a {
+	width: 100%;
+	height: 100%;
+	position: absolute;
+	top: 0;
+	left: 0;
+}
+
+.alert {
+	padding: 20px;
+	color: $white;
+	background-color: $red;
+	position: fixed;
+	top: 50px;
+	right: 50px;
+	font-size: 2em;
+	border-radius: 5px;
+	z-index: 10000000;
+}
+
+.tooltip {
+	position: relative;
+
+	&:after {
 		position: absolute;
-		top: 0;
-		left: 0;
+		min-width: 80px;
+		margin-left: -75%;
+		text-align: center;
+		padding: 7.5px 6px;
+		border-radius: 2px;
+		background-color: $dark-grey;
+		font-size: 0.9em;
+		color: $white;
+		content: attr(data-tooltip);
+		opacity: 0;
+		transition: all 0.2s ease-in-out 0.1s;
+		visibility: hidden;
 	}
 
-	.alert {
-		padding: 20px;
-		color: white;
-		background-color: red;
-		position: fixed;
-		top: 50px;
-		right: 50px;
-		font-size: 2em;
-		border-radius: 5px;
-		z-index: 10000000;
+	&:hover:after {
+		opacity: 1;
+		visibility: visible;
 	}
+}
 
-	.tooltip {
-		position: relative;
+.tooltip-top {
+	&:after {
+		bottom: 150%;
+	}
 
+	&:hover {
 		&:after {
-			 position: absolute;
-			 min-width: 80px;
-			 margin-left: -75%;
-			 text-align: center;
-			 padding: 7.5px 6px;
-			 border-radius: 2px;
-			 background-color: #323232;
-			 font-size: .9em;
-			 color: #fff;
-			 content: attr(data-tooltip);
-			 opacity: 0;
-			 transition: all .2s ease-in-out .1s;
-			 visibility: hidden;
+			bottom: 120%;
 		}
+	}
+}
 
-		&:hover:after {
-			 opacity: 1;
-			 visibility: visible;
-		}
+.tooltip-bottom {
+	&:after {
+		top: 155%;
 	}
 
-	.tooltip-top {
+	&:hover {
 		&:after {
-			 bottom: 150%;
-		}
-
-		&:hover {
-			&:after { bottom: 120%; }
+			top: 125%;
 		}
 	}
+}
 
+.tooltip-left {
+	&:after {
+		bottom: -10px;
+		right: 130%;
+		min-width: 100px;
+	}
 
-	.tooltip-bottom {
+	&:hover {
 		&:after {
-			 top: 155%;
+			right: 110%;
 		}
+	}
+}
 
-		&:hover {
-			&:after { top: 125%; }
-		}
+.tooltip-right {
+	&:after {
+		bottom: -10px;
+		left: 190%;
+		min-width: 100px;
 	}
 
-	.tooltip-left {
+	&:hover {
 		&:after {
-			 bottom: -10px;
-			 right: 130%;
-			 min-width: 100px;
+			left: 200%;
 		}
-
-		&:hover {
-			&:after { right: 110%; }
+	}
+}
+
+.button:focus,
+.button:active {
+	border-color: #dbdbdb !important;
+}
+.input:focus,
+.input:active {
+	border-color: $primary-color !important;
+}
+button.delete:focus {
+	background-color: rgba(10, 10, 10, 0.3);
+}
+
+.tag {
+	padding-right: 6px !important;
+}
+
+.button {
+	&.is-success {
+		background-color: $green !important;
+
+		&:hover,
+		&:focus {
+			background-color: darken($green, 5%) !important;
 		}
 	}
+	&.is-primary {
+		background-color: $primary-color !important;
 
-	.tooltip-right {
-		&:after {
-			 bottom: -10px;
-			 left: 190%;
-			 min-width: 100px;
+		&:hover,
+		&:focus {
+			background-color: darken($primary-color, 5%) !important;
 		}
+	}
+	&.is-danger {
+		background-color: $red !important;
 
-		&:hover {
-			 &:after { left: 200%; }
+		&:hover,
+		&:focus {
+			background-color: darken($red, 5%) !important;
 		}
 	}
+	&.is-info {
+		background-color: $blue !important;
 
-	.button:focus, .button:active { border-color: #dbdbdb !important; }
-	.input:focus, .input:active { border-color: #03a9f4 !important; }
-	button.delete:focus { background-color: rgba(10, 10, 10, 0.3); }
-
-	.tag { padding-right: 6px !important; }
+		&:hover,
+		&:focus {
+			background-color: darken($blue, 5%) !important;
+		}
+	}
+}
 
-	.button.is-success { background-color: #00B16A !important; }
+.center {
+	text-align: center;
+}
 </style>

+ 11 - 5
frontend/Dockerfile

@@ -1,18 +1,24 @@
-FROM node
+FROM node:12
 
 RUN apt-get update
 RUN apt-get install nginx -y
 
-RUN npm install -g webpack@1.14.0
+RUN npm install -g yarn
+
+RUN yarn global add snyk
+RUN yarn global add webpack@4.35.3
+RUN yarn global add webpack-cli@3.3.5
+RUN yarn global add webpack-dev-server@3.7.2
 
 RUN mkdir -p /opt
 WORKDIR /opt
 ADD package.json /opt/package.json
 
-RUN npm install
+RUN yarn install
 
 RUN mkdir -p /run/nginx
 
-EXPOSE 80
+COPY bootstrap.sh /opt/
+RUN chmod u+x /opt/bootstrap.sh
 
-CMD nginx -c /opt/app/nginx.conf; cd /opt/app; npm run development-watch
+CMD bash /opt/bootstrap.sh

+ 93 - 0
frontend/api/auth.js

@@ -0,0 +1,93 @@
+import Toast from "toasters";
+import io from "../io";
+
+// when Vuex needs to interact with socket.io
+
+export default {
+	register(user) {
+		return new Promise((resolve, reject) => {
+			const { username, email, password, recaptchaToken } = user;
+
+			io.getSocket(socket => {
+				socket.emit(
+					"users.register",
+					username,
+					email,
+					password,
+					recaptchaToken,
+					res => {
+						if (res.status === "success") {
+							if (res.SID) {
+								return lofig.get("cookie").then(cookie => {
+									const date = new Date();
+									date.setTime(
+										new Date().getTime() +
+											2 * 365 * 24 * 60 * 60 * 1000
+									);
+									const secure = cookie.secure
+										? "secure=true; "
+										: "";
+									document.cookie = `SID=${
+										res.SID
+									}; expires=${date.toGMTString()}; domain=${
+										cookie.domain
+									}; ${secure}path=/`;
+
+									return resolve({ status: "success" });
+								});
+							}
+							return reject(new Error("You must login"));
+						}
+
+						return reject(new Error(res.message));
+					}
+				);
+			});
+		});
+	},
+	login(user) {
+		return new Promise((resolve, reject) => {
+			const { email, password } = user;
+
+			io.getSocket(socket => {
+				socket.emit("users.login", email, password, res => {
+					if (res.status === "success") {
+						return lofig.get("cookie").then(cookie => {
+							const date = new Date();
+							date.setTime(
+								new Date().getTime() +
+									2 * 365 * 24 * 60 * 60 * 1000
+							);
+							const secure = cookie.secure ? "secure=true; " : "";
+							let domain = "";
+							if (cookie.domain !== "localhost")
+								domain = ` domain=${cookie.domain};`;
+							document.cookie = `${cookie.SIDname}=${
+								res.SID
+							}; expires=${date.toGMTString()}; ${domain}${secure}path=/`;
+							return resolve({ status: "success" });
+						});
+					}
+
+					return reject(new Error(res.message));
+				});
+			});
+		});
+	},
+	logout() {
+		return new Promise((resolve, reject) => {
+			io.getSocket(socket => {
+				socket.emit("users.logout", result => {
+					if (result.status === "success") {
+						return lofig.get("cookie").then(cookie => {
+							document.cookie = `${cookie.SIDname}=;expires=Thu, 01 Jan 1970 00:00:01 GMT;`;
+							return window.location.reload();
+						});
+					}
+					new Toast({ content: result.message, timeout: 4000 });
+					return reject(new Error(result.message));
+				});
+			});
+		});
+	}
+};

+ 15 - 16
frontend/auth.js

@@ -1,37 +1,36 @@
 let callbacks = [];
-let bannedCallbacks = [];
+const bannedCallbacks = [];
 
 export default {
-
 	ready: false,
 	authenticated: false,
-	username: '',
-	userId: '',
-	role: 'default',
+	username: "",
+	userId: "",
+	role: "default",
 	banned: null,
 	ban: {},
 
-	getStatus: function (cb) {
-		if (this.ready) cb(this.authenticated, this.role, this.username, this.userId);
+	getStatus(cb) {
+		if (this.ready)
+			cb(this.authenticated, this.role, this.username, this.userId);
 		else callbacks.push(cb);
 	},
 
-	setBanned: function (ban) {
-		let _this = this;
-		_this.banned = true;
-		_this.ban = ban;
+	setBanned(ban) {
+		this.banned = true;
+		this.ban = ban;
 		bannedCallbacks.forEach(callback => {
-			callback(true, _this.ban);
+			callback(true, this.ban);
 		});
 	},
 
-	isBanned: function (cb) {
+	isBanned(cb) {
 		if (this.ready) return cb(false);
 		if (!this.ready && this.banned === true) return cb(true, this.ban);
-		bannedCallbacks.push(cb);
+		return bannedCallbacks.push(cb);
 	},
 
-	data: function (authenticated, role, username, userId) {
+	data(authenticated, role, username, userId) {
 		this.authenticated = authenticated;
 		this.role = role;
 		this.username = username;
@@ -45,4 +44,4 @@ export default {
 		});
 		callbacks = [];
 	}
-}
+};

+ 9 - 0
frontend/bootstrap.sh

@@ -0,0 +1,9 @@
+#!/bin/bash
+
+if [ "$FRONTEND_MODE" == "prod" ] ; then
+	cd /opt/app ; yarn run $FRONTEND_MODE
+	nginx -c /opt/app/$FRONTEND_MODE.nginx.conf -g "daemon off;"
+elif [ "$FRONTEND_MODE" == "dev" ] ; then
+	nginx -c /opt/app/$FRONTEND_MODE.nginx.conf
+	cd /opt/app; yarn run $FRONTEND_MODE
+fi

+ 0 - 12
frontend/build/browserconfig.xml

@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<browserconfig>
-  <msapplication>
-    <tile>
-      <square70x70logo src="/mstile-70x70.png?v=06042016"/>
-      <square150x150logo src="/mstile-150x150.png?v=06042016"/>
-      <square310x310logo src="/mstile-310x310.png?v=06042016"/>
-      <wide310x150logo src="/mstile-310x150.png?v=06042016"/>
-      <TileColor>#2d89ef</TileColor>
-    </tile>
-  </msapplication>
-</browserconfig>

+ 0 - 10
frontend/build/config/template.json

@@ -1,10 +0,0 @@
-{
-	"recaptcha": {
-		"key": ""
-	},
-  	"serverDomain": "",
-  	"cookie": {
-		"domain": "",
-		"secure": false
-	}
-}

+ 0 - 57
frontend/build/index.tpl.html

@@ -1,57 +0,0 @@
-<!DOCTYPE html>
-<html lang='en'>
-<head>
-	<title>Musare</title>
-
-	<meta charset='UTF-8'>
-	<meta http-equiv='X-UA-Compatible' content='IE=edge'>
-	<meta name='viewport' content='width=device-width, initial-scale=1, user-scalable=no'>
-	<meta name='keywords' content='music, musare, listen, station, station, radio, edm, chill, community, official, rooms, room, party, good, mus, pop'>
-	<meta name='description' content='On Musare you can listen to lots of different songs, playing 24/7 in our official stations and in user-made community stations!'>
-	<meta name='copyright' content='© Copyright Musare 2015-2018 All Right Reserved'>
-
-	<link rel='apple-touch-icon' sizes='57x57' href='/apple-touch-icon-57x57.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='60x60' href='/apple-touch-icon-60x60.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='72x72' href='/apple-touch-icon-72x72.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='76x76' href='/apple-touch-icon-76x76.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='114x114' href='/apple-touch-icon-114x114.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='120x120' href='/apple-touch-icon-120x120.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='144x144' href='/apple-touch-icon-144x144.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='152x152' href='/apple-touch-icon-152x152.png?v=06042016'>
-	<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon-180x180.png?v=06042016'>
-	<link rel='icon' type='image/png' href='/favicon-32x32.png?v=06042016' sizes='32x32'>
-	<link rel='icon' type='image/png' href='/favicon-194x194.png?v=06042016' sizes='194x194'>
-	<link rel='icon' type='image/png' href='/favicon-96x96.png?v=06042016' sizes='96x96'>
-	<link rel='icon' type='image/png' href='/android-chrome-192x192.png?v=06042016' sizes='192x192'>
-	<link rel='icon' type='image/png' href='/favicon-16x16.png?v=06042016' sizes='16x16'>
-	<link rel='manifest' href='/manifest.json?v=06042016'>
-	<link rel='mask-icon' href='/safari-pinned-tab.svg?v=06042016' color='#03a9f4'>
-	<link rel='shortcut icon' href='/favicon.ico?v=06042016'>
-	<meta name='msapplication-TileColor' content='#03a9f4'>
-	<meta name='msapplication-TileImage' content='/mstile-144x144.png?v=06042016'>
-	<meta name='theme-color' content='#03a9f4'>
-	<meta name='google' content='nositelinkssearchbox' />
-
-
-	<link href='https://fonts.googleapis.com/css?family=Roboto:100,400' rel='stylesheet'>
-	<link href='https://fonts.googleapis.com/icon?family=Material+Icons' rel='stylesheet'>
-	<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.2.3/css/bulma.min.css">
-	<link rel='stylesheet' href='/index.css'>
-	<script src='https://www.youtube.com/iframe_api'></script>
-	<script type='text/javascript' src='/vendor/jquery.min.js'></script>
-	<script type='text/javascript' src='/vendor/moment.min.js'></script>
-	<script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/socket.io/1.4.8/socket.io.min.js'></script>
-	<script type='text/javascript' src='https://musare.com/lofig.min.js'></script>
-	<script>
-		(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
-					(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
-				m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
-		})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
-
-		ga('create', 'UA-93460758-1', 'auto');
-	</script>
-</head>
-<body>
-	<script src='https://www.google.com/recaptcha/api.js'></script>
-</body>
-</html>

Fișier diff suprimat deoarece este prea mare
+ 0 - 1
frontend/build/vendor/jquery.min.js


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
frontend/build/vendor/moment.min.js


+ 18 - 17
frontend/components/404.vue

@@ -1,25 +1,26 @@
 <template>
 	<div class="wrapper">
+		<metadata title="404" />
+
 		<h3><strong>404</strong>&nbsp;Not Found</h3>
-		<button class="button is-black" @click="$router.go('/')">Back to Home</button>
+		<router-link class="button is-black" to="/">
+			Back to Home
+		</router-link>
 	</div>
 </template>
 
-<style type="scss" scoped>
-	* {
-		margin: 0;
-		padding: 0;
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.wrapper {
-		height: 100vh;
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		flex-direction: column;
-	}
+.wrapper {
+	height: 100vh;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	flex-direction: column;
+}
 
-	button {
-		margin-top: 15px;
-	}
-</style>
+a {
+	margin-top: 15px;
+}
+</style>

+ 334 - 201
frontend/components/Admin/News.vue

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

+ 172 - 95
frontend/components/Admin/Punishments.vue

@@ -1,114 +1,191 @@
 <template>
-	<div class='container'>
-		<table class='table is-striped'>
-			<thead>
-			<tr>
-				<td>Type</td>
-				<td>Value</td>
-				<td>Reason</td>
-				<td>Status</td>
-				<td>Options</td>
-			</tr>
-			</thead>
-			<tbody>
-			<tr v-for='(index, punishment) in punishments | orderBy "expiresAt" -1' track-by='$index'>
-				<td v-if='punishment.type === "banUserId"'>User ID</td>
-				<td v-else>IP Address</td>
-				<td>{{ punishment.value }}</td>
-				<td>{{ punishment.reason }}</td>
-				<td>{{ (punishment.active && new Date(punishment.expiresAt).getTime() > Date.now()) ? 'Active' : 'Inactive' }}</td>
-				<td>
-					<button class='button is-primary' @click='view(punishment)'>View</button>
-				</td>
-			</tr>
-			</tbody>
-		</table>
-		<div class='card is-fullwidth'>
-			<header class='card-header'>
-				<p class='card-header-title'>Ban an IP</p>
-			</header>
-			<div class='card-content'>
-				<div class='content'>
-					<label class='label'>Expires In</label>
-					<select v-model='ipBan.expiresAt'>
-						<option value='1h'>1 Hour</option>
-						<option value='12h'>12 Hours</option>
-						<option value='1d'>1 Day</option>
-						<option value='1w'>1 Week</option>
-						<option value='1m'>1 Month</option>
-						<option value='3m'>3 Months</option>
-						<option value='6m'>6 Months</option>
-						<option value='1y'>1 Year</option>
-					</select>
-					<label class='label'>IP</label>
-					<p class='control is-expanded'>
-						<input class='input' type='text' placeholder='IP address (xxx.xxx.xxx.xxx)' v-model='ipBan.ip'>
-					</p>
-					<label class='label'>Reason</label>
-					<p class='control is-expanded'>
-						<input class='input' type='text' placeholder='Reason' v-model='ipBan.reason'>
+	<div>
+		<metadata title="Admin | Punishments" />
+		<div class="container">
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>Type</td>
+						<td>Value</td>
+						<td>Reason</td>
+						<td>Status</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr
+						v-for="(punishment, index) in sortedPunishments"
+						:key="index"
+					>
+						<td v-if="punishment.type === 'banUserId'">
+							User ID
+						</td>
+						<td v-else>
+							IP Address
+						</td>
+						<td>{{ punishment.value }}</td>
+						<td>{{ punishment.reason }}</td>
+						<td>
+							{{
+								punishment.active &&
+								new Date(punishment.expiresAt).getTime() >
+									Date.now()
+									? "Active"
+									: "Inactive"
+							}}
+						</td>
+						<td>
+							<button
+								class="button is-primary"
+								@click="view(punishment)"
+							>
+								View
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+			<div class="card is-fullwidth">
+				<header class="card-header">
+					<p class="card-header-title">
+						Ban an IP
 					</p>
+				</header>
+				<div class="card-content">
+					<div class="content">
+						<label class="label">Expires In</label>
+						<select v-model="ipBan.expiresAt">
+							<option value="1h">
+								1 Hour
+							</option>
+							<option value="12h">
+								12 Hours
+							</option>
+							<option value="1d">
+								1 Day
+							</option>
+							<option value="1w">
+								1 Week
+							</option>
+							<option value="1m">
+								1 Month
+							</option>
+							<option value="3m">
+								3 Months
+							</option>
+							<option value="6m">
+								6 Months
+							</option>
+							<option value="1y">
+								1 Year
+							</option>
+						</select>
+						<label class="label">IP</label>
+						<p class="control is-expanded">
+							<input
+								v-model="ipBan.ip"
+								class="input"
+								type="text"
+								placeholder="IP address (xxx.xxx.xxx.xxx)"
+							/>
+						</p>
+						<label class="label">Reason</label>
+						<p class="control is-expanded">
+							<input
+								v-model="ipBan.reason"
+								class="input"
+								type="text"
+								placeholder="Reason"
+							/>
+						</p>
+					</div>
 				</div>
+				<footer class="card-footer">
+					<a class="card-footer-item" v-on:click="banIP()" href="#"
+						>Ban IP</a
+					>
+				</footer>
 			</div>
-			<footer class='card-footer'>
-				<a class='card-footer-item' @click='banIP()' href='#'>Ban IP</a>
-			</footer>
 		</div>
+		<view-punishment v-if="modals.viewPunishment" />
 	</div>
-	<view-punishment v-show='modals.viewPunishment'></view-punishment>
 </template>
 
 <script>
-	import ViewPunishment from '../Modals/ViewPunishment.vue';
-	import { Toast } from 'vue-roaster';
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
+import Toast from "toasters";
 
-	export default {
-		components: { ViewPunishment },
-		data() {
-			return {
-				punishments: [],
-				modals: { viewPunishment: false },
-				ipBan: {
-					expiresAt: '1h'
-				}
+import ViewPunishment from "../Modals/ViewPunishment.vue";
+import io from "../../io";
+
+export default {
+	components: { ViewPunishment },
+	data() {
+		return {
+			punishments: [],
+			ipBan: {
+				expiresAt: "1h"
 			}
+		};
+	},
+	computed: {
+		sortedPunishments() {
+			//   return _.orderBy(this.punishments, -1);
+			return this.punishments;
 		},
-		methods: {
-			toggleModal: function () {
-				this.modals.viewPunishment = !this.modals.viewPunishment;
-			},
-			view: function (punishment) {
-				this.$broadcast('viewPunishment', punishment);
-			},
-			banIP: function() {
-				let _this = this;
-				_this.socket.emit('punishments.banIP', _this.ipBan.ip, _this.ipBan.reason, _this.ipBan.expiresAt, res => {
-					Toast.methods.addToast(res.message, 6000);
-				});
-			},
-			init: function () {
-				let _this = this;
-				_this.socket.emit('punishments.index', result => {
-					if (result.status === 'success') _this.punishments = result.data;
-				});
-				//_this.socket.emit('apis.joinAdminRoom', 'punishments', () => {});
-			}
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	methods: {
+		view(punishment) {
+			this.viewPunishment(punishment);
+			this.openModal({ sector: "admin", modal: "viewPunishment" });
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket(socket => {
-				_this.socket = socket;
-				if (_this.socket.connected) _this.init();
-				io.onConnect(() => _this.init());
+		banIP() {
+			this.socket.emit(
+				"punishments.banIP",
+				this.ipBan.ip,
+				this.ipBan.reason,
+				this.ipBan.expiresAt,
+				res => {
+					new Toast({ content: res.message, timeout: 6000 });
+				}
+			);
+		},
+		init() {
+			this.socket.emit("punishments.index", res => {
+				if (res.status === "success") this.punishments = res.data;
+			});
+			this.socket.emit("apis.joinAdminRoom", "punishments", () => {});
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("admin/punishments", ["viewPunishment"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			io.onConnect(() => this.init());
+			socket.on("event:admin.punishment.added", punishment => {
+				this.punishments.push(punishment);
 			});
-		}
+		});
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	body { font-family: 'Roboto', sans-serif; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+body {
+	font-family: "Roboto", sans-serif;
+}
 
-	td { vertical-align: middle; }
-	select { margin-bottom: 10px; }
+td {
+	vertical-align: middle;
+}
+select {
+	margin-bottom: 10px;
+}
 </style>

+ 237 - 128
frontend/components/Admin/QueueSongs.vue

@@ -1,149 +1,258 @@
 <template>
-	<div class='container'>
-		<input type='text' class='input' v-model='searchQuery' placeholder='Search for Songs'>
-		<br /><br />
-		<table class='table is-striped'>
-			<thead>
-				<tr>
-					<td>Thumbnail</td>
-					<td>Title</td>
-					<td>YouTube ID</td>
-					<td>Artists</td>
-					<td>Genres</td>
-					<td>Requested By</td>
-					<td>Options</td>
-				</tr>
-			</thead>
-			<tbody>
-				<tr v-for='(index, song) in filteredSongs' track-by='$index'>
-					<td>
-						<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
-					</td>
-					<td>
-						<strong>{{ song.title }}</strong>
-					</td>
-					<td>{{ song.songId }}</td>
-					<td>{{ song.artists.join(', ') }}</td>
-					<td>{{ song.genres.join(', ') }}</td>
-					<td>{{ song.requestedBy }}</td>
-					<td>
-						<button class='button is-primary' @click='edit(song, index)'>Edit</button>
-						<button class='button is-success' @click='add(song)'>Add</button>
-						<button class='button is-danger' @click='remove(song._id, index)'>Remove</button>
-					</td>
-				</tr>
-			</tbody>
-		</table>
+	<div>
+		<metadata title="Admin | Queue songs" />
+		<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">
+				<thead>
+					<tr>
+						<td>Thumbnail</td>
+						<td>Title</td>
+						<td>Artists</td>
+						<td>Genres</td>
+						<td>ID / YouTube ID</td>
+						<td>Requested By</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(song, index) in filteredSongs" :key="index">
+						<td>
+							<img
+								class="song-thumbnail"
+								:src="song.thumbnail"
+								onerror="this.src='/assets/notes-transparent.png'"
+							/>
+						</td>
+						<td>
+							<strong>{{ song.title }}</strong>
+						</td>
+						<td>{{ song.artists.join(", ") }}</td>
+						<td>{{ song.genres.join(", ") }}</td>
+						<td>
+							{{ song._id }}
+							<br />
+							<a
+								:href="
+									'https://www.youtube.com/watch?v=' +
+										`${song.songId}`
+								"
+								target="_blank"
+							>
+								{{ song.songId }}</a
+							>
+						</td>
+						<td>
+							<user-id-to-username
+								:userId="song.requestedBy"
+								:link="true"
+							/>
+						</td>
+						<td class="optionsColumn">
+							<button
+								class="button is-primary"
+								@click="edit(song, index)"
+							>
+								<i class="material-icons">edit</i>
+							</button>
+							<button
+								class="button is-success"
+								@click="add(song)"
+							>
+								<i class="material-icons">add</i>
+							</button>
+							<button
+								class="button is-danger"
+								@click="remove(song._id, index)"
+							>
+								<i class="material-icons">cancel</i>
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+		<edit-song v-if="modals.editSong" />
 	</div>
-	<nav class="pagination">
-		<a class="button" href='#' @click='getSet(position - 1)' v-if='position > 1'><i class="material-icons">navigate_before</i></a>
-		<a class="button" href='#' @click='getSet(position + 1)' v-if='maxPosition > position'><i class="material-icons">navigate_next</i></a>
-	</nav>
-	<edit-song v-show='modals.editSong'></edit-song>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
+import { mapState, mapActions } from "vuex";
+import Vue from "vue";
 
-	import EditSong from '../Modals/EditSong.vue';
-	import io from '../../io';
+import Toast from "toasters";
 
-	export default {
-		components: { EditSong },
-		data() {
-			return {
-				position: 1,
-				maxPosition: 1,
-				searchQuery: '',
-				songs: [],
-				modals: { editSong: false }
-			}
+import EditSong from "../Modals/EditSong.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+import io from "../../io";
+
+export default {
+	components: { EditSong, UserIdToUsername },
+	data() {
+		return {
+			position: 1,
+			maxPosition: 1,
+			searchQuery: "",
+			songs: [],
+			gettingSet: false,
+			loadAllSongs: false
+		};
+	},
+	computed: {
+		filteredSongs() {
+			return this.songs.filter(
+				song =>
+					JSON.stringify(Object.values(song)).indexOf(
+						this.searchQuery
+					) !== -1
+			);
 		},
-		computed: {
-			filteredSongs: function () {
-				return this.$eval('songs | filterBy searchQuery');
-			}
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	watch: {
+		// eslint-disable-next-line func-names
+		"modals.editSong": function(value) {
+			if (value === false) this.stopVideo();
+		}
+	},
+	methods: {
+		edit(song, index) {
+			const newSong = {};
+			Object.keys(song).forEach(n => {
+				newSong[n] = song[n];
+			});
+
+			this.editSong({ index, song: newSong, type: "queueSongs" });
+			this.openModal({ sector: "admin", modal: "editSong" });
 		},
-		watch: {
-			'modals.editSong': function (value) {
-				if (!value) this.$broadcast('stopVideo');
-			}
+		add(song) {
+			this.socket.emit("songs.add", song, res => {
+				if (res.status === "success")
+					new Toast({ content: res.message, timeout: 2000 });
+				else new Toast({ content: res.message, timeout: 4000 });
+			});
 		},
-		methods: {
-			toggleModal: function () {
-				this.modals.editSong = !this.modals.editSong;
-			},
-			getSet: function (position) {
-				let _this = this;
-				this.socket.emit('queueSongs.getSet', position, data => {
-					_this.songs = data;
-					this.position = position;
-				});
-			},
-			edit: function (song, index) {
-				this.$broadcast('editSong', song, index, 'queueSongs');
-			},
-			add: function (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);
-				});
-			},
-			remove: function (id, index) {
-				console.log("Removing ", 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);
-				});
-			},
-			init: function() {
-				let _this = this;
-				_this.socket.emit('queueSongs.index', data => {
-					_this.songs = data.songs;
-					_this.maxPosition = Math.round(data.maxLength / 50);
+		remove(id) {
+			this.socket.emit("queueSongs.remove", id, res => {
+				if (res.status === "success")
+					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.socket.emit('apis.joinAdminRoom', 'queue', data => {});
-			}
+				this.position += 1;
+				this.gettingSet = false;
+				if (this.loadAllSongs && this.maxPosition > this.position - 1)
+					setTimeout(() => {
+						this.getSet();
+					}, 500);
+			});
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				if (_this.socket.connected) {
-					_this.init();
-					_this.socket.on('event:admin.queueSong.added', queueSong => {
-						_this.songs.push(queueSong);
-					});
-					_this.socket.on('event:admin.queueSong.removed', songId => {
-						_this.songs = _this.songs.filter(function(song) {
-							return song._id !== songId;
-						});
-					});
-					_this.socket.on('event:admin.queueSong.updated', updatedSong => {
-						for (let i = 0; i < _this.songs.length; i++) {
-							let song = _this.songs[i];
-							if (song._id === updatedSong._id) {
-								_this.songs.$set(i, updatedSong);
-							}
-						}
-					});
-				}
-				io.onConnect(() => {
-					_this.init();
+		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("queueSongs.length", length => {
+				this.maxPosition = Math.ceil(length / 15);
+				this.getSet();
+			});
+			this.socket.emit("apis.joinAdminRoom", "queue", () => {});
+		},
+		...mapActions("admin/songs", ["stopVideo", "editSong"]),
+		...mapActions("modals", ["openModal"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+
+			this.socket.on("event:admin.queueSong.added", queueSong => {
+				this.songs.push(queueSong);
+			});
+			this.socket.on("event:admin.queueSong.removed", songId => {
+				this.songs = this.songs.filter(song => {
+					return song._id !== songId;
 				});
 			});
-		}
+			this.socket.on("event:admin.queueSong.updated", updatedSong => {
+				for (let i = 0; i < this.songs.length; i += 1) {
+					const song = this.songs[i];
+					if (song._id === updatedSong._id) {
+						Vue.set(this.songs, i, updatedSong);
+					}
+				}
+			});
+
+			if (this.socket.connected) {
+				this.init();
+			}
+			io.onConnect(() => {
+				this.init();
+			});
+		});
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.song-thumbnail {
-		display: block;
-		max-width: 50px;
-		margin: 0 auto;
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.optionsColumn {
+	width: 140px;
+	button {
+		width: 35px;
 	}
+}
+
+.song-thumbnail {
+	display: block;
+	max-width: 50px;
+	margin: 0 auto;
+}
 
-	td { vertical-align: middle; }
+td {
+	vertical-align: middle;
+}
 
-	.is-primary:focus { background-color: #029ce3 !important; }
+.is-primary:focus {
+	background-color: $primary-color !important;
+}
 </style>

+ 142 - 92
frontend/components/Admin/Reports.vue

@@ -1,109 +1,159 @@
 <template>
-	<div class='container'>
-		<table class='table is-striped'>
-			<thead>
-				<tr>
-					<td>Song ID</td>
-					<td>Created By</td>
-					<td>Created At</td>
-					<td>Description</td>
-					<td>Options</td>
-				</tr>
-			</thead>
-			<tbody>
-				<tr v-for='(index, report) in reports' track-by='$index'>
-					<td>
-						<span>{{ report.songId }}</span>
-					</td>
-					<td>
-						<span>{{ report.createdBy }}</span>
-					</td>
-					<td>
-						<span>{{ report.createdAt }}</span>
-					</td>
-					<td>
-						<span>{{ report.description }}</span>
-					</td>
-					<td>
-						<a class='button is-warning' href='#' @click='toggleModal(report)'>Issues Modal</a>
-						<a class='button is-primary' href='#' @click='resolve(report._id)'>Resolve</a>
-					</td>
-				</tr>
-			</tbody>
-		</table>
-	</div>
+	<div>
+		<metadata title="Admin | Reports" />
+		<div class="container">
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>Song ID</td>
+						<td>Author</td>
+						<td>Time of report</td>
+						<td>Description</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(report, index) in reports" :key="index">
+						<td>
+							<span>
+								{{ report.song.songId }}
+								<br />
+								{{ report.song._id }}
+							</span>
+						</td>
+						<td>
+							<user-id-to-username
+								:userId="report.createdBy"
+								:link="true"
+							/>
+						</td>
+						<td>
+							<span :title="report.createdAt">{{
+								formatDistance(
+									new Date(report.createdAt),
+									new Date(),
+									{ addSuffix: true }
+								)
+							}}</span>
+						</td>
+						<td>
+							<span>{{ report.description }}</span>
+						</td>
+						<td>
+							<a
+								class="button is-warning"
+								href="#"
+								@click="view(report)"
+								>View</a
+							>
+							<a
+								class="button is-primary"
+								href="#"
+								@click="resolve(report._id)"
+								>Resolve</a
+							>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
 
-	<issues-modal v-if='modals.report'></issues-modal>
+		<issues-modal v-if="modals.viewReport" />
+	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
+import { formatDistance } from "date-fns";
 
-	import IssuesModal from '../Modals/IssuesModal.vue';
+import Toast from "toasters";
+import io from "../../io";
 
-	export default {
-		data() {
-			return {
-				reports: [],
-				modals: {
-					report: false
-				}
-			}
-		},
-		methods: {
-			init: function() {
-				this.socket.emit('apis.joinAdminRoom', 'reports', data => {});
-			},
-			toggleModal: function (report) {
-				this.modals.report = !this.modals.report;
-				if (this.modals.report) this.editing = report;
-			},
-			resolve: function (reportId) {
-				let _this = this;
-				this.socket.emit('reports.resolve', reportId, res => {
-					Toast.methods.addToast(res.message, 3000);
-					if (res.status === 'success' && this.modals.report) _this.toggleModal();
+import IssuesModal from "../Modals/IssuesModal.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+export default {
+	components: { IssuesModal, UserIdToUsername },
+	data() {
+		return {
+			reports: []
+		};
+	},
+	mounted() {
+		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();
+			});
+		});
+
+		if (this.$route.query.id) {
+			this.socket.emit("reports.findOne", this.$route.query.id, res => {
+				if (res.status === "success") this.view(res.data);
+				else
+					new Toast({
+						content: "Report with that ID not found",
+						timeout: 3000
+					});
+			});
+		}
+	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	methods: {
+		formatDistance,
+		init() {
+			this.socket.emit("apis.joinAdminRoom", "reports", () => {});
 		},
-		ready: function () {
-			let _this = this;
-			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;
+		view(report) {
+			this.viewReport(report);
+			this.openModal({ sector: "admin", modal: "viewReport" });
+		},
+		resolve(reportId) {
+			this.socket.emit("reports.resolve", reportId, res => {
+				new Toast({ content: res.message, timeout: 3000 });
+				if (res.status === "success" && this.modals.viewReport)
+					this.closeModal({
+						sector: "admin",
+						modal: "viewReport"
 					});
-				});
-				_this.socket.on('event:admin.report.created', report => {
-					_this.reports.push(report);
-				});
-				io.onConnect(() => {
-					_this.init();
-				});
 			});
-			if (this.$route.query.id) {
-				this.socket.emit('reports.findOne', this.$route.query.id, res => {
-					if (res.status === 'success') _this.toggleModal(res.data);
-					else Toast.methods.addToast('Report with that ID not found', 3000);
-				});
-			}
 		},
-		components: { IssuesModal }
+		...mapActions("modals", ["openModal", "closeModal"]),
+		...mapActions("admin/reports", ["viewReport"])
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.tag:not(:last-child) { margin-right: 5px; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	td {
-		word-wrap: break-word;
-		max-width: 10vw;
-		vertical-align: middle;
-	}
+.tag:not(:last-child) {
+	margin-right: 5px;
+}
+
+td {
+	word-wrap: break-word;
+	max-width: 10vw;
+	vertical-align: middle;
+}
 </style>

+ 263 - 131
frontend/components/Admin/Songs.vue

@@ -1,152 +1,284 @@
 <template>
-	<div class='container'>
-		<input type='text' class='input' v-model='searchQuery' placeholder='Search for Songs'>
-		<br /><br />
-		<table class='table is-striped'>
-			<thead>
-				<tr>
-					<td>Thumbnail</td>
-					<td>Title</td>
-					<td>YouTube ID</td>
-					<td>Artists</td>
-					<td>Genres</td>
-					<td>Requested By</td>
-					<td>Options</td>
-				</tr>
-			</thead>
-			<tbody>
-				<tr v-for='(index, song) in filteredSongs' track-by='$index'>
-					<td>
-						<img class='song-thumbnail' :src='song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
-					</td>
-					<td>
-						<strong>{{ song.title }}</strong>
-					</td>
-					<td>{{ song.songId }}</td>
-					<td>{{ song.artists.join(', ') }}</td>
-					<td>{{ song.genres.join(', ') }}</td>
-					<td>{{ song.requestedBy }}</td>
-					<td>
-						<button class='button is-primary' @click='edit(song, index)'>Edit</button>
-						<button class='button is-danger' @click='remove(song._id, index)'>Remove</button>
-					</td>
-				</tr>
-			</tbody>
-		</table>
+	<div>
+		<metadata title="Admin | Songs" />
+		<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">
+				<thead>
+					<tr>
+						<td>Thumbnail</td>
+						<td>Title</td>
+						<td>Artists</td>
+						<td>Genres</td>
+						<td class="likesColumn">
+							<i class="material-icons thumbLike">thumb_up</i>
+						</td>
+						<td class="dislikesColumn">
+							<i class="material-icons thumbDislike"
+								>thumb_down</i
+							>
+						</td>
+						<td>ID / Youtube ID</td>
+						<td>Requested By</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(song, index) in filteredSongs" :key="index">
+						<td>
+							<img
+								class="song-thumbnail"
+								:src="song.thumbnail"
+								onerror="this.src='/assets/notes-transparent.png'"
+							/>
+						</td>
+						<td>
+							<strong>{{ song.title }}</strong>
+						</td>
+						<td>{{ song.artists.join(", ") }}</td>
+						<td>{{ song.genres.join(", ") }}</td>
+						<td>{{ song.likes }}</td>
+						<td>{{ song.dislikes }}</td>
+						<td>
+							{{ song._id }}
+							<br />
+							<a
+								:href="
+									'https://www.youtube.com/watch?v=' +
+										`${song.songId}`
+								"
+								target="_blank"
+							>
+								{{ song.songId }}</a
+							>
+						</td>
+						<td>
+							<user-id-to-username
+								:userId="song.requestedBy"
+								:link="true"
+							/>
+						</td>
+						<td class="optionsColumn">
+							<button
+								class="button is-primary"
+								@click="edit(song)"
+							>
+								<i class="material-icons">edit</i>
+							</button>
+							<button
+								class="button is-danger"
+								@click="remove(song._id, index)"
+							>
+								<i class="material-icons">cancel</i>
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+		<edit-song v-if="modals.editSong" />
 	</div>
-	<edit-song v-show='modals.editSong'></edit-song>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-
-	import EditSong from '../Modals/EditSong.vue';
-	import io from '../../io';
-
-	export default {
-		components: { EditSong },
-		data() {
-			return {
-				position: 1,
-				maxPosition: 1,
-				songs: [],
-				searchQuery: '',
-				modals: { editSong: false },
-				editing: {
-					index: 0,
-					song: {}
-				},
-				video: {
-					player: null,
-					paused: false,
-					playerReady: false
-				}
-			}
+import { mapState, mapActions } from "vuex";
+
+import Toast from "toasters";
+
+import EditSong from "../Modals/EditSong.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+import io from "../../io";
+
+export default {
+	components: { EditSong, UserIdToUsername },
+	data() {
+		return {
+			position: 1,
+			maxPosition: 1,
+			searchQuery: "",
+			editing: {
+				index: 0,
+				song: {}
+			},
+			gettingSet: false,
+			loadAllSongs: false
+		};
+	},
+	computed: {
+		filteredSongs() {
+			return this.songs.filter(
+				song =>
+					JSON.stringify(Object.values(song)).indexOf(
+						this.searchQuery
+					) !== -1
+			);
 		},
-		computed: {
-			filteredSongs: function () {
-				return this.$eval('songs | filterBy searchQuery');
-			}
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		}),
+		...mapState("admin/songs", {
+			songs: state => state.songs
+		})
+	},
+	watch: {
+		// eslint-disable-next-line func-names
+		"modals.editSong": function(val) {
+			if (!val) this.stopVideo();
+		}
+	},
+	methods: {
+		edit(song) {
+			this.editSong({ song, type: "songs" });
+			this.openModal({ sector: "admin", modal: "editSong" });
 		},
-		watch: {
-			'modals.editSong': function (value) {
-				if (!value) this.$broadcast('stopVideo');
-			}
+		remove(id) {
+			this.socket.emit("songs.remove", id, res => {
+				if (res.status === "success")
+					new Toast({ content: res.message, timeout: 4000 });
+				else new Toast({ content: res.message, timeout: 8000 });
+			});
 		},
-		methods: {
-			toggleModal: function () {
-				this.modals.editSong = !this.modals.editSong;
-			},
-			edit: function (song, index) {
-				this.$broadcast('editSong', song, index, 'songs');
-			},
-			remove: function (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);
-				});
-			},
-			getSet: function () {
-				let _this = this;
-				_this.socket.emit('songs.getSet', _this.position, data => {
-					data.forEach(song => {
-						_this.songs.push(song);
-					});
-					_this.position = _this.position + 1;
-					if (_this.maxPosition > _this.position - 1) _this.getSet();
-				});
-			},
-			init: function () {
-				let _this = this;
-				_this.songs = [];
-				_this.socket.emit('songs.length', length => {
-					_this.maxPosition = Math.ceil(length / 15);
-					_this.getSet();
+		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.socket.emit('apis.joinAdminRoom', 'songs', () => {});
-			}
+				this.position += 1;
+				this.gettingSet = false;
+				if (this.loadAllSongs && this.maxPosition > this.position - 1)
+					setTimeout(() => {
+						this.getSet();
+					}, 500);
+			});
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				if (_this.socket.connected) {
-					_this.init();
-					_this.socket.on('event:admin.song.added', song => {
-						_this.songs.push(song);
-					});
-					_this.socket.on('event:admin.song.removed', songId => {
-						_this.songs = _this.songs.filter(function(song) {
-							return song._id !== songId;
-						});
-					});
-					_this.socket.on('event:admin.song.updated', updatedSong => {
-						for (let i = 0; i < _this.songs.length; i++) {
-							let song = _this.songs[i];
-							if (song._id === updatedSong._id) {
-								_this.songs.$set(i, updatedSong);
-							}
-						}
+		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();
+			});
+			this.socket.emit("apis.joinAdminRoom", "songs", () => {});
+		},
+		...mapActions("admin/songs", [
+			"stopVideo",
+			"editSong",
+			"addSong",
+			"removeSong",
+			"updateSong"
+		]),
+		...mapActions("modals", ["openModal", "closeModal"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			this.socket.on("event:admin.song.added", song => {
+				this.addSong(song);
+			});
+			this.socket.on("event:admin.song.removed", songId => {
+				this.removeSong(songId);
+			});
+			this.socket.on("event:admin.song.updated", updatedSong => {
+				this.updateSong(updatedSong);
+			});
+
+			if (this.socket.connected) {
+				this.init();
+			}
+			io.onConnect(() => {
+				this.init();
+			});
+		});
+
+		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
+					new Toast({
+						content: "Song with that ID not found",
+						timeout: 3000
 					});
-				}
-				io.onConnect(() => {
-					_this.init();
-				});
 			});
 		}
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	body { font-family: 'Roboto', sans-serif; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+body {
+	font-family: "Roboto", sans-serif;
+}
 
-	.song-thumbnail {
-		display: block;
-		max-width: 50px;
-		margin: 0 auto;
+.optionsColumn {
+	width: 100px;
+	button {
+		width: 35px;
 	}
+}
+
+.likesColumn,
+.dislikesColumn {
+	width: 40px;
+	i {
+		font-size: 20px;
+	}
+	.thumbLike {
+		color: $green !important;
+	}
+	.thumbDislike {
+		color: $red !important;
+	}
+}
+
+.song-thumbnail {
+	display: block;
+	max-width: 50px;
+	margin: 0 auto;
+}
 
-	td { vertical-align: middle; }
+td {
+	vertical-align: middle;
+}
 
-	.is-primary:focus { background-color: #029ce3 !important; }
+.is-primary:focus {
+	background-color: $primary-color !important;
+}
 </style>

+ 348 - 187
frontend/components/Admin/Stations.vue

@@ -1,226 +1,387 @@
 <template>
-	<div class='container'>
-		<table class='table is-striped'>
-			<thead>
-				<tr>
-					<td>ID</td>
-					<td>Name</td>
-					<td>Type</td>
-					<td>Display Name</td>
-					<td>Description</td>
-					<td>Options</td>
-				</tr>
-			</thead>
-			<tbody>
-				<tr v-for='(index, station) in stations' track-by='$index'>
-					<td>
-						<span>{{station._id}}</span>
-					</td>
-					<td>
-						<span>{{station.name}}</span>
-					</td>
-					<td>
-						<span>{{station.type}}</span>
-					</td>
-					<td>
-						<span>{{station.displayName}}</span>
-					</td>
-					<td>
-						<span>{{station.description}}</span>
-					</td>
-					<td>
-						<a class='button is-info' @click='editStation(station)'>Edit</a>
-						<a class='button is-danger' @click='removeStation(index)' href='#'>Remove</a>
-					</td>
-				</tr>
-			</tbody>
-		</table>
-	</div>
-	<div class='container'>
-		<div class='card is-fullwidth'>
-			<header class='card-header'>
-				<p class='card-header-title'>Create official station</p>
-			</header>
-			<div class='card-content'>
-				<div class='content'>
-					<div class='control is-horizontal'>
-						<div class='control is-grouped'>
-							<p class='control is-expanded'>
-								<input class='input' type='text' placeholder='Name' v-model='newStation.name'>
-							</p>
-							<p class='control is-expanded'>
-								<input class='input' type='text' placeholder='Display Name' v-model='newStation.displayName'>
-							</p>
-						</div>
-					</div>
-					<label class='label'>Description</label>
-					<p class='control is-expanded'>
-						<input class='input' type='text' placeholder='Short description' v-model='newStation.description'>
-					</p>
-					<div class="control is-grouped genre-wrapper">
-						<div class="sector">
-							<p class='control has-addons'>
-								<input class='input' id='new-genre' type='text' placeholder='Genre' v-on:keyup.enter='addGenre()'>
-								<a class='button is-info' href='#' @click='addGenre()'>Add genre</a>
-							</p>
-							<span class='tag is-info' v-for='(index, genre) in newStation.genres' track-by='$index'>
-								{{ genre }}
-								<button class='delete is-info' @click='removeGenre(index)'></button>
+	<div>
+		<metadata title="Admin | Stations" />
+		<div class="container">
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>ID</td>
+						<td>Name</td>
+						<td>Type</td>
+						<td>Display Name</td>
+						<td>Description</td>
+						<td>Owner</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(station, index) in stations" :key="index">
+						<td>
+							<span>{{ station._id }}</span>
+						</td>
+						<td>
+							<span>
+								<router-link
+									:to="{
+										name: 'station',
+										params: { id: station.name }
+									}"
+								>
+									{{ station.name }}
+								</router-link>
 							</span>
+						</td>
+						<td>
+							<span>{{ station.type }}</span>
+						</td>
+						<td>
+							<span>{{ station.displayName }}</span>
+						</td>
+						<td>
+							<span>{{ station.description }}</span>
+						</td>
+						<td>
+							<span
+								v-if="station.type === 'official'"
+								title="Musare"
+								>Musare</span
+							>
+							<user-id-to-username
+								v-else
+								:userId="station.owner"
+								:link="true"
+							/>
+						</td>
+						<td>
+							<a class="button is-info" v-on:click="edit(station)"
+								>Edit</a
+							>
+							<a
+								class="button is-danger"
+								href="#"
+								@click="removeStation(index)"
+								>Remove</a
+							>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+		<div class="container">
+			<div class="card is-fullwidth">
+				<header class="card-header">
+					<p class="card-header-title">
+						Create official station
+					</p>
+				</header>
+				<div class="card-content">
+					<div class="content">
+						<div class="control is-horizontal">
+							<div class="control is-grouped">
+								<p class="control is-expanded">
+									<input
+										v-model="newStation.name"
+										class="input"
+										type="text"
+										placeholder="Name"
+									/>
+								</p>
+								<p class="control is-expanded">
+									<input
+										v-model="newStation.displayName"
+										class="input"
+										type="text"
+										placeholder="Display Name"
+									/>
+								</p>
+							</div>
 						</div>
-						<div class="sector">
-							<p class='control has-addons'>
-								<input class='input' id='new-blacklisted-genre' type='text' placeholder='Blacklisted Genre' v-on:keyup.enter='addBlacklistedGenre()'>
-								<a class='button is-info' href='#' @click='addBlacklistedGenre()'>Add blacklisted genre</a>
-							</p>
-							<span class='tag is-info' v-for='(index, genre) in newStation.blacklistedGenres' track-by='$index'>
-								{{ genre }}
-								<button class='delete is-info' @click='removeBlacklistedGenre(index)'></button>
-							</span>
+						<label class="label">Description</label>
+						<p class="control is-expanded">
+							<input
+								v-model="newStation.description"
+								class="input"
+								type="text"
+								placeholder="Short description"
+							/>
+						</p>
+						<div class="control is-grouped genre-wrapper">
+							<div class="sector">
+								<p class="control has-addons">
+									<input
+										id="new-genre"
+										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 newStation.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"
+										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 newStation.blacklistedGenres"
+									:key="index"
+									class="tag is-info"
+								>
+									{{ genre }}
+									<button
+										class="delete is-info"
+										@click="removeBlacklistedGenre(index)"
+									/>
+								</span>
+							</div>
 						</div>
 					</div>
 				</div>
+				<footer class="card-footer">
+					<a
+						class="card-footer-item"
+						href="#"
+						@click="createStation()"
+						>Create</a
+					>
+				</footer>
 			</div>
-			<footer class='card-footer'>
-				<a class='card-footer-item' @click='createStation()' href='#'>Create</a>
-			</footer>
 		</div>
-	</div>
 
-	<edit-station v-show='modals.editStation'></edit-station>
+		<edit-station v-if="modals.editStation" store="admin/stations" />
+	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
 
-	import EditStation from '../Modals/EditStation.vue';
+import Toast from "toasters";
+import io from "../../io";
 
-	export default {
-		components: { EditStation },
-		data() {
-			return {
-				stations: [],
-				newStation: {
-					genres: [],
-					blacklistedGenres: []
-				},
-				modals: { editStation: false }
-			}
-		},
-		methods: {
-			toggleModal: function () {
-				this.modals.editStation	= !this.modals.editStation;
-			},
-			createStation: function () {
-				let _this = this;
-				let { newStation: { name, displayName, description, genres, blacklistedGenres } } = this;
-
-				if (name == undefined) return Toast.methods.addToast('Field (Name) cannot be empty', 3000);
-				if (displayName == undefined) return Toast.methods.addToast('Field (Display Name) cannot be empty', 3000);
-				if (description == undefined) return Toast.methods.addToast('Field (Description) cannot be empty', 3000);
+import EditStation from "../Modals/EditStation.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
 
-				_this.socket.emit('stations.create', {
+export default {
+	components: { EditStation, UserIdToUsername },
+	data() {
+		return {
+			stations: [],
+			newStation: {
+				genres: [],
+				blacklistedGenres: []
+			}
+		};
+	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.station
+		})
+	},
+	methods: {
+		createStation() {
+			const {
+				newStation: {
 					name,
-					type: 'official',
 					displayName,
 					description,
 					genres,
-					blacklistedGenres,
-				}, result => {
-					Toast.methods.addToast(result.message, 3000);
-					if (result.status == 'success') this.newStation = {
-						genres: [],
-						blacklistedGenres: []
-					}
+					blacklistedGenres
+				}
+			} = this;
+
+			if (name === undefined)
+				return new Toast({
+					content: "Field (Name) cannot be empty",
+					timeout: 3000
 				});
-			},
-			removeStation: function (index) {
-				this.socket.emit('stations.remove', this.stations[index]._id, res => {
-					Toast.methods.addToast(res.message, 3000);
+			if (displayName === undefined)
+				return new Toast({
+					content: "Field (Display Name) cannot be empty",
+					timeout: 3000
 				});
-			},
-			editStation: function (station) {
-				this.$broadcast('editStation', {
-					_id: station._id,
-					name: station.name,
-					type: station.type,
-					partyMode: station.partyMode,
-					description: station.description,
-					privacy: station.privacy,
-					displayName: station.displayName
+			if (description === undefined)
+				return new Toast({
+					content: "Field (Description) cannot be empty",
+					timeout: 3000
 				});
-			},
-			addGenre: function () {
-				let genre = $('#new-genre').val().toLowerCase().trim();
-				if (this.newStation.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
-				if (genre) {
-					this.newStation.genres.push(genre);
-					$('#new-genre').val('');
-				}
-				else Toast.methods.addToast('Genre cannot be empty', 3000);
-			},
-			removeGenre: function (index) { this.newStation.genres.splice(index, 1); },
-			addBlacklistedGenre: function () {
-				let genre = $('#new-blacklisted-genre').val().toLowerCase().trim();
-				if (this.newStation.blacklistedGenres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
 
-				if (genre) {
-					this.newStation.blacklistedGenres.push(genre);
-					$('#new-blacklisted-genre').val('');
+			return this.socket.emit(
+				"stations.create",
+				{
+					name,
+					type: "official",
+					displayName,
+					description,
+					genres,
+					blacklistedGenres
+				},
+				result => {
+					new Toast({ content: result.message, timeout: 3000 });
+					if (result.status === "success")
+						this.newStation = {
+							genres: [],
+							blacklistedGenres: []
+						};
+				}
+			);
+		},
+		removeStation(index) {
+			this.socket.emit(
+				"stations.remove",
+				this.stations[index]._id,
+				res => {
+					new Toast({ content: res.message, timeout: 3000 });
 				}
-				else Toast.methods.addToast('Genre cannot be empty', 3000);
-			},
-			removeBlacklistedGenre: function (index) { this.newStation.blacklistedGenres.splice(index, 1); },
-			init: function () {
-				let _this = this;
-				_this.socket.emit('stations.index', data => {
-					_this.stations = data.stations;
+			);
+		},
+		edit(station) {
+			this.editStation({
+				_id: station._id,
+				name: station.name,
+				type: station.type,
+				partyMode: station.partyMode,
+				description: station.description,
+				privacy: station.privacy,
+				displayName: station.displayName,
+				genres: station.genres,
+				blacklistedGenres: station.blacklistedGenres
+			});
+			this.openModal({
+				sector: "station",
+				modal: "editStation"
+			});
+		},
+		addGenre() {
+			const genre = document
+				.getElementById(`new-genre`)
+				.value.toLowerCase()
+				.trim();
+			if (this.newStation.genres.indexOf(genre) !== -1)
+				return new Toast({
+					content: "Genre already exists",
+					timeout: 3000
 				});
-				_this.socket.emit('apis.joinAdminRoom', 'stations', data => {});
+			if (genre) {
+				this.newStation.genres.push(genre);
+				document.getElementById(`new-genre`).value = "";
+				return true;
 			}
+			return new Toast({
+				content: "Genre cannot be empty",
+				timeout: 3000
+			});
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				if (_this.socket.connected) _this.init();
-				_this.socket.on('event:admin.station.added', station => {
-					_this.stations.push(station);
-				});
-				_this.socket.on('event:admin.station.removed', stationId => {
-					_this.stations = _this.stations.filter(station => {
-						return station._id !== stationId;
-					});
+		removeGenre(index) {
+			this.newStation.genres.splice(index, 1);
+		},
+		addBlacklistedGenre() {
+			const genre = document
+				.getElementById(`new-blacklisted-genre`)
+				.value.toLowerCase()
+				.trim();
+			if (this.newStation.blacklistedGenres.indexOf(genre) !== -1)
+				return new Toast({
+					content: "Genre already exists",
+					timeout: 3000
 				});
-				io.onConnect(() => {
-					_this.init();
+
+			if (genre) {
+				this.newStation.blacklistedGenres.push(genre);
+				document.getElementById(`new-blacklisted-genre`).value = "";
+				return true;
+			}
+			return new Toast({
+				content: "Genre cannot be empty",
+				timeout: 3000
+			});
+		},
+		removeBlacklistedGenre(index) {
+			this.newStation.blacklistedGenres.splice(index, 1);
+		},
+		init() {
+			this.socket.emit("stations.index", data => {
+				this.stations = data.stations;
+			});
+			this.socket.emit("apis.joinAdminRoom", "stations", () => {});
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("admin/stations", ["editStation"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			this.socket.on("event:admin.station.added", station => {
+				this.stations.push(station);
+			});
+			this.socket.on("event:admin.station.removed", stationId => {
+				this.stations = this.stations.filter(station => {
+					return station._id !== stationId;
 				});
 			});
-		}
+			io.onConnect(() => {
+				this.init();
+			});
+		});
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.tag {
-		margin-top: 5px;
-		&:not(:last-child) {
-			margin-right: 5px;
-		}
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	td {
-		word-wrap: break-word;
-		max-width: 10vw;
-		vertical-align: middle;
+.tag {
+	margin-top: 5px;
+	&:not(:last-child) {
+		margin-right: 5px;
 	}
+}
 
-	.is-info:focus { background-color: #0398db; }
+td {
+	word-wrap: break-word;
+	max-width: 10vw;
+	vertical-align: middle;
+}
 
-	.genre-wrapper {
-		display: flex;
-    	justify-content: space-around;
-	}
+.is-info:focus {
+	background-color: $primary-color;
+}
+
+.genre-wrapper {
+	display: flex;
+	justify-content: space-around;
+}
 
-	.card-footer-item { color: #029ce3; }
+.card-footer-item {
+	color: $primary-color;
+}
 </style>

+ 274 - 224
frontend/components/Admin/Statistics.vue

@@ -1,18 +1,21 @@
 <template>
-	<div class='container'>
+	<div class="container">
+		<metadata title="Admin | Statistics" />
 		<div class="columns">
-			<div class='card column is-10-desktop is-offset-1-desktop is-12-mobile'>
-				<header class='card-header'>
-					<p class='card-header-title'>
+			<div
+				class="card column is-10-desktop is-offset-1-desktop is-12-mobile"
+			>
+				<header class="card-header">
+					<p class="card-header-title">
 						Average Logs
 					</p>
 				</header>
-				<div class='card-content'>
-					<div class='content'>
+				<div class="card-content">
+					<div class="content">
 						<table class="table">
 							<thead>
 								<tr>
-									<th> </th>
+									<th />
 									<th>Success</th>
 									<th>Error</th>
 									<th>Info</th>
@@ -21,27 +24,51 @@
 							<tbody>
 								<tr>
 									<th><strong>Average per second</strong></th>
-									<th v-bind:title="logs.second.success">{{round(logs.second.success)}}</th>
-									<th v-bind:title="logs.second.error">{{round(logs.second.error)}}</th>
-									<th v-bind:title="logs.second.info">{{round(logs.second.info)}}</th>
+									<th :title="logs.second.success">
+										{{ round(logs.second.success) }}
+									</th>
+									<th :title="logs.second.error">
+										{{ round(logs.second.error) }}
+									</th>
+									<th :title="logs.second.info">
+										{{ round(logs.second.info) }}
+									</th>
 								</tr>
 								<tr>
 									<th><strong>Average per minute</strong></th>
-									<th v-bind:title="logs.minute.success">{{round(logs.minute.success)}}</th>
-									<th v-bind:title="logs.minute.error">{{round(logs.minute.error)}}</th>
-									<th v-bind:title="logs.minute.info">{{round(logs.minute.info)}}</th>
+									<th :title="logs.minute.success">
+										{{ round(logs.minute.success) }}
+									</th>
+									<th :title="logs.minute.error">
+										{{ round(logs.minute.error) }}
+									</th>
+									<th :title="logs.minute.info">
+										{{ round(logs.minute.info) }}
+									</th>
 								</tr>
 								<tr>
 									<th><strong>Average per hour</strong></th>
-									<th v-bind:title="logs.hour.success">{{round(logs.hour.success)}}</th>
-									<th v-bind:title="logs.hour.error">{{round(logs.hour.error)}}</th>
-									<th v-bind:title="logs.hour.info">{{round(logs.hour.info)}}</th>
+									<th :title="logs.hour.success">
+										{{ round(logs.hour.success) }}
+									</th>
+									<th :title="logs.hour.error">
+										{{ round(logs.hour.error) }}
+									</th>
+									<th :title="logs.hour.info">
+										{{ round(logs.hour.info) }}
+									</th>
 								</tr>
 								<tr>
 									<th><strong>Average per day</strong></th>
-									<th v-bind:title="logs.day.success">{{round(logs.day.success)}}</th>
-									<th v-bind:title="logs.day.error">{{round(logs.day.error)}}</th>
-									<th v-bind:title="logs.day.info">{{round(logs.day.info)}}</th>
+									<th :title="logs.day.success">
+										{{ round(logs.day.success) }}
+									</th>
+									<th :title="logs.day.error">
+										{{ round(logs.day.error) }}
+									</th>
+									<th :title="logs.day.info">
+										{{ round(logs.day.info) }}
+									</th>
 								</tr>
 							</tbody>
 						</table>
@@ -49,22 +76,26 @@
 				</div>
 			</div>
 		</div>
-		<br>
+		<br />
 		<div class="columns">
-			<div class='card column is-10-desktop is-offset-1-desktop is-12-mobile'>
-				<div class='card-content'>
-					<div class='content'>
-						<canvas id="minuteChart" height="400"></canvas>
+			<div
+				class="card column is-10-desktop is-offset-1-desktop is-12-mobile"
+			>
+				<div class="card-content">
+					<div class="content">
+						<canvas id="minuteChart" height="400" />
 					</div>
 				</div>
 			</div>
 		</div>
-		<br>
+		<br />
 		<div class="columns">
-			<div class='card column is-10-desktop is-offset-1-desktop is-12-mobile'>
-				<div class='card-content'>
-					<div class='content'>
-						<canvas id="hourChart" height="400"></canvas>
+			<div
+				class="card column is-10-desktop is-offset-1-desktop is-12-mobile"
+			>
+				<div class="card-content">
+					<div class="content">
+						<canvas id="hourChart" height="400" />
 					</div>
 				</div>
 			</div>
@@ -73,228 +104,247 @@
 </template>
 
 <script>
-	import EditUser from '../Modals/EditUser.vue';
-	import io from '../../io';
-	import Chart from 'chart.js'
+import { Line } from "chart.js";
+import "chartjs-adapter-date-fns";
 
-	export default {
-		components: {},
-		data() {
-			return {
-				successUnitsPerMinute: [0,0,0,0,0,0,0,0,0,0],
-				errorUnitsPerMinute: [0,0,0,0,0,0,0,0,0,0],
-				infoUnitsPerMinute: [0,0,0,0,0,0,0,0,0,0],
-				successUnitsPerHour: [0,0,0,0,0,0,0,0,0,0],
-				errorUnitsPerHour: [0,0,0,0,0,0,0,0,0,0],
-				infoUnitsPerHour: [0,0,0,0,0,0,0,0,0,0],
-				minuteChart: null,
-				hourChart: null,
-				logs: {
-					second: {
-						success: 0,
-						error: 0,
-						info: 0
+import io from "../../io";
+
+export default {
+	components: {},
+	data() {
+		return {
+			successUnitsPerMinute: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+			errorUnitsPerMinute: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+			infoUnitsPerMinute: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+			successUnitsPerHour: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+			errorUnitsPerHour: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+			infoUnitsPerHour: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+			minuteChart: null,
+			hourChart: null,
+			logs: {
+				second: {
+					success: 0,
+					error: 0,
+					info: 0
+				},
+				minute: {
+					success: 0,
+					error: 0,
+					info: 0
+				},
+				hour: {
+					success: 0,
+					error: 0,
+					info: 0
+				},
+				day: {
+					success: 0,
+					error: 0,
+					info: 0
+				}
+			}
+		};
+	},
+	mounted() {
+		const minuteCtx = document.getElementById("minuteChart");
+		const hourCtx = document.getElementById("hourChart");
+
+		this.minuteChart = new Line(minuteCtx, {
+			data: {
+				labels: [
+					"-10",
+					"-9",
+					"-8",
+					"-7",
+					"-6",
+					"-5",
+					"-4",
+					"-3",
+					"-2",
+					"-1"
+				],
+				datasets: [
+					{
+						label: "Success",
+						data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+						backgroundColor: ["rgba(75, 192, 192, 0.2)"],
+						borderColor: ["rgba(75, 192, 192, 1)"],
+						borderWidth: 1
 					},
-					minute: {
-						success: 0,
-						error: 0,
-						info: 0
+					{
+						label: "Error",
+						data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+						backgroundColor: ["rgba(255, 99, 132, 0.2)"],
+						borderColor: ["rgba(255,99,132,1)"],
+						borderWidth: 1
 					},
-					hour: {
-						success: 0,
-						error: 0,
-						info: 0
+					{
+						label: "Info",
+						data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+						backgroundColor: ["rgba(54, 162, 235, 0.2)"],
+						borderColor: ["rgba(54, 162, 235, 1)"],
+						borderWidth: 1
+					}
+				]
+			},
+			options: {
+				title: {
+					display: true,
+					text: "Logs per minute"
+				},
+				scales: {
+					yAxes: [
+						{
+							ticks: {
+								beginAtZero: true,
+								stepSize: 1
+							}
+						}
+					]
+				},
+				responsive: true,
+				maintainAspectRatio: false
+			}
+		});
+
+		this.hourChart = new Line(hourCtx, {
+			data: {
+				labels: [
+					"-10",
+					"-9",
+					"-8",
+					"-7",
+					"-6",
+					"-5",
+					"-4",
+					"-3",
+					"-2",
+					"-1"
+				],
+				datasets: [
+					{
+						label: "Success",
+						data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+						backgroundColor: ["rgba(75, 192, 192, 0.2)"],
+						borderColor: ["rgba(75, 192, 192, 1)"],
+						borderWidth: 1
+					},
+					{
+						label: "Error",
+						data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+						backgroundColor: ["rgba(255, 99, 132, 0.2)"],
+						borderColor: ["rgba(255,99,132,1)"],
+						borderWidth: 1
 					},
-					day: {
-						success: 0,
-						error: 0,
-						info: 0
+					{
+						label: "Info",
+						data: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+						backgroundColor: ["rgba(54, 162, 235, 0.2)"],
+						borderColor: ["rgba(54, 162, 235, 1)"],
+						borderWidth: 1
 					}
-				}
+				]
+			},
+			options: {
+				title: {
+					display: true,
+					text: "Logs per hour"
+				},
+				scales: {
+					yAxes: [
+						{
+							ticks: {
+								beginAtZero: true,
+								stepSize: 1
+							}
+						}
+					]
+				},
+				responsive: true,
+				maintainAspectRatio: false
 			}
-		},
-		methods: {
-			init: function () {
-				this.socket.emit('apis.joinAdminRoom', 'statistics', () => {});
-				this.socket.on('event:admin.statistics.success.units.minute', units => {
+		});
+
+		io.getSocket(socket => {
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			io.onConnect(() => this.init());
+		});
+	},
+	methods: {
+		init() {
+			this.socket.emit("apis.joinAdminRoom", "statistics", () => {});
+			this.socket.on(
+				"event:admin.statistics.success.units.minute",
+				units => {
 					this.successUnitsPerMinute = units;
 					this.minuteChart.data.datasets[0].data = units;
 					this.minuteChart.update();
-				});
-				this.socket.on('event:admin.statistics.error.units.minute', units => {
+				}
+			);
+			this.socket.on(
+				"event:admin.statistics.error.units.minute",
+				units => {
 					this.errorUnitsPerMinute = units;
 					this.minuteChart.data.datasets[1].data = units;
 					this.minuteChart.update();
-				});
-				this.socket.on('event:admin.statistics.info.units.minute', units => {
+				}
+			);
+			this.socket.on(
+				"event:admin.statistics.info.units.minute",
+				units => {
 					this.infoUnitsPerMinute = units;
 					this.minuteChart.data.datasets[2].data = units;
 					this.minuteChart.update();
-				});
-				this.socket.on('event:admin.statistics.success.units.hour', units => {
+				}
+			);
+			this.socket.on(
+				"event:admin.statistics.success.units.hour",
+				units => {
 					this.successUnitsPerHour = units;
 					this.hourChart.data.datasets[0].data = units;
 					this.hourChart.update();
-				});
-				this.socket.on('event:admin.statistics.error.units.hour', units => {
-					this.errorUnitsPerHour = units;
-					this.hourChart.data.datasets[1].data = units;
-					this.hourChart.update();
-				});
-				this.socket.on('event:admin.statistics.info.units.hour', units => {
-					this.infoUnitsPerHour = units;
-					this.hourChart.data.datasets[2].data = units;
-					this.hourChart.update();
-				});
-				this.socket.on('event:admin.statistics.logs', logs => {
-					this.logs = logs;
-				});
-			},
-			round: function(number) {
-				return Math.round(number);
-			}
-		},
-		ready: function () {
-			let _this = this;
-			var minuteCtx = document.getElementById("minuteChart");
-			var hourCtx = document.getElementById("hourChart");
-
-			_this.minuteChart = new Chart(minuteCtx, {
-				type: 'line',
-				data: {
-					labels: ["-10", "-9", "-8", "-7", "-6", "-5", "-4", "-3", "-2", "-1"],
-					datasets: [
-						{
-							label: 'Success',
-							data: [0,0,0,0,0,0,0,0,0,0],
-							backgroundColor: [
-								'rgba(75, 192, 192, 0.2)'
-							],
-							borderColor: [
-								'rgba(75, 192, 192, 1)'
-							],
-							borderWidth: 1
-						},
-						{
-							label: 'Error',
-							data: [0,0,0,0,0,0,0,0,0,0],
-							backgroundColor: [
-								'rgba(255, 99, 132, 0.2)'
-							],
-							borderColor: [
-								'rgba(255,99,132,1)'
-							],
-							borderWidth: 1
-						},
-						{
-							label: 'Info',
-							data: [0,0,0,0,0,0,0,0,0,0],
-							backgroundColor: [
-								'rgba(54, 162, 235, 0.2)'
-							],
-							borderColor: [
-								'rgba(54, 162, 235, 1)'
-							],
-							borderWidth: 1
-						}
-					]
-				},
-				options: {
-					title: {
-						display: true,
-						text: 'Logs per minute'
-					},
-					scales: {
-						yAxes: [{
-							ticks: {
-								beginAtZero: true,
-								stepSize: 1
-							}
-						}]
-					},
-					responsive: true,
-					maintainAspectRatio: false
 				}
+			);
+			this.socket.on("event:admin.statistics.error.units.hour", units => {
+				this.errorUnitsPerHour = units;
+				this.hourChart.data.datasets[1].data = units;
+				this.hourChart.update();
 			});
-
-			_this.hourChart = new Chart(hourCtx, {
-				type: 'line',
-				data: {
-					labels: ["-10", "-9", "-8", "-7", "-6", "-5", "-4", "-3", "-2", "-1"],
-					datasets: [
-						{
-							label: 'Success',
-							data: [0,0,0,0,0,0,0,0,0,0],
-							backgroundColor: [
-								'rgba(75, 192, 192, 0.2)'
-							],
-							borderColor: [
-								'rgba(75, 192, 192, 1)'
-							],
-							borderWidth: 1
-						},
-						{
-							label: 'Error',
-							data: [0,0,0,0,0,0,0,0,0,0],
-							backgroundColor: [
-								'rgba(255, 99, 132, 0.2)'
-							],
-							borderColor: [
-								'rgba(255,99,132,1)'
-							],
-							borderWidth: 1
-						},
-						{
-							label: 'Info',
-							data: [0,0,0,0,0,0,0,0,0,0],
-							backgroundColor: [
-								'rgba(54, 162, 235, 0.2)'
-							],
-							borderColor: [
-								'rgba(54, 162, 235, 1)'
-							],
-							borderWidth: 1
-						}
-					]
-				},
-				options: {
-					title: {
-						display: true,
-						text: 'Logs per hour'
-					},
-					scales: {
-						yAxes: [{
-							ticks: {
-								beginAtZero: true,
-								stepSize: 1
-							}
-						}]
-					},
-					responsive: true,
-					maintainAspectRatio: false
-				}
+			this.socket.on("event:admin.statistics.info.units.hour", units => {
+				this.infoUnitsPerHour = units;
+				this.hourChart.data.datasets[2].data = units;
+				this.hourChart.update();
 			});
-
-
-			io.getSocket(socket => {
-				_this.socket = socket;
-				if (_this.socket.connected) _this.init();
-				io.onConnect(() => _this.init());
+			this.socket.on("event:admin.statistics.logs", logs => {
+				this.logs = logs;
 			});
+		},
+		round(number) {
+			return Math.round(number);
 		}
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	body { font-family: 'Roboto', sans-serif; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.user-avatar {
-		display: block;
-		max-width: 50px;
-		margin: 0 auto;
-	}
+body {
+	font-family: "Roboto", sans-serif;
+}
+
+.user-avatar {
+	display: block;
+	max-width: 50px;
+	margin: 0 auto;
+}
 
-	td { vertical-align: middle; }
+td {
+	vertical-align: middle;
+}
 
-	.is-primary:focus { background-color: #029ce3 !important; }
+.is-primary:focus {
+	background-color: $primary-color !important;
+}
 </style>

+ 113 - 85
frontend/components/Admin/Users.vue

@@ -1,100 +1,128 @@
 <template>
-	<div class='container'>
-		<table class='table is-striped'>
-			<thead>
-			<tr>
-				<td>Profile Picture</td>
-				<td>User ID</td>
-				<td>GitHub ID</td>
-				<td>Password</td>
-				<td>Username</td>
-				<td>Role</td>
-				<td>Email Address</td>
-				<td>Email Verified</td>
-				<td>Likes</td>
-				<td>Dislikes</td>
-				<td>Songs Requested</td>
-				<td>Options</td>
-			</tr>
-			</thead>
-			<tbody>
-			<tr v-for='(index, user) in users' track-by='$index'>
-				<td>
-					<img class='user-avatar' src='/assets/notes-transparent.png'>
-				</td>
-				<td>{{ user._id }}</td>
-				<td v-if='user.services.github'>{{ user.services.github.id }}</td>
-				<td v-else>Not Linked</td>
-				<td v-if='user.hasPassword'>Yes</td>
-				<td v-else>Not Linked</td>
-				<td>{{ user.username }}</td>
-				<td>{{ user.role }}</td>
-				<td>{{ user.email.address }}</td>
-				<td>{{ user.email.verified }}</td>
-				<td>{{ user.liked.length }}</td>
-				<td>{{ user.disliked.length }}</td>
-				<td>{{ user.songsRequested }}</td>
-				<td>
-					<button class='button is-primary' @click='edit(user)'>Edit</button>
-				</td>
-			</tr>
-			</tbody>
-		</table>
+	<div>
+		<metadata title="Admin | Users" />
+		<div class="container">
+			<table class="table is-striped">
+				<thead>
+					<tr>
+						<td>Profile Picture</td>
+						<td>User ID</td>
+						<td>GitHub ID</td>
+						<td>Password</td>
+						<td>Username</td>
+						<td>Role</td>
+						<td>Email Address</td>
+						<td>Email Verified</td>
+						<td>Likes</td>
+						<td>Dislikes</td>
+						<td>Songs Requested</td>
+						<td>Options</td>
+					</tr>
+				</thead>
+				<tbody>
+					<tr v-for="(user, index) in users" :key="index">
+						<td>
+							<img
+								class="user-avatar"
+								src="/assets/notes-transparent.png"
+							/>
+						</td>
+						<td>{{ user._id }}</td>
+						<td v-if="user.services.github">
+							{{ user.services.github.id }}
+						</td>
+						<td v-else>
+							Not Linked
+						</td>
+						<td v-if="user.hasPassword">
+							Yes
+						</td>
+						<td v-else>
+							Not Linked
+						</td>
+						<td>{{ user.username }}</td>
+						<td>{{ user.role }}</td>
+						<td>{{ user.email.address }}</td>
+						<td>{{ user.email.verified }}</td>
+						<td>{{ user.liked.length }}</td>
+						<td>{{ user.disliked.length }}</td>
+						<td>{{ user.songsRequested }}</td>
+						<td>
+							<button
+								class="button is-primary"
+								@click="edit(user)"
+							>
+								Edit
+							</button>
+						</td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+		<edit-user v-if="modals.editUser" />
 	</div>
-	<edit-user v-show='modals.editUser'></edit-user>
 </template>
 
 <script>
-	import EditUser from '../Modals/EditUser.vue';
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
 
-	export default {
-		components: { EditUser },
-		data() {
-			return {
-				users: [],
-				modals: { editUser: false }
-			}
-		},
-		methods: {
-			toggleModal: function () {
-				this.modals.editUser = !this.modals.editUser;
-			},
-			edit: function (user) {
-				this.$broadcast('editUser', user);
-			},
-			init: function () {
-				let _this = this;
-				_this.socket.emit('users.index', result => {
-					if (result.status === 'success') _this.users = result.data;
-				});
-				_this.socket.emit('apis.joinAdminRoom', 'users', () => {});
-				_this.socket.on('event:user.username.changed', username => {
-					_this.$parent.$parent.username = username;
-				});
-			}
+import EditUser from "../Modals/EditUser.vue";
+import io from "../../io";
+
+export default {
+	components: { EditUser },
+	data() {
+		return {
+			users: []
+		};
+	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.admin
+		})
+	},
+	methods: {
+		edit(user) {
+			this.editUser(user);
+			this.openModal({ sector: "admin", modal: "editUser" });
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket(socket => {
-				_this.socket = socket;
-				if (_this.socket.connected) _this.init();
-				io.onConnect(() => _this.init());
+		init() {
+			this.socket.emit("users.index", result => {
+				if (result.status === "success") this.users = result.data;
 			});
-		}
+			this.socket.emit("apis.joinAdminRoom", "users", () => {});
+		},
+		...mapActions("admin/users", ["editUser"]),
+		...mapActions("modals", ["openModal"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			if (this.socket.connected) this.init();
+			io.onConnect(() => this.init());
+		});
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	body { font-family: 'Roboto', sans-serif; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.user-avatar {
-		display: block;
-		max-width: 50px;
-		margin: 0 auto;
-	}
+body {
+	font-family: "Roboto", sans-serif;
+}
+
+.user-avatar {
+	display: block;
+	max-width: 50px;
+	margin: 0 auto;
+}
 
-	td { vertical-align: middle; }
+td {
+	vertical-align: middle;
+}
 
-	.is-primary:focus { background-color: #029ce3 !important; }
+.is-primary:focus {
+	background-color: $primary-color !important;
+}
 </style>

+ 138 - 28
frontend/components/MainFooter.vue

@@ -1,47 +1,157 @@
 <template>
-	<footer class='footer'>
-		<div class='container'>
-			<div class='content has-text-centered'>
-				<p>
-					© Copyright Musare 2015 - 2018
-				</p>
-				<p>
-					<a class='icon' href='https://github.com/Musare/MusareNode' target='_blank' title='GitHub Repository'>
-						<img src='/assets/social/github.svg'/>
+	<footer class="footer">
+		<div class="container">
+			<div class="content has-text-centered">
+				<p class="socialIcons">
+					<a
+						class="icon"
+						:href="`${this.socialLinks.github}`"
+						target="_blank"
+						title="GitHub Repository"
+					>
+						<img src="/assets/social/github.svg" />
 					</a>
-					<a class='icon' href='https://twitter.com/MusareApp' target='_blank' title='Twitter Account'>
-						<img src='/assets/social/twitter.svg'/>
+					<a
+						class="icon"
+						:href="`${this.socialLinks.twitter}`"
+						target="_blank"
+						title="Twitter Account"
+					>
+						<img src="/assets/social/twitter.svg" />
 					</a>
-					<a class='icon' href='https://www.facebook.com/MusareMusic/' target='_blank' title='Facebook Page'>
-						<img src='/assets/social/facebook.svg'/>
+					<a
+						class="icon"
+						:href="`${this.socialLinks.facebook}`"
+						target="_blank"
+						title="Facebook Page"
+					>
+						<img src="/assets/social/facebook.svg" />
 					</a>
-					<a class='icon' href='https://discord.gg/Y5NxYGP' target='_blank' title='Discord Server'>
-						<img src='/assets/social/discord.svg'/>
+					<a
+						class="icon"
+						:href="`${this.socialLinks.discord}`"
+						target="_blank"
+						title="Discord Server"
+					>
+						<img src="/assets/social/discord.svg" />
 					</a>
 				</p>
+				<a href="/"
+					><img
+						class="musareFooterLogo"
+						src="/assets/blue_wordmark.png"
+						alt="Musare"
+				/></a>
+				<p class="footerLinks">
+					<router-link title="About Musare" to="/about">
+						About
+					</router-link>
+					<router-link title="The Musare Team" to="/team">
+						Team
+					</router-link>
+					<router-link title="News" to="/news">
+						News
+					</router-link>
+				</p>
+				<p>
+					© Copyright Musare 2015 - 2019
+				</p>
 			</div>
 		</div>
 	</footer>
 </template>
 
-<style lang='scss' scoped>
-	.content a:not(.button) { border: 0; }
+<script>
+export default {
+	data() {
+		return {
+			socialLinks: {
+				github: "",
+				twitter: "",
+				facebook: "",
+				discord: ""
+			}
+		};
+	},
+	mounted() {
+		lofig.get("siteSettings.socialLinks").then(socialLinks => {
+			this.socialLinks = socialLinks;
+		});
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.content a:not(.button) {
+	border: 0;
+}
 
-	.content {
-		display: flex;
-		align-items: center;
-		flex-direction: column;
+.content {
+	display: flex;
+	align-items: center;
+	flex-direction: column;
+}
+
+.footer {
+	position: absolute;
+	right: 0;
+	bottom: 0;
+	left: 0;
+	height: 240px;
+	padding: 40px 20px 40px;
+	border-radius: 33% 33% 0% 0% / 7% 7% 0% 0%;
+	box-shadow: 0 4px 8px 0 rgba(3, 169, 244, 0.65),
+		0 6px 20px 0 rgba(3, 169, 244, 0.4);
+	background-color: $white;
+	width: 100%;
+
+	.musareFooterLogo {
+		display: block;
+		margin-left: auto;
+		margin-right: auto;
+		margin-bottom: 15px;
+		width: 200px;
 	}
 
-	.icon:hover { color: #90298C !important; }
+	.socialIcons {
+		.icon {
+			height: 28px;
+			line-height: 28px;
+			width: 28px;
+		}
+	}
 
-	.nightMode {
-		.footer {
-			background-color: rgb(51, 51, 51);
-			.content {
-				color: #e6e6e6;
-			}
+	.footerLinks {
+		:not(:last-child) {
+			border-right: solid 1px $primary-color;
 		}
+		a {
+			padding: 0 5px;
+			font-size: 18px;
+			color: $primary-color;
+		}
+		a:hover {
+			color: $primary-color;
+			text-decoration: underline;
+		}
+	}
+}
 
+@media only screen and (min-width: 992px) {
+	.footer {
+		height: 180px;
+		.socialIcons {
+			left: 0;
+			top: 35px;
+			position: absolute;
+		}
+		.footerLinks {
+			right: 0;
+			top: 35px;
+			position: absolute;
+		}
 	}
+}
 </style>

+ 131 - 118
frontend/components/MainHeader.vue

@@ -1,157 +1,170 @@
 <template>
 	<nav class="nav is-info">
 		<div class="nav-left">
-			<a class="nav-item is-brand" href="#" v-link="{ path: '/' }">
-				Musare
-			</a>
+			<router-link class="nav-item is-brand" to="/">
+				<img
+					:src="`${this.siteSettings.logo_white}`"
+					:alt="`${this.siteSettings.siteName}` || `Musare`"
+				/>
+			</router-link>
 		</div>
 
-		<span class="nav-toggle" :class="{ 'is-active': isMobile }" @click="isMobile = !isMobile">
-			<span></span>
-			<span></span>
-			<span></span>
+		<span
+			class="nav-toggle"
+			:class="{ 'is-active': isMobile }"
+			@click="isMobile = !isMobile"
+		>
+			<span />
+			<span />
+			<span />
 		</span>
 
 		<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
-			<a class="nav-item is-tab admin" href="#" v-link="{ path: '/admin' }" v-if="$parent.$parent.role === 'admin'">
+			<router-link
+				v-if="role === 'admin'"
+				class="nav-item is-tab admin"
+				to="/admin"
+			>
 				<strong>Admin</strong>
-			</a>
-			<!--a class="nav-item is-tab" href="#">
-				About
-			</a-->
-			<a class="nav-item is-tab" href="#" v-link="{ path: '/team' }">
-				Team
-			</a>
-			<a class="nav-item is-tab" href="#" v-link="{ path: '/about' }">
-				About
-			</a>
-			<a class="nav-item is-tab" href="#" v-link="{ path: '/news' }">
-				News
-			</a>
-			<span class="grouped" v-if="$parent.$parent.loggedIn">
-				<a class="nav-item is-tab" href="#" v-link="{ path: '/u/' + $parent.$parent.username }">
+			</router-link>
+			<span v-if="loggedIn" class="grouped">
+				<router-link
+					class="nav-item is-tab"
+					:to="{
+						name: 'profile',
+						params: { username }
+					}"
+				>
 					Profile
-				</a>
-				<a class="nav-item is-tab" href="#" v-link="{ path: '/settings' }">
-					Settings
-				</a>
-				<a class="nav-item is-tab" href="#" @click="$parent.$parent.logout()">
-					Logout
-				</a>
+				</router-link>
+				<router-link class="nav-item is-tab" to="/settings"
+					>Settings</router-link
+				>
+				<a class="nav-item is-tab" href="#" @click="logout()">Logout</a>
 			</span>
-			<span class="grouped" v-else>
-				<a class="nav-item" href="#" @click="toggleModal('login')">
-					Login
-				</a>
-				<a class="nav-item" href="#" @click="toggleModal('register')">
-					Register
-				</a>
+			<span v-else class="grouped">
+				<a
+					class="nav-item"
+					href="#"
+					@click="
+						openModal({
+							sector: 'header',
+							modal: 'login'
+						})
+					"
+					>Login</a
+				>
+				<a
+					class="nav-item"
+					href="#"
+					@click="
+						openModal({
+							sector: 'header',
+							modal: 'register'
+						})
+					"
+					>Register</a
+				>
 			</span>
 		</div>
 	</nav>
 </template>
 
 <script>
-	export default {
-		data() {
-			return {
-				isMobile: false
+import { mapState, mapActions } from "vuex";
+
+export default {
+	data() {
+		return {
+			isMobile: false,
+			frontendDomain: "",
+			siteSettings: {
+				logo: "",
+				siteName: ""
 			}
-		},
-		methods: {
-			toggleModal: function (type) {
-				this.$dispatch('toggleModal', type);
-			}
-		}
+		};
+	},
+	mounted() {
+		lofig.get("frontendDomain").then(frontendDomain => {
+			this.frontendDomain = frontendDomain;
+		});
+
+		lofig.get("siteSettings").then(siteSettings => {
+			this.siteSettings = siteSettings;
+		});
+	},
+	computed: mapState({
+		modals: state => state.modals.modals.header,
+		role: state => state.user.auth.role,
+		loggedIn: state => state.user.auth.loggedIn,
+		username: state => state.user.auth.username
+	}),
+	methods: {
+		...mapActions("modals", ["openModal"]),
+		...mapActions("user/auth", ["logout"])
 	}
+};
 </script>
 
 <style lang="scss" scoped>
-	.nav {
-		background-color: #03a9f4;
-		height: 64px;
-
-		.nav-menu.is-active {
-			.nav-item {
-				color: #333;
-
-				&:hover {
-					color: #333;
-				}
-			}
-		}
+@import "styles/global.scss";
 
-		.nav-toggle {
-			height: 64px;
-
-			&.is-active span {
-				background-color: #333;
-			}
-		}
-
-		.is-brand {
-			font-size: 2.1rem !important;
-			line-height: 64px !important;
-			padding: 0 20px;
-		}
+.nav {
+	background-color: $primary-color;
+	height: 64px;
+	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
 
+	.nav-menu.is-active {
 		.nav-item {
-			font-size: 15px;
-			color: hsl(0, 0%, 100%);
+			color: $dark-grey-2;
 
 			&:hover {
-				color: hsl(0, 0%, 100%);
+				color: $dark-grey-2;
 			}
 		}
-		.admin {
-			color: #424242;
-		}
 	}
-	.grouped {
-		margin: 0;
-		display: flex;
-		text-decoration: none;
+
+	a.nav-item.is-tab:hover {
+		border-bottom: none;
+		border-top: solid 1px $white;
+		padding-top: 9px;
 	}
-	.nightMode {
-		.nav {
-			background-color: #012332;
-			height: 64px;
-
-			.nav-menu.is-active {
-				.nav-item {
-					color: #333;
-
-					&:hover {
-						color: #333;
-					}
-				}
-			}
 
-			.nav-toggle {
-				height: 64px;
+	.nav-toggle {
+		height: 64px;
 
-				&.is-active span {
-					background-color: #333;
-				}
-			}
+		&.is-active span {
+			background-color: $dark-grey-2;
+		}
+	}
 
-			.is-brand {
-				font-size: 2.1rem !important;
-				line-height: 64px !important;
-				padding: 0 20px;
-			}
+	.is-brand {
+		font-size: 2.1rem !important;
+		line-height: 38px !important;
+		padding: 0 20px;
+		font-family: Pacifico, cursive;
 
-			.nav-item {
-				font-size: 15px;
-				color: hsl(0, 0%, 100%);
+		img {
+			max-height: 38px;
+			color: $musareBlue;
+		}
+	}
 
-				&:hover {
-					color: hsl(0, 0%, 100%);
-				}
-			}
-			.admin strong {
-				color: #03a9f4;
-			}
+	.nav-item {
+		font-size: 17px;
+		color: $white;
+
+		&:hover {
+			color: $white;
 		}
 	}
+	.admin strong {
+		color: #9d42b1;
+	}
+}
+.grouped {
+	margin: 0;
+	display: flex;
+	text-decoration: none;
+}
 </style>

+ 122 - 96
frontend/components/Modals/AddSongToPlaylist.vue

@@ -1,124 +1,150 @@
 <template>
-	<modal title='Add Song To Playlist'>
-		<div slot='body'>
-			<h4 class="songTitle">{{ $parent.currentSong.title }}</h4>
-			<h5 class="songArtist">{{ $parent.currentSong.artists }}</h5>
+	<modal title="Add Song To Playlist">
+		<template v-slot:body>
+			<h4 class="songTitle">
+				{{ currentSong.title }}
+			</h4>
+			<h5 class="songArtist">
+				{{ currentSong.artists }}
+			</h5>
 			<aside class="menu">
 				<p class="menu-label">
 					Playlists
 				</p>
 				<ul class="menu-list">
-					<li v-for='playlist in playlistsArr'>
-						<div class='playlist'>
-							<span class='icon is-small' @click='removeSongFromPlaylist(playlist._id)' v-if='playlists[playlist._id].hasSong'>
+					<li v-for="(playlist, index) in playlistsArr" :key="index">
+						<div class="playlist">
+							<span
+								v-if="playlists[playlist._id].hasSong"
+								class="icon is-small"
+								@click="removeSongFromPlaylist(playlist._id)"
+							>
 								<i class="material-icons">playlist_add_check</i>
 							</span>
-							<span class='icon' @click='addSongToPlaylist(playlist._id)' v-else>
+							<span
+								v-else
+								class="icon"
+								@click="addSongToPlaylist(playlist._id)"
+							>
 								<i class="material-icons">playlist_add</i>
 							</span>
 							{{ playlist.displayName }}
 						</div>
 					</li>
 				</ul>
-				</aside>
-		</div>
+			</aside>
+		</template>
 	</modal>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import io from '../../io';
-	import auth from '../../auth';
+import { mapState } from "vuex";
 
-	export default {
-		data() {
-			return {
-				playlists: {},
-				playlistsArr: [],
-				songId: null,
-				song: null
-			}
-		},
-		methods: {
-			addSongToPlaylist: function (playlistId) {
-				let _this = this;
-				this.socket.emit('playlists.addSongToPlaylist', this.$parent.currentSong.songId, playlistId, res => {
-					Toast.methods.addToast(res.message, 4000);
-					if (res.status === 'success') {
-						_this.playlists[playlistId].songs.push(_this.song);
-					}
-					_this.recalculatePlaylists();
-					//this.$parent.modals.addSongToPlaylist = false;
-				});
-			},
-			removeSongFromPlaylist: function (playlistId) {
-				let _this = this;
-				this.socket.emit('playlists.removeSongFromPlaylist', _this.songId, playlistId, res => {
-					Toast.methods.addToast(res.message, 4000);
-					if (res.status === 'success') {
-						_this.playlists[playlistId].songs.forEach((song, index) => {
-							if (song.songId === _this.songId) _this.playlists[playlistId].songs.splice(index, 1);
-						});
+import Toast from "toasters";
+import Modal from "./Modal.vue";
+import io from "../../io";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			playlists: {},
+			playlistsArr: [],
+			songId: null,
+			song: null
+		};
+	},
+	mounted() {
+		this.songId = this.currentSong.songId;
+		this.song = this.currentSong;
+		io.getSocket(socket => {
+			this.socket = socket;
+			this.socket.emit("playlists.indexForUser", res => {
+				if (res.status === "success") {
+					res.data.forEach(playlist => {
+						this.playlists[playlist._id] = playlist;
+					});
+					this.recalculatePlaylists();
+				}
+			});
+		});
+	},
+	computed: {
+		...mapState("station", {
+			currentSong: state => state.currentSong
+		})
+	},
+	methods: {
+		addSongToPlaylist(playlistId) {
+			this.socket.emit(
+				"playlists.addSongToPlaylist",
+				this.currentSong.songId,
+				playlistId,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+					if (res.status === "success") {
+						this.playlists[playlistId].songs.push(this.song);
 					}
-					_this.recalculatePlaylists();
-					//this.$parent.modals.addSongToPlaylist = false;
-				});
-			},
-			recalculatePlaylists: function() {
-				let _this = this;
-				_this.playlistsArr = Object.values(_this.playlists).map((playlist) => {
-					let hasSong = false;
-					for (let i = 0; i < playlist.songs.length; i++) {
-						if (playlist.songs[i].songId === _this.songId) {
-							hasSong = true;
-						}
+					this.recalculatePlaylists();
+				}
+			);
+		},
+		removeSongFromPlaylist(playlistId) {
+			this.socket.emit(
+				"playlists.removeSongFromPlaylist",
+				this.songId,
+				playlistId,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+					if (res.status === "success") {
+						this.playlists[playlistId].songs.forEach(
+							(song, index) => {
+								if (song.songId === this.songId)
+									this.playlists[playlistId].songs.splice(
+										index,
+										1
+									);
+							}
+						);
 					}
-					playlist.hasSong = hasSong;
-					_this.playlists[playlist._id] = playlist;
-					return playlist;
-				});
-			}
+					this.recalculatePlaylists();
+				}
+			);
 		},
-		ready: function () {
-			let _this = this;
-			this.songId = this.$parent.currentSong.songId;
-			this.song = this.$parent.currentSong;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				_this.socket.emit('playlists.indexForUser', res => {
-					if (res.status === 'success') {
-						res.data.forEach((playlist) => {
-							_this.playlists[playlist._id] = playlist;
-						});
-						_this.recalculatePlaylists();
+		recalculatePlaylists() {
+			this.playlistsArr = Object.values(this.playlists).map(playlist => {
+				let hasSong = false;
+				for (let i = 0; i < playlist.songs.length; i += 1) {
+					if (playlist.songs[i].songId === this.songId) {
+						hasSong = true;
 					}
-				});
+				}
+
+				playlist.hasSong = hasSong; // eslint-disable-line no-param-reassign
+				this.playlists[playlist._id] = playlist;
+				return playlist;
 			});
-		},
-		events: {
-			closeModal: function () {
-				this.$parent.modals.addSongToPlaylist = !this.$parent.modals.addSongToPlaylist;
-			}
-		},
-		components: { Modal }
+		}
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.icon.is-small {
-		margin-right: 10px !important;
-	}
-	.songTitle {
-		font-size: 22px;
-		padding: 0 10px;
-	}
-	.songArtist {
-		font-size: 19px;
-		font-weight: 200;
-		padding: 0 10px;
-	}
-	.menu-label {
-		font-size: 16px;
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.icon.is-small {
+	margin-right: 10px !important;
+}
+.songTitle {
+	font-size: 22px;
+	padding: 0 10px;
+}
+.songArtist {
+	font-size: 19px;
+	font-weight: 200;
+	padding: 0 10px;
+}
+.menu-label {
+	font-size: 16px;
+}
 </style>

+ 200 - 109
frontend/components/Modals/AddSongToQueue.vue

@@ -1,13 +1,30 @@
 <template>
-	<modal title='Add Song To Queue'>
-		<div slot='body'>
-			<aside class='menu' v-if='$parent.$parent.loggedIn && $parent.type === "community"'>
-				<ul class='menu-list'>
-					<li v-for='playlist in playlists' track-by='$index'>
-						<a href='#' target='_blank' @click='$parent.editPlaylist(playlist._id)'>{{ playlist.displayName }}</a>
-						<div class='controls'>
-							<a href='#' @click='selectPlaylist(playlist._id)' v-if="!isPlaylistSelected(playlist._id)"><i class='material-icons'>panorama_fish_eye</i></a>
-							<a href='#' @click='unSelectPlaylist()' v-if="isPlaylistSelected(playlist._id)"><i class='material-icons'>lens</i></a>
+	<modal title="Add Song To Queue">
+		<div slot="body">
+			<aside class="menu" v-if="loggedIn && station.type === 'community'">
+				<ul class="menu-list">
+					<li v-for="(playlist, index) in playlists" :key="index">
+						<a
+							href="#"
+							target="_blank"
+							v-on:click="editPlaylist(playlist._id)"
+							>{{ playlist.displayName }}</a
+						>
+						<div class="controls">
+							<a
+								href="#"
+								v-on:click="selectPlaylist(playlist._id)"
+								v-if="!isPlaylistSelected(playlist._id)"
+							>
+								<i class="material-icons">panorama_fish_eye</i>
+							</a>
+							<a
+								href="#"
+								v-on:click="unSelectPlaylist()"
+								v-if="isPlaylistSelected(playlist._id)"
+							>
+								<i class="material-icons">lens</i>
+							</a>
 						</div>
 					</li>
 				</ul>
@@ -15,27 +32,59 @@
 			</aside>
 			<div class="control is-grouped">
 				<p class="control is-expanded">
-					<input class="input" type="text" placeholder="YouTube Query" v-model='querySearch' autofocus @keyup.enter='submitQuery()'>
+					<input
+						class="input"
+						type="text"
+						placeholder="YouTube Query"
+						v-model="querySearch"
+						autofocus
+						@keyup.enter="submitQuery()"
+					/>
 				</p>
 				<p class="control">
-					<a class="button is-info" @click="submitQuery()" href='#'>
-						Search
-					</a>
+					<a
+						class="button is-info"
+						v-on:click="submitQuery()"
+						href="#"
+						>Search</a
+					>
 				</p>
 			</div>
-			<table class="table">
+			<div class="control is-grouped" v-if="station.type === 'official'">
+				<p class="control is-expanded">
+					<input
+						class="input"
+						type="text"
+						placeholder="YouTube Playlist URL"
+						v-model="importQuery"
+						@keyup.enter="importPlaylist()"
+					/>
+				</p>
+				<p class="control">
+					<a
+						class="button is-info"
+						v-on:click="importPlaylist()"
+						href="#"
+						>Import</a
+					>
+				</p>
+			</div>
+			<table class="table" v-if="queryResults.length > 0">
 				<tbody>
-				<tr v-for="result in queryResults">
-					<td>
-						<img :src="result.thumbnail" />
-					</td>
-					<td>{{ result.title }}</td>
-					<td>
-						<a class="button is-success" @click="addSongToQueue(result.id)" href='#'>
-							Add
-						</a>
-					</td>
-				</tr>
+					<tr v-for="(result, index) in queryResults" :key="index">
+						<td>
+							<img :src="result.thumbnail" />
+						</td>
+						<td>{{ result.title }}</td>
+						<td>
+							<a
+								class="button is-success"
+								v-on:click="addSongToQueue(result.id)"
+								href="#"
+								>Add</a
+							>
+						</td>
+					</tr>
 				</tbody>
 			</table>
 		</div>
@@ -43,103 +92,145 @@
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import io from '../../io';
-	import auth from '../../auth';
+import { mapState, mapActions } from "vuex";
+
+import Toast from "toasters";
+import Modal from "./Modal.vue";
+import io from "../../io";
 
-	export default {
-		data() {
-			return {
-				querySearch: '',
-				queryResults: [],
-				playlists: [],
-				privatePlaylistQueueSelected: null
+export default {
+	data() {
+		return {
+			querySearch: "",
+			queryResults: [],
+			playlists: [],
+			privatePlaylistQueueSelected: null,
+			importQuery: ""
+		};
+	},
+	computed: mapState({
+		loggedIn: state => state.user.auth.loggedIn,
+		station: state => state.station.station
+	}),
+	methods: {
+		isPlaylistSelected(playlistId) {
+			return this.privatePlaylistQueueSelected === playlistId;
+		},
+		selectPlaylist(playlistId) {
+			if (this.station.type === "community") {
+				this.privatePlaylistQueueSelected = playlistId;
+				this.$parent.privatePlaylistQueueSelected = playlistId;
+				this.$parent.addFirstPrivatePlaylistSongToQueue();
 			}
 		},
-		methods: {
-			isPlaylistSelected: function(playlistId) {
-				return this.privatePlaylistQueueSelected === playlistId;
-			},
-			selectPlaylist: function (playlistId) {
-				let _this = this;
-				if (_this.$parent.type === 'community') {
-					_this.privatePlaylistQueueSelected = playlistId;
-					_this.$parent.privatePlaylistQueueSelected = playlistId;
-					_this.$parent.addFirstPrivatePlaylistSongToQueue();
-				}
-			},
-			unSelectPlaylist: function () {
-				let _this = this;
-				if (_this.$parent.type === 'community') {
-					_this.privatePlaylistQueueSelected = null;
-					_this.$parent.privatePlaylistQueueSelected = null;
-				}
-			},
-			addSongToQueue: function (songId) {
-				let _this = this;
-				if (_this.$parent.type === 'community') {
-					_this.socket.emit('stations.addToQueue', _this.$parent.station._id, songId, data => {
-						if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
-						else Toast.methods.addToast(`${data.message}`, 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);
-					});
-				}
-			},
-			submitQuery: function () {
-				let _this = this;
-				let query = _this.querySearch;
-				if (query.indexOf('&index=') !== -1) {
-					query = query.split('&index=');
-					query.pop();
-					query = query.join('');
-				}
-				if (query.indexOf('&list=') !== -1) {
-					query = query.split('&list=');
-					query.pop();
-					query = query.join('');
-				}
-				_this.socket.emit('apis.searchYoutube', query, results => {
-					results = results.data;
-					_this.queryResults = [];
-					for (let i = 0; i < results.items.length; i++) {
-						_this.queryResults.push({
-							id: results.items[i].id.videoId,
-							url: `https://www.youtube.com/watch?v=${this.id}`,
-							title: results.items[i].snippet.title,
-							thumbnail: results.items[i].snippet.thumbnails.default.url
-						});
+		unSelectPlaylist() {
+			if (this.station.type === "community") {
+				this.privatePlaylistQueueSelected = null;
+				this.$parent.privatePlaylistQueueSelected = null;
+			}
+		},
+		addSongToQueue(songId) {
+			console.log(this.station.type);
+			if (this.station.type === "community") {
+				this.socket.emit(
+					"stations.addToQueue",
+					this.station._id,
+					songId,
+					data => {
+						if (data.status !== "success")
+							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")
+						new Toast({
+							content: `Error: ${data.message}`,
+							timeout: 8000
+						});
+					else
+						new Toast({
+							content: `${data.message}`,
+							timeout: 4000
+						});
 				});
 			}
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				_this.socket.emit('playlists.indexForUser', res => {
-					if (res.status === 'success') _this.playlists = res.data;
-				});
-				_this.privatePlaylistQueueSelected = _this.$parent.privatePlaylistQueueSelected;
+		importPlaylist() {
+			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 => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
 		},
-		events: {
-			closeModal: function () {
-				this.$parent.modals.addSongToQueue = !this.$parent.modals.addSongToQueue;
+		submitQuery() {
+			let query = this.querySearch;
+			if (query.indexOf("&index=") !== -1) {
+				query = query.split("&index=");
+				query.pop();
+				query = query.join("");
+			}
+			if (query.indexOf("&list=") !== -1) {
+				query = query.split("&list=");
+				query.pop();
+				query = query.join("");
 			}
+			this.socket.emit("apis.searchYoutube", query, res => {
+				// check for error
+				const { data } = res;
+				this.queryResults = [];
+				for (let i = 0; i < data.items.length; i += 1) {
+					this.queryResults.push({
+						id: data.items[i].id.videoId,
+						url: `https://www.youtube.com/watch?v=${this.id}`,
+						title: data.items[i].snippet.title,
+						thumbnail: data.items[i].snippet.thumbnails.default.url
+					});
+				}
+			});
 		},
-		components: { Modal }
-	}
+		...mapActions("user/playlists", ["editPlaylist"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			this.socket.emit("playlists.indexForUser", res => {
+				if (res.status === "success") this.playlists = res.data;
+			});
+			this.privatePlaylistQueueSelected = this.$parent.privatePlaylistQueueSelected;
+		});
+	},
+	components: { Modal }
+};
 </script>
 
-<style type='scss' scoped>
-	tr td {
-		vertical-align: middle;
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+tr td {
+	vertical-align: middle;
 
-		img { width: 55px; }
+	img {
+		width: 55px;
 	}
+}
+
+.table {
+	margin-bottom: 0;
+}
 </style>

+ 123 - 69
frontend/components/Modals/CreateCommunityStation.vue

@@ -1,91 +1,145 @@
 <template>
-	<modal title='Create Community Station'>
-		<div slot='body'>
+	<modal title="Create Community Station">
+		<template v-slot:body>
 			<!-- validation to check if exists http://bulma.io/documentation/elements/form/ -->
-			<label class='label'>Name (unique lowercase station id)</label>
-			<p class='control'>
-				<input class='input' type='text' placeholder='Name...' v-model='newCommunity.name' autofocus>
+			<label class="label">Name (unique lowercase station id)</label>
+			<p class="control">
+				<input
+					v-model="newCommunity.name"
+					class="input"
+					type="text"
+					placeholder="Name..."
+					autofocus
+				/>
 			</p>
-			<label class='label'>Display Name</label>
-			<p class='control'>
-				<input class='input' type='text' placeholder='Display name...' v-model='newCommunity.displayName'>
+			<label class="label">Display Name</label>
+			<p class="control">
+				<input
+					v-model="newCommunity.displayName"
+					class="input"
+					type="text"
+					placeholder="Display name..."
+				/>
 			</p>
-			<label class='label'>Description</label>
-			<p class='control'>
-				<input class='input' type='text' placeholder='Description...' v-model='newCommunity.description' @keyup.enter="submitModal()">
+			<label class="label">Description</label>
+			<p class="control">
+				<input
+					v-model="newCommunity.description"
+					class="input"
+					type="text"
+					placeholder="Description..."
+					@keyup.enter="submitModal()"
+				/>
 			</p>
-		</div>
-		<div slot='footer'>
-			<a class='button is-primary' @click='submitModal()'>Create</a>
-		</div>
+		</template>
+		<template v-slot:footer>
+			<a class="button is-primary" v-on:click="submitModal()">Create</a>
+		</template>
 	</modal>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import io from '../../io';
-	import validation from '../../validation';
+import { mapActions } from "vuex";
 
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				newCommunity: {
-					name: '',
-					displayName: '',
-					description: ''
-				}
+import Toast from "toasters";
+import Modal from "./Modal.vue";
+import io from "../../io";
+import validation from "../../validation";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			newCommunity: {
+				name: "",
+				displayName: "",
+				description: ""
 			}
-		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-			});
-		},
-		methods: {
-			toggleModal: function () {
-				this.$parent.modals.createCommunityStation = !this.$parent.modals.createCommunityStation;
-			},
-			submitModal: function () {
-				const name = this.newCommunity.name;
-				const displayName = this.newCommunity.displayName;
-				const description = this.newCommunity.description;
-				if (!name || !displayName || !description) return Toast.methods.addToast('Please fill in all fields', 8000);
+		};
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+	},
+	methods: {
+		submitModal() {
+			const { name, displayName, description } = this.newCommunity;
 
-				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);
+			if (!name || !displayName || !description)
+				return new Toast({
+					content: "Please fill in all fields",
+					timeout: 8000
+				});
 
+			if (!validation.isLength(name, 2, 16))
+				return new Toast({
+					content: "Name must have between 2 and 16 characters.",
+					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);
+			if (!validation.regex.az09_.test(name))
+				return new Toast({
+					content:
+						"Invalid name format. Allowed characters: a-z, 0-9 and _.",
+					timeout: 8000
+				});
 
+			if (!validation.isLength(displayName, 2, 32))
+				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);
-				let characters = description.split("");
-				characters = characters.filter(function(character) {
-					return character.charCodeAt(0) === 21328;
+			if (!validation.isLength(description, 2, 200))
+				return new Toast({
+					content:
+						"Description must have between 2 and 200 characters.",
+					timeout: 8000
 				});
-				if (characters.length !== 0) return Toast.methods.addToast('Invalid description format. Swastika\'s are not allowed.', 8000);
 
+			let characters = description.split("");
+
+			characters = characters.filter(character => {
+				return character.charCodeAt(0) === 21328;
+			});
 
-				this.socket.emit('stations.create', {
-					name: name,
-					type: 'community',
-					displayName: displayName,
-					description: description
-				}, res => {
-					if (res.status === 'success') Toast.methods.addToast(`You have added the station successfully`, 4000);
-					else Toast.methods.addToast(res.message, 4000);
+			if (characters.length !== 0)
+				return new Toast({
+					content: "Invalid description format.",
+					timeout: 8000
 				});
-				this.toggleModal();
-			}
+
+			return this.socket.emit(
+				"stations.create",
+				{
+					name,
+					type: "community",
+					displayName,
+					description
+				},
+				res => {
+					if (res.status === "success") {
+						new Toast({
+							content: `You have added the station successfully`,
+							timeout: 4000
+						});
+						this.closeModal({
+							sector: "home",
+							modal: "createCommunityStation"
+						});
+					} else new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
 		},
-		events: {
-			closeModal: function() {
-				this.$parent.modals.createCommunityStation = !this.$parent.modals.createCommunityStation;
-			}
-		}
+		...mapActions("modals", ["closeModal"])
 	}
-</script>
+};
+</script>

+ 312 - 166
frontend/components/Modals/EditNews.vue

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

+ 1749 - 439
frontend/components/Modals/EditSong.vue

@@ -1,531 +1,1841 @@
 <template>
 	<div>
-		<modal title='Edit Song'>
-			<div slot='body'>
-				<h5 class='has-text-centered'>Video Preview</h5>
-				<div class='video-container'>
-					<div id='player'></div>
-					<div class="controls">
-						<form action="#">
-							<p style="margin-top: 0; position: relative;">
-								<input type="range" id="volumeSlider" min="0" max="100" class="active" v-on:change="changeVolume()" v-on:input="changeVolume()">
-							</p>
-						</form>
-						<p class='control has-addons'>
-							<button class='button' @click='settings("pause")' v-if='!video.paused'>
-								<i class='material-icons'>pause</i>
-							</button>
-							<button class='button' @click='settings("play")' v-if='video.paused'>
-								<i class='material-icons'>play_arrow</i>
-							</button>
-							<button class='button' @click='settings("stop")'>
-								<i class='material-icons'>stop</i>
-							</button>
-							<button class='button' @click='settings("skipToLast10Secs")'>
-								<i class='material-icons'>fast_forward</i>
-							</button>
-						</p>
-					</div>
-				</div>
-				<h5 class='has-text-centered'>Thumbnail Preview</h5>
-				<img class='thumbnail-preview' :src='editing.song.thumbnail' onerror="this.src='/assets/notes-transparent.png'">
-
-				<div class="control is-horizontal">
-					<div class="control-label">
-						<label class="label">Thumbnail URL</label>
-					</div>
-					<div class="control">
-						<input class='input' type='text' v-model='editing.song.thumbnail'>
-					</div>
-				</div>
-
-				<h5 class='has-text-centered'>Edit Information</h5>
-
-				<p class='control'>
-					<label class='checkbox'>
-						<input type='checkbox' v-model='editing.song.explicit'>
-						Explicit
-					</label>
-				</p>
-				<label class='label'>Song ID & Title</label>
-				<div class="control is-horizontal">
-					<div class="control is-grouped">
-						<p class='control is-expanded'>
-							<input class='input' type='text' v-model='editing.song.songId'>
-						</p>
-						<p class='control is-expanded'>
-							<input class='input' type='text' v-model='editing.song.title' autofocus>
-						</p>
+		<modal title="Edit Song" class="song-modal">
+			<div slot="body">
+				<div class="left-section">
+					<div class="top-section">
+						<div class="player-section">
+							<div id="player"></div>
+							<canvas
+								id="durationCanvas"
+								height="20"
+								width="530"
+							></canvas>
+							<div class="player-footer">
+								<div class="player-footer-left">
+									<i
+										class="material-icons player-play-pause"
+										v-on:click="settings('play')"
+										v-if="video.paused"
+										>play_arrow</i
+									>
+									<i
+										class="material-icons player-play-pause"
+										v-on:click="settings('pause')"
+										v-if="!video.paused"
+										>pause</i
+									>
+									<i
+										class="material-icons player-stop"
+										v-on:click="settings('stop')"
+										>stop</i
+									>
+									<i
+										class="material-icons player-fast-forward"
+										v-on:click="
+											settings('skipToLast10Secs')
+										"
+										>fast_forward</i
+									>
+								</div>
+								<div class="player-footer-center">
+									<img src="/assets/social/youtube.svg" />
+									<span>
+										<span>
+											{{ youtubeVideoCurrentTime }}
+										</span>
+										/
+										<span>
+											{{ youtubeVideoDuration }}
+											{{ youtubeVideoNote }}
+										</span>
+									</span>
+								</div>
+								<div class="player-footer-right">
+									<input
+										type="range"
+										id="volumeSlider"
+										min="0"
+										max="100"
+										class="active"
+										v-on:change="changeVolume()"
+										v-on:input="changeVolume()"
+									/>
+								</div>
+							</div>
+						</div>
+						<img
+							class="thumbnail-preview"
+							:src="editing.song.thumbnail"
+							onerror="this.src='/assets/notes-transparent.png'"
+						/>
 					</div>
-				</div>
-				<label class='label'>Artists & Genres</label>
-				<div class='control is-horizontal'>
-					<div class='control is-grouped artist-genres'>
-						<div>
-							<p class='control has-addons'>
-								<input class='input' id='new-artist' type='text' placeholder='Artist'>
-								<button class='button is-info' @click='addTag("artists")'>Add Artist</button>
-							</p>
-							<span class='tag is-info' v-for='(index, artist) in editing.song.artists' track-by='$index'>
-								{{ artist }}
-								<button class='delete is-info' @click='removeTag("artists", index)'></button>
-							</span>
+					<div class="edit-section">
+						<div class="control is-grouped">
+							<div class="title-container">
+								<label class="label">Title</label>
+								<p class="control has-addons">
+									<input
+										class="input"
+										type="text"
+										v-model="editing.song.title"
+									/>
+									<button
+										class="button album-get-button"
+										v-on:click="getAlbumData('title')"
+									>
+										<i class="material-icons">album</i>
+									</button>
+								</p>
+							</div>
+							<div class="duration-container">
+								<label class="label">Duration</label>
+								<p class="control has-addons">
+									<input
+										class="input"
+										type="text"
+										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">
+								<label class="label">Skip duration</label>
+								<p class="control">
+									<input
+										class="input"
+										type="text"
+										v-model.number="
+											editing.song.skipDuration
+										"
+									/>
+								</p>
+							</div>
+						</div>
+						<div class="control is-grouped">
+							<div class="album-art-container">
+								<label class="label">Album art</label>
+								<p class="control has-addons">
+									<input
+										class="input"
+										type="text"
+										v-model="editing.song.thumbnail"
+									/>
+									<button
+										class="button album-get-button"
+										v-on:click="getAlbumData('albumArt')"
+									>
+										<i class="material-icons">album</i>
+									</button>
+								</p>
+							</div>
 						</div>
-						<div>
-							<p class='control has-addons'>
-								<input class='input' id='new-genre' type='text' placeholder='Genre'>
-								<button class='button is-info' @click='addTag("genres")'>Add Genre</button>
-							</p>
-							<span class='tag is-info' v-for='(index, genre) in editing.song.genres' track-by='$index'>
-								{{ genre }}
-								<button class='delete is-info' @click='removeTag("genres", index)'></button>
-							</span>
+						<div class="control is-grouped">
+							<div class="artists-container">
+								<label class="label">Artists</label>
+								<p class="control has-addons">
+									<input
+										class="input"
+										type="text"
+										id="new-artist"
+										v-model="artistInputValue"
+										v-on:blur="blurArtistInput()"
+										v-on:focus="focusArtistInput()"
+										v-on:keydown="keydownArtistInput()"
+									/>
+									<button
+										class="button album-get-button"
+										v-on:click="getAlbumData('artists')"
+									>
+										<i class="material-icons">album</i>
+									</button>
+									<button
+										class="button is-info add-button"
+										v-on:click="addTag('artists')"
+									>
+										<i class="material-icons">add</i>
+									</button>
+								</p>
+								<div
+									class="autosuggest-container"
+									v-if="
+										(artistInputFocussed ||
+											artistAutosuggestContainerFocussed) &&
+											artistAutosuggestItems.length > 0
+									"
+									@mouseover="focusArtistContainer()"
+									@mouseleave="blurArtistContainer()"
+								>
+									<span
+										class="autosuggest-item"
+										tabindex="0"
+										v-on:click="
+											selectArtistAutosuggest(item)
+										"
+										v-for="(item,
+										index) in artistAutosuggestItems"
+										:key="index"
+										>{{ item }}</span
+									>
+								</div>
+								<div class="list-container">
+									<div
+										class="list-item"
+										v-for="(artist, index) in editing.song
+											.artists"
+										:key="index"
+									>
+										<div
+											class="list-item-circle"
+											v-on:click="
+												removeTag('artists', index)
+											"
+										>
+											<i class="material-icons">close</i>
+										</div>
+										<p>{{ artist }}</p>
+									</div>
+								</div>
+							</div>
+							<div class="genres-container">
+								<label class="label">
+									<span>Genres</span>
+									<i
+										class="material-icons"
+										@click="toggleGenreHelper"
+										@dblclick="resetGenreHelper"
+										>info</i
+									>
+								</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()"
+									/>
+									<button
+										class="button album-get-button"
+										v-on:click="getAlbumData('genres')"
+									>
+										<i class="material-icons">album</i>
+									</button>
+									<button
+										class="button is-info add-button"
+										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.song
+											.genres"
+										:key="index"
+									>
+										<div
+											class="list-item-circle"
+											v-on:click="
+												removeTag('genres', index)
+											"
+										>
+											<i class="material-icons">close</i>
+										</div>
+										<p>{{ genre }}</p>
+									</div>
+								</div>
+							</div>
+							<div class="youtube-id-container">
+								<label class="label">YouTube ID</label>
+								<p class="control">
+									<input
+										class="input"
+										type="text"
+										v-model="editing.song.songId"
+									/>
+								</p>
+							</div>
 						</div>
 					</div>
 				</div>
-				<label class='label'>Song Duration</label>
-				<p class='control'>
-					<input class='input' type='text' v-model='editing.song.duration'>
-				</p>
-				<label class='label'>Skip Duration</label>
-				<p class='control'>
-					<input class='input' type='text' v-model='editing.song.skipDuration'>
-				</p>
-				<article class="message" v-if="editing.type === 'songs'">
-					<div class="message-body">
-						<span class="reports-length">
-							{{ reports.length }}
-							<span v-if="reports.length > 1 || reports.length <= 0">&nbsp;Reports</span>
-							<span v-else>&nbsp;Report</span>
-						</span>
-						<div v-for='report in reports'>
-							<a :href='`/admin/reports?id=${report}`' class='report-link'>Report - {{ report }}</a>
+				<div class="right-section">
+					<div class="api-section">
+						<div
+							class="selected-discogs-info"
+							v-if="!editing.song.discogs"
+						>
+							<p class="selected-discogs-info-none">None</p>
 						</div>
-					</div>
-				</article>
-				<hr />
-				<h5 class='has-text-centered'>Spotify Information</h5>
-				<label class='label'>Song title</label>
-				<p class='control'>
-					<input class='input' type='text' v-model='spotify.title'>
-				</p>
-				<label class='label'>Song artist (1 artist full name)</label>
-				<p class='control'>
-					<input class='input' type='text' v-model='spotify.artist'>
-				</p>
-				<button class='button is-success' @click='getSpotifySongs()'>
-					Get Spotify songs
-				</button>
-				<hr />
-				<article class="media" v-for='song in spotify.songs'>
-					<figure class="media-left">
-						<p class="image is-64x64">
-							<img :src="song.thumbnail" onerror="this.src='/assets/notes-transparent.png'">
+						<div
+							class="selected-discogs-info"
+							v-if="editing.song.discogs"
+						>
+							<div class="top-container">
+								<img
+									:src="editing.song.discogs.album.albumArt"
+								/>
+								<div class="right-container">
+									<p class="album-title">
+										{{ editing.song.discogs.album.title }}
+									</p>
+									<div class="bottom-row">
+										<p class="type-year">
+											<span>{{
+												editing.song.discogs.album.type
+											}}</span>
+											•
+											<span>{{
+												editing.song.discogs.album.year
+											}}</span>
+										</p>
+									</div>
+								</div>
+							</div>
+							<div class="bottom-container">
+								<p class="bottom-container-field">
+									Artists:
+									<span>{{
+										editing.song.discogs.album.artists.join(
+											", "
+										)
+									}}</span>
+								</p>
+								<p class="bottom-container-field">
+									Genres:
+									<span>{{
+										editing.song.discogs.album.genres.join(
+											", "
+										)
+									}}</span>
+								</p>
+								<p class="bottom-container-field">
+									Data quality:
+									<span>{{
+										editing.song.discogs.dataQuality
+									}}</span>
+								</p>
+								<p class="bottom-container-field">
+									Track:
+									<span
+										>{{
+											editing.song.discogs.track.position
+										}}.
+										{{
+											editing.song.discogs.track.title
+										}}</span
+									>
+								</p>
+							</div>
+						</div>
+						<p class="control is-expanded">
+							<label class="label">Search query</label>
+							<input
+								class="input"
+								type="text"
+								v-model="discogsQuery"
+								@change="onDiscogsQueryChange"
+							/>
 						</p>
-					</figure>
-					<div class="media-content">
-						<div class="content">
-							<p>
-								<strong>{{song.title}}</strong>
-								<br />
-								<small>Artists: {{song.artists}}</small>, <small>Duration: {{song.duration}}</small>, <small>Explicit: {{song.explicit}}</small>
-								<br />
-								<small>Thumbnail: {{song.thumbnail}}</small>
-							</p>
+						<button
+							class="button is-info is-fullwidth"
+							v-on:click="searchDiscogsForPage(1)"
+						>
+							Search
+						</button>
+						<label
+							class="label"
+							v-if="discogs.apiResults.length > 0"
+							>API results</label
+						>
+						<div
+							class="api-results-container"
+							v-if="discogs.apiResults.length > 0"
+						>
+							<div
+								class="api-result"
+								v-for="(result, index) in discogs.apiResults"
+								:key="index"
+							>
+								<div class="top-container">
+									<img :src="result.album.albumArt" />
+									<div class="right-container">
+										<p class="album-title">
+											{{ result.album.title }}
+										</p>
+										<div class="bottom-row">
+											<img
+												src="/assets/arrow_up.svg"
+												v-if="result.expanded"
+												v-on:click="
+													toggleAPIResult(index)
+												"
+											/>
+											<img
+												src="/assets/arrow_down.svg"
+												v-if="!result.expanded"
+												v-on:click="
+													toggleAPIResult(index)
+												"
+											/>
+											<p class="type-year">
+												<span>{{
+													result.album.type
+												}}</span>
+												•
+												<span>{{
+													result.album.year
+												}}</span>
+											</p>
+										</div>
+									</div>
+								</div>
+								<div
+									class="bottom-container"
+									v-if="result.expanded"
+								>
+									<p class="bottom-container-field">
+										Artists:
+										<span>{{
+											result.album.artists.join(", ")
+										}}</span>
+									</p>
+									<p class="bottom-container-field">
+										Genres:
+										<span>{{
+											result.album.genres.join(", ")
+										}}</span>
+									</p>
+									<p class="bottom-container-field">
+										Data quality:
+										<span>{{ result.dataQuality }}</span>
+									</p>
+									<div class="tracks">
+										<div
+											class="track"
+											tabindex="0"
+											v-for="(track,
+											trackIndex) in result.tracks"
+											:key="trackIndex"
+											v-on:click="
+												selectTrack(index, trackIndex)
+											"
+										>
+											<span>{{ track.position }}.</span>
+											<p>{{ track.title }}</p>
+										</div>
+									</div>
+								</div>
+							</div>
 						</div>
+						<button
+							v-if="
+								discogs.apiResults.length > 0 &&
+									!discogs.disableLoadMore &&
+									discogs.page < discogs.pages
+							"
+							class="button is-fullwidth is-info discogs-load-more"
+							@click="loadNextDiscogsPage()"
+						>
+							Load more...
+						</button>
 					</div>
-				</article>
+				</div>
 			</div>
-			<div slot='footer'>
-				<button class='button is-success' @click='save(editing.song, false)'>
-					<i class='material-icons save-changes'>done</i>
+			<div slot="footer" class="footer-buttons">
+				<button
+					class="button is-success"
+					v-on:click="save(editing.song, false)"
+				>
+					<i class="material-icons save-changes">done</i>
 					<span>&nbsp;Save</span>
 				</button>
-				<button class='button is-success' @click='save(editing.song, true)'>
-					<i class='material-icons save-changes'>done</i>
+				<button
+					class="button is-success"
+					v-on:click="save(editing.song, true)"
+				>
+					<i class="material-icons save-changes">done</i>
 					<span>&nbsp;Save and close</span>
 				</button>
-				<button class='button is-danger' @click='$parent.toggleModal()'>
+				<button
+					class="button is-danger"
+					v-on:click="
+						closeModal({ sector: 'admin', modal: 'editSong' })
+					"
+				>
 					<span>&nbsp;Close</span>
 				</button>
 			</div>
 		</modal>
+		<div
+			id="genre-helper-container"
+			v-bind:style="{
+				width: genreHelper.width + 'px',
+				height: genreHelper.height + 'px',
+				top: genreHelper.top + 'px',
+				left: genreHelper.left + 'px'
+			}"
+			v-if="genreHelper.shown"
+			@mousedown="onResizeGenreHelper"
+		>
+			<div
+				class="genre-helper-header"
+				@mousedown="onDragGenreHelper"
+			></div>
+			<div class="genre-helper-body">
+				<span>Blues</span><span>Country</span><span>Disco</span
+				><span>Funk</span><span>Hip-Hop</span><span>Jazz</span
+				><span>Metal</span><span>Oldies</span><span>Other</span
+				><span>Pop</span><span>Rap</span><span>Reggae</span
+				><span>Rock</span><span>Techno</span><span>Trance</span
+				><span>Classical</span><span>Instrumental</span
+				><span>House</span><span>Electronic</span
+				><span>Christian Rap</span><span>Lo-Fi</span><span>Musical</span
+				><span>Rock 'n' Roll</span><span>Opera</span
+				><span>Drum & Bass</span><span>Club-House</span
+				><span>Indie</span><span>Heavy Metal</span
+				><span>Christian rock</span><span>Dubstep</span>
+			</div>
+		</div>
 	</div>
 </template>
 
 <script>
-	import io from '../../io';
-	import validation from '../../validation';
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				editing: {
-					index: 0,
-					song: {},
-					type: ''
-				},
-				reports: 0,
-				video: {
-					player: null,
-					paused: true,
-					playerReady: false,
-					autoPlayed: false
-				},
-				spotify: {
-					title: '',
-					artist: '',
-					songs: []
-				}
-			}
-		},
-		methods: {
-			save: function (song, close) {
-				let _this = this;
+import { mapState, mapActions } from "vuex";
+import Toast from "toasters";
 
-				if (!song.title) return Toast.methods.addToast('Please fill in all fields', 8000);
-				if (!song.thumbnail) return Toast.methods.addToast('Please fill in all fields', 8000);
+import io from "../../io";
+import validation from "../../validation";
+import Modal from "./Modal.vue";
 
+export default {
+	components: { Modal },
+	data() {
+		return {
+			discogsQuery: "",
+			youtubeVideoDuration: 0.0,
+			youtubeVideoCurrentTime: 0.0,
+			youtubeVideoNote: "",
+			useHTTPS: false,
+			discogs: {
+				apiResults: [],
+				page: 1,
+				pages: 1,
+				disableLoadMore: false
+			},
+			artistInputValue: "",
+			genreInputValue: "",
+			artistInputFocussed: false,
+			genreInputFocussed: false,
+			genreAutosuggestContainerFocussed: false,
+			artistAutosuggestContainerFocussed: false,
+			keydownArtistInputTimeout: 0,
+			keydownGenreInputTimeout: 0,
+			artistAutosuggestItems: [],
+			genreAutosuggestItems: [],
+			genreHelper: {
+				width: 200,
+				height: 200,
+				top: 0,
+				left: 0,
+				shown: false,
+				pos1: 0,
+				pos2: 0,
+				pos3: 0,
+				pos4: 0
+			},
+			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"
+			]
+		};
+	},
+	computed: {
+		...mapState("admin/songs", {
+			video: state => state.video,
+			editing: state => state.editing,
+			songs: state => state.songs
+		}),
+		...mapState("modals", {
+			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));
 
-				// Title
-				if (!validation.isLength(song.title, 1, 64)) return Toast.methods.addToast('Title must have between 1 and 64 characters.', 8000);
-				if (!validation.regex.ascii.test(song.title)) return Toast.methods.addToast('Invalid title format. Only ascii characters are allowed.', 8000);
-
+			if (!song.title)
+				return new Toast({
+					content: "Please fill in all fields",
+					timeout: 8000
+				});
+			if (!song.thumbnail)
+				return new Toast({
+					content: "Please fill in all fields",
+					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);
-				let error;
-				song.artists.forEach((artist) => {
-					if (!validation.isLength(artist, 1, 32)) return error = 'Artist must have between 1 and 32 characters.';
-					if (!validation.regex.ascii.test(artist)) return error = 'Invalid artist format. Only ascii characters are allowed.';
-					if (artist === 'NONE') return error = 'Invalid artist format. Artists are not allowed to be named "NONE".';
+			// Duration
+			if (
+				Number(song.skipDuration) + Number(song.duration) >
+				this.youtubeVideoDuration
+			) {
+				return new Toast({
+					content:
+						"Duration can't be higher than the length of the video",
+					timeout: 8000
 				});
-				if (error) return Toast.methods.addToast(error, 8000);
+			}
 
+			// Title
+			if (!validation.isLength(song.title, 1, 100))
+				return new Toast({
+					content: "Title must have between 1 and 100 characters.",
+					timeout: 8000
+				});
 
-				// Genres
-				error = undefined;
-				song.genres.forEach((genre) => {
-					if (!validation.isLength(genre, 1, 16)) return error = 'Genre must have between 1 and 16 characters.';
-					if (!validation.regex.az09_.test(genre)) return error = 'Invalid genre format. Only ascii characters are allowed.';
+			// Artists
+			if (song.artists.length < 1 || song.artists.length > 10)
+				return new Toast({
+					content:
+						"Invalid artists. You must have at least 1 artist and a maximum of 10 artists.",
+					timeout: 8000
 				});
-				if (error) return Toast.methods.addToast(error, 8000);
+			let error;
+			song.artists.forEach(artist => {
+				if (!validation.isLength(artist, 1, 64)) {
+					error = "Artist must have between 1 and 64 characters.";
+					return error;
+				}
+				if (artist === "NONE") {
+					error =
+						'Invalid artist format. Artists are not allowed to be named "NONE".';
+					return error;
+				}
 
+				return false;
+			});
+			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 (song.thumbnail.indexOf('https://') !== 0) return Toast.methods.addToast('Thumbnail must start with "https://".', 8000);
+			// Genres
+			error = undefined;
+			song.genres.forEach(genre => {
+				if (!validation.isLength(genre, 1, 32)) {
+					error = "Genre must have between 1 and 32 characters.";
+					return error;
+				}
+				if (!validation.regex.ascii.test(genre)) {
+					error =
+						"Invalid genre format. Only ascii characters are allowed.";
+					return error;
+				}
 
+				return false;
+			});
+			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 });
 
-				this.socket.emit(`${_this.editing.type}.update`, song._id, song, res => {
-					Toast.methods.addToast(res.message, 4000);
-					if (res.status === 'success') {
-						_this.$parent.songs.forEach(lSong => {
-							if (song._id === lSong._id) {
-								for (let n in song) {
-									lSong[n] = song[n];
-								}
+			// Thumbnail
+			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 new Toast({
+					content: 'Thumbnail must start with "https://".',
+					timeout: 8000
+				});
+			}
+
+			if (
+				!this.useHTTPS &&
+				(song.thumbnail.indexOf("http://") !== 0 &&
+					song.thumbnail.indexOf("https://") !== 0)
+			) {
+				return new Toast({
+					content: 'Thumbnail must start with "http://".',
+					timeout: 8000
+				});
+			}
+
+			return this.socket.emit(
+				`${this.editing.type}.update`,
+				song._id,
+				song,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+					if (res.status === "success") {
+						this.songs.forEach(originalSong => {
+							const updatedSong = song;
+							if (originalSong._id === updatedSong._id) {
+								Object.keys(originalSong).forEach(n => {
+									updatedSong[n] = originalSong[n];
+									return originalSong[n];
+								});
 							}
 						});
 					}
-					if (close) _this.$parent.toggleModal();
+					if (close)
+						this.closeModal({
+							sector: "admin",
+							modal: "editSong"
+						});
+				}
+			);
+		},
+		toggleAPIResult(index) {
+			const apiResult = this.discogs.apiResults[index];
+			if (apiResult.expanded === true) apiResult.expanded = false;
+			else if (apiResult.gotMoreInfo === true) apiResult.expanded = true;
+			else {
+				fetch(apiResult.album.resourceUrl)
+					.then(response => {
+						return response.json();
+					})
+					.then(data => {
+						apiResult.album.artists = [];
+						apiResult.album.artistIds = [];
+						const artistRegex = new RegExp(" \\([0-9]+\\)$");
+
+						apiResult.dataQuality = data.data_quality;
+						data.artists.forEach(artist => {
+							apiResult.album.artists.push(
+								artist.name.replace(artistRegex, "")
+							);
+							apiResult.album.artistIds.push(artist.id);
+						});
+						apiResult.tracks = data.tracklist.map(track => {
+							return {
+								position: track.position,
+								title: track.title
+							};
+						});
+						apiResult.expanded = true;
+						apiResult.gotMoreInfo = true;
+					});
+			}
+		},
+		fillDuration() {
+			this.editing.song.duration =
+				this.youtubeVideoDuration - this.editing.song.skipDuration;
+		},
+		getAlbumData(type) {
+			if (!this.editing.song.discogs) return;
+			if (type === "title")
+				this.updateSongField({
+					field: "title",
+					value: this.editing.song.discogs.track.title
 				});
-			},
-			settings: function (type) {
-				let _this = this;
-				switch(type) {
-					case 'stop':
-						_this.video.player.stopVideo();
-						_this.video.paused = true;
-						break;
-					case 'pause':
-						_this.video.player.pauseVideo();
-						_this.video.paused = true;
-						break;
-					case 'play':
-						_this.video.player.playVideo();
-						_this.video.paused = false;
-						break;
-					case 'skipToLast10Secs':
-						_this.video.player.seekTo((_this.editing.song.duration - 10) + _this.editing.song.skipDuration);
-						break;
+			if (type === "albumArt")
+				this.updateSongField({
+					field: "thumbnail",
+					value: this.editing.song.discogs.album.albumArt
+				});
+			if (type === "genres")
+				this.updateSongField({
+					field: "genres",
+					value: JSON.parse(
+						JSON.stringify(this.editing.song.discogs.album.genres)
+					)
+				});
+			if (type === "artists")
+				this.updateSongField({
+					field: "artists",
+					value: JSON.parse(
+						JSON.stringify(this.editing.song.discogs.album.artists)
+					)
+				});
+		},
+		searchDiscogsForPage(page) {
+			const query = this.discogsQuery;
+
+			this.socket.emit("apis.searchDiscogs", query, page, res => {
+				if (res.status === "success") {
+					if (page === 1)
+						new Toast({
+							content: `Successfully searched. Got ${res.results.length} results.`,
+							timeout: 4000
+						});
+					else
+						new Toast({
+							content: `Successfully got ${res.results.length} more results.`,
+							timeout: 4000
+						});
+
+					if (page === 1) {
+						this.discogs.apiResults = [];
+					}
+
+					this.discogs.pages = res.pages;
+
+					this.discogs.apiResults = this.discogs.apiResults.concat(
+						res.results.map(result => {
+							const type =
+								result.type.charAt(0).toUpperCase() +
+								result.type.slice(1);
+
+							return {
+								expanded: false,
+								gotMoreInfo: false,
+								album: {
+									id: result.id,
+									title: result.title,
+									type,
+									year: result.year,
+									genres: result.genre,
+									albumArt: result.cover_image,
+									resourceUrl: result.resource_url
+								}
+							};
+						})
+					);
+
+					this.discogs.page = page;
+					this.discogs.disableLoadMore = false;
+				} else new Toast({ content: res.message, timeout: 8000 });
+			});
+		},
+		loadNextDiscogsPage() {
+			this.discogs.disableLoadMore = true;
+			this.searchDiscogsForPage(this.discogs.page + 1);
+		},
+		onDiscogsQueryChange() {
+			this.discogs.page = 1;
+			this.discogs.pages = 1;
+			this.discogs.apiResults = [];
+			this.discogs.disableLoadMore = false;
+		},
+		selectTrack(apiResultIndex, trackIndex) {
+			const apiResult = JSON.parse(
+				JSON.stringify(this.discogs.apiResults[apiResultIndex])
+			);
+			apiResult.track = apiResult.tracks[trackIndex];
+			delete apiResult.tracks;
+			delete apiResult.expanded;
+			delete apiResult.gotMoreInfo;
+
+			this.selectDiscogsInfo(apiResult);
+		},
+		blurArtistInput() {
+			this.artistInputFocussed = false;
+		},
+		focusArtistInput() {
+			this.artistInputFocussed = true;
+		},
+		blurArtistContainer() {
+			this.artistAutosuggestContainerFocussed = false;
+		},
+		focusArtistContainer() {
+			this.artistAutosuggestContainerFocussed = true;
+		},
+		keydownArtistInput() {
+			clearTimeout(this.keydownArtistInputTimeout);
+			this.keydownArtistInputTimeout = setTimeout(() => {
+				// Do things here to query the artist
+			}, 1000);
+		},
+		selectArtistAutosuggest(value) {
+			this.artistInputValue = value;
+		},
+		blurGenreInput() {
+			this.genreInputFocussed = false;
+		},
+		focusGenreInput() {
+			this.genreInputFocussed = true;
+		},
+		blurGenreContainer() {
+			this.genreAutosuggestContainerFocussed = false;
+		},
+		focusGenreContainer() {
+			this.genreAutosuggestContainerFocussed = 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);
+		},
+		selectGenreAutosuggest(value) {
+			this.genreInputValue = value;
+		},
+		settings(type) {
+			switch (type) {
+				default:
+					break;
+				case "stop":
+					this.stopVideo();
+					this.pauseVideo(true);
+					break;
+				case "pause":
+					this.pauseVideo(true);
+					break;
+				case "play":
+					this.pauseVideo(false);
+					break;
+				case "skipToLast10Secs":
+					if (this.video.paused) this.pauseVideo(false);
+					this.video.player.seekTo(
+						this.editing.song.duration -
+							10 +
+							this.editing.song.skipDuration
+					);
+					break;
+			}
+		},
+		changeVolume() {
+			const volume = document.getElementById("volumeSlider").value;
+			localStorage.setItem("volume", volume);
+			this.video.player.setVolume(volume);
+			if (volume > 0) this.video.player.unMute();
+		},
+		addTag(type) {
+			if (type === "genres") {
+				const genre = document
+					.getElementById("new-genre")
+					.value.toLowerCase()
+					.trim();
+				if (this.editing.song.genres.indexOf(genre) !== -1)
+					return new Toast({
+						content: "Genre already exists",
+						timeout: 3000
+					});
+				if (genre) {
+					this.editing.song.genres.push(genre);
+					document.getElementById("new-genre").value = "";
+					return false;
 				}
-			},
-			changeVolume: function () {
-				let local = this;
-				let volume = $("#volumeSlider").val();
-				localStorage.setItem("volume", volume);
-				local.video.player.setVolume(volume);
-				if (volume > 0) local.video.player.unMute();
-			},
-			addTag: function (type) {
-				if (type == 'genres') {
-					let genre = $('#new-genre').val().toLowerCase().trim();
-					if (this.editing.song.genres.indexOf(genre) !== -1) return Toast.methods.addToast('Genre already exists', 3000);
-					if (genre) {
-						this.editing.song.genres.push(genre);
-						$('#new-genre').val('');
-					} else Toast.methods.addToast('Genre cannot be empty', 3000);
-				} else if (type == 'artists') {
-					let artist = $('#new-artist').val();
-					if (this.editing.song.artists.indexOf(artist) !== -1) return Toast.methods.addToast('Artist already exists', 3000);
-					if ($('#new-artist').val() !== '') {
-						this.editing.song.artists.push(artist);
-						$('#new-artist').val('');
-					} else Toast.methods.addToast('Artist cannot be empty', 3000);
+
+				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 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;
 				}
-			},
-			removeTag: function (type, index) {
-				if (type == 'genres') this.editing.song.genres.splice(index, 1);
-				else if (type == 'artists') this.editing.song.artists.splice(index, 1);
-			},
-			getSpotifySongs: function() {
-				this.socket.emit('apis.getSpotifySongs', this.spotify.title, this.spotify.artist, (res) => {
-					if (res.status === 'success') {
-						Toast.methods.addToast(`Succesfully got ${res.songs.length} song${(res.songs.length !== 1) ? 's' : ''}.`, 3000);
-						this.spotify.songs = res.songs;
-					} else Toast.methods.addToast(`Failed to get songs. ${res.message}`, 3000);
+				return new Toast({
+					content: "Artist cannot be empty",
+					timeout: 3000
 				});
 			}
+
+			return false;
+		},
+		removeTag(type, index) {
+			if (type === "genres") this.editing.song.genres.splice(index, 1);
+			else if (type === "artists")
+				this.editing.song.artists.splice(index, 1);
 		},
-		ready: function () {
+		drawCanvas() {
+			const canvasElement = document.getElementById("durationCanvas");
+			const ctx = canvasElement.getContext("2d");
 
-			let _this = this;
+			const videoDuration = Number(this.youtubeVideoDuration);
 
-			io.getSocket(socket => {
-				_this.socket = socket;
-			});
+			const skipDuration = Number(this.editing.song.skipDuration);
+			const duration = Number(this.editing.song.duration);
+			const afterDuration = videoDuration - (skipDuration + duration);
 
-			setInterval(() => {
-				if (_this.video.paused === false && _this.playerReady && _this.video.player.getCurrentTime() - _this.editing.song.skipDuration > _this.editing.song.duration) {
-					_this.video.paused = false;
-					_this.video.player.stopVideo();
-				}
-			}, 200);
-
-			this.video.player = new YT.Player('player', {
-				height: 315,
-				width: 560,
-				videoId: this.editing.song.songId,
-				playerVars: { controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0, autoplay: 1 },
-				startSeconds: _this.editing.song.skipDuration,
-				events: {
-					'onReady': () => {
-						let volume = parseInt(localStorage.getItem("volume"));
-						volume = (typeof volume === "number") ? volume : 20;
-						_this.video.player.seekTo(_this.editing.song.skipDuration);
-						_this.video.player.setVolume(volume);
-						if (volume > 0) _this.video.player.unMute();
-						_this.playerReady = true;
-					},
-					'onStateChange': event => {
-						if (event.data === 1) {
-							if (!_this.video.autoPlayed) {
-								_this.video.autoPlayed = true;
-								return _this.video.player.stopVideo();
-							}
+			const width = 530;
 
-							_this.video.paused = false;
-							let youtubeDuration = _this.video.player.getDuration();
-							youtubeDuration -= _this.editing.song.skipDuration;
-							if (_this.editing.song.duration > youtubeDuration) {
-								this.video.player.stopVideo();
-								_this.video.paused = true;
-								Toast.methods.addToast("Video can't play. Specified duration is bigger than the YouTube song duration.", 4000);
-							} else if (_this.editing.song.duration <= 0) {
-								this.video.player.stopVideo();
-								_this.video.paused = true;
-								Toast.methods.addToast("Video can't play. Specified duration has to be more than 0 seconds.", 4000);
-							}
+			const currentTime = this.video.player.getCurrentTime();
 
-							if (_this.video.player.getCurrentTime() < _this.editing.song.skipDuration) {
-								_this.video.player.seekTo(10);
-							}
-						} else if (event.data === 2) {
-							this.video.paused = true;
-						}
-					}
-				}
-			});
+			const widthSkipDuration = (skipDuration / videoDuration) * width;
+			const widthDuration = (duration / videoDuration) * width;
+			const widthAfterDuration = (afterDuration / videoDuration) * width;
+
+			const widthCurrentTime = (currentTime / videoDuration) * width;
+
+			const skipDurationColor = "#F42003";
+			const durationColor = "#03A9F4";
+			const afterDurationColor = "#41E841";
+			const currentDurationColor = "#3b25e8";
 
-			let volume = parseInt(localStorage.getItem("volume"));
-			volume = (typeof volume === "number") ? volume : 20;
-			$("#volumeSlider").val(volume);
+			ctx.fillStyle = skipDurationColor;
+			ctx.fillRect(0, 0, widthSkipDuration, 20);
+			ctx.fillStyle = durationColor;
+			ctx.fillRect(widthSkipDuration, 0, widthDuration, 20);
+			ctx.fillStyle = afterDurationColor;
+			ctx.fillRect(
+				widthSkipDuration + widthDuration,
+				0,
+				widthAfterDuration,
+				20
+			);
 
+			ctx.fillStyle = currentDurationColor;
+			ctx.fillRect(widthCurrentTime, 0, 1, 20);
 		},
-		events: {
-			closeModal: function () {
-				this.$parent.modals.editSong = false;
+		onDragGenreHelper(e) {
+			const e1 = e || window.event;
+			e1.preventDefault();
+
+			this.genreHelper.pos3 = e1.clientX;
+			this.genreHelper.pos4 = e1.clientY;
+
+			document.onmousemove = e => {
+				const e2 = e || window.event;
+				e2.preventDefault();
+				// calculate the new cursor position:
+				this.genreHelper.pos1 = this.genreHelper.pos3 - e.clientX;
+				this.genreHelper.pos2 = this.genreHelper.pos4 - e.clientY;
+				this.genreHelper.pos3 = e.clientX;
+				this.genreHelper.pos4 = e.clientY;
+				// set the element's new position:
+				this.genreHelper.top =
+					this.genreHelper.top - this.genreHelper.pos2;
+				this.genreHelper.left =
+					this.genreHelper.left - this.genreHelper.pos1;
+			};
+
+			document.onmouseup = () => {
+				document.onmouseup = null;
+				document.onmousemove = null;
+
+				this.saveGenreHelper();
+			};
+		},
+		onResizeGenreHelper(e) {
+			if (e.target.id !== "genre-helper-container") return;
+
+			document.onmouseup = () => {
+				document.onmouseup = null;
+
+				const { height, width } = e.target.style;
+
+				this.genreHelper.height = Number(
+					height
+						.split("")
+						.splice(0, height.length - 2)
+						.join("")
+				);
+				this.genreHelper.width = Number(
+					width
+						.split("")
+						.splice(0, width.length - 2)
+						.join("")
+				);
+
+				this.saveGenreHelper();
+			};
+		},
+		toggleGenreHelper() {
+			this.genreHelper.shown = !this.genreHelper.shown;
+			this.saveGenreHelper();
+		},
+		resetGenreHelper() {
+			this.genreHelper.top = 0;
+			this.genreHelper.left = 0;
+			this.genreHelper.width = 200;
+			this.genreHelper.height = 200;
+			this.saveGenreHelper();
+		},
+		saveGenreHelper() {
+			localStorage.setItem(
+				"genreHelper",
+				JSON.stringify({
+					height: this.genreHelper.height,
+					width: this.genreHelper.width,
+					top: this.genreHelper.top,
+					left: this.genreHelper.left,
+					shown: this.genreHelper.shown
+				})
+			);
+		},
+		...mapActions("admin/songs", [
+			"stopVideo",
+			"loadVideoById",
+			"pauseVideo",
+			"getCurrentTime",
+			"editSong",
+			"updateSongField",
+			"selectDiscogsInfo"
+		]),
+		...mapActions("modals", ["closeModal"])
+	},
+	mounted() {
+		// if (this.modals.editSong = false) this.video.player.stopVideo();
+
+		// this.loadVideoById(
+		//   this.editing.song.songId,
+		//   this.editing.song.skipDuration
+		// );
+
+		if (localStorage.genreHelper) {
+			const genreHelper = JSON.parse(localStorage.getItem("genreHelper"));
+			this.genreHelper.height = genreHelper.height;
+			this.genreHelper.width = genreHelper.width;
+			this.genreHelper.top = genreHelper.top;
+			this.genreHelper.left = genreHelper.left;
+			this.genreHelper.shown = genreHelper.shown;
+		}
+
+		this.discogsQuery = this.editing.song.title;
+
+		lofig.get("cookie.secure").then(useHTTPS => {
+			this.useHTTPS = useHTTPS;
+		});
+
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+
+		this.interval = setInterval(() => {
+			if (
+				this.editing.song.duration !== -1 &&
+				this.video.paused === false &&
+				this.playerReady &&
+				this.video.player.getCurrentTime() -
+					this.editing.song.skipDuration >
+					this.editing.song.duration
+			) {
+				this.video.paused = false;
 				this.video.player.stopVideo();
+				this.drawCanvas();
+			}
+			if (this.playerReady) {
+				this.youtubeVideoCurrentTime = this.video.player
+					.getCurrentTime()
+					.toFixed(3);
+			}
+
+			if (this.video.paused === false) this.drawCanvas();
+		}, 200);
+
+		this.video.player = new window.YT.Player("player", {
+			height: 298,
+			width: 530,
+			videoId: this.editing.song.songId,
+			playerVars: {
+				controls: 0,
+				iv_load_policy: 3,
+				rel: 0,
+				showinfo: 0,
+				autoplay: 1
 			},
-			editSong: function (song, index, type) {
-				let _this = this;
-				this.video.player.loadVideoById(song.songId, this.editing.song.skipDuration);
-				let newSong = {};
-				for (let n in song) {
-					newSong[n] = song[n];
-				}
-				this.editing = {
-					index,
-					song: newSong,
-					type
-				};
-				if (type === 'songs') {
-					_this.socket.emit('reports.getReportsForSong', song.songId, res => {
-						if (res.status === 'success') _this.reports = res.data;
-					});
+			startSeconds: this.editing.song.skipDuration,
+			events: {
+				onReady: () => {
+					let volume = parseInt(localStorage.getItem("volume"));
+					volume = typeof volume === "number" ? volume : 20;
+					console.log(`Seekto: ${this.editing.song.skipDuration}`);
+					this.video.player.seekTo(this.editing.song.skipDuration);
+					this.video.player.setVolume(volume);
+					if (volume > 0) this.video.player.unMute();
+					this.youtubeVideoDuration = this.video.player.getDuration();
+					this.youtubeVideoNote = "(~)";
+					this.playerReady = true;
+
+					this.drawCanvas();
+				},
+				onStateChange: event => {
+					this.drawCanvas();
+
+					if (event.data === 1) {
+						if (!this.video.autoPlayed) {
+							this.video.autoPlayed = true;
+							return this.video.player.stopVideo();
+						}
+
+						this.video.paused = false;
+						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 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 new Toast({
+								content:
+									"Video can't play. Specified duration has to be more than 0 seconds.",
+								timeout: 4000
+							});
+						}
+
+						if (
+							this.video.player.getCurrentTime() <
+							this.editing.song.skipDuration
+						) {
+							return this.video.player.seekTo(
+								this.editing.song.skipDuration
+							);
+						}
+					} else if (event.data === 2) {
+						this.video.paused = true;
+					}
+
+					return false;
 				}
-				this.$parent.toggleModal();
-			},
-			stopVideo: function () {
-				this.video.player.stopVideo();
 			}
-		}
+		});
+
+		let volume = parseInt(localStorage.getItem("volume"));
+		document.getElementById("volumeSlider").value = volume =
+			typeof volume === "number" ? volume : 20;
+	},
+	beforeDestroy() {
+		this.playerReady = false;
+		clearInterval(this.interval);
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	input[type=range] {
-		-webkit-appearance: none;
-		width: 100%;
-		margin: 7.3px 0;
-	}
+<style lang="scss">
+@import "styles/global.scss";
 
-	input[type=range]:focus {
-		outline: none;
-	}
+#genre-helper-container {
+	background-color: white;
+	position: fixed;
+	z-index: 10000000;
+	resize: both;
+	overflow: auto;
+	border: 1px solid #d3d3d3;
+	min-height: 50px !important;
+	min-width: 50px !important;
 
-	input[type=range]::-webkit-slider-runnable-track {
+	.genre-helper-header {
+		cursor: move;
+		z-index: 100000001;
+		background-color: $musareBlue;
+		padding: 10px;
+		display: block;
+		height: 10px;
 		width: 100%;
-		height: 5.2px;
-		cursor: pointer;
-		box-shadow: 0;
-		background: #c2c0c2;
-		border-radius: 0;
-		border: 0;
 	}
 
-	input[type=range]::-webkit-slider-thumb {
-		box-shadow: 0;
-		border: 0;
-		height: 19px;
-		width: 19px;
-		border-radius: 15px;
-		background: #03a9f4;
-		cursor: pointer;
-		-webkit-appearance: none;
-		margin-top: -6.5px;
-	}
+	.genre-helper-body {
+		display: flex;
+		flex-wrap: wrap;
+		justify-content: space-evenly;
 
-	input[type=range]::-moz-range-track {
-		width: 100%;
-		height: 5.2px;
-		cursor: pointer;
-		box-shadow: 0;
-		background: #c2c0c2;
-		border-radius: 0;
-		border: 0;
+		span {
+			padding: 3px 6px;
+		}
 	}
+}
 
-	input[type=range]::-moz-range-thumb {
-		box-shadow: 0;
-		border: 0;
-		height: 19px;
-		width: 19px;
-		border-radius: 15px;
-		background: #03a9f4;
-		cursor: pointer;
-		-webkit-appearance: none;
-		margin-top: -6.5px;
+.song-modal {
+	.modal-card-title {
+		text-align: center;
+		margin-left: 24px;
 	}
 
-	input[type=range]::-ms-track {
-		width: 100%;
-		height: 5.2px;
-		cursor: pointer;
-		box-shadow: 0;
-		background: #c2c0c2;
-		border-radius: 1.3px;
-	}
+	.modal-card {
+		width: 1160px;
+		height: 100%;
 
-	input[type=range]::-ms-fill-lower {
-		background: #c2c0c2;
-		border: 0;
-		border-radius: 0;
-		box-shadow: 0;
+		.modal-card-body {
+			padding: 16px;
+		}
 	}
+}
+</style>
 
-	input[type=range]::-ms-fill-upper {
-		background: #c2c0c2;
-		border: 0;
-		border-radius: 0;
-		box-shadow: 0;
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
+.modal-card-body > div {
+	display: flex;
+	height: 100%;
+}
 
-	input[type=range]::-ms-thumb {
-		box-shadow: 0;
-		border: 0;
-		height: 15px;
-		width: 15px;
-		border-radius: 15px;
-		background: #03a9f4;
-		cursor: pointer;
-		-webkit-appearance: none;
-		margin-top: 1.5px;
-	}
+.left-section {
+	display: flex;
+	flex-direction: column;
+	margin-right: 16px;
 
-	.controls {
+	.top-section {
 		display: flex;
-		flex-direction: column;
-		align-items: center;
-	}
 
-	.artist-genres {
-		display: flex;
-    	justify-content: space-between;
-	}
+		.player-section {
+			width: 530px;
+			display: flex;
+			flex-direction: column;
 
-	#volumeSlider { margin-bottom: 15px; }
+			.player-footer {
+				background-color: #f4f4f4;
+				border: 1px rgba(163, 224, 255, 0.75) solid;
+				border-radius: 0px 0px 5px 5px;
+				display: flex;
+				justify-content: space-between;
+				height: 54px;
 
-	.has-text-centered { padding: 10px; }
+				> * {
+					width: 33.3%;
+					display: flex;
+					align-items: center;
+				}
 
-	.thumbnail-preview {
-		display: flex;
-		margin: 0 auto 25px auto;
-		max-width: 200px;
-		width: 100%;
-	}
+				.player-footer-left {
+					flex: 1;
 
-	.modal-card-body, .modal-card-foot { border-top: 0; }
+					.material-icons {
+						font-size: 38px;
+						cursor: pointer;
+					}
 
-	.label, .checkbox, h5 {
-		font-weight: normal;
-	}
+					.player-play-pause {
+						color: $musareBlue;
+					}
 
-	.video-container {
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-		padding: 10px;
+					.player-stop {
+						color: $red;
+					}
 
-		iframe { pointer-events: none; }
+					.player-fast-forward {
+						color: $green;
+					}
+				}
+
+				.player-footer-center {
+					justify-content: center;
+					align-items: center;
+					flex: 2;
+					font-size: 21px;
+					font-weight: 400;
+
+					img {
+						height: 21px;
+						margin-right: 12px;
+						filter: invert(26%) sepia(54%) saturate(6317%)
+							hue-rotate(2deg) brightness(92%) contrast(115%);
+					}
+				}
+
+				.player-footer-right {
+					justify-content: right;
+					flex: 1;
+
+					#volumeSlider {
+						width: 126px;
+						margin-right: 10px;
+						background-color: #f4f4f4;
+					}
+				}
+			}
+		}
+
+		.thumbnail-preview {
+			width: 189px;
+			height: 189px;
+			margin-left: 16px;
+		}
 	}
 
-	.save-changes { color: #fff; }
+	.edit-section {
+		width: 735px;
+		background-color: #f4f4f4;
+		border: 1px rgba(163, 224, 255, 0.75) solid;
+		margin-top: 16px;
+		flex: 1;
+		overflow: auto;
+		border-radius: 5px;
 
-	.tag:not(:last-child) { margin-right: 5px; }
+		.album-get-button {
+			background-color: $purple;
+			color: white;
+			width: 32px;
+			text-align: center;
+			border-width: 0;
+		}
 
-	.reports-length {
-		color: #ff4545;
-		font-weight: bold;
-		display: flex;
-		justify-content: center;
+		.duration-fill-button {
+			background-color: $red;
+			color: $white;
+			width: 32px;
+			text-align: center;
+			border-width: 0;
+		}
+
+		.add-button {
+			background-color: $musareBlue !important;
+			width: 32px;
+
+			i {
+				font-size: 32px;
+			}
+		}
+
+		> div {
+			margin: 16px;
+		}
+
+		input {
+			width: 100%;
+		}
+
+		.title-container {
+			width: calc((100% - 32px) / 2);
+		}
+
+		.duration-container {
+			margin-right: 16px;
+			margin-left: 16px;
+			width: calc((100% - 32px) / 4);
+		}
+
+		.skip-duration-container {
+			width: calc((100% - 32px) / 4);
+		}
+
+		.album-art-container {
+			width: 100%;
+		}
+
+		.artists-container {
+			width: calc((100% - 32px) / 3);
+			position: relative;
+		}
+
+		.genres-container {
+			width: calc((100% - 32px) / 3);
+			margin-left: 16px;
+			margin-right: 16px;
+			position: relative;
+
+			label {
+				display: flex;
+
+				i {
+					font-size: 15px;
+					align-self: center;
+					margin-left: 5px;
+					color: $musareBlue;
+					cursor: pointer;
+					-webkit-user-select: none;
+					-moz-user-select: none;
+					-ms-user-select: none;
+					user-select: none;
+				}
+			}
+		}
+
+		.youtube-id-container {
+			width: calc((100% - 32px) / 3);
+		}
+
+		.list-item-circle {
+			background-color: $musareBlue;
+			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;
+
+			i {
+				color: $musareBlue;
+				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;
+			}
+		}
 	}
+}
+
+.right-section {
+	display: flex;
+	flex-wrap: wrap;
+
+	.api-section {
+		width: 376px;
+		background-color: #f4f4f4;
+		border: 1px rgba(163, 224, 255, 0.75) solid;
+		border-radius: 5px;
+		padding: 16px;
+		overflow: auto;
+		height: 100%;
+
+		> label {
+			margin-top: 12px;
+		}
+
+		.top-container {
+			display: flex;
+
+			img {
+				height: 85px;
+				width: 85px;
+			}
+
+			.right-container {
+				padding: 8px;
+				display: flex;
+				flex-direction: column;
+				flex: 1;
+
+				.album-title {
+					flex: 1;
+					font-weight: 600;
+				}
+
+				.bottom-row {
+					display: flex;
+					flex-flow: row;
+					line-height: 15px;
+
+					img {
+						height: 15px;
+						align-self: end;
+						flex: 1;
+						user-select: none;
+						-moz-user-select: none;
+						-ms-user-select: none;
+						-webkit-user-select: none;
+						cursor: pointer;
+					}
+
+					p {
+						text-align: right;
+					}
+
+					.type-year {
+						font-size: 13px;
+						align-self: end;
+					}
+				}
+			}
+		}
+
+		.bottom-container {
+			padding: 12px;
+
+			.bottom-container-field {
+				line-height: 16px;
+				margin-bottom: 8px;
+				font-weight: 600;
+
+				span {
+					font-weight: 400;
+				}
+			}
+
+			.bottom-container-field:last-of-type {
+				margin-bottom: 0;
+			}
+		}
+
+		.selected-discogs-info {
+			background-color: white;
+			border: 1px solid $purple;
+			border-radius: 5px;
+			margin-bottom: 16px;
+
+			.selected-discogs-info-none {
+				font-size: 18px;
+				text-align: center;
+			}
+
+			.bottom-row > p {
+				flex: 1;
+			}
+		}
+
+		.api-result {
+			background-color: white;
+			border: 0.5px solid $musareBlue;
+			border-radius: 5px;
+			margin-bottom: 16px;
+		}
 
-	.report-link {
-		color: #000;
+		button {
+			background-color: $musareBlue !important;
+		}
+
+		.tracks {
+			margin-top: 12px;
+
+			.track:first-child {
+				margin-top: 0;
+				border-radius: 3px 3px 0 0;
+			}
+
+			.track:last-child {
+				border-radius: 0 0 3px 3px;
+			}
+
+			.track {
+				border: 0.5px solid black;
+				margin-top: -1px;
+				line-height: 16px;
+				display: flex;
+				cursor: pointer;
+
+				span {
+					font-weight: 600;
+					display: inline-block;
+					margin-top: 7px;
+					margin-bottom: 7px;
+					margin-left: 7px;
+				}
+
+				p {
+					display: inline-block;
+					margin: 7px;
+					flex: 1;
+				}
+			}
+
+			.track:hover,
+			.track:focus {
+				background-color: #f4f4f4;
+			}
+		}
+
+		.discogs-load-more {
+			margin-bottom: 8px;
+		}
 	}
+}
+
+.footer-buttons {
+	margin-left: auto;
+	margin-right: auto;
+}
+
+input[type="range"] {
+	-webkit-appearance: none;
+	width: 100%;
+	margin: 8.5px 0;
+}
+input[type="range"]:focus {
+	outline: none;
+}
+input[type="range"]::-webkit-slider-runnable-track {
+	width: 100%;
+	height: 3px;
+	cursor: pointer;
+	box-shadow: none;
+	background: #7e7e7e;
+	border-radius: none;
+	border: none;
+}
+input[type="range"]::-webkit-slider-thumb {
+	box-shadow: none;
+	border: none;
+	height: 20px;
+	width: 20px;
+	border-radius: 100px;
+	background: #03a9f4;
+	cursor: pointer;
+	-webkit-appearance: none;
+	margin-top: -8.5px;
+}
+input[type="range"]:focus::-webkit-slider-runnable-track {
+	background: #7e7e7e;
+}
+input[type="range"]::-moz-range-track {
+	width: 100%;
+	height: 3px;
+	cursor: pointer;
+	box-shadow: none;
+	background: #7e7e7e;
+	border-radius: none;
+	border: none;
+}
+input[type="range"]::-moz-range-thumb {
+	box-shadow: none;
+	border: none;
+	height: 20px;
+	width: 20px;
+	border-radius: 100px;
+	background: #03a9f4;
+	cursor: pointer;
+}
+input[type="range"]::-ms-track {
+	width: 100%;
+	height: 3px;
+	cursor: pointer;
+	background: transparent;
+	border-color: transparent;
+	color: transparent;
+}
+input[type="range"]::-ms-fill-lower {
+	background: #717171;
+	border: none;
+	border-radius: none;
+	box-shadow: none;
+}
+input[type="range"]::-ms-fill-upper {
+	background: #7e7e7e;
+	border: none;
+	border-radius: none;
+	box-shadow: none;
+}
+input[type="range"]::-ms-thumb {
+	box-shadow: none;
+	border: none;
+	height: 20px;
+	width: 20px;
+	border-radius: 100px;
+	background: #03a9f4;
+	cursor: pointer;
+	height: 3px;
+}
+input[type="range"]:focus::-ms-fill-lower {
+	background: #7e7e7e;
+}
+input[type="range"]:focus::-ms-fill-upper {
+	background: #7e7e7e;
+}
 </style>

+ 986 - 163
frontend/components/Modals/EditStation.vue

@@ -1,217 +1,1040 @@
 <template>
-	<div>
-		<modal title='Edit Station'>
-			<div slot='body'>
-				<label class='label'>Name</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Station Name' v-model='editing.name'>
-				</p>
-				<label class='label'>Display name</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Station Display Name' v-model='editing.displayName'>
-				</p>
-				<label class='label'>Description</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Station Display Name' v-model='editing.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><br>
-				<p class='control'>
-					<label class="checkbox party-mode-inner">
-						<input type="checkbox" v-model="editing.partyMode">
-						&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="$parent.station.partyMode">
-					<br>
-					<br>
-					<label class='label'>Queue lock</label>
-					<small v-if="$parent.station.partyMode">With the queue locked, only owners (you) can add songs to the queue.</small><br>
-					<button class='button is-danger' v-if='!$parent.station.locked' @click="$parent.toggleLock()">Lock the queue</button>
-					<button class='button is-success' v-if='$parent.station.locked' @click="$parent.toggleLock()">Unlock the queue</button>
+	<modal title="Edit Station" class="edit-station-modal">
+		<template v-slot:body>
+			<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 slot='footer'>
-				<button class='button is-success' @click='update()'>Update Settings</button>
-				<button class='button is-danger' @click='deleteStation()' v-if="$parent.type === 'community'">Delete station</button>
+			<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
+					"
+				>
+					<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>
-		</modal>
-	</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 { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import io from '../../io';
-	import validation from '../../validation';
-
-	export default {
-		data: function() {
-			return {
-				editing: {
-					_id: '',
-					name: '',
-					type: '',
-					displayName: '',
-					description: '',
-					privacy: 'private',
-					partyMode: false
-				}
+import { mapState, mapActions } from "vuex";
+
+import Toast from "toasters";
+import Modal from "./Modal.vue";
+import io from "../../io";
+import validation from "../../validation";
+
+export default {
+	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 => {
+			this.socket = socket;
+			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();
+			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.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();
 			}
 		},
-		methods: {
-			update: function () {
-				if (this.$parent.station.name !== this.editing.name) this.updateName();
-				if (this.$parent.station.displayName !== this.editing.displayName) this.updateDisplayName();
-				if (this.$parent.station.description !== this.editing.description) this.updateDescription();
-				if (this.$parent.station.privacy !== this.editing.privacy) this.updatePrivacy();
-				if (this.$parent.station.partyMode !== this.editing.partyMode) this.updatePartyMode();
-			},
-			updateName: function () {
-				const name = this.editing.name;
-				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);
-
-
-				this.socket.emit('stations.updateName', this.editing._id, name, res => {
-					if (res.status === 'success') {
-						if (this.$parent.station) _this.$parent.station.name = name;
+		updateName() {
+			const { name } = this.editing;
+			if (!validation.isLength(name, 2, 16))
+				return new Toast({
+					content: "Name must have between 2 and 16 characters.",
+					timeout: 8000
+				});
+			if (!validation.regex.az09_.test(name))
+				return new Toast({
+					content:
+						"Invalid name format. Allowed characters: a-z, 0-9 and _.",
+					timeout: 8000
+				});
+
+			return this.socket.emit(
+				"stations.updateName",
+				this.editing._id,
+				name,
+				res => {
+					if (res.status === "success") {
+						if (this.station) this.station.name = name;
 						else {
 							this.$parent.stations.forEach((station, index) => {
-								if (station._id === this.editing._id) return this.$parent.stations[index].name = name;
+								if (station._id === this.editing._id) {
+									this.$parent.stations[index].name = name;
+									return name;
+								}
+
+								return false;
 							});
 						}
 					}
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			updateDisplayName: function () {
-				const displayName = this.editing.displayName;
-				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);
 
+					new Toast({ content: res.message, timeout: 8000 });
+				}
+			);
+		},
+		updateDisplayName() {
+			const { displayName } = this.editing;
+			if (!validation.isLength(displayName, 2, 32))
+				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
+				});
 
-				this.socket.emit('stations.updateDisplayName', this.editing._id, displayName, res => {
-					if (res.status === 'success') {
-						if (this.$parent.station) _this.$parent.station.displayName = displayName;
+			return this.socket.emit(
+				"stations.updateDisplayName",
+				this.editing._id,
+				displayName,
+				res => {
+					if (res.status === "success") {
+						if (this.station)
+							this.station.displayName = displayName;
 						else {
 							this.$parent.stations.forEach((station, index) => {
-								if (station._id === this.editing._id) return this.$parent.stations[index].displayName = displayName;
+								if (station._id === this.editing._id) {
+									this.$parent.stations[
+										index
+									].displayName = displayName;
+									return displayName;
+								}
+
+								return false;
 							});
 						}
 					}
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			updateDescription: function () {
-				const description = this.editing.description;
-				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(function(character) {
-					return character.charCodeAt(0) === 21328;
+
+					new Toast({ content: res.message, timeout: 8000 });
+				}
+			);
+		},
+		updateDescription() {
+			const { description } = this.editing;
+			if (!validation.isLength(description, 2, 200))
+				return new Toast({
+					content:
+						"Description must have between 2 and 200 characters.",
+					timeout: 8000
 				});
-				if (characters.length !== 0) return Toast.methods.addToast('Invalid description format. Swastika\'s are not allowed.', 8000);
 
+			let characters = description.split("");
+			characters = characters.filter(character => {
+				return character.charCodeAt(0) === 21328;
+			});
+			if (characters.length !== 0)
+				return new Toast({
+					content: "Invalid description format.",
+					timeout: 8000
+				});
 
-				this.socket.emit('stations.updateDescription', this.editing._id, description, res => {
-					if (res.status === 'success') {
-						if (_this.$parent.station) _this.$parent.station.description = description;
+			return this.socket.emit(
+				"stations.updateDescription",
+				this.editing._id,
+				description,
+				res => {
+					if (res.status === "success") {
+						if (this.station)
+							this.station.description = description;
 						else {
-							_this.$parent.stations.forEach((station, index) => {
-								if (station._id === station._id) return _this.$parent.stations[index].description = 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 new Toast({
+							content: res.message,
+							timeout: 4000
+						});
 					}
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			updatePrivacy: function () {
-				let _this = this;
-				this.socket.emit('stations.updatePrivacy', this.editing._id, this.editing.privacy, res => {
-					if (res.status === 'success') {
-						if (_this.$parent.station) _this.$parent.station.privacy = _this.editing.privacy;
+
+					return new Toast({ content: res.message, timeout: 8000 });
+				}
+			);
+		},
+		updatePrivacyLocal(privacy) {
+			if (this.editing.privacy === privacy) return;
+			this.editing.privacy = privacy;
+			this.privacyDropdownActive = false;
+		},
+		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 === station._id) return _this.$parent.stations[index].privacy = _this.editing.privacy;
+							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 new Toast({
+							content: res.message,
+							timeout: 4000
+						});
 					}
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			updatePartyMode: function () {
-				let _this = this;
-				this.socket.emit('stations.updatePartyMode', this.editing._id, this.editing.partyMode, res => {
-					if (res.status === 'success') {
-						if (_this.$parent.station) _this.$parent.station.partyMode = _this.editing.partyMode;
-						else {
-							_this.$parent.stations.forEach((station, index) => {
-								if (station._id === station._id) return _this.$parent.stations[index].partyMode = _this.editing.partyMode;
-							});
-						}
-						return Toast.methods.addToast(res.message, 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].genres = genres;
+								return genres;
+							}
+
+							return false;
+						});
+
+						return new Toast({
+							content: res.message,
+							timeout: 4000
+						});
 					}
-					Toast.methods.addToast(res.message, 8000);
-				});
-			},
-			deleteStation: function() {
-				let _this = this;
-				this.socket.emit('stations.remove', this.editing._id, res => {
-					Toast.methods.addToast(res.message, 8000);
+
+					return new Toast({ content: res.message, timeout: 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 new Toast({
+							content: res.message,
+							timeout: 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 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
 				});
-			}
+			});
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket(socket => {
-				_this.socket = socket;
+		deleteStation() {
+			this.socket.emit("stations.remove", this.editing._id, res => {
+				if (res.status === "success")
+					this.closeModal({
+						sector: "station",
+						modal: "editStation"
+					});
+				return new Toast({ content: res.message, timeout: 8000 });
 			});
 		},
-		events: {
-			closeModal: function() {
-				this.$parent.modals.editStation = false;
-			},
-			editStation: function(station) {
-				for (let prop in station) {
-					this.editing[prop] = station[prop];
+		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;
 				}
-				this.$parent.modals.editStation = true;
+
+				return new Toast({
+					content: "Blacklist genre cannot be empty",
+					timeout: 3000
+				});
 			}
+
+			return false;
 		},
-		components: { Modal }
-	}
+		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 type='scss' scoped>
-	.controls {
-		display: flex;
+<style lang="scss">
+.edit-station-modal {
+	.modal-card-title {
+		text-align: center;
+		margin-left: 24px;
+	}
+
+	.modal-card {
+		width: 800px;
+		height: 550px;
 
-		a {
+		.modal-card-body {
+			padding: 16px;
 			display: flex;
-    		align-items: center;
 		}
 	}
+}
+</style>
+
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.table { margin-bottom: 0; }
+.section {
+	border: 1px solid #a3e0ff;
+	background-color: #f4f4f4;
+	border-radius: 5px;
+	padding: 16px;
+}
 
-	h5 { padding: 20px 0; }
+.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;
+			}
 
-	.party-mode-inner, .party-mode-outer {
+			&.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;
+		}
+	}
+}
+
+.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;
+		}
 	}
+}
+
+.col {
+	display: grid;
+	grid-column-gap: 16px;
+}
+
+.col-1 {
+	grid-template-columns: auto;
+}
 
-	.select:after { border-color: #029ce3; }
+.col-2 {
+	grid-template-columns: auto auto;
+}
 </style>

+ 185 - 95
frontend/components/Modals/EditUser.vue

@@ -1,14 +1,30 @@
 <template>
 	<div>
-		<modal title='Edit User'>
-			<div slot='body'>
+		<modal title="Edit User">
+			<div slot="body">
 				<p class="control has-addons">
-					<input class='input is-expanded' type='text' placeholder='Username' v-model='editing.username' autofocus>
-					<a class="button is-info" @click='updateUsername()'>Update Username</a>
+					<input
+						v-model="editing.username"
+						class="input is-expanded"
+						type="text"
+						placeholder="Username"
+						autofocus
+					/>
+					<a class="button is-info" v-on:click="updateUsername()"
+						>Update Username</a
+					>
 				</p>
 				<p class="control has-addons">
-					<input class='input is-expanded' type='text' placeholder='Username' v-model='editing.email' autofocus>
-					<a class="button is-info" @click='updateEmail()'>Update Email Address</a>
+					<input
+						v-model="editing.email.address"
+						class="input is-expanded"
+						type="text"
+						placeholder="Email Address"
+						autofocus
+					/>
+					<a class="button is-info" v-on:click="updateEmail()"
+						>Update Email Address</a
+					>
 				</p>
 				<p class="control has-addons">
 					<span class="select">
@@ -17,37 +33,55 @@
 							<option>admin</option>
 						</select>
 					</span>
-					<a class="button is-info" @click='updateRole()'>Update Role</a>
+					<a class="button is-info" v-on:click="updateRole()"
+						>Update Role</a
+					>
 				</p>
-				<hr>
+				<hr />
 				<p class="control has-addons">
 					<span class="select">
-						<select v-model='ban.expiresAt'>
-							<option value='1h'>1 Hour</option>
-							<option value='12h'>12 Hours</option>
-							<option value='1d'>1 Day</option>
-							<option value='1w'>1 Week</option>
-							<option value='1m'>1 Month</option>
-							<option value='3m'>3 Months</option>
-							<option value='6m'>6 Months</option>
-							<option value='1y'>1 Year</option>
+						<select v-model="ban.expiresAt">
+							<option value="1h">1 Hour</option>
+							<option value="12h">12 Hours</option>
+							<option value="1d">1 Day</option>
+							<option value="1w">1 Week</option>
+							<option value="1m">1 Month</option>
+							<option value="3m">3 Months</option>
+							<option value="6m">6 Months</option>
+							<option value="1y">1 Year</option>
 						</select>
 					</span>
-					<input class='input is-expanded' type='text' placeholder='Ban reason' v-model='ban.reason' autofocus>
-					<a class="button is-error" @click='banUser()'>Ban user</a>
+					<input
+						v-model="ban.reason"
+						class="input is-expanded"
+						type="text"
+						placeholder="Ban reason"
+						autofocus
+					/>
+					<a class="button is-error" v-on:click="banUser()"
+						>Ban user</a
+					>
 				</p>
 			</div>
-			<div slot='footer'>
+			<div slot="footer">
 				<!--button class='button is-warning'>
 					<span>&nbsp;Send Verification Email</span>
 				</button>
 				<button class='button is-warning'>
 					<span>&nbsp;Send Password Reset Email</span>
-				</button-->
-				<button class='button is-warning' @click='removeSessions()'>
+        </button-->
+				<button class="button is-warning" v-on:click="removeSessions()">
 					<span>&nbsp;Remove all sessions</span>
 				</button>
-				<button class='button is-danger' @click='$parent.toggleModal()'>
+				<button
+					class="button is-danger"
+					@click="
+						closeModal({
+							sector: 'admin',
+							modal: 'editUser'
+						})
+					"
+				>
 					<span>&nbsp;Close</span>
 				</button>
 			</div>
@@ -56,92 +90,148 @@
 </template>
 
 <script>
-	import io from '../../io';
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import validation from '../../validation';
+import { mapState, mapActions } from "vuex";
 
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				editing: {},
-				ban: {
-					expiresAt: '1h'
-				}
-			}
-		},
-		methods: {
-			updateUsername: function () {
-				const username = this.editing.username;
-				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);
+import Toast from "toasters";
+import io from "../../io";
+import Modal from "./Modal.vue";
+import validation from "../../validation";
 
-
-				this.socket.emit(`users.updateUsername`, this.editing._id, username, res => {
-					Toast.methods.addToast(res.message, 4000);
+export default {
+	components: { Modal },
+	data() {
+		return {
+			ban: {
+				expiresAt: "1h"
+			}
+		};
+	},
+	computed: {
+		...mapState("admin/users", {
+			editing: state => state.editing
+		}),
+		...mapState({
+			userId: state => state.user.auth.userId
+		})
+	},
+	methods: {
+		updateUsername() {
+			const { username } = this.editing;
+			if (!validation.isLength(username, 2, 32))
+				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
 				});
-			},
-			updateEmail: function () {
-				const email = this.editing.email;
-				if (!validation.isLength(email, 3, 254)) return Toast.methods.addToast('Email must have between 3 and 254 characters.', 8000);
-				if (email.indexOf('@') !== email.lastIndexOf('@') || !validation.regex.emailSimple.test(email)) return Toast.methods.addToast('Invalid email format.', 8000);
-
 
-				this.socket.emit(`users.updateEmail`, this.editing._id, email, res => {
-					Toast.methods.addToast(res.message, 4000);
+			return this.socket.emit(
+				`users.updateUsername`,
+				this.editing._id,
+				username,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		updateEmail() {
+			const email = this.editing.email.address;
+			if (!validation.isLength(email, 3, 254))
+				return new Toast({
+					content: "Email must have between 3 and 254 characters.",
+					timeout: 8000
 				});
-			},
-			updateRole: function () {
-				this.socket.emit(`users.updateRole`, this.editing._id, this.editing.role, res => {
-					Toast.methods.addToast(res.message, 4000);
-					if (
-							res.status === 'success' &&
-							this.editing.role === 'default' &&
-							this.editing._id === this.$parent.$parent.$parent.userId
-					) location.reload();
+			if (
+				email.indexOf("@") !== email.lastIndexOf("@") ||
+				!validation.regex.emailSimple.test(email) ||
+				!validation.regex.ascii.test(email)
+			)
+				return new Toast({
+					content: "Invalid email format.",
+					timeout: 8000
 				});
-			},
-			banUser: function () {
-				const reason = this.ban.reason;
-				if (!validation.isLength(reason, 1, 64)) return Toast.methods.addToast('Reason must have between 1 and 64 characters.', 8000);
-				if (!validation.regex.ascii.test(reason)) return Toast.methods.addToast('Invalid reason format. Only ascii characters are allowed.', 8000);
 
-				this.socket.emit(`users.banUserById`, this.editing._id, this.ban.reason, this.ban.expiresAt, res => {
-					Toast.methods.addToast(res.message, 4000);
+			return this.socket.emit(
+				`users.updateEmail`,
+				this.editing._id,
+				email,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		updateRole() {
+			this.socket.emit(
+				`users.updateRole`,
+				this.editing._id,
+				this.editing.role,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+					if (
+						res.status === "success" &&
+						this.editing.role === "default" &&
+						this.editing._id === this.userId
+					)
+						window.location.reload();
+				}
+			);
+		},
+		banUser() {
+			const { reason } = this.ban;
+			if (!validation.isLength(reason, 1, 64))
+				return new Toast({
+					content: "Reason must have between 1 and 64 characters.",
+					timeout: 8000
 				});
-			},
-			removeSessions: function () {
-				this.socket.emit(`users.removeSessions`, this.editing._id, res => {
-					Toast.methods.addToast(res.message, 4000);
+			if (!validation.regex.ascii.test(reason))
+				return new Toast({
+					content:
+						"Invalid reason format. Only ascii characters are allowed.",
+					timeout: 8000
 				});
-			}
+
+			return this.socket.emit(
+				`users.banUserById`,
+				this.editing._id,
+				this.ban.reason,
+				this.ban.expiresAt,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket(socket => _this.socket = socket );
+		removeSessions() {
+			this.socket.emit(`users.removeSessions`, this.editing._id, res => {
+				new Toast({ content: res.message, timeout: 4000 });
+			});
 		},
-		events: {
-			closeModal: function () {
-				this.$parent.modals.editUser = false;
-			},
-			editUser: function (user) {
-				this.editing = {
-					_id: user._id,
-					username: user.username,
-					email: user.email.address,
-					role: user.role
-				};
-				this.$parent.toggleModal();
-			}
-		}
+		...mapActions("modals", ["closeModal"])
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			return socket;
+		});
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.save-changes { color: #fff; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.save-changes {
+	color: $white;
+}
 
-	.tag:not(:last-child) { margin-right: 5px; }
+.tag:not(:last-child) {
+	margin-right: 5px;
+}
 
-	.select:after { border-color: #029ce3; }
+.select:after {
+	border-color: $primary-color;
+}
 </style>

+ 94 - 19
frontend/components/Modals/IssuesModal.vue

@@ -1,15 +1,48 @@
 <template>
-	<modal title='Report'>
-		<div slot='body'>
+	<modal title="Report">
+		<div slot="body">
+			<router-link
+				v-if="$route.query.returnToSong"
+				class="button is-dark back-to-song"
+				:to="{
+					path: '/admin/songs',
+					query: { id: report.songId }
+				}"
+			>
+				<i class="material-icons">keyboard_return</i> &nbsp; Edit Song
+			</router-link>
+
 			<article class="message">
 				<div class="message-body">
-					<strong>Song ID: </strong>{{ $parent.editing.songId }}<br/>
-					<strong>Created By: </strong>{{ $parent.editing.createdBy }}<br/>
-					<strong>Created At: </strong>{{ $parent.editing.createdAt }}<br/>
-					<span v-if='$parent.editing.description'><strong>Description: </strong>{{ $parent.editing.description }}</span>
+					<strong>Song ID:</strong>
+					{{ report.song.songId }} / {{ report.song._id }}
+					<br />
+					<strong>Author:</strong>
+					<user-id-to-username
+						:userId="report.createdBy"
+						:alt="report.createdBy"
+					/>
+					<br />
+					<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>
+						{{ report.description }}
+					</span>
 				</div>
 			</article>
-			<table class='table is-narrow' v-if='$parent.editing.issues.length > 0'>
+			<table v-if="report.issues.length > 0" class="table is-narrow">
 				<thead>
 					<tr>
 						<td>Issue</td>
@@ -17,7 +50,7 @@
 					</tr>
 				</thead>
 				<tbody>
-					<tr v-for='(index, issue) in $parent.editing.issues' track-by='$index'>
+					<tr v-for="(issue, index) in report.issues" :key="index">
 						<td>
 							<span>{{ issue.name }}</span>
 						</td>
@@ -28,11 +61,31 @@
 				</tbody>
 			</table>
 		</div>
-		<div slot='footer'>
-			<a class='button is-primary' @click='$parent.resolve($parent.editing._id)' href='#'>
+		<div slot="footer">
+			<a
+				class="button is-primary"
+				href="#"
+				@click="$parent.resolve(report._id)"
+			>
 				<span>Resolve</span>
 			</a>
-			<a class='button is-danger' @click='$parent.toggleModal()' href='#'>
+			<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="
+					closeModal({
+						sector: 'admin',
+						modal: 'viewReport'
+					})
+				"
+				href="#"
+			>
 				<span>Cancel</span>
 			</a>
 		</div>
@@ -40,14 +93,36 @@
 </template>
 
 <script>
-	import Modal from './Modal.vue';
+import { mapActions, mapState } from "vuex";
+import { formatDistance } from "date-fns";
+
+import UserIdToUsername from "../UserIdToUsername.vue";
+import Modal from "./Modal.vue";
 
-	export default {
-		components: { Modal },
-		events: {
-			closeModal: function () {
-				this.$parent.modals.report = false;
-			}
+export default {
+	computed: {
+		...mapState("admin/reports", {
+			report: state => state.report
+		})
+	},
+	mounted() {
+		if (this.$route.query.returnToSong) {
+			this.closeModal({ sector: "admin", modal: "editSong" });
 		}
-	}
+	},
+	methods: {
+		formatDistance,
+		...mapActions("modals", ["closeModal"])
+	},
+	components: { Modal, UserIdToUsername }
+};
 </script>
+
+<style lang="scss">
+@import "styles/global.scss";
+
+.back-to-song {
+	display: flex;
+	margin-bottom: 20px;
+}
+</style>

+ 127 - 53
frontend/components/Modals/Login.vue

@@ -1,74 +1,148 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Login</p>
-				<button class='delete' @click='toggleModal()'></button>
+	<div class="modal is-active">
+		<div
+			class="modal-background"
+			@click="
+				closeModal({
+					sector: 'header',
+					modal: 'login'
+				})
+			"
+		/>
+		<div class="modal-card">
+			<header class="modal-card-head">
+				<p class="modal-card-title">
+					Login
+				</p>
+				<button
+					class="delete"
+					@click="
+						closeModal({
+							sector: 'header',
+							modal: 'login'
+						})
+					"
+				/>
 			</header>
-			<section class='modal-card-body'>
+			<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 class='input' type='text' placeholder='Email...' v-model='$parent.login.email'>
-				</p>
-				<label class='label'>Password</label>
-				<p class='control'>
-					<input class='input' type='password' placeholder='Password...' v-model='$parent.login.password' v-on:keypress='$parent.submitOnEnter(submitModal, $event)'>
-				</p>
-				<p>By logging in/registering you agree to our <a href="/terms" v-link="{ path: '/terms' }">Terms of Service</a> and <a href="/privacy" v-link="{ path: '/privacy' }">Privacy Policy</a>.</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")'>Submit</a>
-				<a class='button is-github' :href='$parent.serverDomain + "/auth/github/authorize"' @click="githubRedirect()">
-					<div class='icon'>
-						<img class='invert' src='/assets/social/github.svg'/>
+			<footer class="modal-card-foot">
+				<a class="button is-primary" href="#" @click="submitModal()"
+					>Submit</a
+				>
+				<a
+					class="button is-github"
+					:href="serverDomain + '/auth/github/authorize'"
+					@click="githubRedirect()"
+				>
+					<div class="icon">
+						<img class="invert" src="/assets/social/github.svg" />
 					</div>
 					&nbsp;&nbsp;Login with GitHub
 				</a>
-				<a href='/reset_password' @click='resetPassword()'>Forgot password?</a>
+				<a href="/reset_password" v-on:click="resetPassword()"
+					>Forgot password?</a
+				>
 			</footer>
 		</div>
 	</div>
 </template>
 
 <script>
-	export default {
-		methods: {
-			toggleModal: function () {
-				if (this.$router._currentRoute.path === '/login') location.href = '/';
-				else this.$dispatch('toggleModal', 'login');
-			},
-			submitModal: function () {
-				this.$dispatch('login');
-				this.toggleModal();
-			},
-			resetPassword: function () {
-				this.toggleModal();
-				this.$router.go('/reset_password');
-			},
-			githubRedirect: function() {
-			    localStorage.setItem('github_redirect', this.$route.path)
-			}
+import { mapActions } from "vuex";
+
+import Toast from "toasters";
+
+export default {
+	data() {
+		return {
+			email: "",
+			password: "",
+			serverDomain: ""
+		};
+	},
+	methods: {
+		submitModal() {
+			this.login({
+				email: this.email,
+				password: this.password
+			})
+				.then(res => {
+					if (res.status === "success") window.location.reload();
+				})
+				.catch(
+					err => new Toast({ content: err.message, timeout: 5000 })
+				);
 		},
-		events: {
-			closeModal: function() {
-				this.$dispatch('toggleModal', 'login');
-			}
-		}
+		resetPassword() {
+			this.closeModal({ sector: "header", modal: "login" });
+			this.$router.go("/reset_password");
+		},
+		githubRedirect() {
+			localStorage.setItem("github_redirect", this.$route.path);
+		},
+		...mapActions("modals", ["closeModal"]),
+		...mapActions("user/auth", ["login"])
+	},
+	mounted() {
+		lofig.get("serverDomain").then(serverDomain => {
+			this.serverDomain = serverDomain;
+		});
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.button.is-github {
-		background-color: #333;
-		color: #fff !important;
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.button.is-github {
+	background-color: $dark-grey-2;
+	color: $white !important;
+}
 
-	.is-github:focus { background-color: #1a1a1a; }
-	.is-primary:focus { background-color: #029ce3 !important; }
+.is-github:focus {
+	background-color: $dark-grey-3;
+}
+.is-primary:focus {
+	background-color: $primary-color !important;
+}
 
-	.invert { filter: brightness(5); }
+.invert {
+	filter: brightness(5);
+}
 
-	a { color: #029ce3; }
+a {
+	color: $primary-color;
+}
 </style>

+ 60 - 54
frontend/components/Modals/MobileAlert.vue

@@ -1,75 +1,81 @@
 <template>
-	<div class='modal' :class='{ "is-active": isModalActive }'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<button class='delete' @click='toggleModal()'></button>
+	<div class="modal" :class="{ 'is-active': isModalActive }">
+		<div class="modal-background" />
+		<div class="modal-card">
+			<header class="modal-card-head">
+				<button class="delete" @click="toggleModal()" />
 			</header>
-			<section class='modal-card-body'>
-				<h5>Musare doesn't work very well on mobile right now, we are working on this!</h5>
+			<section class="modal-card-body">
+				<h5>
+					Musare doesn't work very well on mobile right now, we are
+					working on this!
+				</h5>
 			</section>
 		</div>
 	</div>
 </template>
 
 <script>
-	import io from '../../io';
-
-	export default {
-		data() {
-			return {
-				isModalActive: false
-			}
-		},
-		ready: function () {
-			let _this = this;
-			if (!localStorage.getItem('mobileOptimization')) {
-				this.toggleModal();
-				localStorage.setItem('mobileOptimization', true);
-			}
-		},
-		methods: {
-			toggleModal: function () {
-				let _this = this;
-				_this.isModalActive = !_this.isModalActive;
-				if (_this.isModalActive) {
-					setTimeout(() => {
-						this.isModalActive = false;
-					}, 4000);
-				}
-			}
-		},
-		events: {
-			closeModal: function() {
-				this.isModalActive = false;
+export default {
+	data() {
+		return {
+			isModalActive: false
+		};
+	},
+	mounted() {
+		if (!localStorage.getItem("mobileOptimization")) {
+			this.toggleModal();
+			localStorage.setItem("mobileOptimization", true);
+		}
+	},
+	methods: {
+		toggleModal() {
+			this.isModalActive = !this.isModalActive;
+			if (this.isModalActive) {
+				setTimeout(() => {
+					this.isModalActive = false;
+				}, 4000);
 			}
 		}
+	},
+	events: {
+		closeModal() {
+			this.isModalActive = false;
+		}
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	@media (min-width: 735px) {
-		.modal {
-			display: none;
-		}
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.modal-card {
-		margin: 0 20px !important;
+@media (min-width: 735px) {
+	.modal {
+		display: none;
 	}
+}
 
-	.modal-card-head {
-		border-bottom: none;
-		background-color: ghostwhite;
-		padding: 15px;
-	}
+.modal-card {
+	margin: 0 20px !important;
+}
 
-	.delete {
+.modal-card-head {
+	border-bottom: none;
+	background-color: ghostwhite;
+	padding: 15px;
+}
+
+.delete {
+	background: transparent;
+	right: 0;
+	position: absolute;
+	&:hover {
 		background: transparent;
-		right: 0;
-		position: absolute;
-		&:hover { background: transparent; }
+	}
 
-		&:before, &:after { background-color: #bbb; }
+	&:before,
+	&:after {
+		background-color: #bbb;
 	}
+}
 </style>

+ 31 - 25
frontend/components/Modals/Modal.vue

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

+ 98 - 65
frontend/components/Modals/Playlists/Create.vue

@@ -1,88 +1,121 @@
 <template>
-	<modal title='Create Playlist'>
-		<div slot='body'>
-			<p class='control is-expanded'>
-				<input class='input' type='text' placeholder='Playlist Display Name' v-model='playlist.displayName' autofocus @keyup.enter='createPlaylist()'>
+	<modal title="Create Playlist">
+		<template v-slot:body>
+			<p class="control is-expanded">
+				<input
+					v-model="playlist.displayName"
+					class="input"
+					type="text"
+					placeholder="Playlist Display Name"
+					autofocus
+					@keyup.enter="createPlaylist()"
+				/>
 			</p>
-		</div>
-		<div slot='footer'>
-			<a class='button is-info' @click='createPlaylist()'>Create Playlist</a>
-		</div>
+		</template>
+		<template v-slot:footer>
+			<a class="button is-info" v-on:click="createPlaylist()"
+				>Create Playlist</a
+			>
+		</template>
 	</modal>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from '../Modal.vue';
-	import io from '../../../io';
-	import validation from '../../../validation';
+import { mapActions } from "vuex";
 
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				playlist: {
-					displayName: null,
-					songs: [],
-					createdBy: this.$parent.$parent.username,
-					createdAt: Date.now()
-				}
+import Toast from "toasters";
+import Modal from "../Modal.vue";
+import io from "../../../io";
+import validation from "../../../validation";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			playlist: {
+				displayName: null,
+				songs: []
 			}
-		},
-		methods: {
-			createPlaylist: function () {
-				const displayName = this.playlist.displayName;
-				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);
+		};
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+	},
+	methods: {
+		createPlaylist() {
+			const { displayName } = this.playlist;
+			if (!validation.isLength(displayName, 2, 32))
+				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 => {
+				new Toast({ content: res.message, timeout: 3000 });
 
-				this.socket.emit('playlists.create', this.playlist, res => {
-					Toast.methods.addToast(res.message, 3000);
-				});
-				this.$parent.modals.createPlaylist = !this.$parent.modals.createPlaylist;
-			}
-		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
+				if (res.status === "success") {
+					this.closeModal({
+						sector: "station",
+						modal: "createPlaylist"
+					});
+					this.editPlaylist(res.data._id);
+					this.openModal({
+						sector: "station",
+						modal: "editPlaylist"
+					});
+				}
 			});
 		},
-		events: {
-			closeModal: function() {
-				this.$parent.modals.createPlaylist = !this.$parent.modals.createPlaylist;
-			}
-		}
+		...mapActions("modals", ["closeModal", "openModal"]),
+		...mapActions("user/playlists", ["editPlaylist"])
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.menu { padding: 0 20px; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.menu-list li {
-		display: flex;
-		justify-content: space-between;
-	}
+.menu {
+	padding: 0 20px;
+}
 
-	.menu-list a:hover { color: #000 !important; }
+.menu-list li {
+	display: flex;
+	justify-content: space-between;
+}
 
-	li a {
-		display: flex;
-    	align-items: center;
-	}
+.menu-list a:hover {
+	color: $black !important;
+}
 
-	.controls {
-		display: flex;
+li a {
+	display: flex;
+	align-items: center;
+}
 
-		a {
-			display: flex;
-    		align-items: center;
-		}
-	}
+.controls {
+	display: flex;
 
-	.table {
-		margin-bottom: 0;
+	a {
+		display: flex;
+		align-items: center;
 	}
+}
+
+.table {
+	margin-bottom: 0;
+}
 
-	h5 { padding: 20px 0; }
-</style>
+h5 {
+	padding: 20px 0;
+}
+</style>

+ 354 - 208
frontend/components/Modals/Playlists/Edit.vue

@@ -1,267 +1,413 @@
 <template>
-	<modal title='Edit Playlist'>
-		<div slot='body'>
+	<modal title="Edit Playlist">
+		<div slot="body">
 			<nav class="level">
 				<div class="level-item has-text-centered">
 					<div>
-						<p class="heading">Total Length</p>
-						<p class="title">{{ totalLength() }}</p>
+						<p class="heading">
+							Total Length
+						</p>
+						<p class="title">
+							{{ totalLength() }}
+						</p>
 					</div>
 				</div>
 			</nav>
 			<hr />
-			<aside class='menu' v-if='playlist.songs && playlist.songs.length > 0'>
-				<ul class='menu-list'>
-					<li v-for='song in playlist.songs' track-by='$index'>
-						<a :href='' target='_blank'>{{ song.title }}</a>
-						<div class='controls'>
-							<a href='#' @click='promoteSong(song.songId)'>
-								<i class='material-icons' v-if='$index > 0'>keyboard_arrow_up</i>
-								<i class='material-icons' style='opacity: 0' v-else>error</i>
+			<aside class="menu">
+				<ul class="menu-list">
+					<li v-for="(song, index) in playlist.songs" :key="index">
+						<a href="#" target="_blank">{{ song.title }}</a>
+						<div class="controls">
+							<a href="#" v-on:click="promoteSong(song.songId)">
+								<i class="material-icons" v-if="index > 0"
+									>keyboard_arrow_up</i
+								>
+								<i
+									v-else
+									class="material-icons"
+									style="opacity: 0"
+									>error</i
+								>
 							</a>
-							<a href='#' @click='demoteSong(song.songId)'>
-								<i class='material-icons' v-if='playlist.songs.length - 1 !== $index'>keyboard_arrow_down</i>
-								<i class='material-icons' style='opacity: 0' v-else>error</i>
+							<a href="#" v-on:click="demoteSong(song.songId)">
+								<i
+									v-if="playlist.songs.length - 1 !== index"
+									class="material-icons"
+									>keyboard_arrow_down</i
+								>
+								<i
+									v-else
+									class="material-icons"
+									style="opacity: 0"
+									>error</i
+								>
+							</a>
+							<a
+								href="#"
+								v-on:click="removeSongFromPlaylist(song.songId)"
+							>
+								<i class="material-icons">delete</i>
 							</a>
-							<a href='#' @click='removeSongFromPlaylist(song.songId)'><i class='material-icons'>delete</i></a>
 						</div>
 					</li>
 				</ul>
 				<br />
 			</aside>
-			<div class='control is-grouped'>
-				<p class='control is-expanded'>
-					<input class='input' type='text' placeholder='Search for Song to add' v-model='songQuery' autofocus @keyup.enter='searchForSongs()'>
+			<div class="control is-grouped">
+				<p class="control is-expanded">
+					<input
+						v-model="songQuery"
+						class="input"
+						type="text"
+						placeholder="Search for Song to add"
+						autofocus
+						@keyup.enter="searchForSongs()"
+					/>
 				</p>
-				<p class='control'>
-					<a class='button is-info' @click='searchForSongs()' href="#">Search</a>
+				<p class="control">
+					<a class="button is-info" @click="searchForSongs()" href="#"
+						>Search</a
+					>
 				</p>
 			</div>
-			<table class='table' v-if='songQueryResults.length > 0'>
+			<table v-if="songQueryResults.length > 0" class="table">
 				<tbody>
-				<tr v-for='result in songQueryResults'>
-					<td>
-						<img :src='result.thumbnail' />
-					</td>
-					<td>{{ result.title }}</td>
-					<td>
-						<a class='button is-success' @click='addSongToPlaylist(result.id)' href='#'>
-							Add
-						</a>
-					</td>
-				</tr>
+					<tr
+						v-for="(result, index) in songQueryResults"
+						:key="index"
+					>
+						<td>
+							<img :src="result.thumbnail" />
+						</td>
+						<td>{{ result.title }}</td>
+						<td>
+							<a
+								class="button is-success"
+								href="#"
+								@click="addSongToPlaylist(result.id)"
+								>Add</a
+							>
+						</td>
+					</tr>
 				</tbody>
 			</table>
-			<div class='control is-grouped'>
-				<p class='control is-expanded'>
-					<input class='input' type='text' placeholder='YouTube Playlist URL' v-model='importQuery' @keyup.enter="importPlaylist()">
+			<div class="control is-grouped">
+				<p class="control is-expanded">
+					<input
+						v-model="importQuery"
+						class="input"
+						type="text"
+						placeholder="YouTube Playlist URL"
+						@keyup.enter="importPlaylist()"
+					/>
 				</p>
-				<p class='control'>
-					<a class='button is-info' @click='importPlaylist()' href="#">Import</a>
+				<p class="control">
+					<a class="button is-info" @click="importPlaylist()" href="#"
+						>Import</a
+					>
 				</p>
 			</div>
 			<h5>Edit playlist details:</h5>
-			<div class='control is-grouped'>
-				<p class='control is-expanded'>
-					<input class='input' type='text' placeholder='Playlist Display Name' v-model='playlist.displayName' @keyup.enter="renamePlaylist()">
+			<div class="control is-grouped">
+				<p class="control is-expanded">
+					<input
+						v-model="playlist.displayName"
+						class="input"
+						type="text"
+						placeholder="Playlist Display Name"
+						@keyup.enter="renamePlaylist()"
+					/>
 				</p>
-				<p class='control'>
-					<a class='button is-info' @click='renamePlaylist()' href="#">Rename</a>
+				<p class="control">
+					<a class="button is-info" @click="renamePlaylist()" href="#"
+						>Rename</a
+					>
 				</p>
 			</div>
 		</div>
-		<div slot='footer'>
-			<a class='button is-danger' @click='removePlaylist()' href="#">Remove Playlist</a>
+		<div slot="footer">
+			<a class="button is-danger" v-on:click="removePlaylist()" href="#"
+				>Remove Playlist</a
+			>
 		</div>
 	</modal>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from '../Modal.vue';
-	import io from '../../../io';
-	import validation from '../../../validation';
+import { mapState, mapActions } from "vuex";
 
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				playlist: {songs: []},
-				songQueryResults: [],
-				songQuery: '',
-				importQuery: ''
-			}
-		},
-		methods: {
-			formatTime: function (length) {
-				let duration = moment.duration(length, 'seconds');
-				function getHours() {return Math.floor(duration.asHours());}
-				if (length <= 0) return '0 seconds';
-				else return ((getHours() > 0 ? (getHours() > 1 ? (getHours() < 10 ? ('0' + getHours() + ' hours ') : (getHours() + ' hours ')) : ('0' + getHours() + ' hour ')) : '') + (duration.minutes() > 0 ? (duration.minutes() > 1 ? (duration.minutes() < 10 ? ('0' + duration.minutes() + ' minutes ') : (duration.minutes() + ' minutes ')) : ('0' + duration.minutes() + ' minute ')) : '') + (duration.seconds() > 0 ? (duration.seconds() > 1 ? (duration.seconds() < 10 ? ('0' + duration.seconds() + ' seconds ') : (duration.seconds() + ' seconds ')) : ('0' + duration.seconds() + ' second ')) : ''));
-			},
-			totalLength: function() {
-			    let length = 0;
-			    this.playlist.songs.forEach((song) => {
-			        length += song.duration;
-				});
-			    return this.formatTime(length);
-			},
-			searchForSongs: function () {
-				let _this = this;
-				let query = _this.songQuery;
-				if (query.indexOf('&index=') !== -1) {
-					query = query.split('&index=');
-					query.pop();
-					query = query.join('');
+import Toast from "toasters";
+import Modal from "../Modal.vue";
+import io from "../../../io";
+import validation from "../../../validation";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			playlist: { songs: [] },
+			songQueryResults: [],
+			songQuery: "",
+			importQuery: ""
+		};
+	},
+	computed: mapState("user/playlists", {
+		editing: state => state.editing
+	}),
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			this.socket.emit("playlists.getPlaylist", this.editing, res => {
+				if (res.status === "success") this.playlist = res.data;
+				this.playlist.oldId = res.data._id;
+			});
+			this.socket.on("event:playlist.addSong", data => {
+				if (this.playlist._id === data.playlistId)
+					this.playlist.songs.push(data.song);
+			});
+			this.socket.on("event:playlist.removeSong", data => {
+				if (this.playlist._id === data.playlistId) {
+					this.playlist.songs.forEach((song, index) => {
+						if (song.songId === data.songId)
+							this.playlist.songs.splice(index, 1);
+					});
 				}
-				if (query.indexOf('&list=') !== -1) {
-					query = query.split('&list=');
-					query.pop();
-					query = query.join('');
+			});
+			this.socket.on("event:playlist.updateDisplayName", data => {
+				if (this.playlist._id === data.playlistId)
+					this.playlist.displayName = data.displayName;
+			});
+			this.socket.on("event:playlist.moveSongToBottom", data => {
+				if (this.playlist._id === data.playlistId) {
+					let songIndex;
+					this.playlist.songs.forEach((song, index) => {
+						if (song.songId === data.songId) songIndex = index;
+					});
+					const song = this.playlist.songs.splice(songIndex, 1)[0];
+					this.playlist.songs.push(song);
 				}
-				_this.socket.emit('apis.searchYoutube', query, res => {
-					if (res.status == 'success') {
-						_this.songQueryResults = [];
-						for (let i = 0; i < res.data.items.length; i++) {
-							_this.songQueryResults.push({
-								id: res.data.items[i].id.videoId,
-								url: `https://www.youtube.com/watch?v=${this.id}`,
-								title: res.data.items[i].snippet.title,
-								thumbnail: res.data.items[i].snippet.thumbnails.default.url
-							});
-						}
-					} else if (res.status === 'error') Toast.methods.addToast(res.message, 3000);
-				});
-			},
-			addSongToPlaylist: function (id) {
-				let _this = this;
-				_this.socket.emit('playlists.addSongToPlaylist', id, _this.playlist._id, res => {
-					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			importPlaylist: function () {
-				let _this = this;
-				Toast.methods.addToast('Starting to import your playlist. This can take some time to do.', 4000);
-				this.socket.emit('playlists.addSetToPlaylist', _this.importQuery, _this.playlist._id, res => {
-					if (res.status === 'success') _this.playlist.songs = res.data;
-					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			removeSongFromPlaylist: function (id) {
-				let _this = this;
-				this.socket.emit('playlists.removeSongFromPlaylist', id, _this.playlist._id, res => {
-					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			renamePlaylist: function () {
-				const displayName = this.playlist.displayName;
-				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);
+			});
+			this.socket.on("event:playlist.moveSongToTop", data => {
+				if (this.playlist._id === data.playlistId) {
+					let songIndex;
+					this.playlist.songs.forEach((song, index) => {
+						if (song.songId === data.songId) songIndex = index;
+					});
+					const song = this.playlist.songs.splice(songIndex, 1)[0];
+					this.playlist.songs.unshift(song);
+				}
+			});
+		});
+	},
+	methods: {
+		formatTime(duration) {
+			if (duration <= 0) return "0 seconds";
 
+			const hours = Math.floor(duration / (60 * 60));
+			const formatHours = () => {
+				if (hours > 0) {
+					if (hours > 1) {
+						if (hours < 10) return `0${hours} hours `;
+						return `${hours} hours `;
+					}
+					return `0${hours} hour `;
+				}
+				return "";
+			};
 
-				this.socket.emit('playlists.updateDisplayName', this.playlist._id, this.playlist.displayName, res => {
-					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			removePlaylist: function () {
-				let _this = this;
-				_this.socket.emit('playlists.remove', _this.playlist._id, res => {
-					Toast.methods.addToast(res.message, 3000);
-					if (res.status === 'success') {
-						_this.$parent.modals.editPlaylist = !_this.$parent.modals.editPlaylist;
+			const minutes = Math.floor((duration - hours) / 60);
+			const formatMinutes = () => {
+				if (minutes > 0) {
+					if (minutes > 1) {
+						if (minutes < 10) return `0${minutes} minutes `;
+						return `${minutes} minutes `;
 					}
-				});
-			},
-			promoteSong: function (songId) {
-				let _this = this;
-				_this.socket.emit('playlists.moveSongToTop', _this.playlist._id, songId, res => {
-					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			demoteSong: function (songId) {
-				let _this = this;
-				_this.socket.emit('playlists.moveSongToBottom', _this.playlist._id, songId, res => {
-					Toast.methods.addToast(res.message, 4000);
-				});
-			}
-		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				_this.socket.emit('playlists.getPlaylist', _this.$parent.playlistBeingEdited, res => {
-					if (res.status === 'success') _this.playlist = res.data; _this.playlist.oldId = res.data._id;
-				});
-				_this.socket.on('event:playlist.addSong', data => {
-					if (_this.playlist._id === data.playlistId) _this.playlist.songs.push(data.song);
-				});
-				_this.socket.on('event:playlist.removeSong', data => {
-					if (_this.playlist._id === data.playlistId) {
-						_this.playlist.songs.forEach((song, index) => {
-							if (song.songId === data.songId) _this.playlist.songs.splice(index, 1);
-						});
+					return `0${minutes} minute `;
+				}
+				return "";
+			};
+
+			const seconds = Math.floor(
+				duration - hours * 60 * 60 - minutes * 60
+			);
+			const formatSeconds = () => {
+				if (seconds > 0) {
+					if (seconds > 1) {
+						if (seconds < 10) return `0${seconds} seconds `;
+						return `${seconds} seconds `;
 					}
-				});
-				_this.socket.on('event:playlist.updateDisplayName', data => {
-					if (_this.playlist._id === data.playlistId) _this.playlist.displayName = data.displayName;
-				});
-				_this.socket.on('event:playlist.moveSongToBottom', data => {
-					if (_this.playlist._id === data.playlistId) {
-						let songIndex;
-						_this.playlist.songs.forEach((song, index) => {
-							if (song.songId === data.songId) songIndex = index;
+					return `0${seconds} second `;
+				}
+				return "";
+			};
+
+			return formatHours() + formatMinutes() + formatSeconds();
+		},
+		totalLength() {
+			let length = 0;
+			this.playlist.songs.forEach(song => {
+				length += song.duration;
+			});
+			return this.formatTime(length);
+		},
+		searchForSongs() {
+			let query = this.songQuery;
+			if (query.indexOf("&index=") !== -1) {
+				query = query.split("&index=");
+				query.pop();
+				query = query.join("");
+			}
+			if (query.indexOf("&list=") !== -1) {
+				query = query.split("&list=");
+				query.pop();
+				query = query.join("");
+			}
+			this.socket.emit("apis.searchYoutube", query, res => {
+				if (res.status === "success") {
+					this.songQueryResults = [];
+					for (let i = 0; i < res.data.items.length; i += 1) {
+						this.songQueryResults.push({
+							id: res.data.items[i].id.videoId,
+							url: `https://www.youtube.com/watch?v=${this.id}`,
+							title: res.data.items[i].snippet.title,
+							thumbnail:
+								res.data.items[i].snippet.thumbnails.default.url
 						});
-						let song = _this.playlist.songs.splice(songIndex, 1)[0];
-						_this.playlist.songs.push(song);
 					}
+				} else if (res.status === "error")
+					new Toast({ content: res.message, timeout: 3000 });
+			});
+		},
+		addSongToPlaylist(id) {
+			this.socket.emit(
+				"playlists.addSongToPlaylist",
+				id,
+				this.playlist._id,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		importPlaylist() {
+			new Toast({
+				content:
+					"Starting to import your playlist. This can take some time to do.",
+				timeout: 4000
+			});
+			this.socket.emit(
+				"playlists.addSetToPlaylist",
+				this.importQuery,
+				this.playlist._id,
+				res => {
+					if (res.status === "success")
+						this.playlist.songs = res.data;
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		removeSongFromPlaylist(id) {
+			this.socket.emit(
+				"playlists.removeSongFromPlaylist",
+				id,
+				this.playlist._id,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		renamePlaylist() {
+			const { displayName } = this.playlist;
+			if (!validation.isLength(displayName, 2, 32))
+				return new Toast({
+					content:
+						"Display name must have between 2 and 32 characters.",
+					timeout: 8000
 				});
-				_this.socket.on('event:playlist.moveSongToTop', (data) => {
-					if (_this.playlist._id === data.playlistId) {
-						let songIndex;
-						_this.playlist.songs.forEach((song, index) => {
-							if (song.songId === data.songId) songIndex = index;
-						});
-						let song = _this.playlist.songs.splice(songIndex, 1)[0];
-						_this.playlist.songs.unshift(song);
-					}
+			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 => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		removePlaylist() {
+			this.socket.emit("playlists.remove", this.playlist._id, res => {
+				new Toast({ content: res.message, timeout: 3000 });
+				if (res.status === "success") {
+					this.closeModal();
+				}
 			});
 		},
-		events: {
-			closeModal: function() {
-				this.$parent.modals.editPlaylist = !this.$parent.modals.editPlaylist;
-			}
-		}
+		promoteSong(songId) {
+			this.socket.emit(
+				"playlists.moveSongToTop",
+				this.playlist._id,
+				songId,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		demoteSong(songId) {
+			this.socket.emit(
+				"playlists.moveSongToBottom",
+				this.playlist._id,
+				songId,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		...mapActions("modals", ["closeModal"])
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.menu { padding: 0 20px; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.menu-list li {
-		display: flex;
-		justify-content: space-between;
-	}
+.menu {
+	padding: 0 20px;
+}
 
-	.menu-list a:hover { color: #000 !important; }
+.menu-list li {
+	display: flex;
+	justify-content: space-between;
+}
 
-	li a {
-		display: flex;
-    	align-items: center;
-	}
+.menu-list a:hover {
+	color: $black !important;
+}
 
-	.controls {
-		display: flex;
+li a {
+	display: flex;
+	align-items: center;
+}
 
-		a {
-			display: flex;
-    		align-items: center;
-		}
-	}
+.controls {
+	display: flex;
 
-	.table {
-		margin-bottom: 0;
+	a {
+		display: flex;
+		align-items: center;
 	}
+}
+
+.table {
+	margin-bottom: 0;
+}
 
-	h5 { padding: 20px 0; }
+h5 {
+	padding: 20px 0;
+}
 </style>

+ 159 - 65
frontend/components/Modals/Register.vue

@@ -1,33 +1,78 @@
 <template>
-	<div class='modal is-active'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'>Register</p>
-				<button class='delete' @click='toggleModal()'></button>
+	<div class="modal is-active">
+		<div
+			class="modal-background"
+			@click="
+				closeModal({
+					sector: 'header',
+					modal: 'register'
+				})
+			"
+		/>
+		<div class="modal-card">
+			<header class="modal-card-head">
+				<p class="modal-card-title">
+					Register
+				</p>
+				<button
+					class="delete"
+					@click="
+						closeModal({
+							sector: 'header',
+							modal: 'register'
+						})
+					"
+				/>
 			</header>
-			<section class='modal-card-body'>
+			<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 class='input' type='text' placeholder='Email...' v-model='$parent.register.email' autofocus>
+				<label class="label">Email</label>
+				<p class="control">
+					<input
+						v-model="email"
+						class="input"
+						type="email"
+						placeholder="Email..."
+						autofocus
+					/>
+				</p>
+				<label class="label">Username</label>
+				<p class="control">
+					<input
+						v-model="username"
+						class="input"
+						type="text"
+						placeholder="Username..."
+					/>
 				</p>
-				<label class='label'>Username</label>
-				<p class='control'>
-					<input class='input' type='text' placeholder='Username...' v-model='$parent.register.username'>
+				<label class="label">Password</label>
+				<p class="control">
+					<input
+						v-model="password"
+						class="input"
+						type="password"
+						placeholder="Password..."
+						@keypress="$parent.submitOnEnter(submitModal, $event)"
+					/>
 				</p>
-				<label class='label'>Password</label>
-				<p class='control'>
-					<input class='input' type='password' placeholder='Password...' v-model='$parent.register.password' v-on:keypress='$parent.submitOnEnter(submitModal, $event)'>
+				<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>
-				<div id="recaptcha"></div>
-				<p>By logging in/registering you agree to our <a href="/terms" v-link="{ path: '/terms' }">Terms of Service</a> and <a href="/privacy" v-link="{ path: '/privacy' }">Privacy Policy</a>.</p>
 			</section>
-			<footer class='modal-card-foot'>
-				<a class='button is-primary' href='#' @click='submitModal()'>Submit</a>
-				<a class='button is-github' :href='$parent.serverDomain + "/auth/github/authorize"' @click="githubRedirect()">
-					<div class='icon'>
-						<img class='invert' src='/assets/social/github.svg'/>
+			<footer class="modal-card-foot">
+				<a class="button is-primary" href="#" @click="submitModal()"
+					>Submit</a
+				>
+				<a
+					class="button is-github"
+					:href="serverDomain + '/auth/github/authorize'"
+					@click="githubRedirect()"
+				>
+					<div class="icon">
+						<img class="invert" src="/assets/social/github.svg" />
 					</div>
 					&nbsp;&nbsp;Register with GitHub
 				</a>
@@ -37,56 +82,105 @@
 </template>
 
 <script>
-	export default {
-		data() {
-			return {
-				recaptcha: {
-					key: ''
-				}
-			}
-		},
-		ready: function () {
-			let _this = this;
-			lofig.get('recaptcha', obj => {
-				_this.recaptcha.key = obj.key;
-				_this.recaptcha.id = grecaptcha.render('recaptcha', {
-					'sitekey' : _this.recaptcha.key
+import { mapActions } from "vuex";
+
+import Toast from "toasters";
+
+export default {
+	data() {
+		return {
+			username: "",
+			email: "",
+			password: "",
+			recaptcha: {
+				key: "",
+				token: ""
+			},
+			serverDomain: ""
+		};
+	},
+	mounted() {
+		lofig.get("serverDomain").then(serverDomain => {
+			this.serverDomain = serverDomain;
+		});
+
+		lofig.get("recaptcha").then(obj => {
+			this.recaptcha.key = obj.key;
+
+			const recaptchaScript = document.createElement("script");
+			recaptchaScript.onload = () => {
+				grecaptcha.ready(() => {
+					grecaptcha
+						.execute(this.recaptcha.key, { action: "login" })
+						.then(token => {
+							this.recaptcha.token = token;
+						});
 				});
-			});
+			};
+
+			recaptchaScript.setAttribute(
+				"src",
+				`https://www.google.com/recaptcha/api.js?render=${this.recaptcha.key}`
+			);
+			document.head.appendChild(recaptchaScript);
+		});
+	},
+	methods: {
+		submitModal() {
+			this.register({
+				username: this.username,
+				email: this.email,
+				password: this.password,
+				recaptchaToken: this.recaptcha.token
+			})
+				.then(res => {
+					if (res.status === "success") window.location.reload();
+				})
+				.catch(
+					err => new Toast({ content: err.message, timeout: 5000 })
+				);
 		},
-		methods: {
-			toggleModal: function () {
-				if (this.$router._currentRoute.path === '/register') location.href = '/';
-				else this.$dispatch('toggleModal', 'register');
-			},
-			submitModal: function () {
-				this.$dispatch('register', this.recaptcha.id);
-				this.toggleModal();
-			},
-			githubRedirect: function() {
-				localStorage.setItem('github_redirect', this.$route.path)
-			}
+		githubRedirect() {
+			localStorage.setItem("github_redirect", this.$route.path);
 		},
-		events: {
-			closeModal: function() {
-				this.$dispatch('toggleModal', 'register');
-			}
-		}
+		...mapActions("modals", ["closeModal"]),
+		...mapActions("user/auth", ["register"])
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.button.is-github {
-		background-color: #333;
-		color: #fff !important;
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.is-github:focus { background-color: #1a1a1a; }
-	.is-primary:focus { background-color: #028bca !important; }
+.button.is-github {
+	background-color: $dark-grey-2;
+	color: $white !important;
+}
 
-	.invert { filter: brightness(5); }
+.is-github:focus {
+	background-color: $dark-grey-3;
+}
+.is-primary:focus {
+	background-color: #028bca !important;
+}
+
+.invert {
+	filter: brightness(5);
+}
+
+#recaptcha {
+	padding: 10px 0;
+}
+
+a {
+	color: $primary-color;
+}
+</style>
 
-	#recaptcha { padding: 10px 0; }
+<style lang="scss">
+@import "styles/global.scss";
 
-	a { color: #029ce3; }
+.grecaptcha-badge {
+	z-index: 2000;
+}
 </style>

+ 241 - 175
frontend/components/Modals/Report.vue

@@ -1,89 +1,147 @@
 <template>
-	<modal title='Report'>
-		<div slot='body'>
-			<div class='columns song-types'>
-				<div class='column song-type' v-if='$parent.previousSong !== null'>
-					<div class='card is-fullwidth' :class="{ 'is-highlight-active': isPreviousSongActive }" @click="highlight('previousSong')">
-						<header class='card-header'>
-							<p class='card-header-title'>
+	<modal title="Report">
+		<div slot="body">
+			<div class="columns song-types">
+				<div v-if="previousSong !== null" class="column song-type">
+					<div
+						class="card is-fullwidth"
+						:class="{ 'is-highlight-active': isPreviousSongActive }"
+						@click="highlight('previousSong')"
+					>
+						<header class="card-header">
+							<p class="card-header-title">
 								Previous Song
 							</p>
 						</header>
-						<div class='card-content'>
-							<article class='media'>
-								<figure class='media-left'>
-									<p class='image is-64x64'>
-										<img :src='$parent.previousSong.thumbnail' onerror='this.src="/assets/notes-transparent.png"'>
+						<div class="card-content">
+							<article class="media">
+								<figure class="media-left">
+									<p class="image is-64x64">
+										<img
+											:src="previousSong.thumbnail"
+											onerror='this.src="/assets/notes-transparent.png"'
+										/>
 									</p>
 								</figure>
-								<div class='media-content'>
-									<div class='content'>
+								<div class="media-content">
+									<div class="content">
 										<p>
-											<strong>{{ $parent.previousSong.title }}</strong>
-											<br>
-											<small>{{ $parent.previousSong.artists.split(' ,') }}</small>
+											<strong>{{
+												previousSong.title
+											}}</strong>
+											<br />
+											<small>{{
+												previousSong.artists.split(" ,")
+											}}</small>
 										</p>
 									</div>
 								</div>
 							</article>
 						</div>
-						<a @click=highlight('previousSong') href='#' class='absolute-a'></a>
+						<a
+							href="#"
+							class="absolute-a"
+							@click="highlight('previousSong')"
+						/>
 					</div>
 				</div>
-				<div class='column song-type' v-if='$parent.currentSong !== {}'>
-					<div class='card is-fullwidth'  :class="{ 'is-highlight-active': isCurrentSongActive }" @click="highlight('currentSong')">
-						<header class='card-header'>
-							<p class='card-header-title'>
+				<div v-if="currentSong !== {}" class="column song-type">
+					<div
+						class="card is-fullwidth"
+						:class="{ 'is-highlight-active': isCurrentSongActive }"
+						@click="highlight('currentSong')"
+					>
+						<header class="card-header">
+							<p class="card-header-title">
 								Current Song
 							</p>
 						</header>
-						<div class='card-content'>
-							<article class='media'>
-								<figure class='media-left'>
-									<p class='image is-64x64'>
-										<img :src='$parent.currentSong.thumbnail' onerror='this.src="/assets/notes-transparent.png"'>
+						<div class="card-content">
+							<article class="media">
+								<figure class="media-left">
+									<p class="image is-64x64">
+										<img
+											:src="currentSong.thumbnail"
+											onerror='this.src="/assets/notes-transparent.png"'
+										/>
 									</p>
 								</figure>
-								<div class='media-content'>
-									<div class='content'>
+								<div class="media-content">
+									<div class="content">
 										<p>
-											<strong>{{ $parent.currentSong.title }}</strong>
-											<br>
-											<small>{{ $parent.currentSong.artists.split(' ,') }}</small>
+											<strong>{{
+												currentSong.title
+											}}</strong>
+											<br />
+											<small>{{
+												currentSong.artists.split(" ,")
+											}}</small>
 										</p>
 									</div>
 								</div>
 							</article>
 						</div>
-						<a @click=highlight('currentSong') href='#' class='absolute-a'></a>
+						<a
+							href="#"
+							class="absolute-a"
+							@click="highlight('currentSong')"
+						/>
 					</div>
 				</div>
 			</div>
-			<div class='edit-report-wrapper'>
-				<div class='columns is-multiline'>
-					<div class='column is-half' v-for='issue in issues'>
-						<label class='label'>{{ issue.name }}</label>
-						<p class='control' v-for='reason in issue.reasons' track-by='$index'>
-							<label class='checkbox'>
-								<input type='checkbox' @click='toggleIssue(issue.name, reason)'>
+			<div class="edit-report-wrapper">
+				<div class="columns is-multiline">
+					<div
+						v-for="(issue, index) in issues"
+						class="column is-half"
+						:key="index"
+					>
+						<label class="label">{{ issue.name }}</label>
+						<p
+							v-for="(reason, index) in issue.reasons"
+							class="control"
+							:key="index"
+						>
+							<label class="checkbox">
+								<input
+									type="checkbox"
+									@click="toggleIssue(issue.name, reason)"
+								/>
 								{{ reason }}
 							</label>
 						</p>
 					</div>
-					<div class='column'>
-						<label class='label'>Other</label>
-						<textarea class='textarea' maxlength='400' placeholder='Any other details...' @keyup='updateCharactersRemaining()' v-model='report.description'></textarea>
-						<div class='textarea-counter'>{{ charactersRemaining }}</div>
+					<div class="column">
+						<label class="label">Other</label>
+						<textarea
+							v-model="report.description"
+							class="textarea"
+							maxlength="400"
+							placeholder="Any other details..."
+							@keyup="updateCharactersRemaining()"
+						/>
+						<div class="textarea-counter">
+							{{ charactersRemaining }}
+						</div>
 					</div>
 				</div>
 			</div>
 		</div>
-		<div slot='footer'>
-			<a class='button is-success' @click='create()' href='#'>
-				<i class='material-icons save-changes'>done</i>
+		<div slot="footer">
+			<a class="button is-success" @click="create()" href="#">
+				<i class="material-icons save-changes">done</i>
 				<span>&nbsp;Create</span>
 			</a>
-			<a class='button is-danger' @click='$parent.modals.report = !$parent.modals.report' href='#'>
+			<a
+				class="button is-danger"
+				href="#"
+				@click="
+					closeModal({
+						sector: 'station',
+						modal: 'report'
+					})
+				"
+			>
 				<span>&nbsp;Cancel</span>
 			</a>
 		</div>
@@ -91,151 +149,159 @@
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import io from '../../io';
+import { mapState, mapActions } from "vuex";
 
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				charactersRemaining: 400,
-				isPreviousSongActive: false,
-				isCurrentSongActive: true,
-				report: {
-					resolved: false,
-					songId: this.$parent.currentSong.songId,
-					description: '',
-					issues: [
-						{ name: 'Video', reasons: [] },
-						{ name: 'Title', reasons: [] },
-						{ name: 'Duration', reasons: [] },
-						{ name: 'Artists', reasons: [] },
-						{ name: 'Thumbnail', reasons: [] }
-					]
-				},
+import Toast from "toasters";
+import Modal from "./Modal.vue";
+import io from "../../io";
+
+export default {
+	components: { Modal },
+	data() {
+		return {
+			charactersRemaining: 400,
+			isPreviousSongActive: false,
+			isCurrentSongActive: true,
+			report: {
+				resolved: false,
+				songId: "",
+				description: "",
 				issues: [
-					{
-						name: 'Video',
-						reasons: [
-							'Doesn\'t exist',
-							'It\'s private',
-							'It\'s not available in my country'
-						]
-					},
-					{
-						name: 'Title',
-						reasons: [
-							'Incorrect',
-							'Inappropriate'
-						]
-					},
-					{
-						name: 'Duration',
-						reasons: [
-							'Skips too soon',
-							'Skips too late',
-							'Starts too soon',
-							'Starts too late'
-						]
-					},
-					{
-						name: 'Artists',
-						reasons: [
-							'Incorrect',
-							'Inappropriate'
-						]
-					},
-					{
-						name: 'Thumbnail',
-						reasons: [
-							'Incorrect',
-							'Inappropriate',
-							'Doesn\'t exist'
-						]
-					}
+					{ name: "Video", reasons: [] },
+					{ name: "Title", reasons: [] },
+					{ name: "Duration", reasons: [] },
+					{ name: "Artists", reasons: [] },
+					{ name: "Thumbnail", reasons: [] }
 				]
-			}
-		},
-		methods: {
-			create: function () {
-				let _this = this;
-				console.log(this.report);
-				_this.socket.emit('reports.create', _this.report, res => {
-					Toast.methods.addToast(res.message, 4000);
-					if (res.status == 'success') _this.$parent.modals.report = !_this.$parent.modals.report;
-				});
-			},
-			updateCharactersRemaining: function () {
-				this.charactersRemaining = 400 - $('.textarea').val().length;
 			},
-			highlight: function (type) {
-				if (type == 'currentSong') {
-					this.report.songId = this.$parent.currentSong.songId
-					this.isPreviousSongActive = false;
-					this.isCurrentSongActive = true;
-				} else if (type == 'previousSong') {
-					this.report.songId = this.$parent.previousSong.songId
-					this.isCurrentSongActive = false;
-					this.isPreviousSongActive = true;
-				}
-			},
-			toggleIssue: function (name, reason) {
-				for (let z = 0; z < this.report.issues.length; z++) {
-					if (this.report.issues[z].name == name) {
-						if (this.report.issues[z].reasons.indexOf(reason) > -1) {
-							this.report.issues[z].reasons.splice(
-								this.report.issues[z].reasons.indexOf(reason), 1
-							);
-						} else this.report.issues[z].reasons.push(reason);
-					}
+			issues: [
+				{
+					name: "Video",
+					reasons: [
+						"Doesn't exist",
+						"It's private",
+						"It's not available in my country"
+					]
+				},
+				{
+					name: "Title",
+					reasons: ["Incorrect", "Inappropriate"]
+				},
+				{
+					name: "Duration",
+					reasons: [
+						"Skips too soon",
+						"Skips too late",
+						"Starts too soon",
+						"Starts too late"
+					]
+				},
+				{
+					name: "Artists",
+					reasons: ["Incorrect", "Inappropriate"]
+				},
+				{
+					name: "Thumbnail",
+					reasons: ["Incorrect", "Inappropriate", "Doesn't exist"]
 				}
-			}
+			]
+		};
+	},
+	computed: mapState({
+		currentSong: state => state.station.currentSong,
+		previousSong: state => state.station.previousSong
+	}),
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+
+		this.report.songId = this.currentSong.songId;
+	},
+	methods: {
+		create() {
+			console.log(this.report);
+			this.socket.emit("reports.create", this.report, res => {
+				new Toast({ content: res.message, timeout: 4000 });
+				if (res.status === "success")
+					this.closeModal({
+						sector: "station",
+						modal: "report"
+					});
+			});
 		},
-		events: {
-			closeModal: function () {
-				this.$parent.modals.report = !this.$parent.modals.report;
+		updateCharactersRemaining() {
+			this.charactersRemaining =
+				400 - document.getElementsByClassName("textarea").value.length;
+		},
+		highlight(type) {
+			if (type === "currentSong") {
+				this.report.songId = this.currentSong.songId;
+				this.isPreviousSongActive = false;
+				this.isCurrentSongActive = true;
+			} else if (type === "previousSong") {
+				this.report.songId = this.previousSong.songId;
+				this.isCurrentSongActive = false;
+				this.isPreviousSongActive = true;
 			}
 		},
-		ready: function () {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-			});
+		toggleIssue(name, reason) {
+			for (let z = 0; z < this.report.issues.length; z += 1) {
+				if (this.report.issues[z].name === name) {
+					if (this.report.issues[z].reasons.indexOf(reason) > -1) {
+						this.report.issues[z].reasons.splice(
+							this.report.issues[z].reasons.indexOf(reason),
+							1
+						);
+					} else this.report.issues[z].reasons.push(reason);
+				}
+			}
 		},
+		...mapActions("modals", ["closeModal"])
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	h6 { margin-bottom: 15px; }
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.song-type:first-of-type { padding-left: 0; }
-	.song-type:last-of-type { padding-right: 0; }
+h6 {
+	margin-bottom: 15px;
+}
 
-	.media-content {
-		display: flex;
-		align-items: center;
-		height: 64px;
-	}
+.song-type:first-of-type {
+	padding-left: 0;
+}
+.song-type:last-of-type {
+	padding-right: 0;
+}
 
-	.radio-controls .control {
-		display: flex;
-		align-items: center;
-	}
+.media-content {
+	display: flex;
+	align-items: center;
+	height: 64px;
+}
 
-	.textarea-counter {
-		text-align: right;
-	}
+.radio-controls .control {
+	display: flex;
+	align-items: center;
+}
 
-	@media screen and (min-width: 769px) {
-		.radio-controls .control-label { padding-top: 0 !important; }
-	}
+.textarea-counter {
+	text-align: right;
+}
 
-	.edit-report-wrapper {
-		padding: 20px;
+@media screen and (min-width: 769px) {
+	.radio-controls .control-label {
+		padding-top: 0 !important;
 	}
+}
 
-	.is-highlight-active {
-		border: 3px #03a9f4 solid;
-	}
+.edit-report-wrapper {
+	padding: 20px;
+}
+
+.is-highlight-active {
+	border: 3px $primary-color solid;
+}
 </style>

+ 91 - 47
frontend/components/Modals/ViewPunishment.vue

@@ -1,21 +1,70 @@
 <template>
 	<div>
-		<modal title='View Punishment'>
-			<div slot='body'>
+		<modal title="View Punishment">
+			<div slot="body">
 				<article class="message">
 					<div class="message-body">
-						<strong>Type: </strong>{{ punishment.type }}<br/>
-						<strong>Value: </strong>{{ punishment.value }}<br/>
-						<strong>Reason: </strong>{{ punishment.reason }}<br/>
-						<strong>Active: </strong>{{ punishment.active }}<br/>
-						<strong>Expires at: </strong>{{ moment(punishment.expiresAt).format('MMMM Do YYYY, h:mm:ss a'); }} ({{ moment(punishment.expiresAt).fromNow() }})<br/>
-						<strong>Punished at: </strong>{{ moment(punishment.punishedAt).format('MMMM Do YYYY, h:mm:ss a') }} ({{ moment(punishment.punishedAt).fromNow() }})<br/>
-						<strong>Punished by: </strong>{{ punishment.punishedBy }}<br/>
+						<strong>Type:</strong>
+						{{ punishment.type }}
+						<br />
+						<strong>Value:</strong>
+						{{ punishment.value }}
+						<br />
+						<strong>Reason:</strong>
+						{{ punishment.reason }}
+						<br />
+						<strong>Active:</strong>
+						{{ punishment.active }}
+						<br />
+						<strong>Expires at:</strong>
+						{{
+							format(
+								parseISO(punishment.expiresAt),
+								"MMMM do yyyy, h:mm:ss a"
+							)
+						}}
+						({{
+							formatDistance(
+								parseISO(punishment.expiresAt),
+								new Date(),
+								{ addSuffix: true }
+							)
+						}})
+						<br />
+						<strong>Punished at:</strong>
+						{{
+							format(
+								parseISO(punishment.punishedAt),
+								"MMMM do yyyy, h:mm:ss a"
+							)
+						}}
+						({{
+							formatDistance(
+								parseISO(punishment.punishedAt),
+								new Date(),
+								{ addSuffix: true }
+							)
+						}})
+						<br />
+						<strong>Punished by:</strong>
+						<user-id-to-username
+							:userId="punishment.punishedBy"
+							:alt="punishment.punishedBy"
+						/>
+						<br />
 					</div>
 				</article>
 			</div>
-			<div slot='footer'>
-				<button class='button is-danger' @click='$parent.toggleModal()'>
+			<div slot="footer">
+				<button
+					class="button is-danger"
+					@click="
+						closeModal({
+							sector: 'admin',
+							modal: 'viewPunishment'
+						})
+					"
+				>
 					<span>&nbsp;Close</span>
 				</button>
 			</div>
@@ -24,41 +73,36 @@
 </template>
 
 <script>
-	import io from '../../io';
-	import { Toast } from 'vue-roaster';
-	import Modal from './Modal.vue';
-	import validation from '../../validation';
+import { mapState, mapActions } from "vuex";
+import { format, formatDistance, parseISO } from "date-fns"; // eslint-disable-line no-unused-vars
 
-	export default {
-		components: { Modal },
-		data() {
-			return {
-				punishment: {},
-				ban: {},
-				moment
-			}
-		},
-		methods: {},
-		ready: function () {
-			let _this = this;
-			io.getSocket(socket => _this.socket = socket );
-		},
-		events: {
-			closeModal: function () {
-				this.$parent.modals.viewPunishment = false;
-			},
-			viewPunishment: function (punishment) {
-				this.punishment = {
-					type: punishment.type,
-					value: punishment.value,
-					reason: punishment.reason,
-					active: punishment.active,
-					expiresAt: punishment.expiresAt,
-					punishedAt: punishment.punishedAt,
-					punishedBy: punishment.punishedBy
-				};
-				this.$parent.toggleModal();
-			}
-		}
+import io from "../../io";
+import Modal from "./Modal.vue";
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+export default {
+	components: { Modal, UserIdToUsername },
+	data() {
+		return {
+			ban: {}
+		};
+	},
+	computed: {
+		...mapState("admin/punishments", {
+			punishment: state => state.punishment
+		})
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+			return socket;
+		});
+	},
+	methods: {
+		...mapActions("modals", ["closeModal"]),
+		format,
+		formatDistance,
+		parseISO
 	}
-</script>
+};
+</script>

+ 148 - 87
frontend/components/Modals/WhatIsNew.vue

@@ -1,37 +1,69 @@
 <template>
-	<div class='modal' :class='{ "is-active": isModalActive }' v-if='news !== null'>
-		<div class='modal-background'></div>
-		<div class='modal-card'>
-			<header class='modal-card-head'>
-				<p class='modal-card-title'><strong>{{ news.title }}</strong> ({{ formatDate(news.createdAt) }})</p>
-				<button class='delete' @click='toggleModal()'></button>
+	<div
+		v-if="news !== null"
+		class="modal"
+		:class="{ 'is-active': isModalActive }"
+	>
+		<div class="modal-background" />
+		<div class="modal-card">
+			<header class="modal-card-head">
+				<p class="modal-card-title">
+					<strong>{{ news.title }}</strong>
+					({{ formatDate(news.createdAt) }})
+				</p>
+				<button class="delete" v-on:click="toggleModal()" />
 			</header>
-			<section class='modal-card-body'>
-				<div class='content'>
+			<section class="modal-card-body">
+				<div class="content">
 					<p>{{ news.description }}</p>
 				</div>
-				<div class='sect' v-show='news.features.length > 0'>
-					<div class='sect-head-features'>The features are so great</div>
-					<ul class='sect-body'>
-						<li v-for='li in news.features'>{{ li }}</li>
+				<div v-show="news.features.length > 0" class="sect">
+					<div class="sect-head-features">
+						The features are so great
+					</div>
+					<ul class="sect-body">
+						<li
+							v-for="(feature, index) in news.features"
+							:key="index"
+						>
+							{{ feature }}
+						</li>
 					</ul>
 				</div>
-				<div class='sect' v-show='news.improvements.length > 0'>
-					<div class='sect-head-improvements'>Improvements</div>
-					<ul class='sect-body'>
-						<li v-for='li in news.improvements'>{{ li }}</li>
+				<div v-show="news.improvements.length > 0" class="sect">
+					<div class="sect-head-improvements">
+						Improvements
+					</div>
+					<ul class="sect-body">
+						<li
+							v-for="(improvement, index) in news.improvements"
+							:key="index"
+						>
+							{{ improvement }}
+						</li>
 					</ul>
 				</div>
-				<div class='sect' v-show='news.bugs.length > 0'>
-					<div class='sect-head-bugs'>Bugs Smashed</div>
-					<ul class='sect-body'>
-						<li v-for='li in news.bugs'>{{ li }}</li>
+				<div v-show="news.bugs.length > 0" class="sect">
+					<div class="sect-head-bugs">
+						Bugs Smashed
+					</div>
+					<ul class="sect-body">
+						<li v-for="(bug, index) in news.bugs" :key="index">
+							{{ bug }}
+						</li>
 					</ul>
 				</div>
-				<div class='sect' v-show='news.upcoming.length > 0'>
-					<div class='sect-head-upcoming'>Coming Soon to a Musare near you</div>
-					<ul class='sect-body'>
-						<li v-for='li in news.upcoming'>{{ li }}</li>
+				<div v-show="news.upcoming.length > 0" class="sect">
+					<div class="sect-head-upcoming">
+						Coming Soon to a Musare near you
+					</div>
+					<ul class="sect-body">
+						<li
+							v-for="(upcoming, index) in news.upcoming"
+							:key="index"
+						>
+							{{ upcoming }}
+						</li>
 					</ul>
 				</div>
 			</section>
@@ -40,88 +72,117 @@
 </template>
 
 <script>
-	import io from '../../io';
+import { format } from "date-fns";
 
-	export default {
-		data() {
-			return {
-				isModalActive: false,
-				news: null
-			}
-		},
-		ready: function () {
-			let _this = this;
-			io.getSocket(true, socket => {
-				_this.socket = socket;
-				_this.socket.emit('news.newest', res => {
-					_this.news = res.data;
-					if (_this.news && localStorage.getItem('firstVisited')) {
-						if (localStorage.getItem('whatIsNew')) {
-							if (parseInt(localStorage.getItem('whatIsNew')) < res.data.createdAt) {
-								this.toggleModal();
-								localStorage.setItem('whatIsNew', res.data.createdAt);
-							}
-						} else {
-							if (parseInt(localStorage.getItem('firstVisited')) < res.data.createdAt) {
-								this.toggleModal();
-							}
-							localStorage.setItem('whatIsNew', res.data.createdAt);
+import io from "../../io";
+
+export default {
+	data() {
+		return {
+			isModalActive: false,
+			news: null
+		};
+	},
+	mounted() {
+		io.getSocket(true, socket => {
+			this.socket = socket;
+			this.socket.emit("news.newest", res => {
+				this.news = res.data;
+				if (this.news && localStorage.getItem("firstVisited")) {
+					if (localStorage.getItem("whatIsNew")) {
+						if (
+							parseInt(localStorage.getItem("whatIsNew")) <
+							res.data.createdAt
+						) {
+							this.toggleModal();
+							localStorage.setItem(
+								"whatIsNew",
+								res.data.createdAt
+							);
 						}
 					} else {
-						if (!localStorage.getItem('firstVisited')) localStorage.setItem('firstVisited', Date.now());
+						if (
+							parseInt(localStorage.getItem("firstVisited")) <
+							res.data.createdAt
+						) {
+							this.toggleModal();
+						}
+						localStorage.setItem("whatIsNew", res.data.createdAt);
 					}
-				});
+				} else if (!localStorage.getItem("firstVisited"))
+					localStorage.setItem("firstVisited", Date.now());
 			});
+		});
+	},
+	methods: {
+		toggleModal() {
+			this.isModalActive = !this.isModalActive;
 		},
-		methods: {
-			toggleModal: function () {
-				this.isModalActive = !this.isModalActive;
-			},
-			formatDate: unix => {
-				return moment(unix).format('DD-MM-YYYY');
-			}
-		},
-		events: {
-			closeModal: function() {
-				this.isModalActive = false;
-			}
+		formatDate: unix => {
+			return format(unix, "dd-MM-yyyy");
+		}
+	},
+	events: {
+		closeModal() {
+			this.isModalActive = false;
 		}
 	}
+};
 </script>
 
-<style lang='scss' scoped>
-	.modal-card-head {
-		border-bottom: none;
-		background-color: ghostwhite;
-		padding: 15px;
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.modal-card-head {
+	border-bottom: none;
+	background-color: ghostwhite;
+	padding: 15px;
+}
 
-	.modal-card-title { font-size: 14px; }
+.modal-card-title {
+	font-size: 14px;
+}
 
-	.delete {
+.delete {
+	background: transparent;
+	&:hover {
 		background: transparent;
-		&:hover { background: transparent; }
+	}
 
-		&:before, &:after { background-color: #bbb; }
+	&:before,
+	&:after {
+		background-color: #bbb;
 	}
+}
 
-	.sect {
-		div[class^='sect-head'], div[class*=' sect-head']{
-			padding: 12px;
-			text-transform: uppercase;
-			font-weight: bold;
-			color: #fff;
-		}
+.sect {
+	div[class^="sect-head"],
+	div[class*=" sect-head"] {
+		padding: 12px;
+		text-transform: uppercase;
+		font-weight: bold;
+		color: $white;
+	}
 
-		.sect-head-features { background-color: dodgerblue; }
-		.sect-head-improvements { background-color: seagreen; }
-		.sect-head-bugs { background-color: brown; }
-		.sect-head-upcoming { background-color: mediumpurple; }
+	.sect-head-features {
+		background-color: dodgerblue;
+	}
+	.sect-head-improvements {
+		background-color: seagreen;
+	}
+	.sect-head-bugs {
+		background-color: brown;
+	}
+	.sect-head-upcoming {
+		background-color: mediumpurple;
+	}
 
-		.sect-body {
-			padding: 15px 25px;
+	.sect-body {
+		padding: 15px 25px;
 
-			li { list-style-type: disc; }
+		li {
+			list-style-type: disc;
 		}
 	}
+}
 </style>

+ 183 - 130
frontend/components/Sidebars/Playlist.vue

@@ -1,160 +1,213 @@
 <template>
-	<div class='sidebar' transition='slide' v-if='$parent.sidebars.playlist'>
-		<div class='inner-wrapper'>
-			<div class='title'>Playlists</div>
-
-			<aside class='menu' v-if='playlists.length > 0'>
-				<ul class='menu-list'>
-					<li v-for='playlist in playlists'>
+	<div class="sidebar" transition="slide">
+		<div class="inner-wrapper">
+			<div class="title">
+				Playlists
+			</div>
+
+			<aside v-if="playlists.length > 0" class="menu">
+				<ul class="menu-list">
+					<li v-for="(playlist, index) in playlists" :key="index">
 						<span>{{ playlist.displayName }}</span>
 						<!--Will play playlist in community station Kris-->
-						<div class='icons-group'>
-							<a href='#' @click='selectPlaylist(playlist._id)' v-if="isNotSelected(playlist._id) && !this.$parent.$parent.station.partyMode">
-								<i class='material-icons'>play_arrow</i>
+						<div class="icons-group">
+							<a
+								v-if="
+									isNotSelected(playlist._id) &&
+										!station.partyMode
+								"
+								href="#"
+								@click="selectPlaylist(playlist._id)"
+							>
+								<i class="material-icons">play_arrow</i>
 							</a>
-							<a href='#' @click='editPlaylist(playlist._id)'>
-								<i class='material-icons'>edit</i>
+							<a href="#" v-on:click="edit(playlist._id)">
+								<i class="material-icons">edit</i>
 							</a>
 						</div>
 					</li>
 				</ul>
 			</aside>
 
-			<div class='none-found' v-else>No Playlists found</div>
-
-			<a class='button create-playlist' href='#' @click='$parent.modals.createPlaylist = !$parent.modals.createPlaylist'>Create Playlist</a>
+			<div v-else class="none-found">
+				No Playlists found
+			</div>
+
+			<a
+				class="button create-playlist"
+				href="#"
+				@click="
+					openModal({ sector: 'station', modal: 'createPlaylist' })
+				"
+				>Create Playlist</a
+			>
 		</div>
 	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-	import { Edit } from '../Modals/Playlists/Edit.vue';
-	import io from '../../io';
-
-	export default {
-		data() {
-			return {
-				playlists: []
-			}
+import { mapState, mapActions } from "vuex";
+
+import Toast from "toasters";
+import io from "../../io";
+
+export default {
+	data() {
+		return {
+			playlists: []
+		};
+	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.station
+		}),
+		...mapState({
+			station: state => state.station.station
+		})
+	},
+	methods: {
+		edit(id) {
+			this.editPlaylist(id);
+			this.openModal({ sector: "station", modal: "editPlaylist" });
 		},
-		methods: {
-			editPlaylist: function(id) {
-				this.$parent.editPlaylist(id);
-			},
-			selectPlaylist: function(id) {
-				this.socket.emit('stations.selectPrivatePlaylist', this.$parent.station._id, id, (res) => {
-					if (res.status === 'failure') return Toast.methods.addToast(res.message, 8000);
-					Toast.methods.addToast(res.message, 4000);
-				});
-			},
-			isNotSelected: function(id) {
-				let _this = this;
-				//TODO Also change this once it changes for a station
-				if (_this.$parent.station && _this.$parent.station.privatePlaylist === id) return false;
-				return true;
-			}
+		selectPlaylist(id) {
+			this.socket.emit(
+				"stations.selectPrivatePlaylist",
+				this.station._id,
+				id,
+				res => {
+					if (res.status === "failure")
+						return new Toast({
+							content: res.message,
+							timeout: 8000
+						});
+					return new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
 		},
-		ready: function () {
-			// TODO: Update when playlist is removed/created
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-				_this.socket.emit('playlists.indexForUser', res => {
-					if (res.status == 'success') _this.playlists = res.data;
-				});
-				_this.socket.on('event:playlist.create', (playlist) => {
-					_this.playlists.push(playlist);
-				});
-				_this.socket.on('event:playlist.delete', (playlistId) => {
-					_this.playlists.forEach((playlist, index) => {
-						if (playlist._id === playlistId) {
-							_this.playlists.splice(index, 1);
-						}
-					});
+		isNotSelected(id) {
+			// TODO Also change this once it changes for a station
+			if (this.station && this.station.privatePlaylist === id)
+				return false;
+			return true;
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("user/playlists", ["editPlaylist"])
+	},
+	mounted() {
+		// TODO: Update when playlist is removed/created
+		io.getSocket(socket => {
+			this.socket = socket;
+			this.socket.emit("playlists.indexForUser", res => {
+				if (res.status === "success") this.playlists = res.data;
+			});
+			this.socket.on("event:playlist.create", playlist => {
+				this.playlists.push(playlist);
+			});
+			this.socket.on("event:playlist.delete", playlistId => {
+				this.playlists.forEach((playlist, index) => {
+					if (playlist._id === playlistId) {
+						this.playlists.splice(index, 1);
+					}
 				});
-				_this.socket.on('event:playlist.addSong', (data) => {
-					_this.playlists.forEach((playlist, index) => {
-						if (playlist._id === data.playlistId) {
-							_this.playlists[index].songs.push(data.song);
-						}
-					});
+			});
+			this.socket.on("event:playlist.addSong", data => {
+				this.playlists.forEach((playlist, index) => {
+					if (playlist._id === data.playlistId) {
+						this.playlists[index].songs.push(data.song);
+					}
 				});
-				_this.socket.on('event:playlist.removeSong', (data) => {
-					_this.playlists.forEach((playlist, index) => {
-						if (playlist._id === data.playlistId) {
-							_this.playlists[index].songs.forEach((song, index2) => {
-								if (song._id === data.songId) {
-									_this.playlists[index].songs.splice(index2, 1);
-								}
-							});
-						}
-					});
+			});
+			this.socket.on("event:playlist.removeSong", data => {
+				this.playlists.forEach((playlist, index) => {
+					if (playlist._id === data.playlistId) {
+						this.playlists[index].songs.forEach((song, index2) => {
+							if (song._id === data.songId) {
+								this.playlists[index].songs.splice(index2, 1);
+							}
+						});
+					}
 				});
-				_this.socket.on('event:playlist.updateDisplayName', (data) => {
-					_this.playlists.forEach((playlist, index) => {
-						if (playlist._id === data.playlistId) {
-							_this.playlists[index].displayName = data.displayName;
-						}
-					});
+			});
+			this.socket.on("event:playlist.updateDisplayName", data => {
+				this.playlists.forEach((playlist, index) => {
+					if (playlist._id === data.playlistId) {
+						this.playlists[index].displayName = data.displayName;
+					}
 				});
 			});
-		}
+		});
 	}
+};
 </script>
 
-<style type='scss' scoped>
-	.sidebar {
-		position: fixed;
-		z-index: 1;
-		top: 0;
-		right: 0;
-		width: 300px;
-		height: 100vh;
-		background-color: #fff;
-		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
-	}
-
-	.icons-group a {
-		display: flex;
-    	align-items: center;
-	}
-
-	.menu-list li { align-items: center; }
-
-	.inner-wrapper {	
-		top: 64px;
-		position: relative;
-	}
-
-	.slide-transition {
-		transition: transform 0.6s ease-in-out;
-		transform: translateX(0);
-	}
-
-	.slide-enter, .slide-leave { transform: translateX(100%); }
-
-	.title {
-		background-color: rgb(3, 169, 244);
-		text-align: center;
-		padding: 10px;
-		color: white;
-		font-weight: 600;
-	}
-
-	.create-playlist {
-		width: 100%;
-    	margin-top: 20px;
-		height: 40px;
-		border-radius: 0;
-		background: rgba(3, 169, 244, 1);
-    	color: #fff !important;
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.sidebar {
+	position: fixed;
+	z-index: 1;
+	top: 0;
+	right: 0;
+	width: 300px;
+	height: 100vh;
+	background-color: $white;
+	box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16),
+		0 2px 10px 0 rgba(0, 0, 0, 0.12);
+}
+
+.icons-group a {
+	display: flex;
+	align-items: center;
+}
+
+.menu-list li {
+	align-items: center;
+}
+
+.inner-wrapper {
+	top: 60px;
+	position: relative;
+}
+
+.slide-transition {
+	transition: transform 0.6s ease-in-out;
+	transform: translateX(0);
+}
+
+.slide-enter,
+.slide-leave {
+	transform: translateX(100%);
+}
+
+.title {
+	background-color: rgb(3, 169, 244);
+	text-align: center;
+	padding: 10px;
+	color: $white;
+	font-weight: 600;
+}
+
+.create-playlist {
+	width: 100%;
+	margin-top: 20px;
+	height: 40px;
+	border-radius: 0;
+	background: rgba(3, 169, 244, 1);
+	color: $white !important;
+	border: 0;
+
+	&:active,
+	&:focus {
 		border: 0;
-
-		&:active, &:focus { border: 0; }
 	}
+}
 
-	.create-playlist:focus { background: #029ce3; }
+.create-playlist:focus {
+	background: $primary-color;
+}
 
-	.none-found { text-align: center; }
+.none-found {
+	text-align: center;
+}
 </style>

+ 237 - 121
frontend/components/Sidebars/SongsList.vue

@@ -1,171 +1,287 @@
 <template>
-	<div class='sidebar' transition='slide' v-if='$parent.sidebars.songslist'>
-		<div class='inner-wrapper'>
-			<div class='title' v-if='$parent.type === "community"'>Queue</div>
-			<div class='title' v-else>Playlist</div>
+	<div class="sidebar" transition="slide">
+		<div class="inner-wrapper">
+			<div v-if="station.type === 'community'" class="title">
+				Queue
+			</div>
+			<div v-else class="title">
+				Playlist
+			</div>
 
-			<article class="media" v-if="!$parent.noSong">
-				<figure class="media-left" v-if="$parent.currentSong.thumbnail">
+			<article v-if="!noSong" class="media">
+				<figure v-if="currentSong.thumbnail" class="media-left">
 					<p class="image is-64x64">
-						<img :src="$parent.currentSong.thumbnail" onerror="this.src='/assets/notes-transparent.png'">
+						<img
+							:src="currentSong.thumbnail"
+							onerror="this.src='/assets/notes-transparent.png'"
+						/>
 					</p>
 				</figure>
 				<div class="media-content">
 					<div class="content">
 						<p>
-							Current Song: <strong>{{ $parent.currentSong.title }}</strong>
-							<br>
-							<small>{{ $parent.currentSong.artists }}</small>
+							Current Song:
+							<strong>{{ currentSong.title }}</strong>
+							<br />
+							<small>{{ currentSong.artists }}</small>
 						</p>
 					</div>
 				</div>
 				<div class="media-right">
-					{{ $parent.formatTime($parent.currentSong.duration) }}
+					{{ $parent.formatTime(currentSong.duration) }}
 				</div>
 			</article>
-			<p v-if="$parent.noSong" class="center">There is currently no song playing.</p>
+			<p v-if="noSong" class="center">
+				There is currently no song playing.
+			</p>
 
-			<article class="media" v-for='song in $parent.songsList'>
+			<article
+				v-else
+				v-for="(song, index) in songsList"
+				:key="index"
+				class="media"
+			>
 				<div class="media-content">
-					<div class="content" style="display: block;padding-top: 10px;">
-							<strong class="songTitle">{{ song.title }}</strong>
-							<small>{{ song.artists.join(', ') }}</small>
-							<div v-if="this.$parent.$parent.type === 'community' && this.$parent.$parent.station.partyMode === true">
-								<small>Requested by <b>{{this.$parent.$parent.$parent.getUsernameFromId(song.requestedBy)}} {{this.userIdMap['Z' + song.requestedBy]}}</b></small>
-								<i class="material-icons" style="vertical-align: middle;" @click="removeFromQueue(song.songId)" v-if="isOwnerOnly() || isAdminOnly()">delete_forever</i>
-							</div>
+					<div
+						class="content"
+						style="display: block;padding-top: 10px;"
+					>
+						<strong class="songTitle">{{ song.title }}</strong>
+						<small>{{ song.artists.join(", ") }}</small>
+						<div
+							v-if="
+								station.type === 'community' &&
+									station.partyMode === true
+							"
+						>
+							<small>
+								Requested by
+								<b>
+									<user-id-to-username
+										:userId="song.requestedBy"
+										:link="true"
+									/>
+								</b>
+							</small>
+							<i
+								v-if="isOwnerOnly() || isAdminOnly()"
+								class="material-icons"
+								style="vertical-align: middle;"
+								@click="removeFromQueue(song.songId)"
+								>delete_forever</i
+							>
+						</div>
 					</div>
 				</div>
 				<div class="media-right">
-					{{ $parent.$parent.formatTime(song.duration) }}
+					{{ $parent.formatTime(song.duration) }}
 				</div>
 			</article>
-			<div v-if="$parent.type === 'community' && $parent.$parent.loggedIn && $parent.station.partyMode === true">
-				<button class='button add-to-queue' @click='$parent.modals.addSongToQueue = !$parent.modals.addSongToQueue' v-if="($parent.station.locked && isOwnerOnly()) || !$parent.station.locked || ($parent.station.locked && isAdminOnly() && dismissedWarning)">Add Song to Queue</button>
-				<button class='button add-to-queue add-to-queue-warning' @click='dismissedWarning = true' v-if="$parent.station.locked && isAdminOnly() && !isOwnerOnly() && !dismissedWarning">THIS STATION'S QUEUE IS LOCKED.</button>
-				<button class='button add-to-queue add-to-queue-disabled' v-if="$parent.station.locked && !isAdminOnly() && !isOwnerOnly()">THIS STATION'S QUEUE IS LOCKED.</button>
+			<div
+				v-if="
+					station.type === 'community' &&
+						loggedIn &&
+						station.partyMode === true
+				"
+			>
+				<button
+					v-if="
+						(station.locked && isOwnerOnly()) ||
+							!station.locked ||
+							(station.locked &&
+								isAdminOnly() &&
+								dismissedWarning)
+					"
+					class="button add-to-queue"
+					@click="
+						openModal({
+							sector: 'station',
+							modal: 'addSongToQueue'
+						})
+					"
+				>
+					Add Song to Queue
+				</button>
+				<button
+					v-if="
+						station.locked &&
+							isAdminOnly() &&
+							!isOwnerOnly() &&
+							!dismissedWarning
+					"
+					class="button add-to-queue add-to-queue-warning"
+					@click="dismissedWarning = true"
+				>
+					THIS STATION'S QUEUE IS LOCKED.
+				</button>
+				<button
+					v-if="station.locked && !isAdminOnly() && !isOwnerOnly()"
+					class="button add-to-queue add-to-queue-disabled"
+				>
+					THIS STATION'S QUEUE IS LOCKED.
+				</button>
 			</div>
 		</div>
 	</div>
 </template>
 
 <script>
-	import io from '../../io';
-	import { Toast } from 'vue-roaster';
-
-	export default {
-		data: function () {
-			return {
-				dismissedWarning: false,
-				userIdMap: this.$parent.$parent.userIdMap
-			}
+import { mapState, mapActions } from "vuex";
+
+import Toast from "toasters";
+
+import UserIdToUsername from "../UserIdToUsername.vue";
+
+export default {
+	data() {
+		return {
+			dismissedWarning: false
+		};
+	},
+	computed: mapState({
+		loggedIn: state => state.user.auth.loggedIn,
+		userId: state => state.user.auth.userId,
+		role: state => state.user.auth.role,
+		station: state => state.station.station,
+		currentSong: state => state.station.currentSong,
+		songsList: state => state.station.songsList,
+		noSong: state => state.station.noSong
+	}),
+	methods: {
+		isOwnerOnly() {
+			return this.loggedIn && this.userId === this.station.owner;
 		},
-		methods: {
-			isOwnerOnly: function () {
-				return this.$parent.$parent.loggedIn && this.$parent.$parent.userId === this.$parent.station.owner;
-			},
-			isAdminOnly: function() {
-				return this.$parent.$parent.loggedIn && this.$parent.$parent.role === 'admin';
-			},
-			removeFromQueue: function(songId) {
-				socket.emit('stations.removeFromQueue', this.$parent.station._id, songId, res => {
-					if (res.status === 'success') {
-						Toast.methods.addToast('Successfully removed song from the queue.', 4000);
-					} else Toast.methods.addToast(res.message, 8000);
-				});
-			}
+		isAdminOnly() {
+			return this.loggedIn && this.role === "admin";
 		},
-		ready: function () {
-			/*let _this = this;
+		removeFromQueue(songId) {
+			window.socket.emit(
+				"stations.removeFromQueue",
+				this.station._id,
+				songId,
+				res => {
+					if (res.status === "success") {
+						new Toast({
+							content:
+								"Successfully removed song from the queue.",
+							timeout: 4000
+						});
+					} else new Toast({ content: res.message, timeout: 8000 });
+				}
+			);
+		},
+		...mapActions("modals", ["openModal"])
+	},
+	mounted() {
+		/*
 			io.getSocket((socket) => {
-				_this.socket = socket;
+				this.socket = socket;
 
-			});*/
-		}
-	}
+			}); */
+	},
+	components: { UserIdToUsername }
+};
 </script>
 
-<style type='scss' scoped>
-	.sidebar {
-		position: fixed;
-		z-index: 1;
-		top: 0;
-		right: 0;
-		width: 300px;
-		height: 100vh;
-		background-color: #fff;
-		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
-	}
+<style lang="scss" scoped>
+@import "styles/global.scss";
 
-	.inner-wrapper {
-		top: 64px;
-		position: relative;
-		overflow: auto;
-		height: 100%;
-	}
+.sidebar {
+	position: fixed;
+	z-index: 1;
+	top: 0;
+	right: 0;
+	width: 300px;
+	height: 100vh;
+	background-color: $white;
+	box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16),
+		0 2px 10px 0 rgba(0, 0, 0, 0.12);
+}
 
-	.slide-transition {
-		transition: transform 0.6s ease-in-out;
-		transform: translateX(0);
-	}
+.inner-wrapper {
+	top: 60px;
+	position: relative;
+	overflow: auto;
+	height: 100%;
+}
 
-	.slide-enter, .slide-leave { transform: translateX(100%); }
+.slide-transition {
+	transition: transform 0.6s ease-in-out;
+	transform: translateX(0);
+}
 
-	.title {
-		background-color: rgb(3, 169, 244);
-		text-align: center;
-		padding: 10px;
-		color: white;
-		font-weight: 600;
-	}
+.slide-enter,
+.slide-leave {
+	transform: translateX(100%);
+}
 
-	.media { padding: 0 25px; }
+.title {
+	background-color: rgb(3, 169, 244);
+	text-align: center;
+	padding: 10px;
+	color: $white;
+	font-weight: 600;
+}
 
-	.media-content .content {
-		min-height: 64px;
-		display: flex;
-		align-items: center;
-	}
+.media {
+	padding: 0 25px;
+}
 
-	.content p strong { word-break: break-word; }
+.media-content .content {
+	min-height: 64px;
+	display: flex;
+	align-items: center;
+}
 
-	.content p small { word-break: break-word; }
+.content p strong {
+	word-break: break-word;
+}
 
-	.add-to-queue {
-		width: 100%;
-		margin-top: 25px;
-		height: 40px;
-		border-radius: 0;
-		background: rgb(3, 169, 244);
-		color: #fff !important;
-		border: 0;
-		&:active, &:focus { border: 0; }
-	}
+.content p small {
+	word-break: break-word;
+}
 
-	.add-to-queue.add-to-queue-warning {
-		background-color: red;
+.add-to-queue {
+	width: 100%;
+	margin-top: 25px;
+	height: 40px;
+	border-radius: 0;
+	background: rgb(3, 169, 244);
+	color: $white !important;
+	border: 0;
+	&:active,
+	&:focus {
+		border: 0;
 	}
+}
 
-	.add-to-queue.add-to-queue-disabled {
-		background-color: gray;
-	}
-	.add-to-queue.add-to-queue-disabled:focus {
-		background-color: gray;
-	}
+.add-to-queue.add-to-queue-warning {
+	background-color: red;
+}
 
-	.add-to-queue:focus { background: #029ce3; }
+.add-to-queue.add-to-queue-disabled {
+	background-color: gray;
+}
+.add-to-queue.add-to-queue-disabled:focus {
+	background-color: gray;
+}
 
-	.media-right { line-height: 64px; }
+.add-to-queue:focus {
+	background: $primary-color;
+}
 
-	.songTitle {
-		word-wrap: break-word;
-		overflow: hidden;
-		text-overflow: ellipsis;
-		display: -webkit-box;
-		-webkit-box-orient: vertical;
-		-webkit-line-clamp: 2;
-		line-height: 20px;
-		max-height: 40px;
-	}
+.media-right {
+	line-height: 64px;
+}
 
+.songTitle {
+	word-wrap: break-word;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	display: -webkit-box;
+	-webkit-box-orient: vertical;
+	-webkit-line-clamp: 2;
+	line-height: 20px;
+	max-height: 40px;
+}
 </style>

+ 69 - 43
frontend/components/Sidebars/UsersList.vue

@@ -1,12 +1,19 @@
 <template>
-	<div class='sidebar' transition='slide' v-if='$parent.sidebars.users'>
-		<div class='inner-wrapper'>
-			<div class='title'>Users</div>
-			<h5 class="center">Total users: {{$parent.userCount}}</h5>
+	<div class="sidebar" transition="slide">
+		<div class="inner-wrapper">
+			<div class="title">
+				Users
+			</div>
+			<h5 class="center">Total users: {{ userCount }}</h5>
 			<aside class="menu">
 				<ul class="menu-list">
-					<li v-for="user in $parent.users">
-						<a href="#" v-link="{ path: '/u/' + user }" target="_blank">{{user}}</a>
+					<li v-for="(username, index) in users" :key="index">
+						<router-link
+							:to="{ name: 'profile', params: { username } }"
+							target="_blank"
+						>
+							{{ username }}
+						</router-link>
 					</li>
 				</ul>
 			</aside>
@@ -14,41 +21,60 @@
 	</div>
 </template>
 
-<style type='scss' scoped>
-	.sidebar {
-		position: fixed;
-		z-index: 1;
-		top: 0;
-		right: 0;
-		width: 300px;
-		height: 100vh;
-		background-color: #fff;
-		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
-	}
-
-	.inner-wrapper {
-		top: 64px;
-		position: relative;
-	}
-
-	.slide-transition {
-		transition: transform 0.6s ease-in-out;
-		transform: translateX(0);
-	}
-
-	.slide-enter, .slide-leave {
-		transform: translateX(100%);
-	}
-
-	.title {
-		background-color: rgb(3, 169, 244);
-		text-align: center;
-		padding: 10px;
-		color: white;
-		font-weight: 600;
-	}
-
-	.menu { padding: 0 20px; }
-
-	.menu-list li a:hover { color: #000 !important; }
+<script>
+import { mapState } from "vuex";
+
+export default {
+	computed: mapState({
+		users: state => state.station.users,
+		userCount: state => state.station.userCount
+	})
+};
+</script>
+
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.sidebar {
+	position: fixed;
+	z-index: 1;
+	top: 0;
+	right: 0;
+	width: 300px;
+	height: 100vh;
+	background-color: $white;
+	box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16),
+		0 2px 10px 0 rgba(0, 0, 0, 0.12);
+}
+
+.inner-wrapper {
+	top: 60px;
+	position: relative;
+}
+
+.slide-transition {
+	transition: transform 0.6s ease-in-out;
+	transform: translateX(0);
+}
+
+.slide-enter,
+.slide-leave {
+	transform: translateX(100%);
+}
+
+.title {
+	background-color: rgb(3, 169, 244);
+	text-align: center;
+	padding: 10px;
+	color: $white;
+	font-weight: 600;
+}
+
+.menu {
+	padding: 0 20px;
+}
+
+.menu-list li a:hover {
+	color: #000 !important;
+}
 </style>

+ 0 - 339
frontend/components/Station/CommunityHeader.vue

@@ -1,339 +0,0 @@
-<template>
-	<nav class='nav'>
-		<div class='nav-left'>
-			<a class='nav-item is-brand' href='#' v-link='{ path: "/" }' @click='this.$dispatch("leaveStation", title)'>
-				Musare
-			</a>
-		</div>
-
-		<div class='nav-center stationDisplayName'>
-			{{$parent.station.displayName}}
-		</div>
-
-		<span class="nav-toggle" @click="controlBar = !controlBar">
-			<span></span>
-			<span></span>
-			<span></span>
-		</span>
-
-		<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
-			<a class="nav-item is-tab admin" href="#" v-link="{ path: '/admin' }" v-if="$parent.$parent.role === 'admin'">
-				<strong>Admin</strong>
-			</a>
-			<!--a class="nav-item is-tab" href="#">
-				About
-			</a-->
-			<a class="nav-item is-tab" href="#" v-link="{ path: '/team' }">
-				Team
-			</a>
-			<a class="nav-item is-tab" href="#" v-link="{ path: '/about' }">
-				About
-			</a>
-			<a class="nav-item is-tab" href="#" v-link="{ path: '/news' }">
-				News
-			</a>
-			<span class="grouped" v-if="$parent.$parent.loggedIn">
-				<a class="nav-item is-tab" href="#" v-link="{ path: '/u/' + $parent.$parent.username }">
-					Profile
-				</a>
-				<a class="nav-item is-tab" href="#" v-link="{ path: '/settings' }">
-					Settings
-				</a>
-				<a class="nav-item is-tab" href="#" @click="$parent.$parent.logout()">
-					Logout
-				</a>
-			</span>
-			<span class="grouped" v-else>
-				<a class="nav-item" href="#" @click="toggleModal('login')">
-					Login
-				</a>
-				<a class="nav-item" href="#" @click="toggleModal('register')">
-					Register
-				</a>
-			</span>
-		</div>
-
-	</nav>
-	<div class="control-sidebar" :class="{ 'show-controlBar': controlBar }">
-		<div class='inner-wrapper'>
-			<div v-if='isOwner()'>
-				<a class="sidebar-item" href='#' v-if='isOwner()' @click='$parent.editStation()'>
-					<span class='icon'>
-						<i class='material-icons'>settings</i>
-					</span>
-					<span class="icon-purpose">Station settings</span>
-				</a>
-				<a v-if='isOwner()' class="sidebar-item" href='#' @click='$parent.skipStation()'>
-					<span class='icon'>
-						<i class='material-icons'>skip_next</i>
-					</span>
-					<span class="icon-purpose">Skip current song</span>
-				</a>
-				<a class="sidebar-item" href='#' v-if='isOwner() && $parent.paused' @click='$parent.resumeStation()'>
-					<span class='icon'>
-						<i class='material-icons'>play_arrow</i>
-					</span>
-					<span class="icon-purpose">Resume station</span>
-				</a>
-				<a class="sidebar-item" href='#' v-if='isOwner() && !$parent.paused' @click='$parent.pauseStation()'>
-					<span class='icon'>
-						<i class='material-icons'>pause</i>
-					</span>
-					<span class="icon-purpose">Pause station</span>
-				</a>
-				<hr>
-			</div>
-			<div v-if="$parent.$parent.loggedIn && !$parent.noSong">
-				<a v-if='!isOwner() && $parent.$parent.loggedIn && !$parent.noSong' class="sidebar-item" href='#' @click='$parent.voteSkipStation()'>
-					<span class='icon'>
-						<i class='material-icons'>skip_next</i>
-					</span>
-					<span class="skip-votes">{{ $parent.currentSong.skipVotes }}</span>
-					<span class="icon-purpose">Skip current song</span>
-				</a>
-				<a v-if='$parent.$parent.loggedIn && !$parent.noSong' class="sidebar-item" href='#' @click='$parent.modals.addSongToPlaylist = true'>
-					<span class='icon'>
-						<i class='material-icons'>playlist_add</i>
-					</span>
-					<span class="icon-purpose">Add current song to playlist</span>
-				</a>
-				<hr>
-			</div>
-			<a class="sidebar-item" href='#' @click='$parent.toggleSidebar("songslist")' v-if='$parent.station.partyMode === true'>
-				<span class='icon'>
-					<i class='material-icons'>queue_music</i>
-				</span>
-				<span class="icon-purpose">Show the station queue</span>
-			</a>
-			<a class="sidebar-item" href='#' @click='$parent.toggleSidebar("users")'>
-				<span class='icon'>
-					<i class='material-icons'>people</i>
-				</span>
-				<span class="icon-purpose">Display users in the station</span>
-			</a>
-			<a class="sidebar-item" href='#' @click='$parent.toggleSidebar("playlist")' v-if='$parent.$parent.loggedIn'>
-				<span class='icon'>
-					<i class='material-icons'>library_music</i>
-				</span>
-				<span class="icon-purpose">Show your playlists</span>
-			</a>
-		</div>
-	</div>
-</template>
-
-<script>
-	export default {
-		data() {
-			return {
-				title: this.$route.params.id,
-				isMobile: false,
-				controlBar: true
-			}
-		},
-		methods: {
-			isOwner: function () {
-				return this.$parent.$parent.loggedIn && (this.$parent.$parent.role === 'admin' || this.$parent.$parent.userId === this.$parent.station.owner);
-			},
-			toggleModal: function (type) {
-				this.$dispatch('toggleModal', type);
-			}
-		}
-	}
-</script>
-
-<style lang='scss' scoped>
-	.nav {
-		background-color: #03a9f4;
-		line-height: 64px;
-
-		.is-brand {
-			font-size: 2.1rem !important;
-			line-height: 64px !important;
-			padding: 0 20px;
-		}
-	}
-
-	a.nav-item {
-		color: hsl(0, 0%, 100%);
-		font-size: 15px;
-
-		&:hover {
-			color: hsl(0, 0%, 100%);
-		}
-
-		.admin {
-			color: #424242;
-		}
-
-		padding: 0 12px;
-		.icon {
-			height: 64px;
-			i {
-				font-size: 2rem;
-				line-height: 64px;
-				height: 64px;
-				width: 34px;
-			}
-		}
-	}
-
-	.grouped {
-		margin: 0;
-		display: flex;
-		text-decoration: none;
-	}
-
-	.skip-votes {
-		position: relative;
-		left: 11px;
-	}
-
-	.nav-toggle {
-		height: 64px;
-	}
-
-	@media screen and (max-width: 998px) {
-		.nav-menu {
-		    background-color: white;
-		    box-shadow: 0 4px 7px rgba(10, 10, 10, 0.1);
-		    left: 0;
-		    display: none;
-		    right: 0;
-		    top: 100%;
-		    position: absolute;
-		}
-		.nav-toggle {
-	    	display: block;
-		}
-	}
-
-	.logo {
-		font-size: 2.1rem;
-		line-height: 64px;
-		padding-left: 20px !important;
-		padding-right: 20px !important;
-	}
-
-	.nav-center {
-		display: flex;
-    align-items: center;
-		color: #03a9f4;
-		font-size: 22px;
-		position: absolute;
-		margin: auto;
-		top: 50%;
-		left: 50%;
-		transform: translate(-50%, -50%);
-	}
-
-	.nav-right.is-active .nav-item {
-		background: #03a9f4;
-    	border: 0;
-	}
-
-	.control-sidebar {
-		position: fixed;
-		z-index: 1;
-		top: 0;
-		left: 0;
-		width: 64px;
-		height: 100vh;
-		background-color: #03a9f4;
-		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
-
-		@media (max-width: 998px) {
-			display: none;
-		}
-		.inner-wrapper {
-			@media (min-width: 999px) {
-				.mobile-only {
-					display: none;
-				}
-				.desktop-only {
-					display: flex;
-				}
-			}
-			@media (max-width: 998px) {
-				.mobile-only {
-					display: flex;
-				}
-				.desktop-only {
-					display: none;
-					visibility: hidden;
-				}
-			}
-		}
-	}
-
-	.show-controlBar {
-		display: block;
-	}
-
-	.inner-wrapper {
-		top: 64px;
-		position: relative;
-	}
-
-	.control-sidebar .material-icons {
-		width: 100%;
-		font-size: 2rem;
-	}
-	.control-sidebar .sidebar-item {
-		font-size: 2rem;
-		height: 50px;
-		color: white;
-		-webkit-box-align: center;
-		-ms-flex-align: center;
-		align-items: center;
-		display: -webkit-box;
-		display: -ms-flexbox;
-		display: flex;
-		-webkit-box-flex: 0;
-		-ms-flex-positive: 0;
-		flex-grow: 0;
-		-ms-flex-negative: 0;
-		flex-shrink: 0;
-		-webkit-box-pack: center;
-		-ms-flex-pack: center;
-		justify-content: center;
-		width: 100%;
-		position: relative;
-	}
-	.control-sidebar .sidebar-top-hr {
-		margin: 0 0 20px 0;
-	}
-
-	.sidebar-item .icon-purpose {
-		visibility: hidden;
-		width: 160px;
-		font-size: 12px;
-		background-color: rgba(3, 169, 244, 0.8);
-		color: #fff;
-		text-align: center;
-		border-radius: 6px;
-		padding: 5px;
-		position: absolute;
-		z-index: 1;
-		left: 115%;
-		opacity: 0;
-    	transition: opacity 0.5s;
-		display: none;
-	}
-
-	.sidebar-item .icon-purpose::after {
-		content: "";
-	    position: absolute;
-	    top: 50%;
-	    right: 100%;
-	    margin-top: -5px;
-	    border-width: 5px;
-	    border-style: solid;
-	    border-color: transparent rgba(3, 169, 244, 0.8) transparent transparent;
-	}
-
-	.sidebar-item:hover .icon-purpose {
-		visibility: visible;
-		opacity: 1;
-		display: block;
-	}
-</style>

+ 0 - 349
frontend/components/Station/OfficialHeader.vue

@@ -1,349 +0,0 @@
-<template>
-	<nav class='nav'>
-		<div class='nav-left'>
-			<a class='nav-item is-brand' href='#' v-link='{ path: "/" }' @click='this.$dispatch("leaveStation", title)'>
-				Musare
-			</a>
-		</div>
-
-		<div class='nav-center stationDisplayName'>
-			{{ $parent.station.displayName }}
-		</div>
-
-		<span class="nav-toggle" @click="controlBar = !controlBar">
-			<span></span>
-			<span></span>
-			<span></span>
-		</span>
-
-		<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
-			<a class="nav-item is-tab admin" href="#" v-link="{ path: '/admin' }" v-if="$parent.$parent.role === 'admin'">
-				<strong>Admin</strong>
-			</a>
-			<!--a class="nav-item is-tab" href="#">
-				About
-			</a-->
-			<a class="nav-item is-tab" href="#" v-link="{ path: '/team' }">
-				Team
-			</a>
-			<a class="nav-item is-tab" href="#" v-link="{ path: '/about' }">
-				About
-			</a>
-			<a class="nav-item is-tab" href="#" v-link="{ path: '/news' }">
-				News
-			</a>
-			<span class="grouped" v-if="$parent.$parent.loggedIn">
-				<a class="nav-item is-tab" href="#" v-link="{ path: '/u/' + $parent.$parent.username }">
-					Profile
-				</a>
-				<a class="nav-item is-tab" href="#" v-link="{ path: '/settings' }">
-					Settings
-				</a>
-				<a class="nav-item is-tab" href="#" @click="$parent.$parent.logout()">
-					Logout
-				</a>
-			</span>
-			<span class="grouped" v-else>
-				<a class="nav-item" href="#" @click="toggleModal('login')">
-					Login
-				</a>
-				<a class="nav-item" href="#" @click="toggleModal('register')">
-					Register
-				</a>
-			</span>
-		</div>
-
-	</nav>
-	<div class="control-sidebar" :class="{ 'show-controlBar': controlBar }">
-		<div class='inner-wrapper'>
-			<div v-if='isOwner()'>
-				<a class="sidebar-item" href='#' v-if='isOwner()' @click='$parent.editStation()'>
-					<span class='icon'>
-						<i class='material-icons'>settings</i>
-					</span>
-					<span class="icon-purpose">Station settings</span>
-				</a>
-				<a v-if='isOwner()' class="sidebar-item" href='#' @click='$parent.skipStation()'>
-					<span class='icon'>
-						<i class='material-icons'>skip_next</i>
-					</span>
-					<span class="icon-purpose">Skip current song</span>
-				</a>
-				<a class="sidebar-item" href='#' v-if='isOwner() && !$parent.paused' @click='$parent.pauseStation()'>
-					<span class='icon'>
-						<i class='material-icons'>pause</i>
-					</span>
-					<span class="icon-purpose">Pause station</span>
-				</a>
-				<a class="sidebar-item" href='#' v-if='isOwner() && $parent.paused' @click='$parent.resumeStation()'>
-					<span class='icon'>
-						<i class='material-icons'>play_arrow</i>
-					</span>
-					<span class="icon-purpose">Resume station</span>
-				</a>
-				<hr>
-			</div>
-			<div v-if="$parent.$parent.loggedIn">
-				<a class="sidebar-item" href='#' @click='$parent.modals.addSongToQueue = !$parent.modals.addSongToQueue' v-if='$parent.type === "official" && $parent.$parent.loggedIn'>
-					<span class='icon'>
-						<i class='material-icons'>queue</i>
-					</span>
-					<span class="icon-purpose">Add song to queue</span>
-				</a>
-				<a v-if='!isOwner() && $parent.$parent.loggedIn && !$parent.noSong' class="sidebar-item" href='#' @click='$parent.voteSkipStation()'>
-					<span class='icon'>
-						<i class='material-icons'>skip_next</i>
-					</span>
-					<span class="skip-votes">{{$parent.currentSong.skipVotes}}</span>
-					<span class="icon-purpose">Skip current song</span>
-				</a>
-				<a v-if='$parent.$parent.loggedIn && !$parent.noSong && !$parent.simpleSong' class="sidebar-item" href='#' @click='$parent.modals.report = !$parent.modals.report'>
-					<span class='icon'>
-						<i class='material-icons'>report</i>
-					</span>
-					<span class="icon-purpose">Report a song</span>
-				</a>
-				<a v-if='$parent.$parent.loggedIn && !$parent.noSong' class="sidebar-item" href='#' @click='$parent.modals.addSongToPlaylist = true'>
-					<span class='icon'>
-						<i class='material-icons'>playlist_add</i>
-					</span>
-					<span class="icon-purpose">Add current song to playlist</span>
-				</a>
-				<hr>
-			</div>
-			<a class="sidebar-item" href='#' @click='$parent.toggleSidebar("songslist")'>
-				<span class='icon'>
-					<i class='material-icons'>queue_music</i>
-				</span>
-				<span class="icon-purpose">Show the station queue</span>
-			</a>
-			<a class="sidebar-item" href='#' @click='$parent.toggleSidebar("users")'>
-				<span class='icon'>
-					<i class='material-icons'>people</i>
-				</span>
-				<span class="icon-purpose">Display users in the station</span>
-			</a>
-		</div>
-	</div>
-</template>
-
-<script>
-	export default {
-		data() {
-			return {
-				title: this.$route.params.id,
-				isMobile: false,
-				controlBar: false
-			}
-		},
-		methods: {
-			isOwner: function () {
-				return this.$parent.$parent.loggedIn && this.$parent.$parent.role === 'admin';
-			},
-			toggleModal: function (type) {
-				this.$dispatch('toggleModal', type);
-			}
-		}
-	}
-</script>
-
-<style lang='scss' scoped>
-	.nav {
-		background-color: #03a9f4;
-		line-height: 64px;
-
-		.is-brand {
-			font-size: 2.1rem !important;
-			line-height: 64px !important;
-			padding: 0 20px;
-		}
-	}
-
-	a.nav-item {
-		color: hsl(0, 0%, 100%);
-		font-size: 15px;
-
-		&:hover {
-			color: hsl(0, 0%, 100%);
-		}
-
-		.admin {
-			color: #424242;
-		}
-
-		padding: 0 12px;
-		.icon {
-			height: 64px;
-			i {
-				font-size: 2rem;
-				line-height: 64px;
-				height: 64px;
-				width: 34px;
-			}
-		}
-	}
-
-	.grouped {
-		margin: 0;
-		display: flex;
-		text-decoration: none;
-	}
-
-	.skip-votes {
-		position: relative;
-		left: 11px;
-	}
-
-	.nav-toggle {
-		height: 64px;
-	}
-
-	@media screen and (max-width: 998px) {
-		.nav-menu {
-		    background-color: white;
-		    box-shadow: 0 4px 7px rgba(10, 10, 10, 0.1);
-		    left: 0;
-		    display: none;
-		    right: 0;
-		    top: 100%;
-		    position: absolute;
-		}
-		.nav-toggle {
-	    	display: block;
-		}
-	}
-
-	.logo {
-		font-size: 2.1rem;
-		line-height: 64px;
-		padding-left: 20px !important;
-		padding-right: 20px !important;
-	}
-
-	.nav-center {
-		display: flex;
-    	align-items: center;
-		color: #03a9f4;
-		font-size: 22px;
-		position: absolute;
-		margin: auto;
-		top: 50%;
-		left: 50%;
-		transform: translate(-50%, -50%);
-	}
-
-	.nav-right.is-active .nav-item {
-		background: #03a9f4;
-    	border: 0;
-	}
-
-	.hidden {
-		display: none;
-	}
-
-	.control-sidebar {
-		position: fixed;
-		z-index: 1;
-		top: 0;
-		left: 0;
-		width: 64px;
-		height: 100vh;
-		background-color: #03a9f4;
-		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
-
-		@media (max-width: 998px) {
-			display: none;
-		}
-		.inner-wrapper {
-			@media (min-width: 999px) {
-				.mobile-only {
-					display: none;
-				}
-				.desktop-only {
-					display: flex;
-				}
-			}
-			@media (max-width: 998px) {
-				.mobile-only {
-					display: flex;
-				}
-				.desktop-only {
-					display: none;
-					visibility: hidden;
-				}
-			}
-		}
-	}
-
-	.show-controlBar {
-		display: block;
-	}
-
-	.inner-wrapper {
-		top: 64px;
-		position: relative;
-	}
-
-	.control-sidebar .material-icons {
-		width: 100%;
-		font-size: 2rem;
-	}
-	.control-sidebar .sidebar-item {
-		font-size: 2rem;
-		height: 50px;
-		color: white;
-		-webkit-box-align: center;
-	    -ms-flex-align: center;
-	    align-items: center;
-	    display: -webkit-box;
-	    display: -ms-flexbox;
-	    display: flex;
-	    -webkit-box-flex: 0;
-	    -ms-flex-positive: 0;
-	    flex-grow: 0;
-	    -ms-flex-negative: 0;
-	    flex-shrink: 0;
-	    -webkit-box-pack: center;
-	    -ms-flex-pack: center;
-	    justify-content: center;
-		width: 100%;
-		position: relative;
-	}
-	.control-sidebar .sidebar-top-hr {
-		margin: 0 0 20px 0;
-	}
-
-	.sidebar-item .icon-purpose {
-		visibility: hidden;
-		width: 160px;
-		font-size: 12px;
-		background-color: rgba(3, 169, 244, 0.8);
-		color: #fff;
-		text-align: center;
-		border-radius: 6px;
-		padding: 5px;
-		position: absolute;
-		z-index: 1;
-		left: 115%;
-		opacity: 0;
-    	transition: opacity 0.5s;
-		display: none;
-	}
-
-	.sidebar-item .icon-purpose::after {
-		content: "";
-	    position: absolute;
-	    top: 50%;
-	    right: 100%;
-	    margin-top: -5px;
-	    border-width: 5px;
-	    border-style: solid;
-	    border-color: transparent rgba(3, 169, 244, 0.8) transparent transparent;
-	}
-
-	.sidebar-item:hover .icon-purpose {
-		visibility: visible;
-		opacity: 1;
-		display: block;
-	}
-</style>

+ 1624 - 1004
frontend/components/Station/Station.vue

@@ -1,1172 +1,1792 @@
 <template>
-	<official-header v-if='type == "official"'></official-header>
-	<community-header v-if='type == "community"'></community-header>
-
-	<song-queue v-if='modals.addSongToQueue'></song-queue>
-	<add-to-playlist v-if='modals.addSongToPlaylist'></add-to-playlist>
-	<edit-playlist v-if='modals.editPlaylist'></edit-playlist>
-	<create-playlist v-if='modals.createPlaylist'></create-playlist>
-	<edit-station v-show='modals.editStation'></edit-station>
-	<report v-if='modals.report'></report>
-
-	<songs-list-sidebar v-if='sidebars.songslist'></songs-list-sidebar>
-	<playlist-sidebar v-if='sidebars.playlist'></playlist-sidebar>
-	<users-sidebar v-if='sidebars.users'></users-sidebar>
-
-	<div class='progress' v-show='!ready'></div>
-	<div class='station' v-show="ready">
-		<div v-show="noSong" class="no-song">
-			<h1>No song is currently playing</h1>
-			<h4 v-if='type === "community" && station.partyMode && (!station.locked || (station.locked && $parent.loggedIn && $parent.userId === station.owner))'>
-				<a href='#' class='no-song' @click='modals.addSongToQueue = true'>Add a song to the queue</a>
-			</h4>
-			<h4 v-if='type === "community" && !station.partyMode && $parent.userId === station.owner && !station.privatePlaylist'>
-				<a href='#' class='no-song' @click='sidebars.playlist = true'>Play a private playlist</a>
-			</h4>
-			<h1 v-if='type === "community" && !station.partyMode && $parent.userId === station.owner && station.privatePlaylist'>Maybe you can add some songs to your selected private playlist and then press the skip button</h1>
-		</div>
-		<div class="columns" v-show="!noSong">
-			<div class="column is-8-desktop is-offset-2-desktop is-12-mobile">
-				<div class="video-container">
-					<div id="player"></div>
-				</div>
-				<div class="seeker-bar-container white" id="preview-progress">
-					<div class="seeker-bar light-blue" style="width: 0%;"></div>
-				</div>
+	<div>
+		<metadata
+			v-if="exists && !loading"
+			v-bind:title="`${station.displayName}`"
+		/>
+		<metadata v-else-if="!exists && !loading" v-bind:title="`Not found`" />
+
+		<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" store="station" />
+		<report v-if="modals.report" />
+
+		<transition name="slide">
+			<songs-list-sidebar v-if="sidebars.songslist" />
+		</transition>
+		<transition name="slide">
+			<playlist-sidebar v-if="sidebars.playlist" />
+		</transition>
+		<transition name="slide">
+			<users-sidebar v-if="sidebars.users" />
+		</transition>
+
+		<div v-show="loading" class="progress" />
+		<div v-show="!loading && exists" class="station">
+			<div v-show="noSong" class="no-song">
+				<h1>No song is currently playing</h1>
+				<h4
+					v-if="
+						station.type === 'community' &&
+							station.partyMode &&
+							this.loggedIn &&
+							(!station.locked ||
+								(station.locked &&
+									this.userId === station.owner))
+					"
+				>
+					<a
+						href="#"
+						class="no-song"
+						@click="
+							openModal({
+								sector: 'station',
+								modal: 'addSongToQueue'
+							})
+						"
+						>Add a song to the queue</a
+					>
+				</h4>
+				<h4
+					v-if="
+						station.type === 'community' &&
+							!station.partyMode &&
+							this.userId === station.owner &&
+							!station.privatePlaylist
+					"
+				>
+					<a
+						href="#"
+						class="no-song"
+						@click="sidebars.playlist = true"
+						>Play a private playlist</a
+					>
+				</h4>
+				<h1
+					v-if="
+						station.type === 'community' &&
+							!station.partyMode &&
+							this.userId === station.owner &&
+							station.privatePlaylist
+					"
+				>
+					Maybe you can add some songs to your selected private
+					playlist and then press the skip button
+				</h1>
 			</div>
-			<div class="desktop-only column is-3-desktop card playlistCard experimental">
-				<div class='title' v-if='type === "community"'>Queue</div>
-				<div class='title' v-else>Playlist</div>
-				<article class="media" v-if="!noSong">
-					<figure class="media-left">
-						<p class="image is-64x64">
-							<img :src="currentSong.thumbnail" onerror="this.src='/assets/notes-transparent.png'">
-						</p>
-					</figure>
-					<div class="media-content">
-						<div class="content">
+			<div v-show="!noSong" class="columns">
+				<div
+					class="column is-8-desktop is-offset-2-desktop is-12-mobile"
+				>
+					<div class="video-container">
+						<div id="player" />
+						<div
+							class="player-can-not-autoplay"
+							v-if="!canAutoplay"
+						>
 							<p>
-								Current Song:
-								<br>
-								<strong>{{ currentSong.title }}</strong>
-								<br>
-								<small>{{ currentSong.artists }}</small>
+								Please click anywhere on the screen for the
+								video to start
 							</p>
 						</div>
 					</div>
-					<div class="media-right">
-						{{ formatTime(currentSong.duration) }}
+					<div
+						id="preview-progress"
+						class="seeker-bar-container white"
+					>
+						<div class="seeker-bar light-blue" style="width: 0%;" />
+					</div>
+				</div>
+				<div
+					class="desktop-only column is-3-desktop card playlistCard experimental"
+				>
+					<div v-if="station.type === 'community'" class="title">
+						Queue
+					</div>
+					<div v-else class="title">
+						Playlist
 					</div>
-				</article>
-				<p v-if="noSong" class="center">There is currently no song playing.</p>
-
-				<article class="media" v-for='song in songsList'>
-					<div class="media-content">
-						<div class="content">
-								<strong class="songTitle">{{ song.title }}</strong>
-								<br>
-								<small>{{ song.artists.join(', ') }}</small>
-								<br>
+					<article v-if="!noSong" class="media">
+						<figure class="media-left">
+							<p class="image is-64x64">
+								<img
+									:src="currentSong.thumbnail"
+									onerror="this.src='/assets/notes-transparent.png'"
+								/>
+							</p>
+						</figure>
+						<div class="media-content">
+							<div class="content">
+								<p>
+									Current Song:
+									<br />
+									<strong>{{ currentSong.title }}</strong>
+									<br />
+									<small>{{ currentSong.artists }}</small>
+								</p>
+							</div>
+						</div>
+						<div class="media-right">
+							{{ formatTime(currentSong.duration) }}
+						</div>
+					</article>
+					<p v-if="noSong" class="center">
+						There is currently no song playing.
+					</p>
+
+					<article
+						v-for="(song, index) in songsList"
+						:key="index"
+						class="media"
+					>
+						<div class="media-content">
+							<div class="content">
+								<strong class="songTitle">{{
+									song.title
+								}}</strong>
+								<br />
+								<small>{{ song.artists.join(", ") }}</small>
+								<br />
 								<div v-if="station.partyMode">
-									<br>
-									<small>Requested by <b>{{this.$parent.$parent.getUsernameFromId(song.requestedBy)}} {{this.userIdMap['Z' + song.requestedBy]}}</b></small>
-									<button class="button" @click="removeFromQueue(song.songId)" v-if="isOwnerOnly() || isAdminOnly()">REMOVE</button>
+									<br />
+									<small>
+										Requested by
+										<b>
+											<user-id-to-username
+												:userId="song.requestedBy"
+												:link="true"
+											/>
+										</b>
+									</small>
+									<button
+										v-if="isOwnerOnly() || isAdminOnly()"
+										class="button"
+										@click="removeFromQueue(song.songId)"
+									>
+										REMOVE
+									</button>
 								</div>
+							</div>
 						</div>
-					</div>
-					<div class="media-right">
-						{{ $parent.formatTime(song.duration) }}
-					</div>
-				</article>
-				<a class='button add-to-queue' href='#' @click='modals.addSongToQueue = !modals.addSongToQueue' v-if="type === 'community' && $parent.loggedIn">Add Song to Queue</a>
+						<div class="media-right">
+							{{ formatTime(song.duration) }}
+						</div>
+					</article>
+					<a
+						v-if="station.type === 'community' && loggedIn"
+						class="button add-to-queue"
+						href="#"
+						@click="
+							openModal({
+								sector: 'station',
+								modal: 'addSongToQueue'
+							})
+						"
+						>Add a song to the queue</a
+					>
+				</div>
 			</div>
-		</div>
-		<div class="desktop-only columns is-mobile" v-show="!noSong">
-			<div class="column is-8-desktop is-offset-2-desktop is-12-mobile">
-				<div class="columns is-mobile">
-					<div class="column is-12-desktop">
-						<h4 id="time-display">{{timeElapsed}} / {{formatTime(currentSong.duration)}}</h4>
-						<h3>{{currentSong.title}}</h3>
-						<h4 class="thin" style="margin-left: 0">{{currentSong.artists}}</h4>
-						<div class="columns is-mobile">
-							<form style="margin-top: 12px; margin-bottom: 0;" action="#" class="column is-7-desktop is-4-mobile">
-								<p class='volume-slider-wrapper'>
-									<i class="material-icons" @click='toggleMute()' v-if='muted'>volume_mute</i>
-									<i class="material-icons" @click='toggleMute()' v-else>volume_down</i>
-									<input type="range" id="volumeSlider" min="0" max="10000" class="active" v-on:change="changeVolume()" v-on:input="changeVolume()">
-									<i class="material-icons" @click='increaseVolume()'>volume_up</i>
-								</p>
-							</form>
-							<div class="column is-8-mobile is-5-desktop" style="float: right;">
-								<ul id="ratings" v-if="currentSong.likes !== -1 && currentSong.dislikes !== -1">
-									<li id="like" class="right" @click="toggleLike()">
-										<span class="flow-text">{{currentSong.likes}} </span>
-										<i id="thumbs_up" class="material-icons grey-text" v-bind:class="{ liked: liked }">thumb_up</i>
-										<a class='absolute-a behind' @click="toggleLike()" href='#'></a>
-									</li>
-									<li style="margin-right: 10px;" id="dislike" class="right" @click="toggleDislike()">
-										<span class="flow-text">{{currentSong.dislikes}} </span>
-										<i id="thumbs_down" class="material-icons grey-text" v-bind:class="{ disliked: disliked }">thumb_down</i>
-										<a class='absolute-a behind' @click="toggleDislike()" href='#'></a>
-									</li>
-								</ul>
+			<div v-show="!noSong" class="desktop-only columns is-mobile">
+				<div
+					class="column is-8-desktop is-offset-2-desktop is-12-mobile"
+				>
+					<div class="columns is-mobile">
+						<div class="column is-12-desktop">
+							<h4 id="time-display">
+								{{ timeElapsed }} /
+								{{ formatTime(currentSong.duration) }}
+							</h4>
+							<h3>{{ currentSong.title }}</h3>
+							<h4 class="thin" style="margin-left: 0">
+								{{ currentSong.artists }}
+							</h4>
+							<div class="columns is-mobile">
+								<form
+									style="margin-top: 12px; margin-bottom: 0;"
+									action="#"
+									class="column is-7-desktop is-4-mobile"
+								>
+									<p class="volume-slider-wrapper">
+										<i
+											v-if="muted"
+											class="material-icons"
+											@click="toggleMute()"
+											>volume_mute</i
+										>
+										<i
+											v-else
+											class="material-icons"
+											@click="toggleMute()"
+											>volume_down</i
+										>
+										<input
+											id="volumeSlider"
+											type="range"
+											min="0"
+											max="10000"
+											class="active"
+											@change="changeVolume()"
+											@input="changeVolume()"
+										/>
+										<i
+											class="material-icons"
+											@click="increaseVolume()"
+											>volume_up</i
+										>
+									</p>
+								</form>
+								<div
+									class="column is-8-mobile is-5-desktop"
+									style="float: right;"
+								>
+									<ul
+										v-if="
+											currentSong.likes !== -1 &&
+												currentSong.dislikes !== -1
+										"
+										id="ratings"
+									>
+										<li
+											id="like"
+											class="right"
+											@click="toggleLike()"
+										>
+											<span class="flow-text">{{
+												currentSong.likes
+											}}</span>
+											<i
+												id="thumbs_up"
+												class="material-icons grey-text"
+												:class="{ liked: liked }"
+												>thumb_up</i
+											>
+											<a
+												class="absolute-a behind"
+												href="#"
+												@click="toggleLike()"
+											/>
+										</li>
+										<li
+											id="dislike"
+											style="margin-right: 10px;"
+											class="right"
+											@click="toggleDislike()"
+										>
+											<span class="flow-text">{{
+												currentSong.dislikes
+											}}</span>
+											<i
+												id="thumbs_down"
+												class="material-icons grey-text"
+												:class="{
+													disliked: disliked
+												}"
+												>thumb_down</i
+											>
+											<a
+												class="absolute-a behind"
+												href="#"
+												@click="toggleDislike()"
+											/>
+										</li>
+									</ul>
+								</div>
 							</div>
 						</div>
-					</div>
-					<div class="column is-3-desktop experimental" v-if="!simpleSong">
-						<img class="image" :src="currentSong.thumbnail" alt="Song Thumbnail" onerror="this.src='/assets/notes-transparent.png'" />
+						<div
+							v-if="!currentSong.simpleSong"
+							class="column is-3-desktop experimental"
+						>
+							<img
+								class="image"
+								:src="currentSong.thumbnail"
+								alt="Song Thumbnail"
+								onerror="this.src='/assets/notes-transparent.png'"
+							/>
+						</div>
 					</div>
 				</div>
 			</div>
-		</div>
-		<div class="mobile-only" v-show="!noSong">
-			<div>
+			<div v-show="!noSong" class="mobile-only">
 				<div>
 					<div>
-						<h3>{{currentSong.title}}</h3>
-						<h4 class="thin">{{currentSong.artists}}</h4>
-						<h5>{{timeElapsed}} / {{formatTime(currentSong.duration)}}</h5>
 						<div>
-							<form class="columns" action="#">
-								<p class='column is-11-mobile volume-slider-wrapper'>
-									<i class="material-icons" @click='toggleMute()' v-if='muted'>volume_mute</i>
-									<i class="material-icons" @click='toggleMute()' v-else>volume_down</i>
-									<input type="range" id="volumeSlider" min="0" max="10000" class="active" v-on:change="changeVolume()" v-on:input="changeVolume()">
-									<i class="material-icons" @click='increaseVolume()'>volume_up</i>
-								</p>
-							</form>
+							<h3>{{ currentSong.title }}</h3>
+							<h4 class="thin">
+								{{ currentSong.artists }}
+							</h4>
+							<h5>
+								{{ timeElapsed }} /
+								{{ formatTime(currentSong.duration) }}
+							</h5>
 							<div>
-								<ul id="ratings" style="display: inline-block;" v-if="currentSong.likes !== -1 && currentSong.dislikes !== -1">
-									<li id="dislike" style="display: inline-block;margin-right: 10px;" @click="toggleDislike()">
-										<span class="flow-text">{{currentSong.dislikes}} </span>
-										<i id="thumbs_down" class="material-icons grey-text" v-bind:class="{ disliked: disliked }">thumb_down</i>
-										<a class='absolute-a behind' @click="toggleDislike()" href='#'></a>
-									</li>
-									<li id="like" style="display: inline-block;" @click="toggleLike()">
-										<span class="flow-text">{{currentSong.likes}} </span>
-										<i id="thumbs_up" class="material-icons grey-text" v-bind:class="{ liked: liked }">thumb_up</i>
-										<a class='absolute-a behind' @click="toggleLike()" href='#'></a>
-									</li>
-								</ul>
+								<form class="columns" action="#">
+									<p
+										class="column is-11-mobile volume-slider-wrapper"
+									>
+										<i
+											v-if="muted"
+											class="material-icons"
+											@click="toggleMute()"
+											>volume_mute</i
+										>
+										<i
+											v-else
+											class="material-icons"
+											@click="toggleMute()"
+											>volume_down</i
+										>
+										<input
+											id="volumeSlider"
+											type="range"
+											min="0"
+											max="10000"
+											class="active"
+											@change="changeVolume()"
+											@input="changeVolume()"
+										/>
+										<i
+											class="material-icons"
+											@click="increaseVolume()"
+											>volume_up</i
+										>
+									</p>
+								</form>
+								<div>
+									<ul
+										v-if="
+											currentSong.likes !== -1 &&
+												currentSong.dislikes !== -1
+										"
+										id="ratings"
+										style="display: inline-block;"
+									>
+										<li
+											id="dislike"
+											style="display: inline-block;margin-right: 10px;"
+											@click="toggleDislike()"
+										>
+											<span class="flow-text">{{
+												currentSong.dislikes
+											}}</span>
+											<i
+												id="thumbs_down"
+												class="material-icons grey-text"
+												:class="{
+													disliked: disliked
+												}"
+												>thumb_down</i
+											>
+											<a
+												class="absolute-a behind"
+												href="#"
+												@click="toggleDislike()"
+											/>
+										</li>
+										<li
+											id="like"
+											style="display: inline-block;"
+											@click="toggleLike()"
+										>
+											<span class="flow-text">{{
+												currentSong.likes
+											}}</span>
+											<i
+												id="thumbs_up"
+												class="material-icons grey-text"
+												:class="{ liked: liked }"
+												>thumb_up</i
+											>
+											<a
+												class="absolute-a behind"
+												href="#"
+												@click="toggleLike()"
+											/>
+										</li>
+									</ul>
+								</div>
 							</div>
 						</div>
 					</div>
 				</div>
 			</div>
 		</div>
+
+		<Z404 v-if="!exists"></Z404>
 	</div>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
-
-	import SongQueue from '../Modals/AddSongToQueue.vue';
-	import AddToPlaylist from '../Modals/AddSongToPlaylist.vue';
-	import EditPlaylist from '../Modals/Playlists/Edit.vue';
-	import CreatePlaylist from '../Modals/Playlists/Create.vue';
-	import EditStation from '../Modals/EditStation.vue';
-	import Report from '../Modals/Report.vue';
-
-	import SongsListSidebar from '../Sidebars/SongsList.vue';
-	import PlaylistSidebar from '../Sidebars/Playlist.vue';
-	import UsersSidebar from '../Sidebars/UsersList.vue';
-
-	import OfficialHeader from './OfficialHeader.vue';
-	import CommunityHeader from './CommunityHeader.vue';
-	import MainFooter from '../MainFooter.vue';
-	import io from '../../io';
-	import auth from '../../auth';
-
-	export default {
-		data() {
-			return {
-				ready: false,
-				type: '',
-				playerReady: false,
-				previousSong: null,
-				currentSong: {},
-				player: undefined,
-				timePaused: 0,
-				paused: false,
-				muted: false,
-				timeElapsed: '0:00',
-				liked: false,
-				disliked: false,
-				modals: {
-					addSongToQueue: false,
-					addSongToPlaylist: false,
-					editPlaylist: false,
-					createPlaylist: false,
-					editStation: false,
-					report: false
-				},
-				sidebars: {
-					songslist: false,
-					users: false,
-					playlist: false
-				},
-				noSong: false,
-				simpleSong: false,
-				songsList: [],
-				timeBeforePause: 0,
-				station: {},
-				skipVotes: 0,
-				privatePlaylistQueueSelected: null,
-				automaticallyRequestedSongId: null,
-				systemDifference: 0,
-				users: [],
-				userCount: 0,
-				userIdMap: this.$parent.userIdMap
-			}
-		},
-		methods: {
-			isOwnerOnly: function () {
-				return this.$parent.loggedIn && this.$parent.userId === this.station.owner;
-			},
-			isAdminOnly: function() {
-				return this.$parent.loggedIn && this.$parent.role === 'admin';
-			},
-			removeFromQueue: function(songId) {
-				socket.emit('stations.removeFromQueue', this.station._id, songId, res => {
-					if (res.status === 'success') {
-						Toast.methods.addToast('Successfully removed song from the queue.', 4000);
-					} else Toast.methods.addToast(res.message, 8000);
-				});
+import { mapState, mapActions } from "vuex";
+import Toast from "toasters";
+
+import StationHeader from "./StationHeader.vue";
+
+import UserIdToUsername from "../UserIdToUsername.vue";
+import Z404 from "../404.vue";
+
+import io from "../../io";
+
+export default {
+	data() {
+		return {
+			title: "Station",
+			loading: true,
+			ready: false,
+			exists: true,
+			playerReady: false,
+			player: undefined,
+			timePaused: 0,
+			muted: false,
+			timeElapsed: "0:00",
+			liked: false,
+			disliked: false,
+			sidebars: {
+				songslist: false,
+				users: false,
+				playlist: false
 			},
-			editPlaylist: function (id) {
-				this.playlistBeingEdited = id;
-				this.modals.editPlaylist = !this.modals.editPlaylist;
-			},
-			editStation: function () {
-				let _this = this;
-				this.$broadcast('editStation', {
-					_id: _this.station._id,
-					name: _this.station.name,
-					type: _this.type,
-					partyMode: _this.station.partyMode,
-					description: _this.station.description,
-					privacy: _this.station.privacy,
-					displayName: _this.station.displayName
-				});
-			},
-			toggleSidebar: function (type) {
-				Object.keys(this.sidebars).forEach(sidebar => {
-					if (sidebar !== type) this.sidebars[sidebar] = false;
-					else this.sidebars[type] = !this.sidebars[type];
-				});
-			},
-			youtubeReady: function() {
-				let local = this;
-				if (!local.player) {
-					local.player = new YT.Player("player", {
-						height: 270,
-						width: 480,
-						videoId: local.currentSong.songId,
-						startSeconds: local.getTimeElapsed() / 1000 + local.currentSong.skipDuration,
-						playerVars: {controls: 0, iv_load_policy: 3, rel: 0, showinfo: 0},
-						events: {
-							'onReady': function (event) {
-								local.playerReady = true;
-								let volume = parseInt(localStorage.getItem("volume"));
-								volume = (typeof volume === "number") ? volume : 20;
-								local.player.setVolume(volume);
-								if (volume > 0) local.player.unMute();
-								local.playVideo();
-							},
-							'onError': function(err) {
-								console.log("iframe error", err);
-								local.voteSkipStation();
-							},
-							'onStateChange': function (event) {
-								if (event.data === 1 && local.videoLoading === true) {
-									local.videoLoading = false;
-									local.player.seekTo(local.getTimeElapsed() / 1000 + local.currentSong.skipDuration, true);
-									if (local.paused) local.player.pauseVideo();
-								} else if (event.data === 1 && local.paused) {
-									local.player.seekTo(local.timeBeforePause / 1000, true);
-									local.player.pauseVideo();
-								}
-								if (event.data === 2 && !local.paused && !local.noSong && (local.player.getDuration() / 1000) < local.currentSong.duration) {
-									local.player.seekTo(local.getTimeElapsed() / 1000 + local.currentSong.skipDuration, true);
-									local.player.playVideo();
-								}
+			timeBeforePause: 0,
+			skipVotes: 0,
+			privatePlaylistQueueSelected: null,
+			automaticallyRequestedSongId: null,
+			systemDifference: 0,
+			attemptsToPlayVideo: 0,
+			canAutoplay: true,
+			lastTimeRequestedIfCanAutoplay: 0,
+			seeking: false,
+			playbackRate: 1
+		};
+	},
+	computed: {
+		...mapState("modals", {
+			modals: state => state.modals.station
+		}),
+		...mapState("station", {
+			station: state => state.station,
+			currentSong: state => state.currentSong,
+			songsList: state => state.songsList,
+			paused: state => state.paused,
+			noSong: state => state.noSong
+		}),
+		...mapState({
+			loggedIn: state => state.user.auth.loggedIn,
+			userId: state => state.user.auth.userId,
+			role: state => state.user.auth.role
+		})
+	},
+	methods: {
+		isOwnerOnly() {
+			return this.loggedIn && this.userId === this.station.owner;
+		},
+		isAdminOnly() {
+			return this.loggedIn && this.role === "admin";
+		},
+		removeFromQueue(songId) {
+			window.socket.emit(
+				"stations.removeFromQueue",
+				this.station._id,
+				songId,
+				res => {
+					if (res.status === "success") {
+						new Toast({
+							content:
+								"Successfully removed song from the queue.",
+							timeout: 4000
+						});
+					} else new Toast({ content: res.message, timeout: 8000 });
+				}
+			);
+		},
+		toggleSidebar(type) {
+			Object.keys(this.sidebars).forEach(sidebar => {
+				if (sidebar !== type) this.sidebars[sidebar] = false;
+				else this.sidebars[type] = !this.sidebars[type];
+			});
+		},
+		youtubeReady() {
+			if (!this.player) {
+				this.player = new window.YT.Player("player", {
+					height: 270,
+					width: 480,
+					videoId: this.currentSong.songId,
+					startSeconds:
+						this.getTimeElapsed() / 1000 +
+						this.currentSong.skipDuration,
+					playerVars: {
+						controls: 0,
+						iv_load_policy: 3,
+						rel: 0,
+						showinfo: 0
+					},
+					events: {
+						onReady: () => {
+							this.playerReady = true;
+							let volume = parseInt(
+								localStorage.getItem("volume")
+							);
+
+							volume = typeof volume === "number" ? volume : 20;
+
+							this.player.setVolume(volume);
+
+							if (volume > 0) {
+								this.player.unMute();
+							}
+
+							if (this.muted) this.player.mute();
+
+							this.playVideo();
+						},
+						onError: err => {
+							console.log("iframe error", err);
+							this.voteSkipStation();
+						},
+						onStateChange: event => {
+							if (
+								event.data === window.YT.PlayerState.PLAYING &&
+								this.videoLoading === true
+							) {
+								this.videoLoading = false;
+								this.player.seekTo(
+									this.getTimeElapsed() / 1000 +
+										this.currentSong.skipDuration,
+									true
+								);
+								if (this.paused) this.player.pauseVideo();
+							} 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 === window.YT.PlayerState.PAUSED &&
+								!this.paused &&
+								!this.noSong &&
+								this.player.getDuration() / 1000 <
+									this.currentSong.duration
+							) {
+								this.player.seekTo(
+									this.getTimeElapsed() / 1000 +
+										this.currentSong.skipDuration,
+									true
+								);
+								this.player.playVideo();
 							}
 						}
-					});
-				}
-			},
-			getTimeElapsed: function() {
-				let local = this;
-				if (local.currentSong) return Date.currently() - local.startedAt - local.timePaused;
-				else return 0;
-			},
-			playVideo: function() {
-				let local = this;
-				if (local.playerReady) {
-					local.videoLoading = true;
-					local.player.loadVideoById(local.currentSong.songId, local.getTimeElapsed() / 1000 + local.currentSong.skipDuration);
-
-					if (local.currentSong.artists) local.currentSong.artists = local.currentSong.artists.join(", ");
-					if (window.stationInterval !== 0) clearInterval(window.stationInterval);
-					window.stationInterval = setInterval(function () {
-						local.resizeSeekerbar();
-						local.calculateTimeElapsed();
-					}, 150);
-				}
-			},
-			resizeSeekerbar: function() {
-				let local = this;
-				if (!local.paused) {
-					$(".seeker-bar").width(parseFloat(((local.getTimeElapsed() / 1000) / local.currentSong.duration * 100)) + "%");
-				}
-			},
-			formatTime: function(duration) {
-				let d = moment.duration(duration, 'seconds');
+					}
+				});
+			}
+		},
+		getTimeElapsed() {
+			if (this.currentSong) {
+				let { timePaused } = this;
+				if (this.paused) timePaused += Date.currently() - this.pausedAt;
+				return Date.currently() - this.startedAt - timePaused;
+			}
+			return 0;
+		},
+		playVideo() {
+			if (this.playerReady) {
+				this.videoLoading = true;
+				this.player.loadVideoById(
+					this.currentSong.songId,
+					this.getTimeElapsed() / 1000 + this.currentSong.skipDuration
+				);
+
+				if (window.stationInterval !== 0)
+					clearInterval(window.stationInterval);
+				window.stationInterval = setInterval(() => {
+					this.resizeSeekerbar();
+					this.calculateTimeElapsed();
+				}, 150);
+			}
+		},
+		resizeSeekerbar() {
+			if (!this.paused) {
+				document.getElementsByClassName(
+					"seeker-bar"
+				)[0].style.width = `${parseFloat(
+					(this.getTimeElapsed() / 1000 / this.currentSong.duration) *
+						100
+				)}%`;
+			}
+		},
+		formatTime(duration) {
+			if (duration) {
 				if (duration < 0) return "0:00";
-				return ((d.hours() > 0) ? (d.hours() < 10 ? ("0" + d.hours() + ":") : (d.hours() + ":")) : "") + (d.minutes() + ":") + (d.seconds() < 10 ? ("0" + d.seconds()) : d.seconds());
-			},
-			calculateTimeElapsed: function() {
-				let local = this;
-				let currentTime = Date.currently();
 
-				if (local.currentTime !== undefined && local.paused) {
-					local.timePaused += (Date.currently() - local.currentTime);
-					local.currentTime = undefined;
-				}
+				const hours = Math.floor(duration / (60 * 60));
+				const minutes = Math.floor((duration - hours) / 60);
+				const seconds = Math.floor(
+					duration - hours * 60 * 60 - minutes * 60
+				);
 
-				let duration = (Date.currently() - local.startedAt - local.timePaused) / 1000;
-				let songDuration = local.currentSong.duration;
-				if (songDuration <= duration) local.player.pauseVideo();
-				if ((!local.paused) && duration <= songDuration) local.timeElapsed = local.formatTime(duration);
-			},
-			toggleLock: function () {
-				let _this = this;
-				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);
-				});
-			},
-			changeVolume: function() {
-				let local = this;
-				let volume = $("#volumeSlider").val();
-				localStorage.setItem("volume", volume / 100);
-				if (local.playerReady) {
-					local.player.setVolume(volume / 100);
-					if (volume > 0) local.player.unMute();
+				const formatHours = () => {
+					if (hours > 0) {
+						if (hours < 10) return `0${hours}:`;
+						return `${hours}:`;
+					}
+					return "";
+				};
+
+				return `${formatHours()}${minutes}:${
+					seconds < 10 ? `0${seconds}` : seconds
+				}`;
+			}
+			return false;
+		},
+		calculateTimeElapsed() {
+			if (
+				this.playerReady &&
+				this.currentSong &&
+				this.player.getPlayerState() === -1
+			) {
+				if (this.attemptsToPlayVideo >= 5) {
+					if (
+						Date.now() - this.lastTimeRequestedIfCanAutoplay >
+						2000
+					) {
+						this.lastTimeRequestedIfCanAutoplay = Date.now();
+						window.canAutoplay.video().then(({ result }) => {
+							if (result) {
+								this.attemptsToPlayVideo = 0;
+								this.canAutoplay = true;
+							} else {
+								this.canAutoplay = false;
+							}
+						});
+					}
+				} else {
+					this.player.playVideo();
+					this.attemptsToPlayVideo += 1;
 				}
-			},
-			resumeLocalStation: function() {
-				this.paused = false;
-				if (!this.noSong) {
-					if (this.playerReady) {
-						this.player.seekTo(this.getTimeElapsed() / 1000 + this.currentSong.skipDuration);
-						this.player.playVideo();
+			}
+
+			if (!this.paused) {
+				const timeElapsed = this.getTimeElapsed();
+				const currentPlayerTime = this.player.getCurrentTime() * 1000;
+
+				const difference = timeElapsed - currentPlayerTime;
+				// 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");
+					playbackRate = 0.8;
+				} else if (difference < -50) {
+					// console.log("Difference0.9");
+					playbackRate = 0.9;
+				} else if (difference < -25) {
+					// console.log("Difference0.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");
+					playbackRate = 1.2;
+				} else if (difference > 50) {
+					// console.log("Difference1.1");
+					playbackRate = 1.1;
+				} else if (difference > 25) {
+					// console.log("Difference1.01");
+					playbackRate = 1.05;
+				} else if (this.player.getPlaybackRate !== 1.0) {
+					// console.log("NDifference1.0");
+					this.player.setPlaybackRate(1.0);
 				}
-			},
-			pauseLocalStation: function() {
-				this.paused = true;
-				if (!this.noSong) {
-					this.timeBeforePause = this.getTimeElapsed();
-					if (this.playerReady) this.player.pauseVideo();
+
+				if (this.playbackRate !== playbackRate) {
+					this.player.setPlaybackRate(playbackRate);
+					this.playbackRate = playbackRate;
 				}
-			},
-			skipStation: function () {
-				let _this = this;
-				_this.socket.emit('stations.forceSkip', _this.station._id, data => {
-					if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
-					else Toast.methods.addToast('Successfully skipped the station\'s current song.', 4000);
-				});
-			},
-			voteSkipStation: function () {
-				let _this = this;
-				_this.socket.emit('stations.voteSkip', _this.station._id, data => {
-					if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
-					else Toast.methods.addToast('Successfully voted to skip the current song.', 4000);
-				});
-			},
-			resumeStation: function () {
-				let _this = this;
-				_this.socket.emit('stations.resume', _this.station._id, data => {
-					if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
-					else Toast.methods.addToast('Successfully resumed the station.', 4000);
-				});
-			},
-			pauseStation: function () {
-				let _this = this;
-				_this.socket.emit('stations.pause', _this.station._id, data => {
-					if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
-					else Toast.methods.addToast('Successfully paused the station.', 4000);
-				});
-			},
-			toggleMute: function () {
-				if (this.playerReady) {
-					let previousVolume = parseFloat(localStorage.getItem("volume"));
-					let volume = this.player.getVolume() * 100 <= 0 ? previousVolume : 0;
-					this.muted = !this.muted;
-					$("#volumeSlider").val(volume * 100);
-					this.player.setVolume(volume);
-					if (!this.muted) localStorage.setItem("volume", volume);
+			}
+
+			/* if (this.currentTime !== undefined && this.paused) {
+				this.timePaused += Date.currently() - this.currentTime;
+				this.currentTime = undefined;
+			} */
+
+			let { timePaused } = this;
+			if (this.paused) timePaused += Date.currently() - this.pausedAt;
+
+			const duration =
+				(Date.currently() - this.startedAt - timePaused) / 1000;
+
+			const songDuration = this.currentSong.duration;
+			if (songDuration <= duration) this.player.pauseVideo();
+			if (!this.paused && duration <= songDuration)
+				this.timeElapsed = this.formatTime(duration);
+		},
+		toggleLock() {
+			window.socket.emit("stations.toggleLock", this.station._id, res => {
+				if (res.status === "success") {
+					new Toast({
+						content: "Successfully toggled the queue lock.",
+						timeout: 4000
+					});
+				} else new Toast({ content: res.message, timeout: 8000 });
+			});
+		},
+		changeVolume() {
+			const volume = document.getElementById("volumeSlider").value;
+			localStorage.setItem("volume", volume / 100);
+			if (this.playerReady) {
+				this.player.setVolume(volume / 100);
+				if (volume > 0) {
+					this.player.unMute();
+					localStorage.setItem("muted", false);
+					this.muted = false;
 				}
-			},
-			increaseVolume: function () {
+			}
+		},
+		resumeLocalStation() {
+			this.updatePaused(false);
+			if (!this.noSong) {
 				if (this.playerReady) {
-					let previousVolume = parseInt(localStorage.getItem("volume"));
-					let volume = previousVolume + 5;
-					if (previousVolume === 0) this.muted = false;
-					if (volume > 100) volume = 100;
-					$("#volumeSlider").val(volume * 100);
-					this.player.setVolume(volume);
-					localStorage.setItem("volume", volume);
+					this.player.seekTo(
+						this.getTimeElapsed() / 1000 +
+							this.currentSong.skipDuration
+					);
+					this.player.playVideo();
 				}
-			},
-			toggleLike: function() {
-				let _this = this;
-				if (_this.liked) _this.socket.emit('songs.unlike', _this.currentSong.songId, data => {
-					if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
-				}); else _this.socket.emit('songs.like', _this.currentSong.songId, data => {
-					if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
-				});
-			},
-			toggleDislike: function() {
-				let _this = this;
-				if (_this.disliked) return _this.socket.emit('songs.undislike', _this.currentSong.songId, data => {
-					if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
-				});
-				_this.socket.emit('songs.dislike', _this.currentSong.songId, data => {
-					if (data.status !== 'success') Toast.methods.addToast(`Error: ${data.message}`, 8000);
-				});
-			},
-			addFirstPrivatePlaylistSongToQueue: function() {
-				let _this = this;
-				let isInQueue = false;
-				let userId = _this.$parent.userId;
-				if (_this.type === 'community') {
-					_this.songsList.forEach((queueSong) => {
-						if (queueSong.requestedBy === userId) isInQueue = true;
+			}
+		},
+		pauseLocalStation() {
+			this.updatePaused(true);
+			if (!this.noSong) {
+				this.timeBeforePause = this.getTimeElapsed();
+				if (this.playerReady) this.player.pauseVideo();
+			}
+		},
+		skipStation() {
+			this.socket.emit("stations.forceSkip", this.station._id, data => {
+				if (data.status !== "success")
+					new Toast({
+						content: `Error: ${data.message}`,
+						timeout: 8000
+					});
+				else
+					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")
+					new Toast({
+						content: `Error: ${data.message}`,
+						timeout: 8000
+					});
+				else
+					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")
+					new Toast({
+						content: `Error: ${data.message}`,
+						timeout: 8000
 					});
-					if (!isInQueue && _this.privatePlaylistQueueSelected) {
-
-						_this.socket.emit('playlists.getFirstSong', _this.privatePlaylistQueueSelected, data => {
-							if (data.status === 'success') {
-							    if (data.song.duration < 15 * 60) {
-									let songId = data.song._id;
-									_this.automaticallyRequestedSongId = data.song.songId;
-									_this.socket.emit('stations.addToQueue', _this.station._id, data.song.songId, data2 => {
-										if (data2.status === 'success') {
-											_this.socket.emit('playlists.moveSongToBottom', _this.privatePlaylistQueueSelected, data.song.songId, data3 => {
-												if (data3.status === 'success') {
-												}
-											});
+				else
+					new Toast({
+						content: "Successfully resumed the station.",
+						timeout: 4000
+					});
+			});
+		},
+		pauseStation() {
+			this.socket.emit("stations.pause", this.station._id, data => {
+				if (data.status !== "success")
+					new Toast({
+						content: `Error: ${data.message}`,
+						timeout: 8000
+					});
+				else
+					new Toast({
+						content: "Successfully paused the station.",
+						timeout: 4000
+					});
+			});
+		},
+		toggleMute() {
+			if (this.playerReady) {
+				const previousVolume = parseFloat(
+					localStorage.getItem("volume")
+				);
+				const volume =
+					this.player.getVolume() * 100 <= 0 ? previousVolume : 0;
+				this.muted = !this.muted;
+				localStorage.setItem("muted", this.muted);
+				document.getElementById("volumeSlider").value = volume * 100;
+				this.player.setVolume(volume);
+				if (!this.muted) localStorage.setItem("volume", volume);
+			}
+		},
+		increaseVolume() {
+			if (this.playerReady) {
+				const previousVolume = parseInt(localStorage.getItem("volume"));
+				let volume = previousVolume + 5;
+				if (previousVolume === 0) {
+					this.muted = false;
+					localStorage.setItem("muted", false);
+				}
+				if (volume > 100) volume = 100;
+				document.getElementById("volumeSlider").value = volume * 100;
+				this.player.setVolume(volume);
+				localStorage.setItem("volume", volume);
+			}
+		},
+		toggleLike() {
+			if (this.liked)
+				this.socket.emit(
+					"songs.unlike",
+					this.currentSong.songId,
+					data => {
+						if (data.status !== "success")
+							new Toast({
+								content: `Error: ${data.message}`,
+								timeout: 8000
+							});
+					}
+				);
+			else
+				this.socket.emit(
+					"songs.like",
+					this.currentSong.songId,
+					data => {
+						if (data.status !== "success")
+							new Toast({
+								content: `Error: ${data.message}`,
+								timeout: 8000
+							});
+					}
+				);
+		},
+		toggleDislike() {
+			if (this.disliked)
+				return this.socket.emit(
+					"songs.undislike",
+					this.currentSong.songId,
+					data => {
+						if (data.status !== "success")
+							new Toast({
+								content: `Error: ${data.message}`,
+								timeout: 8000
+							});
+					}
+				);
+
+			return this.socket.emit(
+				"songs.dislike",
+				this.currentSong.songId,
+				data => {
+					if (data.status !== "success")
+						new Toast({
+							content: `Error: ${data.message}`,
+							timeout: 8000
+						});
+				}
+			);
+		},
+		addFirstPrivatePlaylistSongToQueue() {
+			let isInQueue = false;
+			if (this.station.type === "community") {
+				this.songsList.forEach(queueSong => {
+					if (queueSong.requestedBy === this.userId) isInQueue = true;
+				});
+				if (!isInQueue && this.privatePlaylistQueueSelected) {
+					this.socket.emit(
+						"playlists.getFirstSong",
+						this.privatePlaylistQueueSelected,
+						data => {
+							if (data.status === "success") {
+								if (data.song.duration < 15 * 60) {
+									this.automaticallyRequestedSongId =
+										data.song.songId;
+									this.socket.emit(
+										"stations.addToQueue",
+										this.station._id,
+										data.song.songId,
+										data2 => {
+											if (data2.status === "success") {
+												this.socket.emit(
+													"playlists.moveSongToBottom",
+													this
+														.privatePlaylistQueueSelected,
+													data.song.songId,
+													data3 => {
+														if (
+															data3.status ===
+															"success"
+														) {} // eslint-disable-line
+													}
+												);
+											}
 										}
-									});
+									);
 								} else {
-									Toast.methods.addToast(`Top song in playlist was too long to be added.`, 3000);
-									_this.socket.emit('playlists.moveSongToBottom', _this.privatePlaylistQueueSelected, data.song.songId, data3 => {
-										if (data3.status === 'success') {
-										    setTimeout(() => {
-										        this.addFirstPrivatePlaylistSongToQueue();
-											}, 3000);
-										}
+									new Toast({
+										content: `Top song in playlist was too long to be added.`,
+										timeout: 3000
 									});
+									this.socket.emit(
+										"playlists.moveSongToBottom",
+										this.privatePlaylistQueueSelected,
+										data.song.songId,
+										data3 => {
+											if (data3.status === "success") {
+												setTimeout(() => {
+													this.addFirstPrivatePlaylistSongToQueue();
+												}, 3000);
+											}
+										}
+									);
 								}
 							}
-						});
-					}
+						}
+					);
 				}
-			},
-			joinStation: function () {
-				let _this = this;
-				_this.socket.emit('stations.join', _this.stationName, res => {
-					if (res.status === 'success') {
-						_this.station = {
-							_id: res.data._id,
-							name: _this.stationName,
-							displayName: res.data.displayName,
-							description: res.data.description,
-							privacy: res.data.privacy,
-							locked: res.data.locked,
-							partyMode: res.data.partyMode,
-							owner: res.data.owner,
-							privatePlaylist: res.data.privatePlaylist
-						};
-						_this.currentSong = (res.data.currentSong) ? res.data.currentSong : {};
-						_this.type = res.data.type;
-						_this.startedAt = res.data.startedAt;
-						_this.paused = res.data.paused;
-						_this.timePaused = res.data.timePaused;
-						_this.userCount = res.data.userCount;
-						_this.users = res.data.users;
-						if (res.data.currentSong) {
-							_this.noSong = false;
-							_this.simpleSong = (res.data.currentSong.likes === -1 && res.data.currentSong.dislikes === -1);
-							if (_this.simpleSong) {
-								_this.currentSong.skipDuration = 0;
-							}
-							_this.youtubeReady();
-							_this.playVideo();
-							_this.socket.emit('songs.getOwnSongRatings', res.data.currentSong.songId, data => {
-								if (_this.currentSong.songId === data.songId) {
-									_this.liked = data.liked;
-									_this.disliked = data.disliked;
+			}
+		},
+		join() {
+			this.socket.emit("stations.join", this.stationName, res => {
+				if (res.status === "success") {
+					this.loading = false;
+
+					const {
+						_id,
+						displayName,
+						description,
+						privacy,
+						locked,
+						partyMode,
+						owner,
+						privatePlaylist,
+						type
+					} = res.data;
+
+					this.joinStation({
+						_id,
+						name: this.stationName,
+						displayName,
+						description,
+						privacy,
+						locked,
+						partyMode,
+						owner,
+						privatePlaylist,
+						type
+					});
+					const currentSong = res.data.currentSong
+						? res.data.currentSong
+						: {};
+					if (currentSong.artists)
+						currentSong.artists = currentSong.artists.join(", ");
+					this.updateCurrentSong(currentSong);
+					this.startedAt = res.data.startedAt;
+					this.updatePaused(res.data.paused);
+					this.timePaused = res.data.timePaused;
+					this.updateUserCount(res.data.userCount);
+					this.updateUsers(res.data.users);
+					this.pausedAt = res.data.pausedAt;
+					if (res.data.currentSong) {
+						this.updateNoSong(false);
+						this.youtubeReady();
+						this.playVideo();
+						this.socket.emit(
+							"songs.getOwnSongRatings",
+							res.data.currentSong.songId,
+							data => {
+								if (this.currentSong.songId === data.songId) {
+									this.liked = data.liked;
+									this.disliked = data.disliked;
 								}
-							});
-						} else {
-							if (_this.playerReady) _this.player.pauseVideo();
-							_this.noSong = true;
-						}
-						if (_this.type === 'community') {
-							_this.socket.emit('stations.getQueue', _this.station._id, data => {
-								if (data.status === 'success') _this.songsList = data.queue;
-							});
-						}
-						if (_this.type === 'official') {
-							_this.socket.emit('stations.getPlaylist', _this.station._id, res => {
-								if (res.status == 'success') _this.songsList = res.data;
-							});
-						}
+							}
+						);
 					} else {
-						_this.$router.go('/404');
-						Toast.methods.addToast(res.message, 3000);
+						if (this.playerReady) this.player.pauseVideo();
+						this.updateNoSong(true);
 					}
 					// UNIX client time before ping
-					let beforePing = Date.now();
-					_this.socket.emit('apis.ping', res => {
+					const beforePing = Date.now();
+					this.socket.emit("apis.ping", pong => {
 						// UNIX client time after ping
-						let afterPing = Date.now();
+						const afterPing = Date.now();
 						// Average time in MS it took between the server responding and the client receiving
-						let connectionLatency = (afterPing - beforePing) / 2;
+						const connectionLatency = (afterPing - beforePing) / 2;
 						console.log(connectionLatency, beforePing - afterPing);
 						// UNIX server time
-						let serverDate = res.date;
+						const serverDate = pong.date;
 						// Difference between the server UNIX time and the client UNIX time after ping, with the connectionLatency added to the server UNIX time
-						let difference = (serverDate + connectionLatency) - afterPing;
+						const difference =
+							serverDate + connectionLatency - afterPing;
 						console.log("Difference: ", difference);
 						if (difference > 3000 || difference < -3000) {
-							console.log("System time difference is bigger than 3 seconds.");
+							console.log(
+								"System time difference is bigger than 3 seconds."
+							);
 						}
-						_this.systemDifference = difference;
+						this.systemDifference = difference;
 					});
-				});
-			}
+				} else {
+					this.loading = false;
+					this.exists = false;
+				}
+			});
 		},
-		ready: function() {
-			let _this = this;
-
-			Date.currently = () => {
-				return new Date().getTime() + _this.systemDifference;
-			};
-
-			_this.stationName = _this.$route.params.id;
-
-			window.stationInterval = 0;
-
-			io.getSocket(socket => {
-				_this.socket = socket;
-
-				io.removeAllListeners();
-				if (_this.socket.connected) _this.joinStation();
-				io.onConnect(() => {
-					_this.joinStation();
-				});
-				_this.socket.emit('stations.findByName', _this.stationName, res => {
-					if (res.status === 'error') {
-						_this.$router.go('/404');
-						Toast.methods.addToast(res.message, 3000);
-					} else {
-						_this.ready = true;
-					}
-				});
-				_this.socket.on('event:songs.next', data => {
-					_this.previousSong = (_this.currentSong.songId) ? _this.currentSong : null;
-					_this.currentSong = (data.currentSong) ? data.currentSong : {};
-					_this.startedAt = data.startedAt;
-					_this.paused = data.paused;
-					_this.timePaused = data.timePaused;
-					if (data.currentSong) {
-						_this.noSong = false;
-						_this.simpleSong = (data.currentSong.likes === -1 && data.currentSong.dislikes === -1);
-						if (_this.simpleSong) _this.currentSong.skipDuration = 0;
-						if (!_this.playerReady) _this.youtubeReady();
-						else _this.playVideo();
-						_this.socket.emit('songs.getOwnSongRatings', data.currentSong.songId, (data) => {
-							if (_this.currentSong.songId === data.songId) {
-								_this.liked = data.liked;
-								_this.disliked = data.disliked;
+		...mapActions("modals", ["openModal"]),
+		...mapActions("station", [
+			"joinStation",
+			"updateUserCount",
+			"updateUsers",
+			"updateCurrentSong",
+			"updatePreviousSong",
+			"updateSongsList",
+			"updatePaused",
+			"updateNoSong"
+		])
+	},
+	mounted() {
+		Date.currently = () => {
+			return new Date().getTime() + this.systemDifference;
+		};
+
+		this.stationName = this.$route.params.id;
+
+		window.stationInterval = 0;
+
+		io.getSocket(socket => {
+			this.socket = socket;
+
+			io.removeAllListeners();
+			if (this.socket.connected) this.join();
+			io.onConnect(this.join);
+			this.socket.emit("stations.existsByName", this.stationName, res => {
+				if (res.status === "failure" || !res.exists) {
+					this.loading = false;
+					this.exists = false;
+				}
+			});
+			this.socket.on("event:songs.next", data => {
+				const previousSong = this.currentSong.songId
+					? this.currentSong
+					: null;
+				this.updatePreviousSong(previousSong);
+				this.updateCurrentSong(
+					data.currentSong ? data.currentSong : {}
+				);
+				this.startedAt = data.startedAt;
+				this.updatePaused(data.paused);
+				this.timePaused = data.timePaused;
+				if (data.currentSong) {
+					this.updateNoSong(false);
+					if (this.currentSong.artists)
+						this.currentSong.artists = this.currentSong.artists.join(
+							", "
+						);
+					if (!this.playerReady) this.youtubeReady();
+					else this.playVideo();
+					this.socket.emit(
+						"songs.getOwnSongRatings",
+						data.currentSong.songId,
+						song => {
+							if (this.currentSong.songId === song.songId) {
+								this.liked = song.liked;
+								this.disliked = song.disliked;
 							}
-						});
-					} else {
-						if (_this.playerReady) _this.player.pauseVideo();
-						_this.noSong = true;
-					}
+						}
+					);
+				} else {
+					if (this.playerReady) this.player.pauseVideo();
+					this.updateNoSong(true);
+				}
 
-					let isInQueue = false;
-					let userId = _this.$parent.userId;
-					_this.songsList.forEach((queueSong) => {
-						if (queueSong.requestedBy === userId) isInQueue = true;
-					});
-					if (!isInQueue && _this.privatePlaylistQueueSelected && (_this.automaticallyRequestedSongId !== _this.currentSong.songId || !_this.currentSong.songId)) {
-						_this.addFirstPrivatePlaylistSongToQueue();
-					}
+				let isInQueue = false;
+				this.songsList.forEach(queueSong => {
+					if (queueSong.requestedBy === this.userId) isInQueue = true;
 				});
+				if (
+					!isInQueue &&
+					this.privatePlaylistQueueSelected &&
+					(this.automaticallyRequestedSongId !==
+						this.currentSong.songId ||
+						!this.currentSong.songId)
+				) {
+					this.addFirstPrivatePlaylistSongToQueue();
+				}
+			});
 
-				_this.socket.on('event:stations.pause', data => {
-					_this.pauseLocalStation();
-				});
+			this.socket.on("event:stations.pause", data => {
+				this.pausedAt = data.pausedAt;
+				this.pauseLocalStation();
+			});
 
-				_this.socket.on('event:stations.resume', data => {
-					_this.timePaused = data.timePaused;
-					_this.resumeLocalStation();
-				});
+			this.socket.on("event:stations.resume", data => {
+				this.timePaused = data.timePaused;
+				this.resumeLocalStation();
+			});
 
-				_this.socket.on('event:stations.remove', () => {
-					location.href = '/';
-				});
+			this.socket.on("event:stations.remove", () => {
+				window.location.href = "/";
+				return true;
+			});
 
-				_this.socket.on('event:song.like', data => {
-					if (!this.noSong) {
-						if (data.songId === _this.currentSong.songId) {
-							_this.currentSong.dislikes = data.dislikes;
-							_this.currentSong.likes = data.likes;
-						}
+			this.socket.on("event:song.like", data => {
+				if (!this.noSong) {
+					if (data.songId === this.currentSong.songId) {
+						this.currentSong.dislikes = data.dislikes;
+						this.currentSong.likes = data.likes;
 					}
-				});
+				}
+			});
 
-				_this.socket.on('event:song.dislike', data => {
-					if (!this.noSong) {
-						if (data.songId === _this.currentSong.songId) {
-							_this.currentSong.dislikes = data.dislikes;
-							_this.currentSong.likes = data.likes;
-						}
+			this.socket.on("event:song.dislike", data => {
+				if (!this.noSong) {
+					if (data.songId === this.currentSong.songId) {
+						this.currentSong.dislikes = data.dislikes;
+						this.currentSong.likes = data.likes;
 					}
-				});
+				}
+			});
 
-				_this.socket.on('event:song.unlike', data => {
-					if (!this.noSong) {
-						if (data.songId === _this.currentSong.songId) {
-							_this.currentSong.dislikes = data.dislikes;
-							_this.currentSong.likes = data.likes;
-						}
+			this.socket.on("event:song.unlike", data => {
+				if (!this.noSong) {
+					if (data.songId === this.currentSong.songId) {
+						this.currentSong.dislikes = data.dislikes;
+						this.currentSong.likes = data.likes;
 					}
-				});
+				}
+			});
 
-				_this.socket.on('event:song.undislike', data => {
-					if (!this.noSong) {
-						if (data.songId === _this.currentSong.songId) {
-							_this.currentSong.dislikes = data.dislikes;
-							_this.currentSong.likes = data.likes;
-						}
+			this.socket.on("event:song.undislike", data => {
+				if (!this.noSong) {
+					if (data.songId === this.currentSong.songId) {
+						this.currentSong.dislikes = data.dislikes;
+						this.currentSong.likes = data.likes;
 					}
-				});
+				}
+			});
 
-				_this.socket.on('event:song.newRatings', data => {
-					if (!this.noSong) {
-						if (data.songId === _this.currentSong.songId) {
-							_this.liked = data.liked;
-							_this.disliked = data.disliked;
-						}
+			this.socket.on("event:song.newRatings", data => {
+				if (!this.noSong) {
+					if (data.songId === this.currentSong.songId) {
+						this.liked = data.liked;
+						this.disliked = data.disliked;
 					}
-				});
-
-				_this.socket.on('event:queue.update', queue => {
-					if (this.type === 'community') this.songsList = queue;
-				});
+				}
+			});
 
-				_this.socket.on('event:song.voteSkipSong', () => {
-					if (this.currentSong) this.currentSong.skipVotes++;
-				});
+			this.socket.on("event:queue.update", queue => {
+				if (this.station.type === "community")
+					this.updateSongsList(queue);
+			});
 
-				_this.socket.on('event:privatePlaylist.selected', (playlistId) => {
-					if (this.type === 'community') {
-						this.station.privatePlaylist = playlistId;
-					}
-				});
+			this.socket.on("event:song.voteSkipSong", () => {
+				if (this.currentSong) this.currentSong.skipVotes += 1;
+			});
 
-				_this.socket.on('event:partyMode.updated', (partyMode) => {
-					if (this.type === 'community') {
-						this.station.partyMode = partyMode;
-					}
-				});
+			this.socket.on("event:privatePlaylist.selected", playlistId => {
+				if (this.station.type === "community") {
+					this.station.privatePlaylist = playlistId;
+				}
+			});
 
-				_this.socket.on('event:newOfficialPlaylist', (playlist) => {
-					if (this.type === 'official') {
-						this.songsList = playlist;
-					}
-				});
+			this.socket.on("event:partyMode.updated", partyMode => {
+				if (this.station.type === "community") {
+					this.station.partyMode = partyMode;
+				}
+			});
 
-				_this.socket.on('event:users.updated', users => {
-					_this.users = users;
-				});
+			this.socket.on("event:newOfficialPlaylist", playlist => {
+				if (this.station.type === "official") {
+					this.updateSongsList(playlist);
+				}
+			});
 
-				_this.socket.on('event:userCount.updated', userCount => {
-					_this.userCount = userCount;
-				});
+			this.socket.on("event:users.updated", users => {
+				this.updateUsers(users);
+			});
 
-				_this.socket.on('event:queueLockToggled', locked => {
-					_this.station.locked = locked;
-				});
+			this.socket.on("event:userCount.updated", userCount => {
+				this.updateUserCount(userCount);
 			});
 
+			this.socket.on("event:queueLockToggled", locked => {
+				this.station.locked = locked;
+			});
+		});
 
+		if (JSON.parse(localStorage.getItem("muted"))) {
+			this.muted = true;
+			this.player.setVolume(0);
+			document.getElementById("volumeSlider").value = 0 * 100;
+		} else {
 			let volume = parseFloat(localStorage.getItem("volume"));
-			volume = (typeof volume === "number" && !isNaN(volume)) ? volume : 20;
+			volume =
+				typeof volume === "number" && !Number.isNaN(volume)
+					? volume
+					: 20;
 			localStorage.setItem("volume", volume);
-			$("#volumeSlider").val(volume * 100);
-		},
-		components: {
-			OfficialHeader,
-			CommunityHeader,
-			SongQueue,
-			AddToPlaylist,
-			EditPlaylist,
-			CreatePlaylist,
-			EditStation,
-			Report,
-			SongsListSidebar,
-			PlaylistSidebar,
-			UsersSidebar,
-			MainFooter
+			document.getElementById("volumeSlider").value = volume * 100;
 		}
+	},
+	components: {
+		StationHeader,
+		SongQueue: () => import("../Modals/AddSongToQueue.vue"),
+		AddToPlaylist: () => import("../Modals/AddSongToPlaylist.vue"),
+		EditPlaylist: () => import("../Modals/Playlists/Edit.vue"),
+		CreatePlaylist: () => import("../Modals/Playlists/Create.vue"),
+		EditStation: () => import("../Modals/EditStation.vue"),
+		Report: () => import("../Modals/Report.vue"),
+		SongsListSidebar: () => import("../Sidebars/SongsList.vue"),
+		PlaylistSidebar: () => import("../Sidebars/Playlist.vue"),
+		UsersSidebar: () => import("../Sidebars/UsersList.vue"),
+		UserIdToUsername,
+		Z404
 	}
+};
 </script>
 
 <style lang="scss">
-	.no-song {
-		color: #03A9F4;
+@import "styles/global.scss";
+
+.player-can-not-autoplay {
+	position: absolute;
+	width: 100%;
+	height: 100%;
+	background: rgba(3, 169, 244, 0.95);
+	display: flex;
+	align-items: center;
+	justify-content: center;
+
+	p {
+		color: $white;
+		font-size: 26px;
 		text-align: center;
 	}
-
-	#volumeSlider {
-		padding: 0 15px;
-    	background: transparent;
-	}
-
-	.volume-slider-wrapper {
-		margin-top: 0;
-		position: relative;
-		display: flex;
-		align-items: center;
-		.material-icons { user-select: none; }
-	}
-
-	.material-icons { cursor: pointer; }
-
-	.stationDisplayName {
-		color: white !important;
+}
+
+.slide-enter-active,
+.slide-leave-active {
+	transition: all 0.3s ease;
+}
+.slide-enter,
+.slide-leave-to {
+	transform: translateX(300px);
+}
+
+.no-song {
+	color: $primary-color;
+	text-align: center;
+}
+
+#volumeSlider {
+	padding: 0 15px;
+	background: transparent;
+}
+
+.volume-slider-wrapper {
+	margin-top: 0;
+	position: relative;
+	display: flex;
+	align-items: center;
+	.material-icons {
+		user-select: none;
 	}
-
-	.add-to-playlist {
-		display: flex;
-	    align-items: center;
-	    justify-content: center;
+}
+
+.material-icons {
+	cursor: pointer;
+}
+
+.stationDisplayName {
+	color: $white !important;
+}
+
+.add-to-playlist {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+}
+
+.slideout {
+	top: 50px;
+	height: 100%;
+	position: fixed;
+	right: 0;
+	width: 350px;
+	background-color: $white;
+	box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16),
+		0 2px 10px 0 rgba(0, 0, 0, 0.12);
+	.slideout-header {
+		text-align: center;
+		background-color: rgb(3, 169, 244) !important;
+		margin: 0;
+		padding-top: 5px;
+		padding-bottom: 7px;
+		color: $white;
 	}
 
-	.slideout {
-		top: 50px;
+	.slideout-content {
 		height: 100%;
-		position: fixed;
-		right: 0;
-		width: 350px;
-		background-color: white;
-		box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16), 0 2px 10px 0 rgba(0, 0, 0, 0.12);
-		.slideout-header {
-			text-align: center;
-			background-color: rgb(3, 169, 244) !important;
-			margin: 0;
-			padding-top: 5px;
-			padding-bottom: 7px;
-			color: white;
-		}
-
-		.slideout-content {
-			height: 100%;
-		}
 	}
-
-	.modal-large {
-		width: 75%;
+}
+
+.modal-large {
+	width: 75%;
+}
+
+.station {
+	flex: 1 0 auto;
+	padding-top: 0.5vw;
+	transition: all 0.1s;
+	margin: 0 auto;
+	max-width: 100%;
+	width: 90%;
+
+	@media only screen and (min-width: 993px) {
+		width: 70%;
 	}
 
-	.station {
-		flex: 1 0 auto;
-		padding-top: 0.5vw;
-		transition: all 0.1s;
-		margin: 0 auto;
-		max-width: 100%;
-		width: 90%;
-
-		@media only screen and (min-width: 993px) {
-			width: 70%;
-		}
-
-		@media only screen and (min-width: 601px) {
-			width: 85%;
-		}
+	@media only screen and (min-width: 601px) {
+		width: 85%;
+	}
 
-		@media (min-width: 999px) {
-			.mobile-only {
-				display: none;
-			}
-			.desktop-only {
-				display: block;
-			}
+	@media (min-width: 999px) {
+		.mobile-only {
+			display: none;
 		}
-		@media (max-width: 998px) {
-			.mobile-only {
-				display: block;
-			}
-			.desktop-only {
-				display: none;
-				visibility: hidden;
-			}
+		.desktop-only {
+			display: block;
 		}
-
+	}
+	@media (max-width: 998px) {
 		.mobile-only {
-			text-align: center;
+			display: block;
 		}
+		.desktop-only {
+			display: none;
+			visibility: hidden;
+		}
+	}
 
-		.playlistCard {
-			margin: 10px;
-			position: relative;
-			padding-bottom: calc(31.25% + 7px);
-			height: 0;
-			overflow-y: scroll;
-
-			.title {
-				background-color: rgb(3, 169, 244);
-				text-align: center;
-				padding: 10px;
-				color: white;
-				font-weight: 600;
-			}
-
-			.media { padding: 0 25px; }
-
-			.media-content .content {
-				min-height: 64px;
-				max-height: 64px;
-				display: flex;
-				align-items: center;
-			}
-
-			.content p strong { word-break: break-word; }
-
-			.content p small { word-break: break-word; }
-
-			.add-to-queue {
-				width: 100%;
-				margin-top: 25px;
-				height: 40px;
-				border-radius: 0;
-				background: rgb(3, 169, 244);
-				color: #fff !important;
-				border: 0;
-				&:active, &:focus { border: 0; }
-			}
-
-			.add-to-queue:focus { background: #029ce3; }
-
-			.media-right { line-height: 64px; }
+	.mobile-only {
+		text-align: center;
+	}
 
-			.songTitle {
-				word-wrap: break-word;
-				overflow: hidden;
-				text-overflow: ellipsis;
-				display: -webkit-box;
-				-webkit-box-orient: vertical;
-				-webkit-line-clamp: 2;
-				line-height: 20px;
-				max-height: 40px;
-				width: 100%;
-			}
+	.playlistCard {
+		margin: 10px;
+		position: relative;
+		padding-bottom: calc(31.25% + 7px);
+		height: 0;
+		overflow-y: scroll;
 
+		.title {
+			background-color: rgb(3, 169, 244);
+			text-align: center;
+			padding: 10px;
+			color: $white;
+			font-weight: 600;
 		}
 
-		input[type=range] {
-			-webkit-appearance: none;
-			width: 100%;
-			margin: 7.3px 0;
+		.media {
+			padding: 0 25px;
 		}
 
-		input[type=range]:focus {
-			outline: none;
+		.media-content .content {
+			min-height: 64px;
+			max-height: 64px;
+			display: flex;
+			align-items: center;
 		}
 
-		input[type=range]::-webkit-slider-runnable-track {
-			width: 100%;
-			height: 5.2px;
-			cursor: pointer;
-			box-shadow: 0;
-			background: #c2c0c2;
-			border-radius: 0;
-			border: 0;
+		.content p strong {
+			word-break: break-word;
 		}
 
-		input[type=range]::-webkit-slider-thumb {
-			box-shadow: 0;
-			border: 0;
-			height: 19px;
-			width: 19px;
-			border-radius: 15px;
-			background: #03a9f4;
-			cursor: pointer;
-			-webkit-appearance: none;
-			margin-top: -6.5px;
+		.content p small {
+			word-break: break-word;
 		}
 
-		input[type=range]::-moz-range-track {
+		.add-to-queue {
 			width: 100%;
-			height: 5.2px;
-			cursor: pointer;
-			box-shadow: 0;
-			background: #c2c0c2;
+			margin-top: 25px;
+			height: 40px;
 			border-radius: 0;
+			background: rgb(3, 169, 244);
+			color: $white !important;
 			border: 0;
+			&:active,
+			&:focus {
+				border: 0;
+			}
 		}
 
-		input[type=range]::-moz-range-thumb {
-			box-shadow: 0;
-			border: 0;
-			height: 19px;
-			width: 19px;
-			border-radius: 15px;
-			background: #03a9f4;
-			cursor: pointer;
-			-webkit-appearance: none;
-			margin-top: -6.5px;
-		}
-
-		input[type=range]::-ms-track {
-			width: 100%;
-			height: 5.2px;
-			cursor: pointer;
-			box-shadow: 0;
-			background: #c2c0c2;
-			border-radius: 1.3px;
-		}
-
-		input[type=range]::-ms-fill-lower {
-			background: #c2c0c2;
-			border: 0;
-			border-radius: 0;
-			box-shadow: 0;
+		.add-to-queue:focus {
+			background: $primary-color;
 		}
 
-		input[type=range]::-ms-fill-upper {
-			background: #c2c0c2;
-			border: 0;
-			border-radius: 0;
-			box-shadow: 0;
+		.media-right {
+			line-height: 64px;
 		}
 
-		input[type=range]::-ms-thumb {
-			box-shadow: 0;
-			border: 0;
-			height: 15px;
-			width: 15px;
-			border-radius: 15px;
-			background: #03a9f4;
-			cursor: pointer;
-			-webkit-appearance: none;
-			margin-top: 1.5px;
-		}
-
-		.video-container {
-			position: relative;
-			padding-bottom: 56.25%;
-			height: 0;
+		.songTitle {
+			word-wrap: break-word;
 			overflow: hidden;
-
-			iframe {
-				position: absolute;
-				top: 0;
-				left: 0;
-				width: 100%;
-				height: 100%;
-			}
-		}
-		.video-col {
-			padding-right: 0.75rem;
-			padding-left: 0.75rem;
-		}
-	}
-
-	.room-title {
-		left: 50%;
-		-webkit-transform: translateX(-50%);
-		transform: translateX(-50%);
-		font-size: 2.1em;
-	}
-
-	#ratings {
-		span {
-			font-size: 1.68rem;
-		}
-
-		i {
-			color: #9e9e9e !important;
-			cursor: pointer;
-			transition: 0.1s color;
+			text-overflow: ellipsis;
+			display: -webkit-box;
+			-webkit-box-orient: vertical;
+			-webkit-line-clamp: 2;
+			line-height: 20px;
+			max-height: 40px;
+			width: 100%;
 		}
 	}
 
-	#time-display {
-		margin-top: 30px;
-		float: right;
-	}
-
-	#thumbs_up:hover, #thumbs_up.liked {
-		color: #87D37C !important;
-	}
-
-	#thumbs_down:hover, #thumbs_down.disliked {
-		color: #EC644B !important;
-	}
-
-	#song-thumbnail {
-		max-width: 100%;
-		width: 85%;
-	}
-
-	.seeker-bar-container {
-		position: relative;
-		height: 7px;
-		display: block;
+	input[type="range"] {
+		-webkit-appearance: none;
 		width: 100%;
-		overflow: hidden;
+		margin: 7.3px 0;
 	}
 
-	.seeker-bar {
-		top: 0;
-		left: 0;
-		bottom: 0;
-		position: absolute;
+	input[type="range"]:focus {
+		outline: none;
 	}
 
-	ul {
-		list-style: none;
-		margin: 0;
-		display: block;
-	}
-
-	h1, h2, h3, h4, h5, h6 {
-		font-weight: 400;
-		line-height: 1.1;
-	}
-
-	h1 a, h2 a, h3 a, h4 a, h5 a, h6 a {
-		font-weight: inherit;
-	}
-
-	h1 {
-		font-size: 4.2rem;
-		line-height: 110%;
-		margin: 2.1rem 0 1.68rem 0;
-	}
-
-	h2 {
-		font-size: 3.56rem;
-		line-height: 110%;
-		margin: 1.78rem 0 1.424rem 0;
-	}
-
-	h3 {
-		font-size: 2.92rem;
-		line-height: 110%;
-		margin: 1.46rem 0 1.168rem 0;
-	}
-
-	h4 {
-		font-size: 2.28rem;
-		line-height: 110%;
-		margin: 1.14rem 0 0.912rem 0;
-	}
-
-	h5 {
-		font-size: 1.64rem;
-		line-height: 110%;
-		margin: 0.82rem 0 0.656rem 0;
-	}
-
-	h6 {
-		font-size: 1rem;
-		line-height: 110%;
-		margin: 0.5rem 0 0.4rem 0;
+	input[type="range"]::-webkit-slider-runnable-track {
+		width: 100%;
+		height: 5.2px;
+		cursor: pointer;
+		box-shadow: 0;
+		background: $light-grey-2;
+		border-radius: 0;
+		border: 0;
 	}
 
-	.thin {
-		font-weight: 200;
+	input[type="range"]::-webkit-slider-thumb {
+		box-shadow: 0;
+		border: 0;
+		height: 19px;
+		width: 19px;
+		border-radius: 15px;
+		background: $primary-color;
+		cursor: pointer;
+		-webkit-appearance: none;
+		margin-top: -6.5px;
 	}
 
-	.left {
-		float: left !important;
+	input[type="range"]::-moz-range-track {
+		width: 100%;
+		height: 5.2px;
+		cursor: pointer;
+		box-shadow: 0;
+		background: $light-grey-2;
+		border-radius: 0;
+		border: 0;
 	}
 
-	.right {
-		float: right !important;
+	input[type="range"]::-moz-range-thumb {
+		box-shadow: 0;
+		border: 0;
+		height: 19px;
+		width: 19px;
+		border-radius: 15px;
+		background: $primary-color;
+		cursor: pointer;
+		-webkit-appearance: none;
+		margin-top: -6.5px;
 	}
 
-	.light-blue {
-		background-color: #03a9f4 !important;
+	input[type="range"]::-ms-track {
+		width: 100%;
+		height: 5.2px;
+		cursor: pointer;
+		box-shadow: 0;
+		background: $light-grey-2;
+		border-radius: 1.3px;
 	}
 
-	.white {
-		background-color: #FFFFFF !important;
+	input[type="range"]::-ms-fill-lower {
+		background: $light-grey-2;
+		border: 0;
+		border-radius: 0;
+		box-shadow: 0;
 	}
 
-	.btn-search {
-		font-size: 14px;
+	input[type="range"]::-ms-fill-upper {
+		background: $light-grey-2;
+		border: 0;
+		border-radius: 0;
+		box-shadow: 0;
 	}
 
-	.menu { padding: 0 10px; }
-
-	.menu-list li a:hover { color: #000 !important; }
-
-	.menu-list li {
-		display: flex;
-		justify-content: space-between;
+	input[type="range"]::-ms-thumb {
+		box-shadow: 0;
+		border: 0;
+		height: 15px;
+		width: 15px;
+		border-radius: 15px;
+		background: $primary-color;
+		cursor: pointer;
+		-webkit-appearance: none;
+		margin-top: 1.5px;
 	}
 
-	.menu-list a {
-		/*padding: 0 10px !important;*/
-	}
+	.video-container {
+		position: relative;
+		padding-bottom: 56.25%;
+		height: 0;
+		overflow: hidden;
 
-	.menu-list a:hover {
-		background-color : transparent;
+		iframe {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+		}
 	}
-
-	.icons-group { display: flex; }
-
-	#like, #dislike {
-		position: relative;
+	.video-col {
+		padding-right: 0.75rem;
+		padding-left: 0.75rem;
 	}
-
-	.behind {
-		z-index: -1;
+}
+
+.room-title {
+	left: 50%;
+	-webkit-transform: translateX(-50%);
+	transform: translateX(-50%);
+	font-size: 2.1em;
+}
+
+#ratings {
+	span {
+		font-size: 1.68rem;
 	}
 
-	.behind:focus {
-		z-index: 0;
+	i {
+		color: #9e9e9e !important;
+		cursor: pointer;
+		transition: 0.1s color;
 	}
-
-	.progress {
-		width: 50px;
-		animation: rotate 0.8s infinite linear;
-		border: 8px solid #03a9f4;
-		border-right-color: transparent;
-		height: 50px;
-		position: absolute;
-		top: 50%;
-		left: 50%;
+}
+
+#time-display {
+	margin-top: 30px;
+	float: right;
+}
+
+#thumbs_up:hover,
+#thumbs_up.liked {
+	color: $green !important;
+}
+
+#thumbs_down:hover,
+#thumbs_down.disliked {
+	color: $red !important;
+}
+
+#song-thumbnail {
+	max-width: 100%;
+	width: 85%;
+}
+
+.seeker-bar-container {
+	position: relative;
+	height: 7px;
+	display: block;
+	width: 100%;
+	overflow: hidden;
+}
+
+.seeker-bar {
+	top: 0;
+	left: 0;
+	bottom: 0;
+	position: absolute;
+}
+
+ul {
+	list-style: none;
+	margin: 0;
+	display: block;
+}
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+	font-weight: 400;
+	line-height: 1.1;
+}
+
+h1 a,
+h2 a,
+h3 a,
+h4 a,
+h5 a,
+h6 a {
+	font-weight: inherit;
+}
+
+h1 {
+	font-size: 4.2rem;
+	line-height: 110%;
+	margin: 2.1rem 0 1.68rem 0;
+}
+
+h2 {
+	font-size: 3.56rem;
+	line-height: 110%;
+	margin: 1.78rem 0 1.424rem 0;
+}
+
+h3 {
+	font-size: 2.92rem;
+	line-height: 110%;
+	margin: 1.46rem 0 1.168rem 0;
+}
+
+h4 {
+	font-size: 2.28rem;
+	line-height: 110%;
+	margin: 1.14rem 0 0.912rem 0;
+}
+
+h5 {
+	font-size: 1.64rem;
+	line-height: 110%;
+	margin: 0.82rem 0 0.656rem 0;
+}
+
+h6 {
+	font-size: 1rem;
+	line-height: 110%;
+	margin: 0.5rem 0 0.4rem 0;
+}
+
+.thin {
+	font-weight: 200;
+}
+
+.left {
+	float: left !important;
+}
+
+.right {
+	float: right !important;
+}
+
+.light-blue {
+	background-color: $primary-color !important;
+}
+
+.white {
+	background-color: $white !important;
+}
+
+.btn-search {
+	font-size: 14px;
+}
+
+.menu {
+	padding: 0 10px;
+}
+
+.menu-list li a:hover {
+	color: #000 !important;
+}
+
+.menu-list li {
+	display: flex;
+	justify-content: space-between;
+}
+
+.menu-list a {
+	/*padding: 0 10px !important;*/
+}
+
+.menu-list a:hover {
+	background-color: transparent;
+}
+
+.icons-group {
+	display: flex;
+}
+
+#like,
+#dislike {
+	position: relative;
+}
+
+.behind {
+	z-index: -1;
+}
+
+.behind:focus {
+	z-index: 0;
+}
+
+.progress {
+	width: 50px;
+	animation: rotate 0.8s infinite linear;
+	border: 8px solid $primary-color;
+	border-right-color: transparent;
+	height: 50px;
+	position: absolute;
+	top: 50%;
+	left: 50%;
+}
+
+@keyframes rotate {
+	0% {
+		transform: rotate(0deg);
 	}
-
-	@keyframes rotate {
-		0% { transform: rotate(0deg); }
-		100% { transform: rotate(360deg); }
+	100% {
+		transform: rotate(360deg);
 	}
+}
 
-	.experimental {
-		display: none !important;
-	}
+.experimental {
+	display: none !important;
+}
 </style>

+ 491 - 0
frontend/components/Station/StationHeader.vue

@@ -0,0 +1,491 @@
+<template>
+	<div>
+		<nav class="nav">
+			<div class="nav-left">
+				<router-link class="nav-item is-brand" :to="{ path: '/' }">
+					<img
+						:src="`${this.siteSettings.logo_white}`"
+						:alt="`${this.siteSettings.siteName}` || `Musare`"
+					/>
+				</router-link>
+			</div>
+
+			<div class="nav-center stationDisplayName">
+				{{ station.displayName }}
+			</div>
+
+			<span class="nav-toggle" v-on:click="controlBar = !controlBar">
+				<span />
+				<span />
+				<span />
+			</span>
+
+			<div class="nav-right nav-menu" :class="{ 'is-active': isMobile }">
+				<router-link
+					v-if="role === 'admin'"
+					class="nav-item is-tab admin"
+					:to="{ path: '/admin' }"
+				>
+					<strong>Admin</strong>
+				</router-link>
+				<span v-if="loggedIn" class="grouped">
+					<router-link
+						class="nav-item is-tab"
+						:to="{ path: '/u/' + username }"
+						>Profile</router-link
+					>
+					<router-link class="nav-item is-tab" to="/settings"
+						>Settings</router-link
+					>
+					<a class="nav-item is-tab" @click="logout()">Logout</a>
+				</span>
+				<span v-else class="grouped">
+					<a
+						class="nav-item"
+						href="#"
+						@click="openModal({ sector: 'header', modal: 'login' })"
+						>Login</a
+					>
+					<a
+						class="nav-item"
+						href="#"
+						@click="
+							openModal({ sector: 'header', modal: 'register' })
+						"
+						>Register</a
+					>
+				</span>
+			</div>
+		</nav>
+		<div class="control-sidebar" :class="{ 'show-controlBar': controlBar }">
+			<div class="inner-wrapper">
+				<div v-if="isOwner()">
+					<a class="sidebar-item" href="#" @click="settings()">
+						<span class="icon">
+							<i class="material-icons">settings</i>
+						</span>
+						<span class="icon-purpose">Station settings</span>
+					</a>
+					<a
+						class="sidebar-item"
+						href="#"
+						@click="$parent.skipStation()"
+					>
+						<span class="icon">
+							<i class="material-icons">skip_next</i>
+						</span>
+						<span class="icon-purpose">Skip current song</span>
+					</a>
+					<a
+						v-if="paused"
+						class="sidebar-item"
+						href="#"
+						@click="$parent.resumeStation()"
+					>
+						<span class="icon">
+							<i class="material-icons">play_arrow</i>
+						</span>
+						<span class="icon-purpose">Resume station</span>
+					</a>
+					<a
+						v-if="!paused"
+						class="sidebar-item"
+						href="#"
+						@click="$parent.pauseStation()"
+					>
+						<span class="icon">
+							<i class="material-icons">pause</i>
+						</span>
+						<span class="icon-purpose">Pause station</span>
+					</a>
+					<hr />
+				</div>
+				<div v-if="loggedIn">
+					<a
+						v-if="station.type === 'official'"
+						class="sidebar-item"
+						href="#"
+						@click="
+							openModal({
+								sector: 'station',
+								modal: 'addSongToQueue'
+							})
+						"
+					>
+						<span class="icon">
+							<i class="material-icons">queue</i>
+						</span>
+						<span class="icon-purpose">Add song to queue</span>
+					</a>
+					<a
+						v-if="!isOwner() && !noSong"
+						class="sidebar-item"
+						href="#"
+						@click="$parent.voteSkipStation()"
+					>
+						<span class="icon">
+							<i class="material-icons">skip_next</i>
+						</span>
+						<span class="skip-votes">{{
+							currentSong.skipVotes
+						}}</span>
+						<span class="icon-purpose">Skip current song</span>
+					</a>
+					<a
+						v-if="!noSong && !currentSong.simpleSong"
+						class="sidebar-item"
+						href="#"
+						@click="
+							openModal({
+								sector: 'station',
+								modal: 'report'
+							})
+						"
+					>
+						<span class="icon">
+							<i class="material-icons">report</i>
+						</span>
+						<span class="icon-purpose">Report a song</span>
+					</a>
+					<a
+						v-if="!noSong"
+						class="sidebar-item"
+						href="#"
+						@click="
+							openModal({
+								sector: 'station',
+								modal: 'addSongToPlaylist'
+							})
+						"
+					>
+						<span class="icon">
+							<i class="material-icons">playlist_add</i>
+						</span>
+						<span class="icon-purpose"
+							>Add current song to playlist</span
+						>
+					</a>
+					<hr v-if="!noSong" />
+				</div>
+				<a
+					v-if="
+						station.partyMode === true ||
+							station.type === 'official'
+					"
+					class="sidebar-item"
+					href="#"
+					@click="$parent.toggleSidebar('songslist')"
+				>
+					<span class="icon">
+						<i class="material-icons">queue_music</i>
+					</span>
+					<span class="icon-purpose">Show the station queue</span>
+				</a>
+				<a
+					class="sidebar-item"
+					href="#"
+					@click="$parent.toggleSidebar('users')"
+				>
+					<span class="icon">
+						<i class="material-icons">people</i>
+					</span>
+					<span class="icon-purpose"
+						>Display users in the station</span
+					>
+				</a>
+				<a
+					v-if="loggedIn && station.type === 'community'"
+					class="sidebar-item"
+					href="#"
+					@click="$parent.toggleSidebar('playlist')"
+				>
+					<span class="icon">
+						<i class="material-icons">library_music</i>
+					</span>
+					<span class="icon-purpose">Show your playlists</span>
+				</a>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapState, mapActions } from "vuex";
+
+export default {
+	data() {
+		return {
+			title: this.$route.params.id,
+			isMobile: false,
+			controlBar: false,
+			frontendDomain: "",
+			siteSettings: {
+				logo: "",
+				siteName: ""
+			}
+		};
+	},
+	computed: mapState({
+		loggedIn: state => state.user.auth.loggedIn,
+		userId: state => state.user.auth.userId,
+		username: state => state.user.auth.username,
+		role: state => state.user.auth.role,
+		station: state => state.station.station,
+		paused: state => state.station.paused,
+		noSong: state => state.station.noSong,
+		currentSong: state => state.station.currentSong
+	}),
+	mounted() {
+		lofig.get("frontendDomain").then(frontendDomain => {
+			this.frontendDomain = frontendDomain;
+		});
+
+		lofig.get("siteSettings").then(siteSettings => {
+			this.siteSettings = siteSettings;
+		});
+	},
+	methods: {
+		isOwner() {
+			return (
+				this.loggedIn &&
+				(this.role === "admin" || this.userId === this.station.owner)
+			);
+		},
+		settings() {
+			this.editStation({
+				_id: this.station._id,
+				name: this.station.name,
+				type: this.station.type,
+				partyMode: this.station.partyMode,
+				description: this.station.description,
+				privacy: this.station.privacy,
+				displayName: this.station.displayName,
+				locked: this.station.locked
+			});
+			this.openModal({
+				sector: "station",
+				modal: "editStation"
+			});
+		},
+		...mapActions("modals", ["openModal"]),
+		...mapActions("station", ["editStation"]),
+		...mapActions("user/auth", ["logout"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+@import "styles/global.scss";
+
+.nav {
+	background-color: $primary-color;
+	line-height: 64px;
+	border-radius: 0% 0% 33% 33% / 0% 0% 7% 7%;
+
+	.is-brand {
+		font-size: 2.1rem !important;
+		line-height: 38px !important;
+		padding: 0 20px;
+		font-family: Pacifico, cursive;
+
+		img {
+			max-height: 38px;
+			color: $musareBlue;
+		}
+	}
+}
+
+a.nav-item {
+	color: $white;
+	font-size: 17px;
+
+	&:hover {
+		color: $white;
+	}
+
+	padding: 0 12px;
+	.icon {
+		height: 64px;
+		i {
+			font-size: 2rem;
+			line-height: 64px;
+			height: 64px;
+			width: 34px;
+		}
+	}
+}
+
+a.nav-item.is-tab:hover {
+	border-bottom: none;
+	border-top: solid 1px $white;
+}
+
+.admin strong {
+	color: $purple;
+}
+
+.grouped {
+	margin: 0;
+	display: flex;
+	text-decoration: none;
+}
+
+.skip-votes {
+	position: relative;
+	left: 11px;
+}
+
+.nav-toggle {
+	height: 64px;
+}
+
+@media screen and (max-width: 998px) {
+	.nav-menu {
+		background-color: $white;
+		box-shadow: 0 4px 7px rgba(10, 10, 10, 0.1);
+		left: 0;
+		display: none;
+		right: 0;
+		top: 100%;
+		position: absolute;
+	}
+	.nav-toggle {
+		display: block;
+	}
+}
+
+.logo {
+	font-size: 2.1rem;
+	line-height: 64px;
+	padding-left: 20px !important;
+	padding-right: 20px !important;
+}
+
+.nav-center {
+	display: flex;
+	align-items: center;
+	color: $primary-color;
+	font-size: 22px;
+	position: absolute;
+	margin: auto;
+	top: 50%;
+	left: 50%;
+	transform: translate(-50%, -50%);
+}
+
+.nav-right.is-active .nav-item {
+	background: $primary-color;
+	border: 0;
+}
+
+.hidden {
+	display: none;
+}
+
+.control-sidebar {
+	position: fixed;
+	z-index: 1;
+	top: 0;
+	left: 0;
+	width: 64px;
+	height: 100vh;
+	background-color: $primary-color;
+	box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.16),
+		0 2px 10px 0 rgba(0, 0, 0, 0.12);
+
+	@media (max-width: 998px) {
+		display: none;
+	}
+	.inner-wrapper {
+		@media (min-width: 999px) {
+			.mobile-only {
+				display: none;
+			}
+			.desktop-only {
+				display: flex;
+			}
+		}
+		@media (max-width: 998px) {
+			.mobile-only {
+				display: flex;
+			}
+			.desktop-only {
+				display: none;
+				visibility: hidden;
+			}
+		}
+	}
+}
+
+.show-controlBar {
+	display: block;
+}
+
+.inner-wrapper {
+	top: 64px;
+	position: relative;
+}
+
+.control-sidebar .material-icons {
+	width: 100%;
+	font-size: 2rem;
+}
+.control-sidebar .sidebar-item {
+	font-size: 2rem;
+	height: 50px;
+	color: $white;
+	-webkit-box-align: center;
+	-ms-flex-align: center;
+	align-items: center;
+	display: -webkit-box;
+	display: -ms-flexbox;
+	display: flex;
+	-webkit-box-flex: 0;
+	-ms-flex-positive: 0;
+	flex-grow: 0;
+	-ms-flex-negative: 0;
+	flex-shrink: 0;
+	-webkit-box-pack: center;
+	-ms-flex-pack: center;
+	justify-content: center;
+	width: 100%;
+	position: relative;
+}
+.control-sidebar .sidebar-top-hr {
+	margin: 0 0 20px 0;
+}
+
+.sidebar-item .icon-purpose {
+	visibility: hidden;
+	width: 160px;
+	font-size: 12px;
+	background-color: rgba(3, 169, 244, 0.8);
+	color: $white;
+	text-align: center;
+	border-radius: 6px;
+	padding: 5px;
+	position: absolute;
+	z-index: 1;
+	left: 115%;
+	opacity: 0;
+	transition: opacity 0.5s;
+	display: none;
+}
+
+.sidebar-item .icon-purpose::after {
+	content: "";
+	position: absolute;
+	top: 50%;
+	right: 100%;
+	margin-top: -5px;
+	border-width: 5px;
+	border-style: solid;
+	border-color: transparent rgba(3, 169, 244, 0.8) transparent transparent;
+}
+
+.sidebar-item:hover .icon-purpose {
+	visibility: visible;
+	opacity: 1;
+	display: block;
+}
+</style>

+ 137 - 82
frontend/components/User/ResetPassword.vue

@@ -1,107 +1,162 @@
 <template>
-	<main-header></main-header>
-	<div class="container">
-		<!--Implement Validation-->
-		<h1>Step {{step}}</h1>
+	<div>
+		<metadata title="Reset password" />
+		<main-header />
+		<div class="container">
+			<!--Implement Validation-->
+			<h1>Step {{ step }}</h1>
 
-		<label class="label" v-if="step === 1">Email Address</label>
-		<div class="control is-grouped" v-if="step === 1">
-			<p class="control is-expanded has-icon has-icon-right">
-				<input class="input" type="email" placeholder="The email address associated with your account" v-model="email">
-			</p>
-			<p class="control">
-				<button class="button is-success" @click="submitEmail()">Request</button>
-				<button @click="step = 2" v-if="step === 1" class="button is-default skip-step">Skip this step</button>
-			</p>
-		</div>
+			<label v-if="step === 1" class="label">Email Address</label>
+			<div v-if="step === 1" class="control is-grouped">
+				<p class="control is-expanded has-icon has-icon-right">
+					<input
+						v-model="email"
+						class="input"
+						type="email"
+						placeholder="The email address associated with your account"
+					/>
+				</p>
+				<p class="control">
+					<button class="button is-success" @click="submitEmail()">
+						Request
+					</button>
+					<button
+						v-if="step === 1"
+						class="button is-default skip-step"
+						@click="step = 2"
+					>
+						Skip this step
+					</button>
+				</p>
+			</div>
 
-		<label class="label" v-if="step === 2">Reset Code</label>
-		<div class="control is-grouped" v-if="step === 2">
-			<p class="control is-expanded has-icon has-icon-right">
-				<input class="input" type="text" placeholder="The reset code that was sent to your account's email address" v-model="code">
-			</p>
-			<p class="control">
-				<button class="button is-success" @click="verifyCode()">Verify reset code</button>
-			</p>
-		</div>
+			<label v-if="step === 2" class="label">Reset Code</label>
+			<div v-if="step === 2" class="control is-grouped">
+				<p class="control is-expanded has-icon has-icon-right">
+					<input
+						v-model="code"
+						class="input"
+						type="text"
+						placeholder="The reset code that was sent to your account's email address"
+					/>
+				</p>
+				<p class="control">
+					<button class="button is-success" v-on:click="verifyCode()">
+						Verify reset code
+					</button>
+				</p>
+			</div>
 
-		<label class="label" v-if="step === 3">Change password</label>
-		<div class="control is-grouped" v-if="step === 3">
-			<p class="control is-expanded has-icon has-icon-right">
-				<input class="input" type="password" placeholder="New password" v-model="newPassword">
-			</p>
-			<p class="control">
-				<button class="button is-success" @click="changePassword()">Change password</button>
-			</p>
+			<label v-if="step === 3" class="label">Change password</label>
+			<div v-if="step === 3" class="control is-grouped">
+				<p class="control is-expanded has-icon has-icon-right">
+					<input
+						v-model="newPassword"
+						class="input"
+						type="password"
+						placeholder="New password"
+					/>
+				</p>
+				<p class="control">
+					<button class="button is-success" @click="changePassword()">
+						Change password
+					</button>
+				</p>
+			</div>
 		</div>
+		<main-footer />
 	</div>
-	<main-footer></main-footer>
 </template>
 
 <script>
-	import { Toast } from 'vue-roaster';
+import Toast from "toasters";
 
-	import MainHeader from '../MainHeader.vue';
-	import MainFooter from '../MainFooter.vue';
+import MainHeader from "../MainHeader.vue";
+import MainFooter from "../MainFooter.vue";
 
-	import LoginModal from '../Modals/Login.vue'
-	import io from '../../io'
+import io from "../../io";
 
-	export default {
-		data() {
-			return {
-				email: '',
-				code: '',
-				newPassword: '',
-				step: 1
-			}
-		},
-		ready: function() {
-			let _this = this;
-			io.getSocket((socket) => {
-				_this.socket = socket;
-			});
-		},
-		methods: {
-			submitEmail: function () {
-				if (!this.email) return Toast.methods.addToast('Email cannot be empty', 8000);
-				this.socket.emit('users.requestPasswordReset', this.email, res => {
-					Toast.methods.addToast(res.message, 8000);
-					if (res.status === 'success') {
+export default {
+	components: { MainHeader, MainFooter },
+	data() {
+		return {
+			email: "",
+			code: "",
+			newPassword: "",
+			step: 1
+		};
+	},
+	mounted() {
+		io.getSocket(socket => {
+			this.socket = socket;
+		});
+	},
+	methods: {
+		submitEmail() {
+			if (!this.email)
+				return new Toast({
+					content: "Email cannot be empty",
+					timeout: 8000
+				});
+			return this.socket.emit(
+				"users.requestPasswordReset",
+				this.email,
+				res => {
+					new Toast({ content: res.message, timeout: 8000 });
+					if (res.status === "success") {
 						this.step = 2;
 					}
+				}
+			);
+		},
+		verifyCode() {
+			if (!this.code)
+				return new Toast({
+					content: "Code cannot be empty",
+					timeout: 8000
 				});
-			},
-			verifyCode: function () {
-				if (!this.code) return Toast.methods.addToast('Code cannot be empty', 8000);
-				this.socket.emit('users.verifyPasswordResetCode', this.code, res => {
-					Toast.methods.addToast(res.message, 8000);
-					if (res.status === 'success') {
+			return this.socket.emit(
+				"users.verifyPasswordResetCode",
+				this.code,
+				res => {
+					new Toast({ content: res.message, timeout: 8000 });
+					if (res.status === "success") {
 						this.step = 3;
 					}
+				}
+			);
+		},
+		changePassword() {
+			if (!this.newPassword)
+				return new Toast({
+					content: "Password cannot be empty",
+					timeout: 8000
 				});
-			},
-			changePassword: function () {
-				if (!this.newPassword) return Toast.methods.addToast('Password cannot be empty', 8000);
-				this.socket.emit('users.changePasswordWithResetCode', this.code, this.newPassword, res => {
-					Toast.methods.addToast(res.message, 8000);
-					if (res.status === 'success') {
-						this.$router.go('/login');
+			return this.socket.emit(
+				"users.changePasswordWithResetCode",
+				this.code,
+				this.newPassword,
+				res => {
+					new Toast({ content: res.message, timeout: 8000 });
+					if (res.status === "success") {
+						this.$router.go("/login");
 					}
-				});
-			}
-		},
-		components: { MainHeader, MainFooter, LoginModal }
+				}
+			);
+		}
 	}
+};
 </script>
 
 <style lang="scss" scoped>
-	.container {
-		padding: 25px;
-	}
+@import "styles/global.scss";
 
-	.skip-step {
-		background-color: #7e7e7e;
-		color: #fff;
-	}
+.container {
+	padding: 25px;
+}
+
+.skip-step {
+	background-color: #7e7e7e;
+	color: $white;
+}
 </style>

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